package com.lemenzo.contacts;

import android.content.*;
import android.database.Cursor;
import android.net.Uri;
import android.provider.ContactsContract;
import java.util.*;
import java.io.InputStream;

public class ContactsRepository {
    private final Context c;
    private static final String[] MANAGED_MIMES={
            ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
            ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE,
            ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE,
            ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE,
            ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE,
            ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE,
            ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE,
            ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE
    };

    public ContactsRepository(Context c){this.c=c.getApplicationContext();}

    public List<ContactSummary> list(String q, boolean starredOnly) {
        ArrayList<ContactSummary> out = new ArrayList<>();
        Uri uri = (q != null && !q.trim().isEmpty())
                ? Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_FILTER_URI, Uri.encode(q.trim()))
                : ContactsContract.Contacts.CONTENT_URI;
        String sel = starredOnly ? ContactsContract.Contacts.STARRED + "=1" : null;
        String[] projection = {
                ContactsContract.Contacts._ID,
                ContactsContract.Contacts.LOOKUP_KEY,
                ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
                ContactsContract.Contacts.PHOTO_THUMBNAIL_URI,
                ContactsContract.Contacts.STARRED,
                ContactsContract.Contacts.HAS_PHONE_NUMBER
        };

        ArrayList<Long> phoneIds = new ArrayList<>();
        try (Cursor cur = c.getContentResolver().query(uri, projection, sel, null,
                ContactsContract.Contacts.SORT_KEY_PRIMARY + " COLLATE LOCALIZED ASC")) {
            if (cur == null) return out;
            while (cur.moveToNext()) {
                long id = cur.getLong(0);
                boolean hasPhone = cur.getInt(5) != 0;
                out.add(new ContactSummary(id, s(cur,1), s(cur,2), "", s(cur,3), cur.getInt(4) != 0));
                if (hasPhone) phoneIds.add(id);
            }
        }

