import Foundation

/// The single seam that unifies the two backends' approval mechanisms
/// (RUNTIME §5): the backend's blocked waiter (Claude MCP call / Codex JSON-RPC
/// request) suspends on `waitForDecision`; UI/iPhone resolve via `resolve` —
/// first responder wins, others learn `.alreadyResolved`.
public actor ApprovalCoordinator {
    public enum ResolveOutcome: Sendable, Equatable {
        case resolved
        case alreadyResolved
        case unknownID
    }

    private struct AlwaysRule {
        let scope: AlwaysScope
        let toolName: String
        /// Canonical input fingerprint (scope .session) or substring pattern
        /// (scope .toolNameWithPattern).
        let pattern: String?

        func matches(toolName: String, input: JSONValue) -> Bool {
            guard self.toolName == toolName else { return false }
            switch scope {
            case .toolName:
                return true
            case .session:
                return pattern == input.canonicalString()
            case .toolNameWithPattern:
                guard let pattern else { return false }
                return input.canonicalString().contains(pattern)
            }
        }
    }

    private var pending: [ApprovalID: CheckedContinuation<ApprovalResolved, Never>] = [:]
    private var pendingRequests: [ApprovalID: ApprovalRequest] = [:]
    private var resolvedIDs: Set<ApprovalID> = []
    private var alwaysRules: [AlwaysRule] = []
    private let now: @Sendable () -> Date

    public init(now: @escaping @Sendable () -> Date = { Date() }) {
        self.now = now
    }

    /// Consulted *before* surfacing a request: a matching always-rule
    /// auto-resolves without ever emitting `approvalRequested` to humans.
    public func cachedDecision(toolName: String, input: JSONValue) -> ApprovalResolved? {
        guard alwaysRules.contains(where: { $0.matches(toolName: toolName, input: input) })
        else { return nil }
        return ApprovalResolved(
            id: .generate(), decision: .allow(), decidedBy: "always-rule", decidedAt: now())
    }

    /// Suspends until a human (or rule recorded mid-flight) resolves the request.
    public func waitForDecision(_ request: ApprovalRequest) async -> ApprovalResolved {
        await withCheckedContinuation { continuation in
            pending[request.id] = continuation
            pendingRequests[request.id] = request
        }
    }

    public func outstandingRequests() -> [ApprovalRequest] {
        Array(pendingRequests.values).sorted { $0.createdAt < $1.createdAt }
    }

    @discardableResult
    public func resolve(_ id: ApprovalID, _ decision: Decision, by responder: String) -> ResolveOutcome {
        if resolvedIDs.contains(id) { return .alreadyResolved }
        guard let continuation = pending.removeValue(forKey: id) else { return .unknownID }
        let request = pendingRequests.removeValue(forKey: id)
        resolvedIDs.insert(id)

        if case .allowAlways(let scope) = decision, let request {
            let pattern: String? =
                switch scope {
                case .session: request.input.canonicalString()
                case .toolName: nil
                case .toolNameWithPattern: request.input.canonicalString()
                }
            alwaysRules.append(AlwaysRule(scope: scope, toolName: request.toolName, pattern: pattern))
        }

        continuation.resume(
            returning: ApprovalResolved(id: id, decision: decision, decidedBy: responder, decidedAt: now()))
        return .resolved
    }

    /// Session teardown: deny everything still suspended so the backend's blocked
    /// waiter returns and the CLI can exit cleanly (ADAPTERS §1.2 timeout/abandon).
    public func cancelOutstanding(reason: String) {
        for (id, continuation) in pending {
            resolvedIDs.insert(id)
            pendingRequests.removeValue(forKey: id)
            continuation.resume(
                returning: ApprovalResolved(
                    id: id, decision: .deny(reason: reason), decidedBy: "system", decidedAt: now()))
        }
        pending.removeAll()
    }
}
