import Foundation
import CryptoKit

public struct DiscoveredSession: Sendable {
    public var title: String
    public var manifestURL: URL
    public var resultsURL: URL
    public var wasAnalyzed: Bool
}

public struct SessionDiscoveryFailure: Sendable {
    public var folderURL: URL
    public var message: String
}

public struct SessionDiscoveryReport: Sendable {
    public var sessions: [DiscoveredSession] = []
    public var failures: [SessionDiscoveryFailure] = []
}

/// Synchronous disk/analysis work. Call off the main thread; inject an isolated analyzer in the app.
public enum SessionDiscovery {
    public static let unreviewedRiderName = "Subject — identity needs review"
    private static let extensions: Set<String> = ["jpg", "jpeg", "png", "heic", "heif", "tif", "tiff"]

    public static func run(dataDirectory: URL, knownResults: [URL: URL] = [:],
                           progress: @Sendable (String) -> Void = { _ in },
                           analyze: (URL, URL) throws -> Void = { manifest, output in
                               _ = try FeasibilityRunner.run(manifestURL: manifest, outputDirectory: output)
                           }) -> SessionDiscoveryReport {
        var result = SessionDiscoveryReport()
        let fm = FileManager.default
        let root = dataDirectory.resolvingSymlinksInPath()
        var normalizedKnownResults: [URL: URL] = [:]
        for (manifest, results) in knownResults.sorted(by: { $0.key.path < $1.key.path }) {
            normalizedKnownResults[manifest.resolvingSymlinksInPath()] = results.resolvingSymlinksInPath()
        }
        guard fm.fileExists(atPath: root.path) else {
            result.failures.append(SessionDiscoveryFailure(folderURL: root, message: "The data folder is missing or unavailable."))
            return result
        }
        do {
            let folders = try fm.contentsOfDirectory(at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles])
                .sorted { $0.lastPathComponent < $1.lastPathComponent }
            for folder in folders {
                do {
                    let attributes = try folder.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
                    guard attributes.isDirectory == true, attributes.isSymbolicLink != true else { continue }
                    let manifestURL = folder.appendingPathComponent("session.json")
                    let photos = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles])
                        .filter { url in
                            guard extensions.contains(url.pathExtension.lowercased()),
                                  let attrs = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else { return false }
                            return attrs.isRegularFile == true && attrs.isSymbolicLink != true
                        }.sorted { $0.lastPathComponent < $1.lastPathComponent }
                    guard fm.fileExists(atPath: manifestURL.path) || !photos.isEmpty else { continue }
                    progress(folder.lastPathComponent)
                    if !fm.fileExists(atPath: manifestURL.path) {
                        try createManifest(photos: photos, url: manifestURL)
                    }
                    let manifest = try SessionManifest.load(url: manifestURL)
                    let referenced = try Set(manifest.pairs.flatMap { pair in
                        try pair.riders.map { try manifest.assetURL(for: pair, rider: $0, manifestURL: manifestURL).standardizedFileURL }
                    })
                    let unlisted = photos.filter { !referenced.contains($0.standardizedFileURL) }
                    if !unlisted.isEmpty {
                        result.failures.append(SessionDiscoveryFailure(folderURL: folder,
                            message: "\(unlisted.count) additional photo(s) are not listed in this session. Existing photo assignments were preserved; review the session manifest to include them."))
                    }
                    let sources = try sourceHashes(manifest: manifest, url: manifestURL)
                    let manifestHash = digest(try Data(contentsOf: manifestURL))
                    var candidates = [URL]()
                    let known = normalizedKnownResults[manifestURL.resolvingSymlinksInPath()]
                    if let known { candidates.append(known) }
                    candidates.append(folder.appendingPathComponent("results.json"))
                    // Only inspect the folder and one level of analysis output, never recurse into other sessions.
                    let children = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles])
                    for child in children.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
                        let attrs = try child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
                        if attrs.isDirectory == true && attrs.isSymbolicLink != true {
                            candidates.append(child.appendingPathComponent("results.json"))
                        }
                    }
                    let validCandidates = candidates.filter { validReport(at: $0, manifestHash: manifestHash, sources: sources) }
                    if let existing = preferredReport(validCandidates, manifestURL: manifestURL, known: known) {
                        result.sessions.append(DiscoveredSession(title: manifest.session, manifestURL: manifestURL, resultsURL: existing, wasAnalyzed: false))
                        continue
                    }
                    let output = folder.appendingPathComponent("analysis-auto-\(UUID().uuidString.lowercased())")
                    try analyze(manifestURL, output)
                    let resultsURL = output.appendingPathComponent("results.json")
                    guard validReport(at: resultsURL, manifestHash: manifestHash, sources: sources) else {
                        throw PoseError.invalidInput("Analysis did not produce a complete source-matched results file. The output has been preserved for inspection.")
                    }
                    result.sessions.append(DiscoveredSession(title: manifest.session, manifestURL: manifestURL, resultsURL: resultsURL, wasAnalyzed: true))
                } catch {
                    result.failures.append(SessionDiscoveryFailure(folderURL: folder, message: error.localizedDescription))
                }
            }
        } catch {
            result.failures.append(SessionDiscoveryFailure(folderURL: root, message: error.localizedDescription))
        }
        return result
    }

    private static func preferredReport(_ candidates: [URL], manifestURL: URL, known: URL?) -> URL? {
        var savedCandidates: [(url: URL, modified: Date)] = []
        for candidate in candidates {
            guard let data = try? Data(contentsOf: candidate) else { continue }
            let reviewURL = manifestURL.deletingLastPathComponent()
                .appendingPathComponent("joint-review-\(digest(data).prefix(12)).json")
            guard let saved = try? ReviewStore.load(from: reviewURL),
                  let baseline = try? ReviewStore.make(manifestURL: manifestURL, reportURL: candidate),
                  (try? ReviewStore.validate(saved, against: baseline)) != nil else { continue }
            // Keep a configured session's valid saved binding stable across launches.
            if candidate == known { return candidate }
            let modified = (try? reviewURL.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
            savedCandidates.append((candidate, modified))
        }
        // Revisions from separate analysis runs are not comparable; prefer the most recently saved review.
        let latest = savedCandidates.sorted {
            $0.modified == $1.modified ? $0.url.path < $1.url.path : $0.modified > $1.modified
        }.first
        return latest?.url ?? candidates.first
    }

    private static func createManifest(photos: [URL], url: URL) throws {
        let pairs = try photos.enumerated().map { index, photo in
            let image = try ImageInput.load(url: photo)
            let rect = PixelRect(x: 0, y: 0, width: Double(image.image.width), height: Double(image.image.height))
            let rider = RiderInput(role: "subject", rider: unreviewedRiderName, panel: rect, crop: rect,
                                   annotations: [], annotationNote: "Automatically imported full image. Confirm the intended rider and crop; no anatomical annotations or identity verification are supplied.")
            return PairInput(id: String(format: "A%03d", index + 1), file: photo.lastPathComponent, riders: [rider])
        }
        let manifest = SessionManifest(schemaVersion: 1, session: url.deletingLastPathComponent().lastPathComponent,
            annotationProvenance: "Automatic folder import. Full-image crops are unreviewed; no ground-truth anatomical annotations or coaching judgments have been inferred.", pairs: pairs)
        let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        // Exclusive creation also protects a manifest authored while discovery was running.
        try encoder.encode(manifest).write(to: url, options: [.withoutOverwriting])
    }

    private static func sourceHashes(manifest: SessionManifest, url: URL) throws -> [String: String] {
        var result: [String: String] = [:]
        for pair in manifest.pairs {
            for rider in pair.riders {
                let source = try manifest.assetURL(for: pair, rider: rider, manifestURL: url)
                result["\(pair.id)-\(rider.role)"] = digest(try Data(contentsOf: source))
            }
        }
        return result
    }

    private static func validReport(at url: URL, manifestHash: String, sources: [String: String]) -> Bool {
        guard let data = try? Data(contentsOf: url),
              let report = try? JSONDecoder().decode(FeasibilityReport.self, from: data),
              report.schemaVersion == 1, report.manifestHash == manifestHash,
              report.crops.count == sources.count else { return false }
        let keys = report.crops.map { "\($0.pairID)-\($0.role)" }
        guard Set(keys).count == keys.count else { return false }
        return report.crops.allSatisfy { row in
            let key = "\(row.pairID)-\(row.role)"
            return sources[key] != nil && row.sourceHash == sources[key]
        }
    }

    private static func digest(_ data: Data) -> String {
        SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
    }
}
