import Foundation
import LoggingModels
@testable import Search
import SearchModels
import SQLite3
import Testing

// MARK: - #860 + #861 — packages.db post-fix coverage

/// Two related fixes both surfaced in the v1.2.0 PR-2 autopilot
/// dev-DB build cycle and both scoped to packages.db's indexer:
///
/// - **#860**: AppleImportsPass joined against `package_files.module`,
///   which carried the package's OWN Swift module name (Soto, Vapor,
///   Rules) — the wrong RHS for "frameworks this package imports".
///   Coverage was 1/183 on a real corpus build. Fix: new
///   `package_imports` table mirroring samples.db's `file_imports`;
///   indexer writes a row per `import X` statement the AST extractor
///   surfaces; pass joins against `package_imports.module_name`.
/// - **#861**: `swift_tools_version` column existed but was 0/183
///   populated because the brew-shipped `availability.json` files
///   were generated by a pre-#225 `PackageAvailabilityAnnotator`
///   that didn't write the field. Fix: when `loadAvailability`
///   decodes a JSON file with `swiftToolsVersion == nil`, fall back
///   to reading `<dir>/Package.swift` directly and parsing line 1
///   via `ASTIndexer.AvailabilityParsers.parseSwiftToolsVersion`.
///
/// Both born from the autopilot session that filed #860 / #861 / #862
/// / #864 in one run.
@Suite("#860 + #861 — package_imports + swift_tools_version fallback", .serialized)
struct Issue860PackageImportsAndSwiftToolsTests {
    private struct InMemoryLookup: Search.StaticConstraintsLookup {
        let entries: [Search.StaticConstraintEntry]
        func allEntries() async throws -> [Search.StaticConstraintEntry] { entries }
    }

