import Contacts
import Foundation
import OpenClawKit

final class ContactsService: ContactsServicing {
    private static var payloadKeys: [CNKeyDescriptor] {
        [
            CNContactIdentifierKey as CNKeyDescriptor,
            CNContactGivenNameKey as CNKeyDescriptor,
            CNContactFamilyNameKey as CNKeyDescriptor,
            CNContactOrganizationNameKey as CNKeyDescriptor,
            CNContactPhoneNumbersKey as CNKeyDescriptor,
            CNContactEmailAddressesKey as CNKeyDescriptor,
        ]
    }

    func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload {
        let store = try await Self.authorizedStore()

        let limit = max(1, min(params.limit ?? 25, 200))

        var contacts: [CNContact] = []
        if let query = params.query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
            let predicate = CNContact.predicateForContacts(matchingName: query)
            contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
        } else {
            let request = CNContactFetchRequest(keysToFetch: Self.payloadKeys)
            try store.enumerateContacts(with: request) { contact, stop in
                contacts.append(contact)
                if contacts.count >= limit {
                    stop.pointee = true
                }
            }
        }

        let sliced = Array(contacts.prefix(limit))
        let payload = sliced.map { Self.payload(from: $0) }

        return OpenClawContactsSearchPayload(contacts: payload)
    }

    func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload {
        let store = try await Self.authorizedStore()

        let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines)
        let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines)
        let organizationName = params.organizationName?.trimmingCharacters(in: .whitespacesAndNewlines)
        let displayName = params.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
        let phoneNumbers = Self.normalizeStrings(params.phoneNumbers)
        let emails = Self.normalizeStrings(params.emails, lowercased: true)

        let hasName = !(givenName ?? "").isEmpty || !(familyName ?? "").isEmpty || !(displayName ?? "").isEmpty
        let hasOrg = !(organizationName ?? "").isEmpty
        let hasDetails = !phoneNumbers.isEmpty || !emails.isEmpty
        guard hasName || hasOrg || hasDetails else {
            throw NSError(domain: "Contacts", code: 2, userInfo: [
                NSLocalizedDescriptionKey: "CONTACTS_INVALID: include a name, organization, phone, or email",
            ])
        }

        if !phoneNumbers.isEmpty || !emails.isEmpty {
            if let existing = try Self.findExistingContact(
                store: store,
                phoneNumbers: phoneNumbers,
                emails: emails)
            {
                return OpenClawContactsAddPayload(contact: Self.payload(from: existing))
            }
        }

        let contact = CNMutableContact()
        contact.givenName = givenName ?? ""
        contact.familyName = familyName ?? ""
        contact.organizationName = organizationName ?? ""
        if contact.givenName.isEmpty && contact.familyName.isEmpty, let displayName {
            contact.givenName = displayName
        }
        contact.phoneNumbers = phoneNumbers.map {
            CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0))
        }
        contact.emailAddresses = emails.map {
            CNLabeledValue(label: CNLabelHome, value: $0 as NSString)
        }

        let save = CNSaveRequest()
        save.add(contact, toContainerWithIdentifier: nil)
        try store.execute(save)

        let persisted: CNContact
        if !contact.identifier.isEmpty {
            persisted = try store.unifiedContact(
                withIdentifier: contact.identifier,
                keysToFetch: Self.payloadKeys)
        } else {
            persisted = contact
        }

        return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
    }

    private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool {
        switch status {
        case .authorized, .limited:
            return true
        case .notDetermined:
            // Don’t prompt during node.invoke; the caller should instruct the user to grant permission.
            // Prompts block the invoke and lead to timeouts in headless flows.
            return false
        case .restricted, .denied:
            return false
        @unknown default:
            return false
        }
    }

    private static func authorizedStore() async throws -> CNContactStore {
        let store = CNContactStore()
        let status = CNContactStore.authorizationStatus(for: .contacts)
        let authorized = await Self.ensureAuthorization(store: store, status: status)
        guard authorized else {
            throw NSError(domain: "Contacts", code: 1, userInfo: [
                NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
            ])
        }
        return store
    }

    private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
        (values ?? [])
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
            .map { lowercased ? $0.lowercased() : $0 }
    }

    private static func findExistingContact(
        store: CNContactStore,
        phoneNumbers: [String],
        emails: [String]) throws -> CNContact?
    {
        if phoneNumbers.isEmpty && emails.isEmpty {
            return nil
        }

        var matches: [CNContact] = []

        for phone in phoneNumbers {
            let predicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: phone))
            let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
            matches.append(contentsOf: contacts)
        }

        for email in emails {
            let predicate = CNContact.predicateForContacts(matchingEmailAddress: email)
            let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
            matches.append(contentsOf: contacts)
        }

        return Self.matchContacts(contacts: matches, phoneNumbers: phoneNumbers, emails: emails)
    }

    private static func matchContacts(
        contacts: [CNContact],
        phoneNumbers: [String],
        emails: [String]) -> CNContact?
    {
        let normalizedPhones = Set(phoneNumbers.map { normalizePhone($0) }.filter { !$0.isEmpty })
        let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty })
        var seen = Set<String>()

        for contact in contacts {
            guard seen.insert(contact.identifier).inserted else { continue }
            let contactPhones = Set(contact.phoneNumbers.map { normalizePhone($0.value.stringValue) })
            let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() })

            if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
                return contact
            }
            if !normalizedEmails.isEmpty, !contactEmails.isDisjoint(with: normalizedEmails) {
                return contact
            }
        }

        return nil
    }

    private static func normalizePhone(_ phone: String) -> String {
        let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
        let digits = trimmed.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) }
        let normalized = String(String.UnicodeScalarView(digits))
        return normalized.isEmpty ? trimmed : normalized
    }

    private static func payload(from contact: CNContact) -> OpenClawContactPayload {
        OpenClawContactPayload(
            identifier: contact.identifier,
            displayName: CNContactFormatter.string(from: contact, style: .fullName)
                ?? "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespacesAndNewlines),
            givenName: contact.givenName,
            familyName: contact.familyName,
            organizationName: contact.organizationName,
            phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue },
            emails: contact.emailAddresses.map { String($0.value) })
    }

#if DEBUG
    static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
        matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
    }
#endif
}
