import Contacts
import Foundation

/// Polls `GET /m/contact-render` on quinn.api every 60s and mutates
/// CNContactStore via `ContactsWriter` once a vCard safety snapshot is on disk.
///
/// Write gates (env-var controlled, no code default = dry-run always):
///   - `MAC_SYNC_CONTACTS_WRITE=1`            — leaves dry-run mode.
///   - `MAC_SYNC_CONTACTS_WRITE_UNLIMITED=1`  — lifts the 10-contact canary cap.
///
/// On the first poll of a process lifetime the full-store vCard dump runs. If
/// the dump fails, the poller refuses to proceed for this session.
@MainActor
final class ContactRenderPoller {
    static let shared = ContactRenderPoller()
    private init() {}

    private let pollInterval: TimeInterval = 60
    private let canaryCap = 10

    private var timer: Timer?
    private var sessionPaths: ContactsBackup.Paths?
    private var canaryCountThisSession = 0
    private var initialBackupAttempted = false

    // MARK: - Observable state (read by MacSyncApp.updateMenuStatus on 5s timer)
    var lastSyncCompletedAt: Date?
    var recentWriteCount: Int = 0
    var lastError: String = ""

    func start() {
        guard timer == nil else { return }
        NSLog("ContactRenderPoller: start interval=\(pollInterval)s")
        let t = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
            Task { @MainActor [weak self] in await self?.tick() }
        }
        timer = t
        // Fire once immediately so the first dump + pull runs without the initial 60s wait.
        Task { @MainActor [weak self] in await self?.tick() }
    }

    func stop() {
        timer?.invalidate()
        timer = nil
    }

    // MARK: - Tick

    private func tick() async {
        do {
            try ensureSession()
        } catch {
            NSLog("ContactRenderPoller: session prepare failed: \(error) — aborting tick")
            return
        }

        guard let paths = sessionPaths else { return }

        let dryRun = !(ProcessInfo.processInfo.environment["MAC_SYNC_CONTACTS_WRITE"] == "1")
        let unlimited = ProcessInfo.processInfo.environment["MAC_SYNC_CONTACTS_WRITE_UNLIMITED"] == "1"

        let response: RenderedContactList
        do {
            response = try await ContactRenderHTTP.fetchDirty()
            lastError = ""
        } catch {
            NSLog("ContactRenderPoller: fetch failed: \(error)")
            lastError = error.localizedDescription
            return
        }

        // UI-requested backup runs BEFORE any writes this cycle so the snapshot
        // reflects the last known-good state. Failure to dump cancels writes.
        if response.backupRequested {
            do {
                let snapshot = try ContactsBackup.prepareSession()
                let count = try ContactsBackup.dumpFullVCard(to: snapshot.fullVCard)
                NSLog("ContactRenderPoller: on-demand backup wrote \(count) contacts → \(snapshot.fullVCard.lastPathComponent)")
                sessionPaths = snapshot
            } catch {
                NSLog("ContactRenderPoller: on-demand backup failed: \(error) — skipping writes this cycle")
                return
            }
            do {
                try await ContactRenderHTTP.backupComplete()
                NSLog("ContactRenderPoller: cleared backup_requested flag")
            } catch {
                NSLog("ContactRenderPoller: backup-complete ack failed: \(error)")
            }
        }

        let rendered = response.items
        if rendered.isEmpty {
            NSLog("ContactRenderPoller: no dirty rows")
            return
        }
        NSLog("ContactRenderPoller: processing \(rendered.count) rows dryRun=\(dryRun) unlimited=\(unlimited) canary=\(canaryCountThisSession)/\(canaryCap)")

        let options = ContactsWriter.Options(
            dryRun: dryRun,
            rollbackURL: paths.rollbackJsonl,
            diffURL: paths.diffJsonl
        )

        var acks: [ContactRenderAckItem] = []
        for row in rendered {
            if !dryRun && !unlimited && canaryCountThisSession >= canaryCap {
                NSLog("ContactRenderPoller: canary cap reached — stopping writes for this session")
                break
            }
            do {
                let result = try ContactsWriter.upsert(row, options: options)
                if !dryRun { canaryCountThisSession += 1 }
                acks.append(ContactRenderAckItem(
                    clientId: result.clientId,
                    iCloudIdentifier: result.identifier,
                    writeState: result.writeState
                ))
                NSLog("ContactRenderPoller: \(result.action.rawValue) handle=\(row.handle) id=\(result.identifier)")
            } catch {
                NSLog("ContactRenderPoller: upsert failed handle=\(row.handle) error=\(error)")
            }
        }

        // Only ack successful real writes. Dry-run results don't link an iCloud
        // identifier — the server still needs to see them as unacked so the
        // follow-up real run picks them up.
        let realAcks = acks.filter { _ in !dryRun }
        recentWriteCount = realAcks.count
        lastSyncCompletedAt = Date()
        guard !realAcks.isEmpty else { return }
        do {
            try await ContactRenderHTTP.ack(items: realAcks)
            NSLog("ContactRenderPoller: acked \(realAcks.count) writes")
        } catch {
            NSLog("ContactRenderPoller: ack failed error=\(error)")
        }
    }

    // MARK: - Session + backup gate

    private func ensureSession() throws {
        if sessionPaths != nil { return }

        // Contacts access must be granted up-front. If it isn't, let the user
        // know via NSLog and abort; the Permissions menu covers the grant flow.
        guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else {
            throw ContactsBackup.BackupError.noContactsAccess
        }

        let paths = try ContactsBackup.prepareSession()
        let count = try ContactsBackup.dumpFullVCard(to: paths.fullVCard)
        NSLog("ContactRenderPoller: backed up \(count) contacts → \(paths.fullVCard.lastPathComponent)")
        sessionPaths = paths
        initialBackupAttempted = true

        // On first-ever session, seed human names from CNContactStore to the
        // server so display_name is populated before any renders emit placeholders.
        if !UserDefaults.standard.bool(forKey: "contactNamesSeeded") {
            Task { @MainActor [weak self] in
                await self?.seedNamesIfNeeded()
            }
        }
    }

    private func seedNamesIfNeeded() async {
        let store = CNContactStore()
        let keys: [CNKeyDescriptor] = [
            CNContactGivenNameKey as CNKeyDescriptor,
            CNContactPhoneNumbersKey as CNKeyDescriptor,
            CNContactEmailAddressesKey as CNKeyDescriptor,
        ]
        let request = CNContactFetchRequest(keysToFetch: keys)
        request.unifyResults = true

        var items: [SeedNameEntry] = []
        do {
            try store.enumerateContacts(with: request) { contact, _ in
                let given = contact.givenName.trimmingCharacters(in: .whitespaces)
                guard !given.isEmpty else { return }
                // Skip contacts already in our [AIRPORT] format.
                if given.hasPrefix("[") { return }

                for phone in contact.phoneNumbers {
                    let raw = phone.value.stringValue.filter { "+0123456789".contains($0) }
                    if raw.isEmpty { continue }
                    items.append(SeedNameEntry(handle: raw, channel: "imessage", displayName: given))
                }
                for email in contact.emailAddresses {
                    let raw = (email.value as String).trimmingCharacters(in: .whitespaces)
                    if raw.isEmpty { continue }
                    items.append(SeedNameEntry(handle: raw, channel: "email", displayName: given))
                }
            }
        } catch {
            NSLog("ContactRenderPoller: seed-names enumeration failed: \(error) — will retry next session")
            return
        }

        NSLog("ContactRenderPoller: seed-names collected \(items.count) handle→name pairs")
        guard !items.isEmpty else {
            UserDefaults.standard.set(true, forKey: "contactNamesSeeded")
            return
        }

        do {
            try await ContactRenderHTTP.seedNames(items: items)
            UserDefaults.standard.set(true, forKey: "contactNamesSeeded")
            NSLog("ContactRenderPoller: seed-names seeded \(items.count) entries — gate closed")
        } catch {
            NSLog("ContactRenderPoller: seed-names POST failed: \(error) — will retry next session")
            // Leave contactNamesSeeded false so the next session retries.
        }
    }
}

