import Contacts
import Darwin
import Foundation
import GRDB
import LilithLogging
import MacSyncShared

private let log = AppLogger.logger(for: "ICalls.Reader")

public struct CallRecord: Sendable {
    public let uniqueId: String
    public let address: String?
    public let normalizedAddress: String?
    public let contactName: String?
    public let direction: String // "incoming" | "outgoing"
    public let callType: String // "telephony" | "facetime_video" | "facetime_audio" | "unknown"
    public let answered: Bool
    public let durationSeconds: Double
    public let startedAt: String // ISO8601
    public let serviceProvider: String?

    public init(
        uniqueId: String,
        address: String?,
        normalizedAddress: String?,
        contactName: String?,
        direction: String,
        callType: String,
        answered: Bool,
        durationSeconds: Double,
        startedAt: String,
        serviceProvider: String?
    ) {
        self.uniqueId = uniqueId
        self.address = address
        self.normalizedAddress = normalizedAddress
        self.contactName = contactName
        self.direction = direction
        self.callType = callType
        self.answered = answered
        self.durationSeconds = durationSeconds
        self.startedAt = startedAt
        self.serviceProvider = serviceProvider
    }
}

/// Reads call history from the system CallHistory.storedata (CoreData SQLite).
///
/// Supports both historical and current filesystem locations.
/// Uses the same readonly GRDB approach as IMessageSync for chat.db.
/// Timestamps are Apple reference date (seconds since 2001-01-01).
/// Incremental via caller-provided `since` Date (compares against ZDATE).
/// Enriches with CNContact display names when ZNAME is absent (best-effort).
public final class CallHistoryReader: @unchecked Sendable {
    public static let shared = CallHistoryReader()

    private let contactStore = CNContactStore()
    private var contactCache: [String: String] = [:] // normalized phone or identifier -> display name

    private let possiblePaths: [String]

    private init() {
        let home = FileManager.default.homeDirectoryForCurrentUser
        possiblePaths = [
            home.appendingPathComponent("Library/Application Support/CallHistoryDB/CallHistory.storedata").path,
            home.appendingPathComponent("Library/CallHistoryDB/CallHistory.storedata").path,
        ]
    }

    public var isAccessible: Bool {
        for p in possiblePaths {
            if FileManager.default.fileExists(atPath: p) && Darwin.access(p, R_OK) == 0 {
                return true
            }
        }
        return false
    }

    private func loadContacts() {
        do {
            let status = CNContactStore.authorizationStatus(for: .contacts)
            if status == .notDetermined {
                // Fire and forget; we are best-effort for names only.
                Task {
                    _ = try? await contactStore.requestAccess(for: .contacts)
                }
                return
            }
            guard status == .authorized else { return }

            let keysToFetch: [CNKeyDescriptor] = [
                CNContactIdentifierKey as CNKeyDescriptor,
                CNContactGivenNameKey as CNKeyDescriptor,
                CNContactFamilyNameKey as CNKeyDescriptor,
                CNContactPhoneNumbersKey as CNKeyDescriptor,
                CNContactEmailAddressesKey as CNKeyDescriptor,
            ]

            let request = CNContactFetchRequest(keysToFetch: keysToFetch)
            try contactStore.enumerateContacts(with: request) { contact, _ in
                let fullName = [contact.givenName, contact.familyName]
                    .filter { !$0.isEmpty }
                    .joined(separator: " ")
                guard !fullName.isEmpty else { return }

                let phones = contact.phoneNumbers.map { PhoneUtils.normalize($0.value.stringValue) }
                let emails = contact.emailAddresses.map { ($0.value as String).lowercased() }

                for phone in phones { self.contactCache[phone] = fullName }
                for email in emails { self.contactCache[email] = fullName }
                self.contactCache[contact.identifier] = fullName
            }
            log.info("Loaded \(self.contactCache.count) contact entries for call enrichment")
        } catch {
            log.warning("Failed to load contacts for call name enrichment: \(error)")
        }
    }

    private func lookupContactName(for address: String) -> String? {
        let norm = PhoneUtils.normalize(address)
        if let name = contactCache[norm] { return name }
        if let name = contactCache[address.lowercased()] { return name }
        return nil
    }

    private func resolvedPath() -> String? {
        for p in possiblePaths where FileManager.default.fileExists(atPath: p) {
            return p
        }
        return nil
    }

    /// Fetch calls with ZDATE strictly after `since` (if provided).
    /// Returns oldest-first so server sees them in order.
    ///
    /// Reads from a **snapshot** of the live DB (the `.storedata` plus its
    /// `-wal`/`-shm` sidecars copied into a temp dir) rather than the live file.
    /// CallHistory commits recent calls to the WAL and checkpoints lazily, so a
    /// readonly connection to the live file routinely misses the newest rows —
    /// exactly the calls triage cares about. Copying the WAL alongside the main
    /// db and opening the copy read-write replays those frames, and never
    /// contends with the system writer's lock.
    public func fetchCalls(since: Date?) -> [CallRecord] {
        guard let livePath = resolvedPath() else {
            log.warning("CallHistory DB not found")
            return []
        }
        if contactCache.isEmpty {
            loadContacts()
        }
        return readCalls(dbPath: livePath, since: since)
    }

