import Foundation

// MARK: - Conflict-aware task coordination (internal project management)
//
// Nucleic runs agents in parallel, each isolated in its own worktree, so edits
// never physically collide — but two agents working the same files (or area)
// produce work that fights at integration time. Before an agent starts a distinct
// task it asks Nucleic (via the `check_conflict` MCP tool) whether the task overlaps
// another *currently active* agent's work; if so, the user gets a Defer / Cancel /
// Override prompt. This file holds the pure, model-free detector and the seam
// (`ConflictArbiter`) the backend calls into.

/// The task an agent is about to start: a one-line description plus the
/// repo-relative paths/areas it expects to touch (its best guess up front).
public struct ConflictCandidate: Sendable, Equatable {
    public let task: String
    public let files: [String]
    public init(task: String, files: [String]) {
        self.task = task
        self.files = files
    }
}

/// One other in-flight session's footprint: what it's working on (`task`, derived
/// from its title/summary) and the repo-relative files its worktree has changed.
public struct ActiveWork: Sendable, Equatable {
    public let sessionID: SessionID
    public let title: String
    public let task: String
    public let changedFiles: [String]
    public init(sessionID: SessionID, title: String, task: String, changedFiles: [String]) {
        self.sessionID = sessionID
        self.title = title
        self.task = task
        self.changedFiles = changedFiles
    }
}

/// A detected overlap between the candidate task and one active session.
public struct ConflictMatch: Sendable, Equatable, Identifiable {
    public let sessionID: SessionID
    public let sessionTitle: String
    /// Repo-relative paths the two tasks both touch (empty for an intent-only match).
    public let sharedPaths: [String]
    /// Short human-readable why, shown in the prompt.
    public let reason: String
    public var id: String { sessionID.rawValue }
    public init(sessionID: SessionID, sessionTitle: String, sharedPaths: [String], reason: String) {
        self.sessionID = sessionID
        self.sessionTitle = sessionTitle
        self.sharedPaths = sharedPaths
        self.reason = reason
    }
}

/// What the agent should do, resolved by the user (or `.noConflict` automatically).
public enum ConflictResolution: String, Sendable, Equatable {
    /// No overlap with active work — proceed silently.
    case noConflict
    /// User filed the task on the Nucleic to-do list; the agent must stop.
    case deferred
    /// User cancelled the task outright; the agent must stop.
    case cancelled
    /// User chose to override the conflict; the agent continues.
    case proceed
}

/// What `arbitrate` returns: the conflict/lock `resolution`, plus an optional `refreshNotice`.
///
/// `refreshNotice` is set when the lock was granted but update-on-grant pulled a parent change into a
/// file this edit targets (LOCKING §4.5) — the worktree on disk is fresh, but the agent's in-context
/// view is stale (it read the file before the lock). The backend blocks this one edit with the notice
/// (the scoped diff + a re-issue instruction) so the agent re-grounds; the lock is held, so its retry
/// proceeds. Riding alongside `.proceed` keeps the existing `ConflictResolution` contract intact.
public struct ArbitrationOutcome: Sendable, Equatable {
    public let resolution: ConflictResolution
    public let refreshNotice: String?
    public init(_ resolution: ConflictResolution, refreshNotice: String? = nil) {
        self.resolution = resolution
        self.refreshNotice = refreshNotice
    }
}

/// The user's button choice in the conflict prompt (maps to a `ConflictResolution`).
public enum ConflictChoice: Sendable, Equatable {
    case `defer`
    case cancel
    case proceed
    /// Queue behind the conflicting work: the agent stays blocked until every session
    /// holding an overlapping file merges/releases it, then resumes as if it had chosen
    /// `proceed`. Multiple waiters on the same file are granted in FIFO order.
    case waitForAccess
}

/// A surfaced conflict awaiting the user's choice — drives the `ConflictSheet`.
public struct ConflictPrompt: Identifiable, Sendable, Equatable {
    public let id: String
    /// The session whose agent asked (and is blocked waiting on the answer).
    public let sessionID: SessionID
    /// The asking session's project, used to file the to-do on "defer".
    public let projectID: ProjectID?
    public let sessionTitle: String
    public let task: String
    public let matches: [ConflictMatch]

    public init(
        id: String, sessionID: SessionID, projectID: ProjectID?, sessionTitle: String,
        task: String, matches: [ConflictMatch]
    ) {
        self.id = id
        self.sessionID = sessionID
        self.projectID = projectID
        self.sessionTitle = sessionTitle
        self.task = task
        self.matches = matches
    }
}

// MARK: - Queue viewer

