import Contacts
import ContactsSyncCore
import Foundation

/// Mutates CNContactStore from server-rendered `RenderedContact` rows.
///
/// Policy (confirmed by Quinn 2026-04-24):
///  - Always overwrite `givenName` with `[AIRPORT] <name-or-placeholder>`.
///  - For `familyName`, emit `<emojis>` plus any preserved human surname as
///    `{extra}`. Example: Quinn had `familyName = "Smith"`, we render `🔥👸🌹`
///    → stored value becomes `🔥👸🌹 Smith`. If the prior familyName is
///    emoji-only (our own prior write), drop it instead of double-stacking.
///  - For `note`, only touch the region between `<lilith-sync v=1>…</lilith-sync>`
///    sentinels. Text outside those sentinels is preserved verbatim.
///  - Match existing contacts by phoneNumber (E.164) or emailAddress. If no
///    match, create a fresh CNContact.
///
/// Env gates (checked by the poller, enforced here):
///  - `MAC_SYNC_CONTACTS_WRITE=1` — leaves dry-run mode (diff JSONL only).
///  - `MAC_SYNC_CONTACTS_WRITE_UNLIMITED=1` — lifts the 10-contact canary cap.
enum ContactsWriter {
    enum WriterError: Error {
        case accessDenied
        case fetchFailed(Error)
        case saveFailed(Error)
        case invalidHandle(String)
    }

    struct Options {
        let dryRun: Bool
        let store: CNContactStore
        let rollbackURL: URL
        let diffURL: URL
        let now: Date

        init(
            dryRun: Bool,
            rollbackURL: URL,
            diffURL: URL,
            store: CNContactStore = CNContactStore(),
            now: Date = Date()
        ) {
            self.dryRun = dryRun
            self.store = store
            self.rollbackURL = rollbackURL
            self.diffURL = diffURL
            self.now = now
        }
    }

    /// Apply one rendered contact. In dry-run mode no CNSaveRequest executes —
    /// we only append to the diff JSONL so Quinn can review before committing.
    static func upsert(_ rendered: RenderedContact, options: Options) throws -> ContactUpsertResult {
        let keys = mutationFetchKeys()
        let existing = try findExisting(
            handle: rendered.handle,
            channel: rendered.channel,
            store: options.store,
            keys: keys
        )

        let mutable: CNMutableContact
        let action: ContactUpsertResult.Action
        let beforeTriple: ContactFieldTriple?

        if let existing {
            beforeTriple = ContactFieldTriple(
                givenName: existing.givenName,
                familyName: existing.familyName,
                note: existing.isKeyAvailable(CNContactNoteKey) ? existing.note : ""
            )
            // Capture pre-state to rollback JSONL BEFORE any mutation — lets us
            // recover the exact prior value even if the subsequent save fails.
            if !options.dryRun {
                let snapshot = ContactSnapshot(contact: existing)
                try ContactsBackup.appendRollbackEntry(snapshot, to: options.rollbackURL)
            }
            mutable = existing.mutableCopy() as! CNMutableContact
            action = .updated
        } else {
            beforeTriple = nil
            mutable = CNMutableContact()
            attachHandle(rendered.handle, channel: rendered.channel, to: mutable)
            action = .created
        }

        apply(rendered: rendered, to: mutable, preservedSurnameFrom: beforeTriple?.familyName)

        let afterTriple = ContactFieldTriple(
            givenName: mutable.givenName,
            familyName: mutable.familyName,
            note: mutable.note
        )

        if options.dryRun {
            let diffEntry = DiffEntry(
                handle: rendered.handle,
                clientId: rendered.clientId,
                action: .dryRun,
                before: beforeTriple,
                after: afterTriple,
                renderedAt: rendered.renderedAt
            )
            try ContactsBackup.appendDiffEntry(diffEntry, to: options.diffURL)
            return ContactUpsertResult(
                handle: rendered.handle,
                clientId: rendered.clientId,
                identifier: mutable.identifier,
                action: .dryRun,
                before: beforeTriple,
                after: afterTriple
            )
        }

        let save = CNSaveRequest()
        if existing != nil {
            save.update(mutable)
        } else {
            save.add(mutable, toContainerWithIdentifier: nil)
        }
        do {
            try options.store.execute(save)
        } catch {
            throw WriterError.saveFailed(error)
        }

        return ContactUpsertResult(
            handle: rendered.handle,
            clientId: rendered.clientId,
            identifier: mutable.identifier,
            action: action,
            before: beforeTriple,
            after: afterTriple
        )
    }

    // MARK: - Mutation helpers

    private static func mutationFetchKeys() -> [CNKeyDescriptor] {
        [
            CNContactGivenNameKey as CNKeyDescriptor,
            CNContactFamilyNameKey as CNKeyDescriptor,
            CNContactPhoneNumbersKey as CNKeyDescriptor,
            CNContactEmailAddressesKey as CNKeyDescriptor,
            CNContactNoteKey as CNKeyDescriptor,
            CNContactIdentifierKey as CNKeyDescriptor,
        ]
    }

    private static func apply(
        rendered: RenderedContact,
        to mutable: CNMutableContact,
        preservedSurnameFrom existingFamilyName: String?
    ) {
        mutable.givenName = rendered.firstName

        let composed = ContactsWriterCore.composeFamilyName(
            emojis: rendered.lastNameEmojis,
            existingFamilyName: existingFamilyName ?? ""
        )
        mutable.familyName = composed

        mutable.note = ContactsWriterCore.mergeNote(
            existing: mutable.isKeyAvailable(CNContactNoteKey) ? mutable.note : "",
            managedBlock: rendered.notesBlock
        )
    }

    // MARK: - Lookup

    private static func findExisting(
        handle: String,
        channel: String,
        store: CNContactStore,
        keys: [CNKeyDescriptor]
    ) throws -> CNContact? {
        let predicate: NSPredicate
        if channel == "email" || handle.contains("@") {
            predicate = CNContact.predicateForContacts(matchingEmailAddress: handle)
        } else {
            // CNPhoneNumber performs its own normalization; passing the raw
            // E.164 string is sufficient for matching across stored formats.
            predicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: handle))
        }

        do {
            let matches = try store.unifiedContacts(matching: predicate, keysToFetch: keys)
            return matches.first
        } catch {
            throw WriterError.fetchFailed(error)
        }
    }

    private static func attachHandle(_ handle: String, channel: String, to mutable: CNMutableContact) {
        if channel == "email" || handle.contains("@") {
            let labeled = CNLabeledValue<NSString>(
                label: CNLabelHome,
                value: handle as NSString
            )
            mutable.emailAddresses = [labeled]
        } else {
            let labeled = CNLabeledValue<CNPhoneNumber>(
                label: CNLabelPhoneNumberMobile,
                value: CNPhoneNumber(stringValue: handle)
            )
            mutable.phoneNumbers = [labeled]
        }
    }
}

/// Serialized row in the dry-run diff JSONL. Separate type from
/// `ContactUpsertResult` because the diff file also needs renderedAt context.
private struct DiffEntry: Encodable {
    let handle: String
    let clientId: Int
    let action: ContactUpsertResult.Action
    let before: ContactFieldTriple?
    let after: ContactFieldTriple
    let renderedAt: String
}
