import CoreLocation
import Foundation
import GRDB
import Photos

// MARK: - Models

/// Metadata for a single photo asset from the Photos library.
public struct PhotoAsset: Sendable {
    public let localIdentifier: String
    public let rawKind: Int          // 0=image/photo, 1=video, 2=audio
    public let rawSubtype: Int       // bitmask: 4=screenshot, 8=live, 32=depth, 64k=slow-mo
    public let width: Int
    public let height: Int
    public let fileSize: Int?
    public let creationDate: Date?
    public let modificationDate: Date?
    public let latitude: Double?
    public let longitude: Double?
    public let isFavorite: Bool
    public let isHidden: Bool
    public let duration: TimeInterval
    public let burstIdentifier: String?
    public let originalFilename: String?

    public var mediaTypeString: String {
        switch rawKind {
        case 1: return "video"
        case 2: return "audio"
        default: return rawSubtype & 8 != 0 ? "live_photo" : "image"
        }
    }

    public var isScreenshot: Bool { rawSubtype & 4 != 0 }
    public var isBurst: Bool { burstIdentifier != nil }
    // Selfie detection deferred to server-side EXIF analysis.
    public var isSelfie: Bool { false }
}

/// Metadata for a Photos library album/collection.
public struct PhotoAlbum: Sendable {
    public let localIdentifier: String
    public let title: String
    public let albumTypeString: String
    public let startDate: Date?
    public let endDate: Date?
    public let assetLocalIdentifiers: [String]
}

// MARK: - Reader

/// Reads metadata and binary data from the macOS Photos library.
///
/// ## Metadata reading strategy: direct SQLite via /bin/cp + GRDB
///
/// PHKit's `PHAsset.fetchAssets` requires a working XPC connection to `photolibraryd`.
/// Ad-hoc signed processes (no Apple Developer cert) cannot authenticate to the
/// `photolibraryd` XPC endpoint on macOS 14+, causing "Unable to send to server;
/// failed after 8 attempts" and an empty result from `fetchAssets`.
///
/// The workaround: copy `Photos.sqlite` + WAL/SHM files to `/tmp/` via `/bin/cp`
/// (bypasses the libswiftDarwin open() interpose), then open the copy with GRDB
/// read-only. The schema uses CoreData naming: `ZASSET` (assets), `ZGENERICALBUM`
/// (albums), `ZADDITIONALASSETATTRIBUTES` (filename + size), etc.
///
/// CoreData timestamps are seconds since January 1, 2001 (reference date).
///
/// ## Binary data: /bin/cat and /bin/cp subprocess workaround
///
/// libswiftDarwin DYLD-interposes `open(2)` across the entire Swift process. When
/// `photolibraryd` is active, `Darwin.open()`, `Data(contentsOf:)`, and `FileManager`
/// reads inside the Photos originals directory hang indefinitely. Spawning `/bin/cat`
/// (images) or `/bin/cp` (videos) as a native C subprocess bypasses this interpose.
/// `Darwin.access()` is not intercepted and is used for existence checks.
public final class PhotosLibraryReader: NSObject, @unchecked Sendable, PHPhotoLibraryChangeObserver {
    public static let shared = PhotosLibraryReader()

    private var authorizationStatus: PHAuthorizationStatus = .notDetermined
    private var libraryReadyContinuation: CheckedContinuation<Void, Never>?
    private let readyLock = NSLock()
    private var libraryRegistered = false

    private override init() {
        super.init()
    }

    // MARK: - PHPhotoLibraryChangeObserver (unused but satisfies protocol)

    public func photoLibraryDidChange(_ changeInstance: PHChange) {
        readyLock.lock()
        defer { readyLock.unlock() }
        guard let cont = libraryReadyContinuation else { return }
        libraryReadyContinuation = nil
        cont.resume()
    }

    // MARK: - Authorization

    /// Register with PHKit for TCC (so Photos access shows in System Settings),
    /// then verify the originals directory is directly readable.
    ///
    /// We do NOT use `PHAsset.fetchAssets` — see type-level documentation.
    ///
    /// - Returns: `true` when the originals directory is directly readable.
    public func requestAuthorization() async -> Bool {
        let status = await withCheckedContinuation { continuation in
            PHPhotoLibrary.requestAuthorization(for: .readWrite) { s in
                continuation.resume(returning: s)
            }
        }
        authorizationStatus = status
        NSLog("PhotosReader: PHKit auth status=\(status.rawValue)")
        if status == .denied || status == .restricted {
            NSLog("PhotosReader: PHKit denied — file reads will use Full Disk Access path")
        }
        let accessible = isAccessible
        NSLog("PhotosReader: direct library access=\(accessible)")
        return accessible
    }

