// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

import Foundation
import SQLite3

struct PersistedSourceRecord: Sendable {
    let sourceID: URL
    let folderURL: URL
    let origin: MinecraftSourceOrigin
    let accessDescriptor: SourceAccessDescriptor
    let availability: SourceAvailability
    let bookmarkData: Data?
    let displayName: String
    let rawItems: [MinecraftContentItem]
    let snapshot: SourceSnapshot?
    let lastScanDate: Date?
    let needsRepair: Bool
}

private struct PersistedItemSnapshotPayload: Codable, Sendable {
    let idPath: String
    let relativePath: String
    let modifiedDate: Date?
    let sizeBytes: Int64?
    let packUUID: String?
    let packVersion: String?

    nonisolated init(_ snapshot: ItemSnapshot) {
        self.idPath = snapshot.id.path
        self.relativePath = snapshot.relativePath
        self.modifiedDate = snapshot.modifiedDate
        self.sizeBytes = snapshot.sizeBytes
        self.packUUID = snapshot.packUUID
        self.packVersion = snapshot.packVersion
    }

    nonisolated var itemSnapshot: ItemSnapshot {
        ItemSnapshot(
            id: URL(fileURLWithPath: idPath),
            relativePath: relativePath,
            modifiedDate: modifiedDate,
            sizeBytes: sizeBytes,
            packUUID: packUUID,
            packVersion: packVersion
        )
    }

    nonisolated init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.idPath = try container.decode(String.self, forKey: .idPath)
        self.relativePath = try container.decode(String.self, forKey: .relativePath)
        self.modifiedDate = try container.decodeIfPresent(Date.self, forKey: .modifiedDate)
        self.sizeBytes = try container.decodeIfPresent(Int64.self, forKey: .sizeBytes)
        self.packUUID = try container.decodeIfPresent(String.self, forKey: .packUUID)
        self.packVersion = try container.decodeIfPresent(String.self, forKey: .packVersion)
    }

    nonisolated func encode(to encoder: any Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(idPath, forKey: .idPath)
        try container.encode(relativePath, forKey: .relativePath)
        try container.encodeIfPresent(modifiedDate, forKey: .modifiedDate)
        try container.encodeIfPresent(sizeBytes, forKey: .sizeBytes)
        try container.encodeIfPresent(packUUID, forKey: .packUUID)
        try container.encodeIfPresent(packVersion, forKey: .packVersion)
    }

    private enum CodingKeys: String, CodingKey {
        case idPath
        case relativePath
        case modifiedDate
        case sizeBytes
        case packUUID
        case packVersion
    }
}

private struct PersistedCollectionSnapshotPayload: Codable, Sendable {
    let folderName: String
    let modifiedDate: Date?
    let childDirectoryCount: Int
    let fingerprint: String

    nonisolated init(_ snapshot: CollectionSnapshot) {
        self.folderName = snapshot.folderName
        self.modifiedDate = snapshot.modifiedDate
        self.childDirectoryCount = snapshot.childDirectoryCount
        self.fingerprint = snapshot.fingerprint
    }

    nonisolated var collectionSnapshot: CollectionSnapshot {
        CollectionSnapshot(
            folderName: folderName,
            modifiedDate: modifiedDate,
            childDirectoryCount: childDirectoryCount,
            fingerprint: fingerprint
        )
    }

    nonisolated init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.folderName = try container.decode(String.self, forKey: .folderName)
        self.modifiedDate = try container.decodeIfPresent(Date.self, forKey: .modifiedDate)
        self.childDirectoryCount = try container.decode(Int.self, forKey: .childDirectoryCount)
        self.fingerprint = try container.decode(String.self, forKey: .fingerprint)
    }

    nonisolated func encode(to encoder: any Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(folderName, forKey: .folderName)
        try container.encodeIfPresent(modifiedDate, forKey: .modifiedDate)
        try container.encode(childDirectoryCount, forKey: .childDirectoryCount)
        try container.encode(fingerprint, forKey: .fingerprint)
    }

    private enum CodingKeys: String, CodingKey {
        case folderName
        case modifiedDate
        case childDirectoryCount
        case fingerprint
    }
}

private struct PersistedSourceSnapshotPayload: Codable, Sendable {
    let sourceIdentifier: String
    let rootModifiedDate: Date?
    let collectionSnapshots: [PersistedCollectionSnapshotPayload]
    let itemSnapshots: [PersistedItemSnapshotPayload]

