import ASTIndexer
import CorePackageIndexingModels
import CoreProtocols
import Foundation
import SharedConstants

extension Core.PackageIndexing {
    /// Walks a downloaded package on disk and writes an `availability.json`
    /// alongside its `manifest.json`, capturing:
    ///
    /// - The package's `Package.swift` `platforms: [...]` block (deployment targets).
    /// - Every `@available(...)` attribute occurrence in `.swift` source under
    ///   `Sources/` and `Tests/`, with file path + line + the parsed platform list.
    ///
    /// Pure on-disk pass — no network. Idempotent: rewrites the JSON each call.
    /// Regex-based; multi-line `@available` attrs aren't recognised and the
    /// scanner doesn't associate hits with specific declarations (would need
    /// AST). Good enough for first-cut ranking signals per #219; an AST upgrade
    /// is a follow-up that can extend `ASTIndexer.Extractor`.
    public actor PackageAvailabilityAnnotator {
        public init() {}

        // `outputFilename` lifted to
        // `Core.PackageIndexing.availabilityFilename` in
        // `CorePackageIndexingModels`. Internally reference the same
        // value via the lifted constant to keep one source of truth.

        // `AnnotationResult` / `FileAvailability` / `Attribute` lifted to
        // top-level under `Core.PackageIndexing.*` in
        // `CorePackageIndexingModels` so `Search.PackageIndexer` can hold
        // them without depending on the full `CorePackageIndexing`
        // target.

        public enum AnnotationError: Error, Sendable, Equatable {
            case missingPackageDirectory(URL)
            case writeFailed(String)
        }

        @discardableResult
        public func annotate(packageDirectory: URL) async throws -> AnnotationResult {
            let manager = FileManager.default
            // Resolve symlinks so /var ↔ /private/var on macOS doesn't trip
            // the relpath stripping below when callers pass a non-resolved
            // URL (e.g. test temp dirs).
            let resolvedDir = packageDirectory.resolvingSymlinksInPath()
            guard manager.fileExists(atPath: resolvedDir.path) else {
                throw AnnotationError.missingPackageDirectory(packageDirectory)
            }

            let packageSwiftURL = resolvedDir.appendingPathComponent("Package.swift")
            let deploymentTargets: [String: String]
            let swiftToolsVersion: String?
            if let manifest = try? String(contentsOf: packageSwiftURL, encoding: .utf8) {
                deploymentTargets = Self.parsePlatforms(from: manifest)
                // #225 Part A — capture the manifest's `// swift-tools-version: X.Y`
                // declaration alongside the platforms block. Both come from
                // the same on-disk read; no extra IO.
                swiftToolsVersion = Self.parseSwiftToolsVersion(from: manifest)
            } else {
                deploymentTargets = [:]
                swiftToolsVersion = nil
            }

            var fileAvailability: [FileAvailability] = []
            var filesScanned = 0
            var totalAttrs = 0

            let basePath = resolvedDir.path
            for subdir in ["Sources", "Tests"] {
                let root = resolvedDir.appendingPathComponent(subdir)
                guard manager.fileExists(atPath: root.path) else { continue }
                let enumerator = Shared.Utils.FileSystem.enumerator(at: root, includingPropertiesForKeys: nil)
                while let next = enumerator?.nextObject() as? URL {
                    guard next.pathExtension == "swift" else { continue }
                    filesScanned += 1
                    guard let source = try? String(contentsOf: next, encoding: .utf8) else { continue }
                    let attrs = Self.extractAvailability(from: source)
                    if !attrs.isEmpty {
                        let resolvedFile = next.resolvingSymlinksInPath().path
                        let relpath: String
                        if resolvedFile.hasPrefix(basePath + "/") {
                            relpath = String(resolvedFile.dropFirst(basePath.count + 1))
                        } else {
                            relpath = resolvedFile
                        }
                        fileAvailability.append(FileAvailability(relpath: relpath, attributes: attrs))
                        totalAttrs += attrs.count
                    }
                }
            }

            // Stable sort by relpath so re-runs produce byte-identical output
            // when the corpus is unchanged.
            fileAvailability.sort { $0.relpath < $1.relpath }

            let result = AnnotationResult(
                version: "1.0",
                annotatedAt: Date(),
                deploymentTargets: deploymentTargets,
                fileAvailability: fileAvailability,
                stats: AnnotationResult.Stats(
                    filesScanned: filesScanned,
                    filesWithAvailability: fileAvailability.count,
                    totalAttributes: totalAttrs
                ),
                swiftToolsVersion: swiftToolsVersion
            )

            let outputURL = packageDirectory.appendingPathComponent(Core.PackageIndexing.availabilityFilename)
            try Self.write(result, to: outputURL)
            return result
        }

        // MARK: - Parsers (delegated to ASTIndexer.AvailabilityParsers, #228)

        /// Forwarder kept for source compatibility with #219 callers.
        /// Real implementation lives in `ASTIndexer.AvailabilityParsers`
        /// so SampleIndex (#228) can reuse without depending on Core.
        public static func parsePlatforms(from packageSwift: String) -> [String: String] {
            ASTIndexer.AvailabilityParsers.parsePlatforms(from: packageSwift)
        }

        public static func extractAvailability(from source: String) -> [Attribute] {
            ASTIndexer.AvailabilityParsers.extractAvailability(from: source)
        }

        /// Forwarder for the swift-tools-version parser (#225 Part A).
        /// Mirrors the `parsePlatforms` shape so existing #219 callers
        /// pick up the new signal with one extra line at the call site.
        public static func parseSwiftToolsVersion(from packageSwift: String) -> String? {
            ASTIndexer.AvailabilityParsers.parseSwiftToolsVersion(from: packageSwift)
        }

        // MARK: - Persistence

        /// Encode `result` and atomically write to `url`. `internal static`
        /// rather than instance-method-on-actor so tests can drive it
        /// without an actor hop.
        static func write(_ result: AnnotationResult, to url: URL) throws {
            let encoder = JSONEncoder()
            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
            encoder.dateEncodingStrategy = .iso8601
            do {
                let data = try encoder.encode(result)
                try data.write(to: url, options: [.atomic])
            } catch {
                throw AnnotationError.writeFailed(error.localizedDescription)
            }
        }
    }
}