    /// Whether the originals directory is directly readable right now.
    public var isAccessible: Bool {
        Darwin.access(photosLibraryURL.appendingPathComponent("originals").path, F_OK) == 0
    }

    /// Diagnostic log of file access state. Fire-and-forget.
    public func checkAccessibility() {
        let path = photosLibraryURL.appendingPathComponent("originals").path
        let ok = Darwin.access(path, F_OK) == 0
        NSLog("PhotosReader: originals directory access(2)=\(ok)")
    }

    // MARK: - Metadata Fetch (direct SQLite)

    /// Fetch photo assets by copying Photos.sqlite to temp and querying with GRDB.
    public func fetchPhotos(since: Date? = nil) -> [PhotoAsset] {
        guard let tempDB = copyPhotosDB() else {
            NSLog("PhotosReader: could not copy Photos.sqlite — originals inaccessible or DB missing")
            return []
        }
        defer { cleanupTempDB(tempDB) }

        do {
            var config = Configuration()
            config.readonly = true
            let queue = try DatabaseQueue(path: tempDB, configuration: config)

            let assets: [PhotoAsset] = try queue.read { db in
                var sql = """
                    SELECT
                        a.ZUUID,
                        a.ZKIND,
                        a.ZKINDSUBTYPE,
                        a.ZWIDTH,
                        a.ZHEIGHT,
                        a.ZDURATION,
                        a.ZDATECREATED,
                        a.ZMODIFICATIONDATE,
                        a.ZLATITUDE,
                        a.ZLONGITUDE,
                        a.ZFAVORITE,
                        a.ZHIDDEN,
                        a.ZAVALANCHEUUID,
                        attrs.ZORIGINALFILENAME,
                        attrs.ZORIGINALFILESIZE
                    FROM ZASSET a
                    LEFT JOIN ZADDITIONALASSETATTRIBUTES attrs ON attrs.ZASSET = a.Z_PK
                    WHERE a.ZTRASHEDSTATE = 0
                    """
                var args: [DatabaseValueConvertible] = []

                if let since {
                    // CoreData reference date is Jan 1, 2001
                    let coredataSince = since.timeIntervalSinceReferenceDate
                    sql += " AND a.ZMODIFICATIONDATE > ?"
                    args.append(coredataSince)
                }

                sql += " ORDER BY a.ZDATECREATED DESC"

                return try Row.fetchAll(db, sql: sql, arguments: StatementArguments(args)).map { row in
                    let uuid: String = row["ZUUID"] ?? UUID().uuidString
                    let kind: Int = row["ZKIND"] ?? 0
                    let subtype: Int = row["ZKINDSUBTYPE"] ?? 0
                    let width: Int = row["ZWIDTH"] ?? 0
                    let height: Int = row["ZHEIGHT"] ?? 0
                    let duration: Double = row["ZDURATION"] ?? 0
                    let createdTs: Double? = row["ZDATECREATED"]
                    let modifiedTs: Double? = row["ZMODIFICATIONDATE"]
                    let lat: Double? = row["ZLATITUDE"]
                    let lon: Double? = row["ZLONGITUDE"]
                    let favorite: Int = row["ZFAVORITE"] ?? 0
                    let hidden: Int = row["ZHIDDEN"] ?? 0
                    let avalanche: String? = row["ZAVALANCHEUUID"]
                    let filename: String? = row["ZORIGINALFILENAME"]
                    let fileSize: Int? = row["ZORIGINALFILESIZE"]

                    // -180.0 is Photos' sentinel for "no location"
                    let hasLocation = (lat != nil && lon != nil && lat! > -179.9 && lon! > -179.9)

                    return PhotoAsset(
                        localIdentifier: uuid + "/L0/001",
                        rawKind: kind,
                        rawSubtype: subtype,
                        width: width,
                        height: height,
                        fileSize: fileSize,
                        creationDate: createdTs.map { Date(timeIntervalSinceReferenceDate: $0) },
                        modificationDate: modifiedTs.map { Date(timeIntervalSinceReferenceDate: $0) },
                        latitude: hasLocation ? lat : nil,
                        longitude: hasLocation ? lon : nil,
                        isFavorite: favorite != 0,
                        isHidden: hidden != 0,
                        duration: duration,
                        burstIdentifier: avalanche,
                        originalFilename: filename
                    )
                }
            }
            NSLog("PhotosReader: fetchPhotos count=\(assets.count) (SQLite)")
            return assets
        } catch {
            NSLog("PhotosReader: fetchPhotos SQLite error: \(error)")
            return []
        }
    }