        if (!phoneIds.isEmpty()) {
            HashMap<Long, String> firstPhones = new HashMap<>();
            final int chunkSize = 500; // Stay comfortably below SQLite/provider bind limits.
            for (int base = 0; base < phoneIds.size(); base += chunkSize) {
                int limit = Math.min(phoneIds.size(), base + chunkSize);
                StringBuilder placeholders = new StringBuilder();
                String[] args = new String[limit - base];
                for (int i = base; i < limit; i++) {
                    if (i > base) placeholders.append(',');
                    placeholders.append('?');
                    args[i - base] = String.valueOf(phoneIds.get(i));
                }
                String selection = ContactsContract.CommonDataKinds.Phone.CONTACT_ID
                        + " IN (" + placeholders + ")";
                String order = ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " ASC, "
                        + ContactsContract.CommonDataKinds.Phone.IS_PRIMARY + " DESC, "
                        + ContactsContract.CommonDataKinds.Phone.IS_SUPER_PRIMARY + " DESC";
                try (Cursor cur = c.getContentResolver().query(
                        ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
                        new String[]{
                                ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
                                ContactsContract.CommonDataKinds.Phone.NUMBER
                        }, selection, args, order)) {
                    if (cur != null) {
                        while (cur.moveToNext()) {
                            long id = cur.getLong(0);
                            if (!firstPhones.containsKey(id)) firstPhones.put(id, s(cur,1));
                        }
                    }
                }
            }
            for (int i = 0; i < out.size(); i++) {
                ContactSummary x = out.get(i);
                String phone = firstPhones.get(x.id);
                if (phone != null && !phone.isEmpty()) {
                    out.set(i, new ContactSummary(x.id, x.lookupKey, x.name, phone, x.photoUri, x.starred));
                }
            }
        }
        return out;
    }

    public List<ContactSummary> listByGroup(long groupId) {
        HashSet<Long> wanted = new HashSet<>();
        try (Cursor cur = c.getContentResolver().query(
                ContactsContract.Data.CONTENT_URI,
                new String[]{ContactsContract.Data.CONTACT_ID},
                ContactsContract.Data.MIMETYPE + "=? AND "
                        + ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID + "=?",
                new String[]{ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE,
                        String.valueOf(groupId)}, null)) {
            if (cur != null) while (cur.moveToNext()) wanted.add(cur.getLong(0));
        }
        ArrayList<ContactSummary> out = new ArrayList<>();
        for (ContactSummary x : list("", false)) if (wanted.contains(x.id)) out.add(x);
        return out;
    }

    public ContactSummary summary(long id) {
        String[] p = {
                ContactsContract.Contacts._ID,
                ContactsContract.Contacts.LOOKUP_KEY,
                ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
                ContactsContract.Contacts.PHOTO_THUMBNAIL_URI,
                ContactsContract.Contacts.STARRED,
                ContactsContract.Contacts.HAS_PHONE_NUMBER
        };
        try (Cursor cur = c.getContentResolver().query(
                ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id),
                p, null, null, null)) {
            if (cur == null || !cur.moveToFirst()) return null;
            String phone = cur.getInt(5) != 0 ? firstPhone(id) : "";
            return new ContactSummary(cur.getLong(0), s(cur,1), s(cur,2),
                    phone, s(cur,3), cur.getInt(4) != 0);
        }
    }

    private String firstPhone(long id) {
        try (Cursor cur = c.getContentResolver().query(
                ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
                new String[]{ContactsContract.CommonDataKinds.Phone.NUMBER},
                ContactsContract.CommonDataKinds.Phone.CONTACT_ID + "=?",
                new String[]{String.valueOf(id)},
                ContactsContract.CommonDataKinds.Phone.IS_PRIMARY + " DESC, "
                        + ContactsContract.CommonDataKinds.Phone.IS_SUPER_PRIMARY + " DESC")) {
            if (cur != null && cur.moveToFirst()) return s(cur,0);
        }
        return "";
    }

    public ContactData get(long contactId){return getInternal(contactId,true);}
    public ContactData getForEdit(long contactId){return getInternal(contactId,false);}
    private ContactData getInternal(long contactId,boolean aggregatePhonesAndEmails){
        ContactData d=new ContactData();d.contactId=contactId;
        String[] cp={ContactsContract.Contacts.LOOKUP_KEY,ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,ContactsContract.Contacts.STARRED};
        try(Cursor cur=c.getContentResolver().query(Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI,String.valueOf(contactId)),cp,null,null,null)){
            if(cur!=null&&cur.moveToFirst()){d.lookupKey=s(cur,0);d.displayName=s(cur,1);d.starred=cur.getInt(2)!=0;}
        }

        long firstRaw=-1,localRaw=-1;
        try(Cursor cur=c.getContentResolver().query(ContactsContract.RawContacts.CONTENT_URI,
                new String[]{ContactsContract.RawContacts._ID,ContactsContract.RawContacts.ACCOUNT_TYPE},
                ContactsContract.RawContacts.CONTACT_ID+"=? AND "+ContactsContract.RawContacts.DELETED+"=0",
                new String[]{String.valueOf(contactId)},null)){
            if(cur!=null)while(cur.moveToNext()){
                long raw=cur.getLong(0);if(firstRaw<0)firstRaw=raw;
                if(cur.isNull(1)||s(cur,1).isEmpty()){localRaw=raw;break;}
            }
        }
        d.rawContactId=localRaw>=0?localRaw:firstRaw;
        d.localEditable=localRaw>=0;

        String[] proj={ContactsContract.Data.RAW_CONTACT_ID,ContactsContract.Data.MIMETYPE,
                ContactsContract.Data.DATA1,ContactsContract.Data.DATA2,ContactsContract.Data.DATA3,
                ContactsContract.Data.DATA4,ContactsContract.Data.DATA5,ContactsContract.Data.DATA6,
                ContactsContract.Data.DATA7,ContactsContract.Data.DATA8,ContactsContract.Data.DATA9,
                ContactsContract.Data.DATA10,ContactsContract.Data.DATA15};
        try(Cursor cur=c.getContentResolver().query(ContactsContract.Data.CONTENT_URI,proj,
                ContactsContract.Data.CONTACT_ID+"=?",new String[]{String.valueOf(contactId)},null)){
            if(cur==null)return d;
            while(cur.moveToNext()){
                long rowRaw=cur.getLong(0);String mime=s(cur,1);
                boolean editableRow=d.rawContactId<0||rowRaw==d.rawContactId;
                if(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE.equals(mime)){
                    String v=s(cur,2);if((aggregatePhonesAndEmails||editableRow)&&!v.isEmpty()&&!d.phones.contains(v)){d.phones.add(v);d.phoneTypes.add(cur.isNull(3)?ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE:cur.getInt(3));}
                }else if(ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE.equals(mime)){
                    String v=s(cur,2);if((aggregatePhonesAndEmails||editableRow)&&!v.isEmpty()&&!d.emails.contains(v)){d.emails.add(v);d.emailTypes.add(cur.isNull(3)?ContactsContract.CommonDataKinds.Email.TYPE_HOME:cur.getInt(3));}
                }else if(!editableRow)continue;
                else if(ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE.equals(mime)){
                    d.displayName=s(cur,2);d.firstName=s(cur,3);d.lastName=s(cur,4);d.middleName=s(cur,5);
                }else if(ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE.equals(mime)){
                    d.company=s(cur,2);d.jobTitle=s(cur,5);
                }else if(ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE.equals(mime)){
                    d.address=s(cur,2);
                }else if(ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE.equals(mime)){
                    if(cur.getInt(3)==ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY)d.birthday=s(cur,2);
                }else if(ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE.equals(mime)){
                    d.notes=s(cur,2);
                }else if(ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE.equals(mime)&&!cur.isNull(12)){
                    d.photoBytes=cur.getBlob(12);
                }
            }
        }
        if(d.photoBytes==null){
            try(InputStream in=ContactsContract.Contacts.openContactPhotoInputStream(c.getContentResolver(),ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI,contactId),true)){
                if(in!=null)d.photoBytes=in.readAllBytes();
            }catch(Exception ignored){}
        }
        return d;
    }

    public long create(ContactData d){
        ArrayList<ContentProviderOperation> ops=new ArrayList<>();
        ops.add(ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
                .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE,null)
                .withValue(ContactsContract.RawContacts.ACCOUNT_NAME,null).build());
        appendDataOps(ops,d,0,true);
        try{
            ContentProviderResult[] r=c.getContentResolver().applyBatch(ContactsContract.AUTHORITY,ops);
            long raw=ContentUris.parseId(r[0].uri);
            try(Cursor cur=c.getContentResolver().query(ContactsContract.RawContacts.CONTENT_URI,
                    new String[]{ContactsContract.RawContacts.CONTACT_ID},ContactsContract.RawContacts._ID+"=?",
                    new String[]{String.valueOf(raw)},null)){
                return cur!=null&&cur.moveToFirst()?cur.getLong(0):-1;
            }
        } catch (SecurityException e) {
            throw e;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public int importContacts(List<ContactData> contacts) {
        if (contacts == null || contacts.isEmpty()) return 0;
        int imported = 0;
        int index = 0;
        while (index < contacts.size()) {
            ArrayList<ContentProviderOperation> ops = new ArrayList<>();
            ArrayList<ContactData> current = new ArrayList<>();
            while (index < contacts.size() && (ops.isEmpty() || ops.size() < 300)) {
                ContactData d = contacts.get(index++);
                if (d == null) continue;
                int rawInsertIndex = ops.size();
                ops.add(ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
                        .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
                        .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
                        .build());
                appendDataOps(ops, d, rawInsertIndex, true);
                current.add(d);
            }
            if (current.isEmpty()) continue;
            try {
                c.getContentResolver().applyBatch(ContactsContract.AUTHORITY, ops);
                imported += current.size();
            } catch (Exception batchError) {
                // A malformed card must not poison an entire large import.
                for (ContactData d : current) {
                    try { create(d); imported++; } catch (RuntimeException ignored) {}
                }
            }
        }
        return imported;
    }


    public interface ExportBatchConsumer {
        void onBatch(List<ContactData> contacts) throws Exception;
    }

    /** Export contacts with bounded memory and two provider queries per chunk, not one query per contact. */
    public int exportContactsBatched(int batchSize, ExportBatchConsumer consumer) throws Exception {
        if (consumer == null) throw new IllegalArgumentException("consumer");
        final int limit=Math.max(25,Math.min(500,batchSize));
        ArrayList<Long> ids=new ArrayList<>(limit);
        int total=0;
        try(Cursor cur=c.getContentResolver().query(ContactsContract.Contacts.CONTENT_URI,
                new String[]{ContactsContract.Contacts._ID},null,null,
                ContactsContract.Contacts.SORT_KEY_PRIMARY+" COLLATE LOCALIZED ASC")){
            if(cur==null)return 0;
            while(cur.moveToNext()){
                ids.add(cur.getLong(0));
                if(ids.size()>=limit){List<ContactData> batch=getMany(ids);consumer.onBatch(batch);total+=batch.size();ids.clear();}
            }
        }
        if(!ids.isEmpty()){List<ContactData> batch=getMany(ids);consumer.onBatch(batch);total+=batch.size();}
        return total;
    }

    private List<ContactData> getMany(List<Long> ids){
        LinkedHashMap<Long,ContactData> map=new LinkedHashMap<>();
        if(ids==null||ids.isEmpty())return new ArrayList<>();
        StringBuilder ph=new StringBuilder();String[] args=new String[ids.size()];
        for(int i=0;i<ids.size();i++){if(i>0)ph.append(',');ph.append('?');args[i]=String.valueOf(ids.get(i));map.put(ids.get(i),new ContactData());}
        String in="("+ph+")";
        try(Cursor cur=c.getContentResolver().query(ContactsContract.Contacts.CONTENT_URI,
                new String[]{ContactsContract.Contacts._ID,ContactsContract.Contacts.LOOKUP_KEY,
                        ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,ContactsContract.Contacts.STARRED},
                ContactsContract.Contacts._ID+" IN "+in,args,null)){
            if(cur!=null)while(cur.moveToNext()){ContactData d=map.get(cur.getLong(0));if(d!=null){d.contactId=cur.getLong(0);d.lookupKey=s(cur,1);d.displayName=s(cur,2);d.starred=cur.getInt(3)!=0;}}
        }
        String[] proj={ContactsContract.Data.CONTACT_ID,ContactsContract.Data.MIMETYPE,
                ContactsContract.Data.DATA1,ContactsContract.Data.DATA2,ContactsContract.Data.DATA3,
                ContactsContract.Data.DATA4,ContactsContract.Data.DATA5,ContactsContract.Data.DATA15};
        try(Cursor cur=c.getContentResolver().query(ContactsContract.Data.CONTENT_URI,proj,
                ContactsContract.Data.CONTACT_ID+" IN "+in,args,
                ContactsContract.Data.CONTACT_ID+" ASC")){
            if(cur!=null)while(cur.moveToNext()){
                ContactData d=map.get(cur.getLong(0));if(d==null)continue;String mime=s(cur,1);
                if(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE.equals(mime)){String v=s(cur,2);if(!v.isEmpty()&&!d.phones.contains(v)){d.phones.add(v);d.phoneTypes.add(cur.isNull(3)?ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE:cur.getInt(3));}}
                else if(ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE.equals(mime)){String v=s(cur,2);if(!v.isEmpty()&&!d.emails.contains(v)){d.emails.add(v);d.emailTypes.add(cur.isNull(3)?ContactsContract.CommonDataKinds.Email.TYPE_HOME:cur.getInt(3));}}
                else if(ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE.equals(mime)){d.displayName=s(cur,2);d.firstName=s(cur,3);d.lastName=s(cur,4);d.middleName=s(cur,5);}
                else if(ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE.equals(mime)){d.company=s(cur,2);d.jobTitle=s(cur,5);}
                else if(ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE.equals(mime))d.address=s(cur,2);
                else if(ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE.equals(mime)&&cur.getInt(3)==ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY)d.birthday=s(cur,2);
                else if(ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE.equals(mime))d.notes=s(cur,2);
                else if(ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE.equals(mime)&&!cur.isNull(7))d.photoBytes=cur.getBlob(7);
            }
        }
        return new ArrayList<>(map.values());
    }

    public void replaceLocal(ContactData d){
        if(d.rawContactId<0||!d.localEditable)throw new IllegalStateException("Contact is not device-local");
        StringBuilder in=new StringBuilder();
        String[] args=new String[MANAGED_MIMES.length+1];args[0]=String.valueOf(d.rawContactId);
        for(int i=0;i<MANAGED_MIMES.length;i++){if(i>0)in.append(",");in.append("?");args[i+1]=MANAGED_MIMES[i];}
        ArrayList<ContentProviderOperation> ops=new ArrayList<>();
        ops.add(ContentProviderOperation.newDelete(ContactsContract.Data.CONTENT_URI)
                .withSelection(ContactsContract.Data.RAW_CONTACT_ID+"=? AND "+ContactsContract.Data.MIMETYPE+" IN ("+in+")",args).build());
        appendDataOps(ops,d,d.rawContactId,false);
        try {
            c.getContentResolver().applyBatch(ContactsContract.AUTHORITY, ops);
        } catch (SecurityException e) {
            throw e;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private void appendDataOps(ArrayList<ContentProviderOperation> ops,ContactData d,long raw,boolean backRef){
        add(ops,raw,backRef,ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
                ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,d.displayName,
                ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,d.firstName,
                ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME,d.middleName,
                ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,d.lastName);
        for(int i=0;i<d.phones.size();i++){String p=d.phones.get(i);if(!p.trim().isEmpty())add(ops,raw,backRef,ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE,ContactsContract.CommonDataKinds.Phone.NUMBER,p.trim(),ContactsContract.CommonDataKinds.Phone.TYPE,i<d.phoneTypes.size()?d.phoneTypes.get(i):ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE);}
        for(int i=0;i<d.emails.size();i++){String e=d.emails.get(i);if(!e.trim().isEmpty())add(ops,raw,backRef,ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE,ContactsContract.CommonDataKinds.Email.ADDRESS,e.trim(),ContactsContract.CommonDataKinds.Email.TYPE,i<d.emailTypes.size()?d.emailTypes.get(i):ContactsContract.CommonDataKinds.Email.TYPE_HOME);}
        if(!d.company.isEmpty()||!d.jobTitle.isEmpty())add(ops,raw,backRef,ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE,
                ContactsContract.CommonDataKinds.Organization.COMPANY,d.company,ContactsContract.CommonDataKinds.Organization.TITLE,d.jobTitle);
        if(!d.address.isEmpty())add(ops,raw,backRef,ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_ITEM_TYPE,
                ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS,d.address);
        if(!d.birthday.isEmpty())add(ops,raw,backRef,ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE,
                ContactsContract.CommonDataKinds.Event.START_DATE,d.birthday,ContactsContract.CommonDataKinds.Event.TYPE,
                ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY);
        if(!d.notes.isEmpty())add(ops,raw,backRef,ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE,
                ContactsContract.CommonDataKinds.Note.NOTE,d.notes);
        if(d.photoBytes!=null&&d.photoBytes.length>0)add(ops,raw,backRef,ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE,
                ContactsContract.CommonDataKinds.Photo.PHOTO,d.photoBytes);
    }

    private void add(ArrayList<ContentProviderOperation> ops,long raw,boolean backRef,String mime,Object... kv){
        ContentProviderOperation.Builder b=ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI);
        if(backRef)b.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID,(int)raw);else b.withValue(ContactsContract.Data.RAW_CONTACT_ID,raw);
        b.withValue(ContactsContract.Data.MIMETYPE,mime);
        for(int i=0;i+1<kv.length;i+=2)b.withValue((String)kv[i],kv[i+1]);ops.add(b.build());
    }

    public void setStarred(long contactId,boolean starred){
        ContentValues v=new ContentValues();v.put(ContactsContract.Contacts.STARRED,starred?1:0);
        c.getContentResolver().update(Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI,String.valueOf(contactId)),v,null,null);
    }
    public void deleteLocal(ContactData d){
        if(d==null||d.rawContactId<0||!d.localEditable)throw new IllegalStateException("Contact is not device-local");
        c.getContentResolver().delete(Uri.withAppendedPath(ContactsContract.RawContacts.CONTENT_URI,String.valueOf(d.rawContactId)),null,null);
    }

    public List<GroupInfo> groups() {
        HashMap<Long,Integer> counts = new HashMap<>();
        try (Cursor cur = c.getContentResolver().query(
                ContactsContract.Data.CONTENT_URI,
                new String[]{ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID},
                ContactsContract.Data.MIMETYPE + "=?",
                new String[]{ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE}, null)) {
            if (cur != null) while (cur.moveToNext()) {
                long id = cur.getLong(0);
                counts.put(id, counts.getOrDefault(id,0) + 1);
            }
        }
        ArrayList<GroupInfo> out=new ArrayList<>();
        try(Cursor cur=c.getContentResolver().query(ContactsContract.Groups.CONTENT_URI,
                new String[]{ContactsContract.Groups._ID,ContactsContract.Groups.TITLE},
                ContactsContract.Groups.DELETED+"=0 AND "+ContactsContract.Groups.ACCOUNT_TYPE+" IS NULL",
                null,ContactsContract.Groups.TITLE+" COLLATE LOCALIZED ASC")){
            if(cur==null)return out;
            while(cur.moveToNext()){
                long id=cur.getLong(0);
                out.add(new GroupInfo(id,s(cur,1),counts.getOrDefault(id,0)));
            }
        }
        return out;
    }

    public long createGroup(String title){
        ContentValues v=new ContentValues();v.put(ContactsContract.Groups.TITLE,title.trim());
        Uri u=c.getContentResolver().insert(ContactsContract.Groups.CONTENT_URI,v);return u==null?-1:ContentUris.parseId(u);
    }
    public void renameGroup(long id,String title){ContentValues v=new ContentValues();v.put(ContactsContract.Groups.TITLE,title.trim());c.getContentResolver().update(ContentUris.withAppendedId(ContactsContract.Groups.CONTENT_URI,id),v,null,null);}
    public void deleteGroup(long id){c.getContentResolver().delete(ContentUris.withAppendedId(ContactsContract.Groups.CONTENT_URI,id),null,null);}
    public void addToGroup(long contactId,long groupId){ContactData d=getForEdit(contactId);if(!d.localEditable)throw new IllegalStateException("Contact is not device-local");ContentValues v=new ContentValues();v.put(ContactsContract.Data.RAW_CONTACT_ID,d.rawContactId);v.put(ContactsContract.Data.MIMETYPE,ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE);v.put(ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID,groupId);c.getContentResolver().insert(ContactsContract.Data.CONTENT_URI,v);}
    public void removeFromGroup(long contactId,long groupId){ContactData d=getForEdit(contactId);if(!d.localEditable)throw new IllegalStateException("Contact is not device-local");c.getContentResolver().delete(ContactsContract.Data.CONTENT_URI,ContactsContract.Data.RAW_CONTACT_ID+"=? AND "+ContactsContract.Data.MIMETYPE+"=? AND "+ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID+"=?",new String[]{String.valueOf(d.rawContactId),ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE,String.valueOf(groupId)});}
    public void mergeLocal(List<ContactSummary> group){
        if(group==null||group.size()<2)return;ContactData target=getForEdit(group.get(0).id);if(!target.localEditable)throw new IllegalStateException("Primary contact is not device-local");
        LinkedHashMap<String,Integer> phones=new LinkedHashMap<>(),emails=new LinkedHashMap<>();for(int j=0;j<target.phones.size();j++)phones.put(target.phones.get(j),j<target.phoneTypes.size()?target.phoneTypes.get(j):ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE);for(int j=0;j<target.emails.size();j++)emails.put(target.emails.get(j),j<target.emailTypes.size()?target.emailTypes.get(j):ContactsContract.CommonDataKinds.Email.TYPE_HOME);ArrayList<ContactData> sources=new ArrayList<>();
        for(int i=1;i<group.size();i++){ContactData x=getForEdit(group.get(i).id);if(!x.localEditable)continue;sources.add(x);for(int j=0;j<x.phones.size();j++)phones.putIfAbsent(x.phones.get(j),j<x.phoneTypes.size()?x.phoneTypes.get(j):ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE);for(int j=0;j<x.emails.size();j++)emails.putIfAbsent(x.emails.get(j),j<x.emailTypes.size()?x.emailTypes.get(j):ContactsContract.CommonDataKinds.Email.TYPE_HOME);if(target.company.isEmpty())target.company=x.company;if(target.jobTitle.isEmpty())target.jobTitle=x.jobTitle;if(target.address.isEmpty())target.address=x.address;if(target.birthday.isEmpty())target.birthday=x.birthday;if(target.notes.isEmpty())target.notes=x.notes;if(target.photoBytes==null)target.photoBytes=x.photoBytes;}
        target.phones.clear();target.phoneTypes.clear();for(Map.Entry<String,Integer> e:phones.entrySet()){target.phones.add(e.getKey());target.phoneTypes.add(e.getValue());}target.emails.clear();target.emailTypes.clear();for(Map.Entry<String,Integer> e:emails.entrySet()){target.emails.add(e.getKey());target.emailTypes.add(e.getValue());}replaceLocal(target);for(ContactData x:sources)deleteLocal(x);
    }

    public List<List<ContactSummary>> duplicates() {
        List<ContactSummary> all = list("", false);
        HashMap<Long,ContactSummary> summaries = new HashMap<>();
        HashMap<Long,String> names = new HashMap<>();
        for (ContactSummary x : all) {
            summaries.put(x.id,x);
            names.put(x.id,normName(x.name));
        }

        Map<String,LinkedHashSet<Long>> buckets = new LinkedHashMap<>();
        String selection = ContactsContract.Data.MIMETYPE + " IN (?,?)";
        String[] args = {
                ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE,
                ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE
        };
        try (Cursor cur = c.getContentResolver().query(
                ContactsContract.Data.CONTENT_URI,
                new String[]{ContactsContract.Data.CONTACT_ID,ContactsContract.Data.MIMETYPE,ContactsContract.Data.DATA1},
                selection,args,null)) {
            if (cur != null) while (cur.moveToNext()) {
                long id=cur.getLong(0);
                if (!summaries.containsKey(id)) continue;
                String mime=s(cur,1), value=s(cur,2);
                if(value.isEmpty()) continue;
                String nn=names.getOrDefault(id,"");
                if(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE.equals(mime)){
                    String np=value.replaceAll("[^0-9+]","");
                    addBucket(buckets,"p:"+np,id);
                    if(!nn.isEmpty())addBucket(buckets,"np:"+nn+":"+np,id);
                } else {
                    String ne=value.trim().toLowerCase(Locale.ROOT);
                    addBucket(buckets,"e:"+ne,id);
                    if(!nn.isEmpty())addBucket(buckets,"ne:"+nn+":"+ne,id);
                }
            }
        }

        ArrayList<List<ContactSummary>> out=new ArrayList<>();
        HashSet<String> seenSets=new HashSet<>();
        for(LinkedHashSet<Long> ids:buckets.values()) if(ids.size()>1){
            ArrayList<Long> sorted=new ArrayList<>(ids);
            Collections.sort(sorted);
            String key=sorted.toString();
            if(!seenSets.add(key))continue;
            ArrayList<ContactSummary> group=new ArrayList<>();
            for(long id:sorted){
                ContactSummary summary=summaries.get(id);
                if(summary!=null)group.add(summary);
            }
            if(group.size()>1)out.add(group);
        }
        return out;
    }

    private void addBucket(Map<String,LinkedHashSet<Long>> b,String key,long id){if(key.length()<=2)return;b.computeIfAbsent(key,k->new LinkedHashSet<>()).add(id);}
    private static String normName(String s){return safe(s).trim().toLowerCase(Locale.ROOT).replaceAll("\\s+"," ");}
    private static String safe(String s){return s==null?"":s;}
    private static String s(Cursor c,int i){return c.isNull(i)?"":c.getString(i);}
}