/// A session participating in the file-lock queue — either holding a lock or waiting
/// for one. `task` is its current objective, shown for context in the queue viewer.
public struct LockParticipant: Sendable, Equatable, Identifiable {
    public let sessionID: SessionID
    public let title: String
    public let task: String
    public var id: String { sessionID.rawValue }
    public init(sessionID: SessionID, title: String, task: String) {
        self.sessionID = sessionID
        self.title = title
        self.task = task
    }
}

/// One locked file and the sessions around it: who currently holds it (its unmerged
/// changes to the file are the implicit lock) and who is queued ("Wait for Access")
/// behind it. Sorted holders first, then waiters, for the queue viewer.
public struct FileLock: Sendable, Equatable, Identifiable {
    public let path: String
    public let holders: [LockParticipant]
    public let waiters: [LockParticipant]
    public var id: String { path }
    public init(path: String, holders: [LockParticipant], waiters: [LockParticipant]) {
        self.path = path
        self.holders = holders
        self.waiters = waiters
    }
}

/// A session queued for access via "Wait for Access", with the files it expects to
/// touch and the sessions currently blocking it (re-checked live, not at prompt time).
/// Intent-only waiters (no predicted files) still appear, with an empty `wantedFiles`.
public struct WaitingSession: Sendable, Equatable, Identifiable {
    public let sessionID: SessionID
    public let title: String
    public let task: String
    public let wantedFiles: [String]
    public let blockedBy: [LockParticipant]
    public var id: String { sessionID.rawValue }
    public init(
        sessionID: SessionID, title: String, task: String,
        wantedFiles: [String], blockedBy: [LockParticipant]
    ) {
        self.sessionID = sessionID
        self.title = title
        self.task = task
        self.wantedFiles = wantedFiles
        self.blockedBy = blockedBy
    }
}

/// A point-in-time view of the file-lock queue across every active session, for the
/// queue viewer. `files` lists each locked path with its holder(s) and queued
/// waiter(s); `waiters` lists every queued session (including intent-only ones that
/// map to no specific file). Built from the same footprints the detector reasons over.
public struct LockQueueSnapshot: Sendable, Equatable {
    public let files: [FileLock]
    public let waiters: [WaitingSession]
    public init(files: [FileLock], waiters: [WaitingSession]) {
        self.files = files
        self.waiters = waiters
    }
    public var isEmpty: Bool { files.isEmpty && waiters.isEmpty }
}

/// The seam the backend's `check_conflict` handler calls. `AppStore` (which alone
/// knows every session) conforms; the shared `ConflictCoordinator` forwards to it.
public protocol ConflictArbiter: AnyObject, Sendable {
    @MainActor func arbitrate(sessionID: SessionID, task: String, files: [String]) async -> ArbitrationOutcome
    /// A ground-truth git op the in-container interceptor shim observed for `sessionID` (the
    /// tokens after `git`, plus its exit code). The arbiter uses it to release file locks the
    /// moment work lands — the certain `git merge` signal the content poll only approximated —
    /// and to record an agent-performed merge as an autoship ship. No-op outside Nucleic Control.
    @MainActor func observeGitOp(sessionID: SessionID, argv: [String], exitCode: Int) async
    /// A ground-truth `gh` action the in-container interceptor shim observed for `sessionID` (the
    /// tokens after `gh`, plus its exit code). The arbiter records the agentic GitHub action
    /// (open/merge a PR, cut a release, write the API, …) for the Control activity feed. No-op
    /// outside Nucleic Control.
    @MainActor func observeGhOp(sessionID: SessionID, argv: [String], exitCode: Int) async
    /// A ground-truth non-git command the in-container interceptor observed for `sessionID` (a
    /// shim or bash-tracer report). The arbiter classifies it and records it into the command
    /// activity feed + structured log. No-op outside Nucleic Control.
    @MainActor func observeCommand(sessionID: SessionID, call: MCPApprovalServer.CommandReportCall) async
    /// Try to claim a host-run slot for `sessionID` before a `host_exec` command runs on the shared
    /// host (HOST_EXEC §concurrency). Registers the command as running and returns `.clear` when no
    /// *similar* command is already running in another session (the fuzzy
    /// `HostCommandConflictDetector`); otherwise registers nothing and returns `.conflict` naming the
    /// overlapping session(s), so the caller can surface the in-chat override prompt. Pass
    /// `override: true` to force the claim (the user dismissed a false positive). Fails open
    /// (`.clear`) when coordination is unavailable, so a gap never blocks a build.
    @MainActor func beginHostCommand(sessionID: SessionID, command: String, override: Bool) async -> HostCommandClearance
    /// Release the host-run slot claimed by `beginHostCommand` once the command finishes (or the
    /// claim is abandoned), so later commands stop conflicting with it. Idempotent.
    @MainActor func endHostCommand(sessionID: SessionID) async
}