    /// Fetch user and smart albums from Photos.sqlite.
    public func fetchAlbums() -> [PhotoAlbum] {
        guard let tempDB = copyPhotosDB() else { return [] }
        defer { cleanupTempDB(tempDB) }

        do {
            var config = Configuration()
            config.readonly = true
            let queue = try DatabaseQueue(path: tempDB, configuration: config)

            return try queue.read { db in
                // Get albums with non-zero asset counts
                let albumRows = try Row.fetchAll(db, sql: """
                    SELECT
                        a.ZUUID,
                        a.ZTITLE,
                        a.ZTRASHEDSTATE,
                        a.ZCACHEDPHOTOSCOUNT,
                        a.ZCACHEDCOUNT,
                        a.ZSTARTDATE,
                        a.ZENDDATE,
                        a.ZCUSTOMKEYASSET,
                        a.ZPARENTFOLDER
                    FROM ZGENERICALBUM a
                    WHERE a.ZTRASHEDSTATE = 0
                      AND a.ZTITLE IS NOT NULL
                    ORDER BY a.ZTITLE ASC
                    """)

                // Discover junction table once — CoreData generates Z_XXAssets per model version
                guard let (jTable, albumCol, assetCol) = try discoverAlbumJunctionTable(db) else {
                    NSLog("PhotosReader: fetchAlbums — no junction table found, skipping album assets")
                    return []
                }

                var albums: [PhotoAlbum] = []
                for row in albumRows {
                    guard let albumUUID: String = row["ZUUID"] else { continue }
                    let title: String = row["ZTITLE"] ?? "Untitled"
                    let startTs: Double? = row["ZSTARTDATE"]
                    let endTs: Double? = row["ZENDDATE"]

                    let assetRows = try Row.fetchAll(db, sql: """
                        SELECT ZUUID FROM ZASSET
                        WHERE ZASSET.Z_PK IN (
                            SELECT \(assetCol) FROM \(jTable)
                            WHERE \(albumCol) = (
                                SELECT Z_PK FROM ZGENERICALBUM WHERE ZUUID = ?
                            )
                        ) AND ZTRASHEDSTATE = 0
                        """, arguments: [albumUUID])

                    let assetIds: [String] = assetRows.compactMap { r -> String? in
                        guard let uuid: String = r["ZUUID"] else { return nil }
                        return uuid + "/L0/001"
                    }

                    guard !assetIds.isEmpty else { continue }

                    albums.append(PhotoAlbum(
                        localIdentifier: albumUUID + "/L0/001",
                        title: title,
                        albumTypeString: "user",
                        startDate: startTs.map { Date(timeIntervalSinceReferenceDate: $0) },
                        endDate: endTs.map { Date(timeIntervalSinceReferenceDate: $0) },
                        assetLocalIdentifiers: assetIds
                    ))
                }

                NSLog("PhotosReader: fetchAlbums count=\(albums.count) (SQLite)")
                return albums
            }
        } catch {
            NSLog("PhotosReader: fetchAlbums SQLite error: \(error)")
            return []
        }
    }

    // MARK: - File Location

    /// Locate the original file for a given asset local identifier.
    ///
    /// Photos stores originals at: `originals/<HEX-PREFIX>/<UUID>.<ext>`
    /// Returns `nil` when the original has not been downloaded from iCloud.
    public func findOriginalFile(uuid: String) -> URL? {
        guard let first = uuid.first else { return nil }
        let hexPrefix = String(first).uppercased()
        let dirPath = photosLibraryURL
            .appendingPathComponent("originals/\(hexPrefix)", isDirectory: true)
            .path
        let extensions = ["heic", "jpeg", "jpg", "heif", "mov", "mp4",
                          "png", "m4v", "tiff", "tif", "gif",
                          "dng", "raw", "cr2", "arw", "bmp"]
        for ext in extensions {
            let path = "\(dirPath)/\(uuid).\(ext)"
            if Darwin.access(path, F_OK) == 0 {
                return URL(fileURLWithPath: path)
            }
        }
        return nil
    }

    // MARK: - Binary Data

