import Foundation

// MARK: - Host-command conflict coordination (HOST_EXEC §concurrency)
//
// Nucleic runs many agents in parallel, but every `host_exec` call escapes the sandbox onto the
// ONE shared macOS host. Two agents launching a similar heavy command at the same time — the
// canonical case being two simultaneous builds — thrash the host toolchain; worse, nvrsion
// sessions that share a single trunk worktree (NVRSION) collide on the very same build directory.
// File locks (Conflict.swift / LOCKING) don't help here: a build touches no *predicted edit*
// files, so it never acquires a lock.
//
// So before a host command runs, Nucleic asks the one object that knows every session (`AppStore`,
// via the `ConflictArbiter` seam) whether a *similar* command is already running in another
// session. The comparison is fuzzy (`HostCommandConflictDetector`), so "swift build" and
// "swift build && swift test" are recognised as the same contended op. On a hit the user gets an
// in-chat prompt naming the conflicting session, with a **Run anyway** override for false
// positives. This file holds the pure, model-free detector and the small value types the seam
// passes around; the registry + prompt live in `AppStore` and `ClaudeCodeBackend`.

/// One session's host command that is **currently running** on the shared host — an entry in
/// `AppStore.runningHostCommands`. `projectID` scopes contention (collisions are per-project — a
/// shared trunk's build directory, NVRSION), and `title` is captured at registration so a conflict
/// can name the session even if it later changes title or goes away mid-build.
public struct RunningHostCommand: Sendable, Equatable, Identifiable {
    public let sessionID: SessionID
    public let projectID: ProjectID
    public let title: String
    public let command: String
    public let startedAt: Date
    public var id: String { sessionID.rawValue }
    public init(sessionID: SessionID, projectID: ProjectID, title: String, command: String, startedAt: Date) {
        self.sessionID = sessionID
        self.projectID = projectID
        self.title = title
        self.command = command
        self.startedAt = startedAt
    }
}

/// A detected overlap between a host command about to run and one already-running command in
/// another session — surfaced in the in-chat override prompt so the user sees *which* session
/// (`sessionTitle`), *what* it's running (`command`), and *why* it's considered similar (`reason`).
public struct HostCommandConflict: Sendable, Equatable, Identifiable {
    public let sessionID: SessionID
    public let sessionTitle: String
    /// The other session's currently-running command.
    public let command: String
    /// Short human-readable why, shown in the prompt (e.g. "both are swift build/compile commands").
    public let reason: String
    public var id: String { sessionID.rawValue }
    public init(sessionID: SessionID, sessionTitle: String, command: String, reason: String) {
        self.sessionID = sessionID
        self.sessionTitle = sessionTitle
        self.command = command
        self.reason = reason
    }
}

/// The result of trying to claim a host-run slot. `.clear` means the slot was claimed (the command
/// was registered as running and may proceed); `.conflict` means a similar command is already
/// running elsewhere and nothing was claimed — the caller surfaces the override prompt and, if the
/// user overrides, re-asks with `override: true`.
public enum HostCommandClearance: Sendable, Equatable {
    case clear
    case conflict([HostCommandConflict])
}

/// Pure, deterministic fuzzy comparison of host commands — the model-free core (mirrors
/// `ConflictDetector`). Reuses `CommandSummary.classify` for the command word + coarse category,
/// then layers a token-similarity net so near-identical commands match even outside the modelled
/// categories. Conservative by design: a false *negative* just lets two commands run (the status
/// quo today), while a false *positive* is always escapable via the user's override.
public enum HostCommandConflictDetector {
    /// The currently-running commands that conflict with `command`. Scoped to `projectID`: contention
    /// is per-project (collisions come from shared build state — most sharply a shared trunk's build
    /// directory, NVRSION), and two commands in *different* projects only share host CPU, which we
    /// deliberately tolerate. Excludes `sessionID`'s own entry, so a session never conflicts with
    /// itself. One `HostCommandConflict` per overlap.
    public static func conflicts(
        command: String, sessionID: SessionID, projectID: ProjectID, against running: [RunningHostCommand]
    ) -> [HostCommandConflict] {
        var out: [HostCommandConflict] = []
        for entry in running where entry.sessionID != sessionID && entry.projectID == projectID {
            guard let reason = similarity(command, entry.command) else { continue }
            out.append(HostCommandConflict(
                sessionID: entry.sessionID, sessionTitle: entry.title,
                command: entry.command, reason: reason))
        }
        return out
    }

    /// Whether two host commands are similar enough that running them concurrently would contend,
    /// returning a short human reason when they match (else nil). Three signals, cheapest first.
    /// Project scoping is the caller's job (`conflicts`); this compares two commands in the abstract.
    ///
    /// 1. **Identical** (after whitespace/case normalization) — unmistakably the same op.
    /// 2. **Both builds** — both classify to the `build` category (`swift`, `make`, `npm`, `cargo`,
    ///    `gcc`, …), regardless of toolchain. Within one project any two builds contend: they thrash
    ///    the toolchain and, for nvrsion sessions sharing one trunk, write the same build directory.
    ///    (Cross-toolchain pairs are caught here too; the per-project scope in `conflicts` is what
    ///    keeps two unrelated projects' builds from flagging.)
    /// 3. **Near-identical text** — a high token-set Jaccard catches the same operation with minor
    ///    flag/argument differences even when it isn't a modelled build (e.g. re-running one script).
    public static func similarity(_ a: String, _ b: String) -> String? {
        let na = normalize(a)
        let nb = normalize(b)
        guard !na.isEmpty, !nb.isEmpty else { return nil }
        if na == nb { return "it's the same command" }

        let ca = CommandSummary.classify(commandLine: a)
        let cb = CommandSummary.classify(commandLine: b)
        if let ca, let cb, ca.recognized, cb.recognized,
            ca.category == "build", cb.category == "build" {
            return "both are build/compile commands"
        }

        if jaccard(tokenSet(na), tokenSet(nb)) >= nearIdenticalThreshold {
            return "the commands are nearly identical"
        }
        return nil
    }

    /// Jaccard threshold above which two commands count as the "same operation, different details".
    /// 0.6 flags `swift build` vs `swift build -c release` ({swift,build} vs {swift,build,-c,release}
    /// = 0.5 — handled by the build-tool rule above) and `pytest tests` re-runs, while leaving
    /// genuinely different commands (`cat a` vs `cat b` = 0.33) alone.
    static let nearIdenticalThreshold = 0.6

    /// Lowercase + collapse runs of whitespace to single spaces, so trivial formatting differences
    /// don't defeat the equality and token comparisons.
    static func normalize(_ s: String) -> String {
        s.lowercased().split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" })
            .joined(separator: " ")
    }

    static func tokenSet(_ normalized: String) -> Set<String> {
        Set(normalized.split(separator: " ").map(String.init))
    }

    static func jaccard(_ a: Set<String>, _ b: Set<String>) -> Double {
        guard !a.isEmpty, !b.isEmpty else { return 0 }
        let union = a.union(b).count
        return union == 0 ? 0 : Double(a.intersection(b).count) / Double(union)
    }
}