/// Pure, deterministic conflict detection — the model-free core (mirrors
/// `HeuristicTriage`). File-path overlap is the strong signal; a task-intent token
/// overlap is a secondary net for when the agent can't predict the files up front.
public enum ConflictDetector {
    /// Detect overlaps between `candidate` and each `active` session. The candidate's
    /// own session must already be excluded by the caller.
    public static func detect(_ candidate: ConflictCandidate, against active: [ActiveWork]) -> [ConflictMatch] {
        let candidatePaths = candidate.files.map(normalize).filter { !$0.isEmpty }
        let candidateTokens = tokens(candidate.task)

        var matches: [ConflictMatch] = []
        for work in active {
            let activePaths = work.changedFiles.map(normalize).filter { !$0.isEmpty }

            // 1. File overlap (primary): any candidate path that equals or is a
            //    path-segment prefix of an active path (or vice versa).
            var shared: [String] = []
            for c in candidatePaths {
                for a in activePaths where pathsOverlap(c, a) {
                    // Record the more specific (longer) of the two as the shared path.
                    shared.append(a.count >= c.count ? a : c)
                }
            }
            if !shared.isEmpty {
                let unique = orderedUnique(shared)
                matches.append(ConflictMatch(
                    sessionID: work.sessionID, sessionTitle: work.title, sharedPaths: unique,
                    reason: "Both touch \(unique.prefix(3).joined(separator: ", "))"
                        + (unique.count > 3 ? " (+\(unique.count - 3) more)" : "")))
                continue
            }

            // 2. Intent overlap (secondary): strong shared-token signal between the two
            //    task descriptions. Conservative so unrelated tasks don't false-positive.
            let activeTokens = tokens(work.task)
            if intentOverlaps(candidateTokens, activeTokens) {
                let common = orderedUnique(candidateTokens.filter { activeTokens.contains($0) })
                matches.append(ConflictMatch(
                    sessionID: work.sessionID, sessionTitle: work.title, sharedPaths: [],
                    reason: "Similar task (\(common.prefix(3).joined(separator: ", ")))"))
            }
        }
        return matches
    }

    // MARK: - Path matching

    /// Trim `./`, surrounding slashes, and whitespace to a bare repo-relative path.
    static func normalize(_ path: String) -> String {
        var p = path.trimmingCharacters(in: .whitespaces)
        while p.hasPrefix("./") { p.removeFirst(2) }
        while p.hasPrefix("/") { p.removeFirst() }
        while p.hasSuffix("/") { p.removeLast() }
        return p
    }

    /// True when two repo-relative paths refer to the same file or one is a directory
    /// ancestor of the other — compared componentwise so `src` never matches `src2/x`.
    static func pathsOverlap(_ a: String, _ b: String) -> Bool {
        guard !a.isEmpty, !b.isEmpty else { return false }
        let ca = a.split(separator: "/")
        let cb = b.split(separator: "/")
        let n = min(ca.count, cb.count)
        for i in 0..<n where ca[i] != cb[i] { return false }
        return true
    }

    // MARK: - Intent matching

    private static let stopwords: Set<String> = [
        "the", "and", "for", "with", "this", "that", "from", "into", "your", "have",
        "make", "add", "fix", "update", "change", "implement", "create", "support",
        "should", "will", "when", "then", "also", "code", "file", "files", "task",
    ]

    /// Significant lowercased tokens of a task description (length ≥ 4, minus stopwords).
    static func tokens(_ text: String) -> [String] {
        let raw = text.lowercased().split { !$0.isLetter && !$0.isNumber }.map(String.init)
        return orderedUnique(raw.filter { $0.count >= 4 && !stopwords.contains($0) })
    }

    /// Conservative: at least two shared significant tokens AND a meaningful Jaccard
    /// similarity, so only genuinely-similar tasks flag without file evidence.
    static func intentOverlaps(_ a: [String], _ b: [String]) -> Bool {
        guard !a.isEmpty, !b.isEmpty else { return false }
        let sa = Set(a), sb = Set(b)
        let shared = sa.intersection(sb).count
        guard shared >= 2 else { return false }
        let union = sa.union(sb).count
        return Double(shared) / Double(union) >= 0.3
    }

    private static func orderedUnique(_ items: [String]) -> [String] {
        var seen = Set<String>()
        var out: [String] = []
        for item in items where seen.insert(item).inserted { out.append(item) }
        return out
    }
}