    nonisolated init(_ snapshot: SourceSnapshot) {
        self.sourceIdentifier = snapshot.sourceID.absoluteString
        self.rootModifiedDate = snapshot.rootModifiedDate
        self.collectionSnapshots = snapshot.collectionSnapshots.map(PersistedCollectionSnapshotPayload.init)
        self.itemSnapshots = snapshot.itemSnapshots.map(PersistedItemSnapshotPayload.init)
    }

    nonisolated var sourceSnapshot: SourceSnapshot {
        SourceSnapshot(
            sourceID: URL(string: sourceIdentifier) ?? URL(fileURLWithPath: sourceIdentifier),
            rootModifiedDate: rootModifiedDate,
            collectionSnapshots: collectionSnapshots.map(\.collectionSnapshot),
            itemSnapshots: itemSnapshots.map(\.itemSnapshot)
        )
    }

    nonisolated init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.sourceIdentifier = try container.decode(String.self, forKey: .sourceIdentifier)
        self.rootModifiedDate = try container.decodeIfPresent(Date.self, forKey: .rootModifiedDate)
        self.collectionSnapshots = try container.decode([PersistedCollectionSnapshotPayload].self, forKey: .collectionSnapshots)
        self.itemSnapshots = try container.decode([PersistedItemSnapshotPayload].self, forKey: .itemSnapshots)
    }

    nonisolated func encode(to encoder: any Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(sourceIdentifier, forKey: .sourceIdentifier)
        try container.encodeIfPresent(rootModifiedDate, forKey: .rootModifiedDate)
        try container.encode(collectionSnapshots, forKey: .collectionSnapshots)
        try container.encode(itemSnapshots, forKey: .itemSnapshots)
    }

    private enum CodingKeys: String, CodingKey {
        case sourceIdentifier
        case rootModifiedDate
        case collectionSnapshots
        case itemSnapshots
    }
}