// MARK: - Seed-names models

struct SeedNameEntry: Codable, Sendable {
    let handle: String
    let channel: String
    let displayName: String
}

struct SeedNameBody: Codable, Sendable {
    let items: [SeedNameEntry]
}

// MARK: - HTTP

enum ContactRenderHTTPError: Error {
    case missingConfig(String)
    case transport(Error)
    case httpStatus(Int, String)
    case decode(Error)
}

/// Thin HTTP client dedicated to quinn.api `/m/contact-render*` routes.
/// Reads base URL + service token from UserDefaults (merged from config.json).
enum ContactRenderHTTP {
    static func fetchDirty(limit: Int = 200) async throws -> RenderedContactList {
        let (url, token) = try resolveBase()
        let endpoint = url.appendingPathComponent("m/contact-render")
        var comps = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
        comps.queryItems = [URLQueryItem(name: "limit", value: String(limit))]

        var request = URLRequest(url: comps.url!)
        request.httpMethod = "GET"
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        let (data, response) = try await URLSession.shared.data(for: request)
        try ensureOK(response, data: data)

        do {
            return try JSONDecoder().decode(RenderedContactList.self, from: data)
        } catch {
            throw ContactRenderHTTPError.decode(error)
        }
    }

    static func backupComplete() async throws {
        let (url, token) = try resolveBase()
        let endpoint = url.appendingPathComponent("m/contact-render/backup-complete")
        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let (data, response) = try await URLSession.shared.data(for: request)
        try ensureOK(response, data: data)
    }

    static func seedNames(items: [SeedNameEntry]) async throws {
        let (url, token) = try resolveBase()
        let endpoint = url.appendingPathComponent("m/contact-render/seed-names")

        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        let body = SeedNameBody(items: items)
        request.httpBody = try JSONEncoder().encode(body)

        let (data, response) = try await URLSession.shared.data(for: request)
        try ensureOK(response, data: data)
    }

    static func ack(items: [ContactRenderAckItem]) async throws {
        let (url, token) = try resolveBase()
        let endpoint = url.appendingPathComponent("m/contact-render/ack")

        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        let body = ContactRenderAckBody(items: items)
        request.httpBody = try JSONEncoder().encode(body)

        let (data, response) = try await URLSession.shared.data(for: request)
        try ensureOK(response, data: data)
    }

    private static func resolveBase() throws -> (URL, String) {
        let defaults = UserDefaults.standard
        guard let urlString = defaults.string(forKey: "quinnApiURL"),
              let url = URL(string: urlString) else {
            throw ContactRenderHTTPError.missingConfig("quinnApiURL not set in config.json")
        }
        guard let token = defaults.string(forKey: "quinnApiServiceToken"), !token.isEmpty else {
            throw ContactRenderHTTPError.missingConfig("quinnApiServiceToken not set in config.json")
        }
        return (url, token)
    }

    private static func ensureOK(_ response: URLResponse, data: Data) throws {
        guard let http = response as? HTTPURLResponse else {
            throw ContactRenderHTTPError.httpStatus(0, "not an http response")
        }
        guard (200..<300).contains(http.statusCode) else {
            let body = String(data: data, encoding: .utf8) ?? "<binary>"
            throw ContactRenderHTTPError.httpStatus(http.statusCode, body)
        }
    }
}