    /// Read image data for an asset via `/bin/cat` subprocess.
    public func requestImageData(
        localIdentifier: String,
        completion: @escaping @Sendable (Data?, String?) -> Void
    ) {
        let uuid = uuidFromLocalIdentifier(localIdentifier)
        guard !uuid.isEmpty else { completion(nil, nil); return }

        DispatchQueue.global(qos: .userInitiated).async {
            guard let fileURL = self.findOriginalFile(uuid: uuid) else {
                NSLog("PhotosReader: original not locally available for \(uuid) — iCloud-only or deleted")
                completion(nil, nil)
                return
            }

            let mimeType = Self.mimeType(fromExtension: fileURL.pathExtension)
            NSLog("PhotosReader: /bin/cat reading \(fileURL.lastPathComponent)")

            let task = Process()
            task.executableURL = URL(fileURLWithPath: "/bin/cat")
            task.arguments = [fileURL.path]
            let outPipe = Pipe()
            task.standardOutput = outPipe
            task.standardError = Pipe()

            do { try task.run() } catch {
                NSLog("PhotosReader: /bin/cat launch failed for \(uuid): \(error)")
                completion(nil, nil)
                return
            }

            let data = outPipe.fileHandleForReading.readDataToEndOfFile()
            task.waitUntilExit()

            guard task.terminationStatus == 0, !data.isEmpty else {
                NSLog("PhotosReader: /bin/cat exit=\(task.terminationStatus) for \(uuid)")
                completion(nil, nil)
                return
            }

            NSLog("PhotosReader: read \(data.count) bytes for \(uuid)")
            completion(data, mimeType)
        }
    }

