//
//  ContactsService.swift
//  Confab
//
//  Read-only Contacts integration: search and "my card" lookup
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.


import Contacts
import Foundation
import os

@Observable
class ContactsService {
    static let shared = ContactsService()

    private let store = CNContactStore()
    private let maxResults = 20
    private let maxScan = 5000

    private init() {}

    // MARK: - Authorization

    var authStatus: CNAuthorizationStatus {
        CNContactStore.authorizationStatus(for: .contacts)
    }

    var authorized: Bool {
        authStatus == .authorized
    }

    var accessState: PersonalDataAccessState {
        let status = authStatus
        Log.mcp.debug("ContactsService.accessState -> status=\(Self.describe(status)) (raw=\(status.rawValue))")
        switch status {
        case .authorized: return .granted
        case .notDetermined: return .notDetermined
        default: return .denied
        }
    }

    @discardableResult
    func requestAccess() async -> Bool {
        let before = CNContactStore.authorizationStatus(for: .contacts)
        Log.mcp.info("ContactsService.requestAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
        return await withCheckedContinuation { continuation in
            store.requestAccess(for: .contacts) { granted, error in
                let after = CNContactStore.authorizationStatus(for: .contacts)
                if let error {
                    let nsError = error as NSError
                    Log.mcp.error("ContactsService.requestAccess: error=\(error.localizedDescription) domain=\(nsError.domain) code=\(nsError.code) userInfo=\(nsError.userInfo); granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
                } else {
                    Log.mcp.info("ContactsService.requestAccess: granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
                }
                continuation.resume(returning: granted)
            }
        }
    }

    nonisolated static func describe(_ status: CNAuthorizationStatus) -> String {
        switch status {
        case .notDetermined: return "notDetermined"
        case .restricted: return "restricted"
        case .denied: return "denied"
        case .authorized: return "authorized"
        case .limited: return "limited"
        @unknown default: return "unknown"
        }
    }

    // MARK: - Tool Schemas

    func getToolSchemas() -> [Tool] {
        [
            makeTool(
                name: "contacts_search",
                description: "Search Contacts by name, phone number, or email address. Returns matching contacts with their phone numbers and emails. This does NOT match relationship labels like 'mother' or 'spouse' — for those, call contacts_get_me first to find the related person's name, then search for that name.",
                properties: [
                    "query": prop("string", "Name, phone number, or email fragment to search for")
                ],
                required: ["query"]
            ),
            makeTool(
                name: "contacts_get_me",
                description: "Get the user's own contact card (\"My Card\" in Contacts.app), if configured. Includes any defined relationships (e.g. mother, spouse, child) with the related person's name — use contacts_search with that name to find their phone/email.",
                properties: [:],
                required: []
            )
        ]
    }

    // MARK: - Tool Execution

    func executeTool(name: String, arguments: String) async -> [String: Any] {
        Log.mcp.info("Executing Contacts tool: \(name)")
        guard authorized else {
            return ["error": "Contacts permission not granted. Grant access in Settings > MCP."]
        }

        switch name {
        case "contacts_search":
            guard let data = arguments.data(using: .utf8),
                  let args = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                  let query = args["query"] as? String, !query.isEmpty else {
                return ["error": "Missing required parameter: query"]
            }
            return search(query: query)

        case "contacts_get_me":
            return getMe()

        default:
            return ["error": "Unknown Contacts tool: \(name)"]
        }
    }

    // MARK: - Implementation

    private let keysToFetch: [CNKeyDescriptor] = [
        CNContactGivenNameKey as CNKeyDescriptor,
        CNContactFamilyNameKey as CNKeyDescriptor,
        CNContactOrganizationNameKey as CNKeyDescriptor,
        CNContactPhoneNumbersKey as CNKeyDescriptor,
        CNContactEmailAddressesKey as CNKeyDescriptor,
        CNContactRelationsKey as CNKeyDescriptor
    ]

    private func search(query: String) -> [String: Any] {
        var matches: [CNContact] = []

        // Fast path: name predicate
        let namePredicate = CNContact.predicateForContacts(matchingName: query)
        if let nameMatches = try? store.unifiedContacts(matching: namePredicate, keysToFetch: keysToFetch) {
            matches.append(contentsOf: nameMatches)
        }

        // Fallback: scan for phone/email substring matches
        if matches.isEmpty {
            let lowerQuery = query.lowercased()
            let digitsQuery = query.filter(\.isNumber)
            let request = CNContactFetchRequest(keysToFetch: keysToFetch)
            var scanned = 0
            try? store.enumerateContacts(with: request) { contact, stop in
                scanned += 1
                if scanned > self.maxScan || matches.count >= self.maxResults {
                    stop.pointee = true
                    return
                }
                let emailMatch = contact.emailAddresses.contains {
                    ($0.value as String).lowercased().contains(lowerQuery)
                }
                let phoneMatch = !digitsQuery.isEmpty && contact.phoneNumbers.contains {
                    $0.value.stringValue.filter(\.isNumber).contains(digitsQuery)
                }
                if emailMatch || phoneMatch {
                    matches.append(contact)
                }
            }
        }

        let deduped = dedupContacts(matches)
        let formatted = deduped.prefix(maxResults).map(formatContact)
        return ["count": formatted.count, "contacts": Array(formatted)]
    }

    /// Collapses contacts that share a phone number or email — Contacts.app's "linked contacts"
    /// merge doesn't catch every real-world duplicate card, so do a best-effort merge here too.
    private func dedupContacts(_ contacts: [CNContact]) -> [CNContact] {
        var result: [CNContact] = []
        outer: for contact in contacts {
            let phones = Set(contact.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) })
            let emails = Set(contact.emailAddresses.map { ($0.value as String).lowercased() })
            for existing in result {
                let existingPhones = Set(existing.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) })
                let existingEmails = Set(existing.emailAddresses.map { ($0.value as String).lowercased() })
                if !phones.isDisjoint(with: existingPhones) || !emails.isDisjoint(with: existingEmails) {
                    continue outer
                }
            }
            result.append(contact)
        }
        return result
    }

    private func getMe() -> [String: Any] {
        guard let me = try? store.unifiedMeContactWithKeys(toFetch: keysToFetch) else {
            return ["error": "No 'My Card' is configured in Contacts.app"]
        }
        return formatContact(me)
    }

    private func formatContact(_ contact: CNContact) -> [String: Any] {
        var item: [String: Any] = [
            "given_name": contact.givenName,
            "family_name": contact.familyName
        ]
        if !contact.organizationName.isEmpty {
            item["organization"] = contact.organizationName
        }
        if !contact.phoneNumbers.isEmpty {
            item["phones"] = contact.phoneNumbers.map { $0.value.stringValue }
        }
        if !contact.emailAddresses.isEmpty {
            item["emails"] = contact.emailAddresses.map { $0.value as String }
        }
        if !contact.contactRelations.isEmpty {
            item["relations"] = contact.contactRelations.map { labeled -> [String: String] in
                let label = labeled.label.map { CNLabeledValue<CNContactRelation>.localizedString(forLabel: $0) } ?? "relation"
                return ["label": label, "name": labeled.value.name]
            }
        }
        return item
    }

    private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
        Tool(
            type: "function",
            function: Tool.Function(
                name: name,
                description: description,
                parameters: Tool.Function.Parameters(
                    type: "object",
                    properties: properties,
                    required: required
                )
            )
        )
    }

    private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
        Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
    }
}