actor SourcePersistenceStore {
    static let shared = SourcePersistenceStore()
    private static let cacheGeneration = "v2026-05-28"

    private let databaseURL: URL

    init(fileManager: FileManager = .default) {
        let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
            ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true)
        let directoryURL = applicationSupportURL
            .appendingPathComponent("World Manager for Minecraft", isDirectory: true)
        self.databaseURL = directoryURL.appendingPathComponent(
            "LibraryCache-\(Self.cacheGeneration).sqlite",
            isDirectory: false
        )
    }

    init(databaseURL: URL) {
        self.databaseURL = databaseURL
    }

    func loadSources() throws -> [PersistedSourceRecord] {
        let database = try openDatabase()
        defer { sqlite3_close(database) }

        let sql = """
        SELECT source_id, folder_path, origin_json, access_descriptor_json, availability_state,
               bookmark_data, display_name, raw_items_json, snapshot_json, last_scan_date
        FROM source_cache
        ORDER BY display_name COLLATE NOCASE ASC;
        """

        var statement: OpaquePointer?
        guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
            throw databaseError(database)
        }
        defer { sqlite3_finalize(statement) }

        var records: [PersistedSourceRecord] = []

        while sqlite3_step(statement) == SQLITE_ROW {
            guard let folderPathPointer = sqlite3_column_text(statement, 1) else {
                continue
            }

            let sourceID = sourceID(from: statement) ?? URL(fileURLWithPath: String(cString: folderPathPointer)).standardizedFileURL
            let folderPath = String(cString: folderPathPointer)
            let bookmarkData = decodeDataColumn(statement: statement, columnIndex: 5)
            let originResult = decodeOrigin(statement: statement, columnIndex: 2, bookmarkData: bookmarkData)
            let origin = originResult.value
            let accessDescriptorResult = decodeAccessDescriptor(statement: statement, columnIndex: 3, origin: origin)
            let accessDescriptor = accessDescriptorResult.value
            let availability = decodeAvailability(statement: statement, columnIndex: 4)
            let displayName = String(cString: sqlite3_column_text(statement, 6))
            let rawItemsResult = decodeRawItems(statement: statement, columnIndex: 7)
            let snapshotResult = decodeSnapshot(statement: statement, columnIndex: 8)
            let rawItems = rawItemsResult.value
            let snapshot = snapshotResult.value
            let lastScanDate = sqlite3_column_type(statement, 9) == SQLITE_NULL
                ? nil
                : Date(timeIntervalSince1970: sqlite3_column_double(statement, 9))

            records.append(
                PersistedSourceRecord(
                    sourceID: sourceID,
                    folderURL: URL(fileURLWithPath: folderPath, isDirectory: true).standardizedFileURL,
                    origin: origin,
                    accessDescriptor: accessDescriptor,
                    availability: availability,
                    bookmarkData: bookmarkData,
                    displayName: displayName,
                    rawItems: rawItems,
                    snapshot: snapshot,
                    lastScanDate: lastScanDate,
                    needsRepair: originResult.didRepair
                        || accessDescriptorResult.didRepair
                        || rawItemsResult.didRepair
                        || snapshotResult.didRepair
                )
            )
        }

        return records
    }

    func save(source: MinecraftSource) throws {
        let database = try openDatabase()
        defer { sqlite3_close(database) }

        try save(
            record: PersistedSourceRecord(
                sourceID: source.id,
                folderURL: source.folderURL,
                origin: source.origin,
                accessDescriptor: source.accessDescriptor,
                availability: source.availability,
                bookmarkData: source.bookmarkData,
                displayName: source.displayName,
                rawItems: source.rawItems,
                snapshot: source.snapshot,
                lastScanDate: source.lastScanDate,
                needsRepair: false
            ),
            on: database
        )
    }

    func repair(record: PersistedSourceRecord) throws {
        let database = try openDatabase()
        defer { sqlite3_close(database) }

        try save(record: record, on: database)
    }

    private func save(record: PersistedSourceRecord, on database: OpaquePointer?) throws {
        let sql = """
        INSERT INTO source_cache (
            source_id,
            folder_path,
            origin_json,
            access_descriptor_json,
            availability_state,
            bookmark_data,
            display_name,
            raw_items_json,
            snapshot_json,
            last_scan_date
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(source_id) DO UPDATE SET
            bookmark_data = excluded.bookmark_data,
            folder_path = excluded.folder_path,
            origin_json = excluded.origin_json,
            access_descriptor_json = excluded.access_descriptor_json,
            availability_state = excluded.availability_state,
            display_name = excluded.display_name,
            raw_items_json = excluded.raw_items_json,
            snapshot_json = excluded.snapshot_json,
            last_scan_date = excluded.last_scan_date;
        """

        var statement: OpaquePointer?
        guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
            throw databaseError(database)
        }
        defer { sqlite3_finalize(statement) }

        try bindText(normalizedIdentifierText(for: record.sourceID), to: statement, at: 1)
        try bindText(record.folderURL.path, to: statement, at: 2)
        try bindJSON(record.origin, to: statement, at: 3)
        try bindJSON(record.accessDescriptor, to: statement, at: 4)
        try bindText(record.availability.rawValue, to: statement, at: 5)
        try bindData(record.bookmarkData, to: statement, at: 6)
        try bindText(record.displayName, to: statement, at: 7)
        try bindJSON(record.rawItems, to: statement, at: 8)
        try bindJSON(record.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 9)

        if let lastScanDate = record.lastScanDate {
            sqlite3_bind_double(statement, 10, lastScanDate.timeIntervalSince1970)
        } else {
            sqlite3_bind_null(statement, 10)
        }

        guard sqlite3_step(statement) == SQLITE_DONE else {
            throw databaseError(database)
        }
    }

    func deleteSource(withID sourceID: URL) throws {
        let database = try openDatabase()
        defer { sqlite3_close(database) }

        let sql = "DELETE FROM source_cache WHERE source_id = ? OR folder_path = ?;"
        var statement: OpaquePointer?
        guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
            throw databaseError(database)
        }
        defer { sqlite3_finalize(statement) }

        try bindText(normalizedIdentifierText(for: sourceID), to: statement, at: 1)
        try bindText(sourceID.isFileURL ? sourceID.standardizedFileURL.path : sourceID.path, to: statement, at: 2)

        guard sqlite3_step(statement) == SQLITE_DONE else {
            throw databaseError(database)
        }
    }

    private func openDatabase() throws -> OpaquePointer? {
        try FileManager.default.createDirectory(
            at: databaseURL.deletingLastPathComponent(),
            withIntermediateDirectories: true
        )

        var database: OpaquePointer?
        guard sqlite3_open(databaseURL.path, &database) == SQLITE_OK else {
            defer { sqlite3_close(database) }
            throw databaseError(database)
        }

        try execute(
            """
            CREATE TABLE IF NOT EXISTS source_cache (
                source_id TEXT,
                folder_path TEXT PRIMARY KEY,
                origin_json BLOB,
                access_descriptor_json BLOB,
                availability_state TEXT,
                bookmark_data BLOB,
                display_name TEXT NOT NULL,
                raw_items_json BLOB NOT NULL,
                snapshot_json BLOB,
                last_scan_date REAL
            );
            """,
            on: database
        )
        let existingColumns = try columns(in: "source_cache", on: database)
        try addColumnIfNeeded("bookmark_data", sql: "ALTER TABLE source_cache ADD COLUMN bookmark_data BLOB;", existingColumns: existingColumns, on: database)
        try addColumnIfNeeded("source_id", sql: "ALTER TABLE source_cache ADD COLUMN source_id TEXT;", existingColumns: existingColumns, on: database)
        try addColumnIfNeeded("origin_json", sql: "ALTER TABLE source_cache ADD COLUMN origin_json BLOB;", existingColumns: existingColumns, on: database)
        try addColumnIfNeeded("access_descriptor_json", sql: "ALTER TABLE source_cache ADD COLUMN access_descriptor_json BLOB;", existingColumns: existingColumns, on: database)
        try addColumnIfNeeded("availability_state", sql: "ALTER TABLE source_cache ADD COLUMN availability_state TEXT;", existingColumns: existingColumns, on: database)
        try execute(
            """
            UPDATE source_cache
            SET source_id = folder_path
            WHERE source_id IS NULL OR source_id = '';
            """,
            on: database
        )
        try execute(
            "CREATE UNIQUE INDEX IF NOT EXISTS source_cache_source_id_idx ON source_cache(source_id);",
            on: database
        )

        return database
    }

    private func columns(in tableName: String, on database: OpaquePointer?) throws -> Set<String> {
        let sql = "PRAGMA table_info(\(tableName));"
        var statement: OpaquePointer?
        guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
            throw databaseError(database)
        }
        defer { sqlite3_finalize(statement) }

        var columns = Set<String>()
        while sqlite3_step(statement) == SQLITE_ROW {
            if let namePointer = sqlite3_column_text(statement, 1) {
                columns.insert(String(cString: namePointer))
            }
        }

        return columns
    }

    private func addColumnIfNeeded(
        _ columnName: String,
        sql: String,
        existingColumns: Set<String>,
        on database: OpaquePointer?
    ) throws {
        guard !existingColumns.contains(columnName) else {
            return
        }

        try execute(sql, on: database)
    }

    private func execute(_ sql: String, on database: OpaquePointer?, ignoringDuplicateColumn: Bool = false) throws {
        guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
            if ignoringDuplicateColumn,
               let database,
               String(cString: sqlite3_errmsg(database)).localizedCaseInsensitiveContains("duplicate column name") {
                return
            }
            throw databaseError(database)
        }
    }

    private func bindText(_ value: String, to statement: OpaquePointer?, at index: Int32) throws {
        let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
        guard sqlite3_bind_text(statement, index, value, -1, transientDestructor) == SQLITE_OK else {
            throw persistenceError("Failed to bind text parameter.")
        }
    }

    private func bindJSON<T: Encodable>(_ value: T, to statement: OpaquePointer?, at index: Int32) throws {
        let data = try JSONEncoder().encode(value)
        try bindData(data, to: statement, at: index)
    }

    private func bindData(_ value: Data?, to statement: OpaquePointer?, at index: Int32) throws {
        guard let value else {
            sqlite3_bind_null(statement, index)
            return
        }

        let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self)

        let result = value.withUnsafeBytes { rawBuffer in
            sqlite3_bind_blob(statement, index, rawBuffer.baseAddress, Int32(value.count), transientDestructor)
        }

        guard result == SQLITE_OK else {
            throw persistenceError("Failed to bind data parameter.")
        }
    }

    private func decodeColumn<T: Decodable>(_ type: T.Type, statement: OpaquePointer?, columnIndex: Int32) throws -> T? {
        guard let data = decodeDataColumn(statement: statement, columnIndex: columnIndex) else {
            return nil
        }

        return try JSONDecoder().decode(type, from: data)
    }

    private func decodeDataColumn(statement: OpaquePointer?, columnIndex: Int32) -> Data? {
        guard sqlite3_column_type(statement, columnIndex) != SQLITE_NULL else {
            return nil
        }

        let byteCount = Int(sqlite3_column_bytes(statement, columnIndex))
        guard
            byteCount > 0,
            let bytes = sqlite3_column_blob(statement, columnIndex)
        else {
            return nil
        }

        return Data(bytes: bytes, count: byteCount)
    }

    private func decodeOrigin(
        statement: OpaquePointer?,
        columnIndex: Int32,
        bookmarkData: Data?
    ) -> (value: MinecraftSourceOrigin, didRepair: Bool) {
        do {
            if let origin = try decodeColumn(MinecraftSourceOrigin.self, statement: statement, columnIndex: columnIndex) {
                return (origin, false)
            }
        } catch {
        }

        return (.localFolder(bookmarkData: bookmarkData), true)
    }

    private func decodeAccessDescriptor(
        statement: OpaquePointer?,
        columnIndex: Int32,
        origin: MinecraftSourceOrigin
    ) -> (value: SourceAccessDescriptor, didRepair: Bool) {
        do {
            if let accessDescriptor = try decodeColumn(SourceAccessDescriptor.self, statement: statement, columnIndex: columnIndex) {
                return (accessDescriptor, false)
            }
        } catch {
        }

        return (
            SourceAccessDescriptor(
                accessorIdentifier: origin.defaultAccessorIdentifier,
                kind: origin.kind,
                refreshStrategy: origin.defaultRefreshStrategy
            ),
            true
        )
    }

    private func decodeRawItems(statement: OpaquePointer?, columnIndex: Int32) -> (value: [MinecraftContentItem], didRepair: Bool) {
        do {
            if let items = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: columnIndex) {
                return (items, false)
            }

            return ([], false)
        } catch {
            guard let data = decodeDataColumn(statement: statement, columnIndex: columnIndex) else {
                return ([], true)
            }

            let items = decodeArrayElementsLeniently(MinecraftContentItem.self, from: data)
            return (items, true)
        }
    }

    private func decodeSnapshot(statement: OpaquePointer?, columnIndex: Int32) -> (value: SourceSnapshot?, didRepair: Bool) {
        do {
            let payload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: columnIndex)
            return (payload?.sourceSnapshot, false)
        } catch {
            return (nil, true)
        }
    }

    private func decodeArrayElementsLeniently<Element: Decodable>(_ type: Element.Type, from data: Data) -> [Element] {
        guard let rawArray = try? JSONSerialization.jsonObject(with: data) as? [Any] else {
            return []
        }

        let decoder = JSONDecoder()
        var decodedElements: [Element] = []
        decodedElements.reserveCapacity(rawArray.count)

        for rawElement in rawArray {
            guard JSONSerialization.isValidJSONObject(rawElement) else {
                continue
            }

            guard let elementData = try? JSONSerialization.data(withJSONObject: rawElement) else {
                continue
            }

            guard let element = try? decoder.decode(Element.self, from: elementData) else {
                continue
            }

            decodedElements.append(element)
        }

        return decodedElements
    }

    private func sourceID(from statement: OpaquePointer?) -> URL? {
        guard let pointer = sqlite3_column_text(statement, 0) else {
            return nil
        }

        let value = String(cString: pointer)
        return URL(string: value) ?? URL(fileURLWithPath: value)
    }

    private func decodeAvailability(statement: OpaquePointer?, columnIndex: Int32) -> SourceAvailability {
        guard
            let pointer = sqlite3_column_text(statement, columnIndex),
            let availability = SourceAvailability(rawValue: String(cString: pointer))
        else {
            return .unknown
        }

        return availability
    }

    private func normalizedIdentifierText(for sourceID: URL) -> String {
        if sourceID.isFileURL {
            return sourceID.standardizedFileURL.absoluteString
        }

        return sourceID.standardized.absoluteString
    }

    private func databaseError(_ database: OpaquePointer?) -> Error {
        persistenceError(String(cString: sqlite3_errmsg(database)))
    }

    private func persistenceError(_ message: String) -> Error {
        NSError(domain: "SourcePersistenceStore", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
    }
}