    private static func makeFreshDB() async throws -> (path: URL, index: Search.PackageIndex) {
        let dir = FileManager.default.temporaryDirectory
            .appendingPathComponent("cupertino-860-pkg-imports-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        let path = dir.appendingPathComponent("packages.db")
        let index = try await Search.PackageIndex(dbPath: path, logger: Logging.NoopRecording())
        return (path, index)
    }

    // MARK: - #860: package_imports table + AppleImports join

    /// Seed one package with K files, each carrying M `package_imports`
    /// rows (M ≥ 1). Mirrors the production indexer path: per .swift
    /// file the AST extractor returns symbols + imports; both lists
    /// land via separate INSERT batches inside the same per-package
    /// transaction.
    private static func seedPackageWithImports(
        at dbPath: URL,
        owner: String,
        repo: String,
        fileImports: [(relpath: String, imports: [String])]
    ) throws {
        var conn: OpaquePointer?
        try #require(sqlite3_open(dbPath.path, &conn) == SQLITE_OK)
        defer { sqlite3_close(conn) }

        var stmt: OpaquePointer?
        defer { sqlite3_finalize(stmt) }

        try #require(sqlite3_prepare_v2(conn, """
        INSERT INTO package_metadata (owner, repo, url, fetched_at, is_apple_official)
        VALUES (?, ?, ?, ?, 0);
        """, -1, &stmt, nil) == SQLITE_OK)
        sqlite3_bind_text(stmt, 1, (owner as NSString).utf8String, -1, nil)
        sqlite3_bind_text(stmt, 2, (repo as NSString).utf8String, -1, nil)
        sqlite3_bind_text(stmt, 3, ("https://example.test/\(owner)/\(repo)" as NSString).utf8String, -1, nil)
        sqlite3_bind_int64(stmt, 4, 0)
        try #require(sqlite3_step(stmt) == SQLITE_DONE)
        let pkgId = sqlite3_last_insert_rowid(conn)
        sqlite3_finalize(stmt)
        stmt = nil

        for (relpath, imports) in fileImports {
            try #require(sqlite3_prepare_v2(conn, """
            INSERT INTO package_files (package_id, relpath, kind, module, size_bytes, indexed_at)
            VALUES (?, ?, 'source', NULL, 100, 0);
            """, -1, &stmt, nil) == SQLITE_OK)
            sqlite3_bind_int64(stmt, 1, pkgId)
            sqlite3_bind_text(stmt, 2, (relpath as NSString).utf8String, -1, nil)
            try #require(sqlite3_step(stmt) == SQLITE_DONE)
            let fileId = sqlite3_last_insert_rowid(conn)
            sqlite3_finalize(stmt)
            stmt = nil

            for module in imports {
                try #require(sqlite3_prepare_v2(conn, """
                INSERT INTO package_imports (file_id, module_name, line, is_exported)
                VALUES (?, ?, 1, 0);
                """, -1, &stmt, nil) == SQLITE_OK)
                sqlite3_bind_int64(stmt, 1, fileId)
                sqlite3_bind_text(stmt, 2, (module as NSString).utf8String, -1, nil)
                try #require(sqlite3_step(stmt) == SQLITE_DONE)
                sqlite3_finalize(stmt)
                stmt = nil
            }
        }
    }

    private static func readAppleImports(at dbPath: URL, owner: String, repo: String) throws -> String? {
        var conn: OpaquePointer?
        try #require(sqlite3_open(dbPath.path, &conn) == SQLITE_OK)
        defer { sqlite3_close(conn) }
        var stmt: OpaquePointer?
        defer { sqlite3_finalize(stmt) }
        try #require(sqlite3_prepare_v2(
            conn,
            "SELECT apple_imports_json FROM package_metadata WHERE owner = ? AND repo = ? LIMIT 1;",
            -1, &stmt, nil
        ) == SQLITE_OK)
        sqlite3_bind_text(stmt, 1, (owner as NSString).utf8String, -1, nil)
        sqlite3_bind_text(stmt, 2, (repo as NSString).utf8String, -1, nil)
        guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
        return sqlite3_column_text(stmt, 0).map { String(cString: $0) }
    }

    private static func appleLookup() -> InMemoryLookup {
        InMemoryLookup(entries: [
            Search.StaticConstraintEntry(docURI: "apple-docs://swiftui/view", constraints: []),
            Search.StaticConstraintEntry(docURI: "apple-docs://combine/publisher", constraints: []),
        ])
    }

    @Test("#860 — fresh DB ships v5 schema with package_imports table + indexes")
    func freshDBHasPackageImportsTable() async throws {
        let (path, index) = try await Self.makeFreshDB()
        defer { try? FileManager.default.removeItem(at: path.deletingLastPathComponent()) }
        await index.disconnect()

        var conn: OpaquePointer?
        try #require(sqlite3_open(path.path, &conn) == SQLITE_OK)
        defer { sqlite3_close(conn) }

        // Schema version.
        var stmt: OpaquePointer?
        try #require(sqlite3_prepare_v2(conn, "PRAGMA user_version;", -1, &stmt, nil) == SQLITE_OK)
        try #require(sqlite3_step(stmt) == SQLITE_ROW)
        let userVersion = sqlite3_column_int(stmt, 0)
        sqlite3_finalize(stmt)
        stmt = nil
        #expect(userVersion == 5)

        // Table exists.
        try #require(sqlite3_prepare_v2(
            conn,
            "SELECT name FROM sqlite_master WHERE type='table' AND name='package_imports';",
            -1, &stmt, nil
        ) == SQLITE_OK)
        let found = sqlite3_step(stmt) == SQLITE_ROW
        sqlite3_finalize(stmt)
        stmt = nil
        #expect(found, "package_imports table missing on fresh v5 DB. See #860.")

        // Indexes (idx_package_imports_file + idx_package_imports_module).
        try #require(sqlite3_prepare_v2(
            conn,
            "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='package_imports';",
            -1, &stmt, nil
        ) == SQLITE_OK)
        var indexes: Set<String> = []
        while sqlite3_step(stmt) == SQLITE_ROW {
            if let ptr = sqlite3_column_text(stmt, 0) {
                indexes.insert(String(cString: ptr))
            }
        }
        #expect(indexes.contains("idx_package_imports_file"))
        #expect(indexes.contains("idx_package_imports_module"))
    }

    @Test("#860 — applyAppleImports joins package_imports and writes the SwiftUI+Combine subset")
    func appleImportsJoinHitsPackageImports() async throws {
        let (path, index) = try await Self.makeFreshDB()
        defer { try? FileManager.default.removeItem(at: path.deletingLastPathComponent()) }
        await index.disconnect()

        // Two files: one importing SwiftUI, one importing Combine +
        // a third-party module that's NOT in the Apple lookup.
        try Self.seedPackageWithImports(
            at: path,
            owner: "gamma",
            repo: "demo",
            fileImports: [
                (relpath: "Sources/Foo.swift", imports: ["SwiftUI"]),
                (relpath: "Sources/Bar.swift", imports: ["Combine", "ThirdPartyHelper"]),
            ]
        )

        let reopened = try await Search.PackageIndex(dbPath: path, logger: Logging.NoopRecording())
        defer { Task { await reopened.disconnect() } }

        let affected = try await reopened.applyAppleImports(lookup: Self.appleLookup(), enrichmentVersion: 1)
        #expect(affected == 1)

        let json = try Self.readAppleImports(at: path, owner: "gamma", repo: "demo")
        #expect(json == "[\"combine\",\"swiftui\"]")
    }

    @Test("#860 — submodule imports (Foundation.URL, Foundation.ProcessInfo) count as Apple framework imports")
    func dottedSubmodulePathsNormalize() async throws {
        let (path, index) = try await Self.makeFreshDB()
        defer { try? FileManager.default.removeItem(at: path.deletingLastPathComponent()) }
        await index.disconnect()

        // Mirrors the real-corpus shape of `mxcl/Version`: only
        // dotted submodule imports of Foundation, no bare
        // `import Foundation`. Pre-fix, the apple-imports pass
        // would NOT match because the join compared the full
        // dotted token (`foundation.url`) against the bare slug
        // (`foundation`).
        try Self.seedPackageWithImports(
            at: path,
            owner: "zeta",
            repo: "submodules-only",
            fileImports: [
                (relpath: "Sources/A.swift", imports: ["Foundation.URL", "Foundation.ProcessInfo"]),
                (relpath: "Sources/B.swift", imports: ["Foundation.OperatingSystemVersion"]),
            ]
        )

        let reopened = try await Search.PackageIndex(dbPath: path, logger: Logging.NoopRecording())
        defer { Task { await reopened.disconnect() } }

        // Need a lookup whose docURIs include `foundation` so it
        // gets into the apple-module set.
        let lookup = InMemoryLookup(entries: [
            Search.StaticConstraintEntry(docURI: "apple-docs://foundation/url", constraints: []),
            Search.StaticConstraintEntry(docURI: "apple-docs://foundation/processinfo", constraints: []),
        ])
        let affected = try await reopened.applyAppleImports(lookup: lookup, enrichmentVersion: 1)
        #expect(affected == 1, "dotted-path imports should normalise to bare framework slug for the apple-imports join. See #860 follow-up.")

        let json = try Self.readAppleImports(at: path, owner: "zeta", repo: "submodules-only")
        #expect(json == "[\"foundation\"]")
    }

    @Test("#860 — package with zero Apple imports stays NULL (no spurious row)")
    func packageWithoutAppleImports() async throws {
        let (path, index) = try await Self.makeFreshDB()
        defer { try? FileManager.default.removeItem(at: path.deletingLastPathComponent()) }
        await index.disconnect()

        try Self.seedPackageWithImports(
            at: path,
            owner: "epsilon",
            repo: "no-apple",
            fileImports: [
                (relpath: "Sources/Foo.swift", imports: ["ThirdPartyHelper", "VaporCustom"]),
            ]
        )

        let reopened = try await Search.PackageIndex(dbPath: path, logger: Logging.NoopRecording())
        defer { Task { await reopened.disconnect() } }

        let affected = try await reopened.applyAppleImports(lookup: Self.appleLookup(), enrichmentVersion: 1)
        #expect(affected == 0)
        let json = try Self.readAppleImports(at: path, owner: "epsilon", repo: "no-apple")
        #expect(json == nil)
    }

    // MARK: - #861: swift_tools_version fallback

    @Test("#861 — loadAvailability falls back to Package.swift when JSON omits swiftToolsVersion")
    func loadAvailabilityFallback() async throws {
        let dir = FileManager.default.temporaryDirectory
            .appendingPathComponent("cupertino-861-fallback-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(at: dir) }

        // availability.json without swiftToolsVersion (pre-#225 shape).
        let json = """
        {
          "annotatedAt": "2026-05-20T00:00:00Z",
          "deploymentTargets": {"iOS": "13.0", "macOS": "10.15"},
          "fileAvailability": [],
          "stats": {"filesScanned": 0, "filesWithAvailability": 0, "totalAttributes": 0},
          "version": "1.0"
        }
        """
        try json.write(
            to: dir.appendingPathComponent("availability.json"),
            atomically: true,
            encoding: .utf8
        )

        // Package.swift with a v5.9 declaration on line 1.
        try """
        // swift-tools-version:5.9
        import PackageDescription
        let package = Package(name: "demo")
        """.write(
            to: dir.appendingPathComponent("Package.swift"),
            atomically: true,
            encoding: .utf8
        )

        let payload = Search.PackageIndexer.loadAvailability(at: dir)
        #expect(payload != nil)
        #expect(payload?.swiftToolsVersion == "5.9", "Fallback should parse Package.swift line 1 when JSON omits the field. See #861.")
    }

    @Test("#861 — fallback returns nil when Package.swift is absent")
    func fallbackHandlesMissingManifest() async throws {
        let dir = FileManager.default.temporaryDirectory
            .appendingPathComponent("cupertino-861-no-manifest-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(at: dir) }

        let json = """
        {
          "annotatedAt": "2026-05-20T00:00:00Z",
          "deploymentTargets": {},
          "fileAvailability": [],
          "stats": {"filesScanned": 0, "filesWithAvailability": 0, "totalAttributes": 0},
          "version": "1.0"
        }
        """
        try json.write(
            to: dir.appendingPathComponent("availability.json"),
            atomically: true,
            encoding: .utf8
        )
        // No Package.swift on disk.

        let payload = Search.PackageIndexer.loadAvailability(at: dir)
        #expect(payload != nil)
        #expect(payload?.swiftToolsVersion == nil)
    }

    @Test("#861 — when JSON carries swiftToolsVersion, it wins over the fallback (no double-read)")
    func jsonValueWinsOverFallback() async throws {
        let dir = FileManager.default.temporaryDirectory
            .appendingPathComponent("cupertino-861-json-wins-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(at: dir) }

        // JSON carries a different value than the manifest does. The
        // JSON should win because that's what the annotator captured
        // at annotation time; the manifest may have moved on since.
        let json = """
        {
          "annotatedAt": "2026-05-20T00:00:00Z",
          "deploymentTargets": {},
          "fileAvailability": [],
          "stats": {"filesScanned": 0, "filesWithAvailability": 0, "totalAttributes": 0},
          "swiftToolsVersion": "5.7",
          "version": "1.0"
        }
        """
        try json.write(
            to: dir.appendingPathComponent("availability.json"),
            atomically: true,
            encoding: .utf8
        )
        try """
        // swift-tools-version:6.0
        import PackageDescription
        """.write(
            to: dir.appendingPathComponent("Package.swift"),
            atomically: true,
            encoding: .utf8
        )

        let payload = Search.PackageIndexer.loadAvailability(at: dir)
        #expect(payload?.swiftToolsVersion == "5.7")
    }
}