    /// Copy a video asset to a temp location via `/bin/cp` subprocess.
    public func requestVideoData(
        localIdentifier: String,
        completion: @escaping @Sendable (URL?, String?) -> Void
    ) {
        let uuid = uuidFromLocalIdentifier(localIdentifier)
        guard !uuid.isEmpty else { completion(nil, nil); return }

        DispatchQueue.global(qos: .userInitiated).async {
            guard let fileURL = self.findOriginalFile(uuid: uuid) else {
                NSLog("PhotosReader: video not locally available for \(uuid)")
                completion(nil, nil)
                return
            }

            let mimeType = Self.mimeType(fromExtension: fileURL.pathExtension)
            let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())
                .appendingPathComponent("\(uuid).\(fileURL.pathExtension)")

            if let size = try? FileManager.default.attributesOfItem(atPath: tempURL.path)[.size] as? Int,
               size > 0 {
                completion(tempURL, mimeType)
                return
            }

            let task = Process()
            task.executableURL = URL(fileURLWithPath: "/bin/cp")
            task.arguments = [fileURL.path, tempURL.path]
            task.standardError = Pipe()

            do {
                try task.run()
                task.waitUntilExit()
            } catch {
                NSLog("PhotosReader: /bin/cp failed for \(uuid): \(error)")
                completion(nil, nil)
                return
            }

            guard task.terminationStatus == 0 else {
                NSLog("PhotosReader: /bin/cp exit=\(task.terminationStatus) for \(uuid)")
                completion(nil, nil)
                return
            }

            NSLog("PhotosReader: video staged to temp for \(uuid)")
            completion(tempURL, mimeType)
        }
    }

    /// Remove any temp copy created by `requestVideoData`.
    public func cleanupVideoTemp(localIdentifier: String) {
        let uuid = uuidFromLocalIdentifier(localIdentifier)
        guard !uuid.isEmpty else { return }
        let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory())
        for ext in ["mov", "mp4", "m4v"] {
            try? FileManager.default.removeItem(at: tmpDir.appendingPathComponent("\(uuid).\(ext)"))
        }
    }

    // MARK: - Auxiliary Metadata

    /// File size (bytes) for an asset's original file, or `nil` if unavailable locally.
    public func getAssetFileSize(localIdentifier: String) -> Int? {
        let uuid = uuidFromLocalIdentifier(localIdentifier)
        guard !uuid.isEmpty, let url = findOriginalFile(uuid: uuid) else { return nil }
        return (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? nil
    }

    /// Original filename for an asset (derived from file on disk, not DB).
    public func getOriginalFilename(localIdentifier: String) -> String? {
        let uuid = uuidFromLocalIdentifier(localIdentifier)
        guard !uuid.isEmpty else { return nil }
        return findOriginalFile(uuid: uuid)?.lastPathComponent
    }

    // MARK: - SQLite helpers

    /// Discover the CoreData-generated album↔asset junction table name and its column names.
    /// CoreData generates names like `Z_30ASSETS` where the number varies by model version.
    /// Returns `(tableName, albumColumn, assetColumn)` or `nil` if not found.
    private func discoverAlbumJunctionTable(_ db: Database) throws -> (String, String, String)? {
        // Dynamic discovery — may miss tables only in the WAL, not yet checkpointed
        var candidates = (try? String.fetchAll(db, sql: """
            SELECT name FROM sqlite_master
            WHERE type = 'table' AND name LIKE 'Z_%ASSETS'
            ORDER BY name
            """)) ?? []

        // Fallback: probe known junction table names directly
        // (sqlite_master may not show WAL-only tables in read-only mode)
        if candidates.isEmpty {
            for name in ["Z_30ASSETS", "Z_28ASSETS", "Z_29ASSETS", "Z_31ASSETS", "Z_32ASSETS"] {
                if let rows = try? Row.fetchAll(db, sql: "PRAGMA table_info(\(name))"), !rows.isEmpty {
                    candidates = [name]
                    break
                }
            }
        }

        for tableName in candidates {
            guard let rows = try? Row.fetchAll(db, sql: "PRAGMA table_info(\(tableName))"),
                  !rows.isEmpty else { continue }
            let colNames = rows.compactMap { r -> String? in r["name"] }
            guard let albumCol = colNames.first(where: { $0.hasSuffix("ALBUMS") }),
                  let assetCol = colNames.first(where: { $0.hasSuffix("ASSETS") && !$0.hasPrefix("Z_FOK") })
            else { continue }
            return (tableName, albumCol, assetCol)
        }
        return nil
    }

    /// Copy Photos.sqlite + WAL/SHM to a temp path so GRDB can open it without the
    /// libswiftDarwin open() interpose blocking. Returns the temp DB path, or nil on failure.
    private func copyPhotosDB() -> String? {
        let dbPath = photosLibraryURL
            .appendingPathComponent("database/Photos.sqlite").path

        guard Darwin.access(dbPath, F_OK) == 0 else {
            NSLog("PhotosReader: Photos.sqlite not accessible at \(dbPath)")
            return nil
        }

        let tempDB = NSTemporaryDirectory() + "mac-sync-photos-\(ProcessInfo.processInfo.processIdentifier).sqlite"

        func cpSubprocess(_ src: String, _ dst: String) -> Bool {
            let t = Process()
            t.executableURL = URL(fileURLWithPath: "/bin/cp")
            t.arguments = [src, dst]
            t.standardError = Pipe()
            guard (try? t.run()) != nil else { return false }
            t.waitUntilExit()
            return t.terminationStatus == 0
        }

        guard cpSubprocess(dbPath, tempDB) else {
            NSLog("PhotosReader: failed to copy Photos.sqlite")
            return nil
        }

        // WAL mode: copy -wal and -shm so GRDB sees committed transactions
        let walSrc = dbPath + "-wal"
        let shmSrc = dbPath + "-shm"
        if Darwin.access(walSrc, F_OK) == 0 { _ = cpSubprocess(walSrc, tempDB + "-wal") }
        if Darwin.access(shmSrc, F_OK) == 0 { _ = cpSubprocess(shmSrc, tempDB + "-shm") }

        return tempDB
    }

    private func cleanupTempDB(_ path: String) {
        try? FileManager.default.removeItem(atPath: path)
        try? FileManager.default.removeItem(atPath: path + "-wal")
        try? FileManager.default.removeItem(atPath: path + "-shm")
    }

    // MARK: - Private helpers

    private func uuidFromLocalIdentifier(_ localIdentifier: String) -> String {
        localIdentifier.components(separatedBy: "/").first ?? ""
    }

    private var photosLibraryURL: URL {
        FileManager.default.homeDirectoryForCurrentUser
            .appendingPathComponent("Pictures/Photos Library.photoslibrary", isDirectory: true)
    }

    public static func mimeType(fromExtension ext: String) -> String {
        switch ext.lowercased() {
        case "jpg", "jpeg": return "image/jpeg"
        case "png":         return "image/png"
        case "heic", "heif": return "image/heic"
        case "gif":         return "image/gif"
        case "tiff", "tif": return "image/tiff"
        case "mov":         return "video/quicktime"
        case "mp4", "m4v":  return "video/mp4"
        case "avi":         return "video/avi"
        case "dng":         return "image/x-adobe-dng"
        default:            return "application/octet-stream"
        }
    }
}
