import Contacts
import Foundation

/// Safety layer that runs before any write to CNContactStore.
///
/// - `dumpFullVCard()` enumerates every contact the user has granted access to
///   and serializes the store to a single .vcf file under Application Support.
///   If this fails, callers MUST abort — no writes proceed without a restorable
///   snapshot on disk.
///
/// - `appendRollbackEntry(before:)` captures per-contact pre-state as a JSONL
///   line so a single modified contact can be restored without re-importing the
///   full backup. The rollback log is append-only for the lifetime of a session.
enum ContactsBackup {
    private static let subdirectory = "contact-backups"
    private static let maxFullBackups = 30

    struct Paths {
        let fullVCard: URL
        let rollbackJsonl: URL
        let diffJsonl: URL
        let sessionId: String
    }

    enum BackupError: Error {
        case noContactsAccess
        case directoryCreation(Error)
        case fetchFailed(Error)
        case serializationFailed(Error)
        case writeFailed(Error)
    }

    /// Resolve + create the session directory for backups. Returns paths for the
    /// three session files (full vCard, rollback JSONL, diff JSONL).
    static func prepareSession(now: Date = Date()) throws -> Paths {
        let fm = FileManager.default
        let appSupport = try fm.url(
            for: .applicationSupportDirectory,
            in: .userDomainMask,
            appropriateFor: nil,
            create: true
        )
        let root = appSupport
            .appendingPathComponent("com.lilith.mac-sync", isDirectory: true)
            .appendingPathComponent(subdirectory, isDirectory: true)
        do {
            try fm.createDirectory(at: root, withIntermediateDirectories: true)
        } catch {
            throw BackupError.directoryCreation(error)
        }

        let stamp = sessionStamp(for: now)
        return Paths(
            fullVCard: root.appendingPathComponent("full-\(stamp).vcf"),
            rollbackJsonl: root.appendingPathComponent("rollback-\(stamp).jsonl"),
            diffJsonl: root.appendingPathComponent("diff-\(stamp).jsonl"),
            sessionId: stamp
        )
    }

    /// Serialize the entire CNContactStore to a single .vcf file. The file is
    /// a native vCard 3.0 archive; the restore CLI re-imports it back.
    @discardableResult
    static func dumpFullVCard(to url: URL, store: CNContactStore = CNContactStore()) throws -> Int {
        guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else {
            throw BackupError.noContactsAccess
        }

        let keys: [CNKeyDescriptor] = [CNContactVCardSerialization.descriptorForRequiredKeys()]
        let request = CNContactFetchRequest(keysToFetch: keys)
        request.unifyResults = true

        var contacts: [CNContact] = []
        do {
            try store.enumerateContacts(with: request) { contact, _ in
                contacts.append(contact)
            }
        } catch {
            throw BackupError.fetchFailed(error)
        }

        let data: Data
        do {
            data = try CNContactVCardSerialization.data(with: contacts)
        } catch {
            throw BackupError.serializationFailed(error)
        }

        do {
            try data.write(to: url, options: .atomic)
        } catch {
            throw BackupError.writeFailed(error)
        }

        pruneOldBackups(in: url.deletingLastPathComponent())
        return contacts.count
    }

    /// Capture the mutable state of a contact to the rollback log before we
    /// overwrite it. Lines are standalone JSON so the restore tool can process
    /// the log line-by-line without holding the whole session in memory.
    static func appendRollbackEntry(_ snapshot: ContactSnapshot, to url: URL) throws {
        let encoder = JSONEncoder()
        encoder.outputFormatting = [.sortedKeys]
        encoder.dateEncodingStrategy = .iso8601
        let data = try encoder.encode(snapshot)
        var line = data
        line.append(0x0a) // newline
        try appendAtomically(line, to: url)
    }

    /// Record a dry-run diff entry. Caller builds the before/after snapshot;
    /// this just persists it. In dry-run mode we never hit CNContactStore, so
    /// the diff file is the authoritative record of "what would change."
    static func appendDiffEntry<T: Encodable>(_ entry: T, to url: URL) throws {
        let encoder = JSONEncoder()
        encoder.outputFormatting = [.sortedKeys]
        let data = try encoder.encode(entry)
        var line = data
        line.append(0x0a)
        try appendAtomically(line, to: url)
    }

    // MARK: - Internals

    private static func appendAtomically(_ data: Data, to url: URL) throws {
        let fm = FileManager.default
        if !fm.fileExists(atPath: url.path) {
            try data.write(to: url, options: .atomic)
            return
        }
        let handle = try FileHandle(forWritingTo: url)
        defer { try? handle.close() }
        try handle.seekToEnd()
        try handle.write(contentsOf: data)
    }

    private static func pruneOldBackups(in directory: URL) {
        let fm = FileManager.default
        guard
            let entries = try? fm.contentsOfDirectory(
                at: directory,
                includingPropertiesForKeys: [.creationDateKey],
                options: [.skipsHiddenFiles]
            )
        else { return }

        let fullBackups = entries
            .filter { $0.lastPathComponent.hasPrefix("full-") && $0.pathExtension == "vcf" }
            .sorted { lhs, rhs in
                (lhs.creationDate ?? .distantPast) > (rhs.creationDate ?? .distantPast)
            }

        for old in fullBackups.dropFirst(maxFullBackups) {
            try? fm.removeItem(at: old)
        }
    }

    private static func sessionStamp(for date: Date) -> String {
        let fmt = DateFormatter()
        fmt.dateFormat = "yyyyMMdd-HHmmss"
        fmt.locale = Locale(identifier: "en_US_POSIX")
        fmt.timeZone = TimeZone(identifier: "UTC")
        return fmt.string(from: date)
    }
}

/// Per-contact pre-state captured into the rollback JSONL. Shape matches what
/// `scripts/restore-contacts.swift` expects when rehydrating a single row.
struct ContactSnapshot: Codable {
    let identifier: String
    let givenName: String
    let familyName: String
    let phoneNumbers: [String]
    let emailAddresses: [String]
    let note: String
    let capturedAt: Date

    init(contact: CNContact, capturedAt: Date = Date()) {
        self.identifier = contact.identifier
        self.givenName = contact.givenName
        self.familyName = contact.familyName
        self.phoneNumbers = contact.phoneNumbers.map { $0.value.stringValue }
        self.emailAddresses = contact.emailAddresses.map { ($0.value as String) }
        self.note = (contact.isKeyAvailable(CNContactNoteKey) ? contact.note : "")
        self.capturedAt = capturedAt
    }
}

private extension URL {
    var creationDate: Date? {
        (try? resourceValues(forKeys: [.creationDateKey]))?.creationDate
    }
}
