import Foundation

/// Shared seam between the per-session backends and the one object that knows about
/// every session (`AppStore`). Injected exactly like `ContainerManager`: created once
/// in the app, captured by the backend factory, and handed to `AppStore`, which
/// registers itself as the arbiter at startup. This breaks the AppStore↔backend cycle.
///
/// **Fail-open**: if no arbiter is attached (tests, early startup) it returns
/// `.noConflict`, so a coordination gap can never block an agent.
public actor ConflictCoordinator {
    /// Held weakly: `AppStore` owns the app's lifetime, not this coordinator.
    private weak var arbiter: (any ConflictArbiter)?

    public init() {}

    public func setArbiter(_ arbiter: any ConflictArbiter) {
        self.arbiter = arbiter
    }

    /// Forward a candidate task to the arbiter for detection + (if needed) the user
    /// prompt. Returns `.noConflict` when nothing is attached.
    public func arbitrate(
        sessionID: SessionID, task: String, files: [String], toolUseID: String? = nil
    ) async -> ArbitrationOutcome {
        guard let arbiter else { return ArbitrationOutcome(.noConflict) }
        return await arbiter.arbitrate(sessionID: sessionID, task: task, files: files, toolUseID: toolUseID)
    }

    /// Take the file locks a raw-shell write needs *before* the command runs — the Bash-tool twin
    /// of the edit gate (docs/NASH.md §5.2, docs/LOCKING.md §4.2).
    ///
    /// `command` is the shell command about to run and `workdir` the directory it runs in (the
    /// container workdir for a sandboxed session, else the host worktree), which is what turns the
    /// command's relative write targets into paths the arbiter can lock. Every named worktree file
    /// the command writes is arbitrated exactly like an `Edit`: contention queues this call until
    /// the holder's work lands, a defer/cancel comes back as a denial, and an update-on-grant
    /// re-ground blocks the command once so the agent re-reads before it overwrites.
    ///
    /// A mutation whose target can't be named statically (`patch` applying a diff, a glob, an
    /// unexpanded `$VAR`) is refused outright: a lock needs a path.
    public func arbitrateShellWrites(
        sessionID: SessionID, command: String, workdir: String?, toolUseID: String?
    ) async -> ShellWriteClearance {
        if let reason = WorktreeMutationGuard.denialReason(command: command) {
            return .deny(reason)
        }
        let writes = WorktreeMutationGuard.lockableWrites(command: command, workdir: workdir)
        guard !writes.isEmpty, let arbiter else { return .clear }
        let outcome = await arbiter.arbitrate(
            sessionID: sessionID, task: "", files: writes, toolUseID: toolUseID)
        switch outcome.resolution {
        case .deferred:
            return .deny(
                "This command writes \(Self.pathList(writes)), which conflicts with another active "
                    + "Nucleic agent; the work was added to the to-do list. Stop now and do not "
                    + "retry — tell the user it was deferred.")
        case .cancelled:
            return .deny(
                "This command writes \(Self.pathList(writes)), which conflicts with another active "
                    + "Nucleic agent, and the user cancelled it. Stop now and do not retry.")
        case .proceed, .noConflict:
            // Lock held. If update-on-grant pulled a parent change into a file this command is
            // about to overwrite, block it once with the scoped diff so the agent re-grounds; the
            // lock is held, so its retry re-grants instantly and runs (LOCKING §4.5).
            if let notice = outcome.refreshNotice { return .deny(notice) }
            return .clear
        }
    }

    private static func pathList(_ paths: [String]) -> String {
        let names = paths.map { ($0 as NSString).lastPathComponent }
        return names.count <= 3
            ? names.joined(separator: ", ")
            : "\(names.prefix(3).joined(separator: ", ")) +\(names.count - 3) more"
    }

    /// Forward a git op the interceptor shim observed to the arbiter. Best-effort: dropped when
    /// no arbiter is attached (tests / early startup).
    public func observeGitOp(sessionID: SessionID, argv: [String], exitCode: Int) async {
        await arbiter?.observeGitOp(sessionID: sessionID, argv: argv, exitCode: exitCode)
    }

    /// Forward a `gh` action the interceptor shim observed to the arbiter. Best-effort, as above.
    public func observeGhOp(sessionID: SessionID, argv: [String], exitCode: Int) async {
        await arbiter?.observeGhOp(sessionID: sessionID, argv: argv, exitCode: exitCode)
    }

    /// Forward a non-git command the interceptor observed to the arbiter. Best-effort: dropped
    /// when no arbiter is attached (tests / early startup).
    public func observeCommand(sessionID: SessionID, call: MCPApprovalServer.CommandReportCall) async {
        await arbiter?.observeCommand(sessionID: sessionID, call: call)
    }

    /// Forward one nash batch — every event of a `/shell-event` post, in order — to the arbiter.
    /// Best-effort, as above.
    public func observeShellEvents(
        sessionID: SessionID, calls: [MCPApprovalServer.ShellReportCall]
    ) async {
        guard !calls.isEmpty else { return }
        await arbiter?.observeShellEvents(sessionID: sessionID, calls: calls)
    }

    /// Forward a single nash agent-shell event to the arbiter. Best-effort, as above.
    public func observeShellEvent(sessionID: SessionID, call: MCPApprovalServer.ShellReportCall) async {
        await arbiter?.observeShellEvents(sessionID: sessionID, calls: [call])
    }

    /// Try to claim a host-run slot before a `host_exec` command runs (HOST_EXEC §concurrency).
    /// **Fail-open**: returns `.clear` when no arbiter is attached, so a coordination gap never
    /// blocks a host command.
    public func beginHostCommand(sessionID: SessionID, command: String, override: Bool) async -> HostCommandClearance {
        guard let arbiter else { return .clear }
        return await arbiter.beginHostCommand(sessionID: sessionID, command: command, override: override)
    }

    /// Release a host-run slot. Best-effort: dropped when no arbiter is attached.
    public func endHostCommand(sessionID: SessionID) async {
        await arbiter?.endHostCommand(sessionID: sessionID)
    }

    /// Join the FIFO wait queue for a host-run slot (HOST_EXEC §concurrency). Best-effort: dropped
    /// when no arbiter is attached (nothing to queue behind).
    public func enqueueHostCommandWait(sessionID: SessionID, command: String) async {
        await arbiter?.enqueueHostCommandWait(sessionID: sessionID, command: command)
    }

    /// Poll for a queued waiter's turn (HOST_EXEC §concurrency). **Fail-open**: returns `.clear`
    /// when no arbiter is attached, so a coordination gap never traps a waiter forever.
    public func claimQueuedHostCommand(sessionID: SessionID, command: String) async -> HostCommandClearance {
        guard let arbiter else { return .clear }
        return await arbiter.claimQueuedHostCommand(sessionID: sessionID, command: command)
    }

    /// Leave the wait queue without claiming. Best-effort: dropped when no arbiter is attached.
    public func dequeueHostCommandWait(sessionID: SessionID) async {
        await arbiter?.dequeueHostCommandWait(sessionID: sessionID)
    }
}