    /// Snapshot `dbPath`, open the copy, and parse matching rows. Internal so
    /// tests can drive it against a fixture DB (incl. unflushed WAL rows).
    func readCalls(dbPath: String, since: Date?) -> [CallRecord] {
        guard let snapshot = makeSnapshot(of: dbPath) else {
            log.warning("CallHistory snapshot failed")
            return []
        }
        defer { try? FileManager.default.removeItem(at: snapshot.dir) }

        do {
            // Read-write (not readonly) on the private copy so SQLite rebuilds
            // the shared-memory index and replays the copied WAL frames.
            let queue = try DatabaseQueue(path: snapshot.dbPath)
            return try queue.read { db in
                var sql = """
                    SELECT
                        Z_PK,
                        ZUNIQUE_ID,
                        ZADDRESS,
                        ZNAME,
                        ZORIGINATED,
                        ZANSWERED,
                        ZDURATION,
                        ZDATE,
                        ZCALLTYPE,
                        ZSERVICE_PROVIDER
                    FROM ZCALLRECORD
                    """
                var arguments: [DatabaseValueConvertible] = []
                if let since = since {
                    sql += " WHERE ZDATE > ?"
                    arguments.append(since.timeIntervalSinceReferenceDate)
                }
                sql += " ORDER BY ZDATE ASC"

                let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
                return rows.map { Self.parse(row: $0, contactLookup: self.lookupContactName) }
            }
        } catch {
            log.warning("readCalls query failed: \(error)")
            return []
        }
    }

    /// Copy the CoreData store and its `-wal`/`-shm` sidecars into a fresh temp
    /// dir. Returns the snapshot dir (caller deletes it) and the copied db path.
    private func makeSnapshot(of dbPath: String) -> (dir: URL, dbPath: String)? {
        let fm = FileManager.default
        let dir = fm.temporaryDirectory.appendingPathComponent("icalls-snap-\(UUID().uuidString)", isDirectory: true)
        do {
            try fm.createDirectory(at: dir, withIntermediateDirectories: true)
            let base = (dbPath as NSString).lastPathComponent
            for suffix in ["", "-wal", "-shm"] {
                let src = dbPath + suffix
                guard fm.fileExists(atPath: src) else { continue }
                try fm.copyItem(atPath: src, toPath: dir.appendingPathComponent(base + suffix).path)
            }
            return (dir, dir.appendingPathComponent(base).path)
        } catch {
            log.warning("makeSnapshot failed: \(error)")
            try? fm.removeItem(at: dir)
            return nil
        }
    }

    /// Map a `ZCALLRECORD` row to a `CallRecord`. Pure (modulo the contact
    /// lookup closure) so the parsing/classification matrix is unit-testable.
    static func parse(row: Row, contactLookup: (String) -> String?) -> CallRecord {
        // Use GRDB's typed subscript (not `as?`): SQLite integers come back as
        // Int64, so `as? Int` silently fails and every call would read as an
        // unanswered incoming. GRDB converts the stored type to the annotation.
        let zpk: Int64 = row["Z_PK"] ?? 0
        let zunique: String = row["ZUNIQUE_ID"] ?? ""
        let uniqueId = zunique.isEmpty ? "zpk:\(zpk)" : zunique

        let rawAddress: String? = row["ZADDRESS"]
        let rawName: String? = row["ZNAME"]
        let nameFromDb = (rawName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? rawName : nil
        let resolvedName = nameFromDb ?? rawAddress.flatMap { contactLookup($0) }

        let originated: Int = row["ZORIGINATED"] ?? 0
        let direction = originated == 1 ? "outgoing" : "incoming"

        let answeredRaw: Int = row["ZANSWERED"] ?? 0
        let answered = answeredRaw != 0
        let duration: Double = row["ZDURATION"] ?? 0.0

        let zdate: Double = row["ZDATE"] ?? 0.0
        let startedAt = ISO8601DateFormatter().string(from: Date(timeIntervalSinceReferenceDate: zdate))

        let ctypeRaw: Int = row["ZCALLTYPE"] ?? 0
        let svc: String? = row["ZSERVICE_PROVIDER"]

        let normAddr = rawAddress.map { PhoneUtils.normalize($0) }

        return CallRecord(
            uniqueId: uniqueId,
            address: rawAddress,
            normalizedAddress: normAddr,
            contactName: resolvedName,
            direction: direction,
            callType: classifyCallType(serviceProvider: svc, callTypeRaw: ctypeRaw),
            answered: answered,
            durationSeconds: duration,
            startedAt: startedAt,
            serviceProvider: svc
        )
    }

    /// Classify a call from its service provider + `ZCALLTYPE`.
    /// FaceTime audio is `ZCALLTYPE == 16`; FaceTime video is everything else
    /// under the FaceTime provider. Cellular maps to `telephony`.
    static func classifyCallType(serviceProvider: String?, callTypeRaw: Int) -> String {
        let svcLower = serviceProvider?.lowercased() ?? ""
        if svcLower.contains("facetime") {
            return callTypeRaw == 16 ? "facetime_audio" : "facetime_video"
        }
        if callTypeRaw == 1 || svcLower.contains("telephony") {
            return "telephony"
        }
        return "unknown"
    }
}
