import Foundation
import Testing

@testable import NucleicCore

@Suite("Orchestra orchestration mode")
struct OrchestrationModeTests {
    @Test func detectsOrchestraSentinel() {
        #expect(OrchestrationMode.isOrchestra("orchestra"))
        #expect(OrchestrationMode.isOrchestra("  Orchestra  "))  // trimmed, case-insensitive
        #expect(!OrchestrationMode.isOrchestra("xhigh"))
        #expect(!OrchestrationMode.isOrchestra("high"))
        #expect(!OrchestrationMode.isOrchestra(nil))
    }

    @Test func stillRecognizesLegacyUltracodeSentinel() {
        // Orchestra was renamed from "Ultracode"; a session persisted under the old token must
        // still resolve to the mode, not be sent to a backend verbatim.
        #expect(OrchestrationMode.isOrchestra("ultracode"))
        #expect(OrchestrationMode.isOrchestra("  Ultracode  "))
        #expect(OrchestrationMode.resolvedEffort("ultracode") == "xhigh")
    }

    @Test func resolvesOrchestraToXhigh() {
        // The sentinel never reaches a backend verbatim — it maps to its real effort level.
        #expect(OrchestrationMode.resolvedEffort("orchestra") == "xhigh")
        #expect(OrchestrationMode.resolvedEffort("Orchestra") == "xhigh")
    }

    // MARK: - Supervisor effort qualifier

    /// The supervisor's effort rides on the sentinel ("orchestra:max"). The load-bearing part is that
    /// it still reads as Orchestra — a qualified value that failed `isOrchestra` would silently drop
    /// the mode's tools and standing prompt.
    @Test func qualifiedSentinelStaysOrchestraAndCarriesItsEffort() {
        #expect(OrchestrationMode.isOrchestra("orchestra:max"))
        #expect(OrchestrationMode.isOrchestra("  Orchestra:MAX  "))  // trimmed, case-insensitive
        #expect(OrchestrationMode.resolvedEffort("orchestra:max") == "max")
        #expect(OrchestrationMode.resolvedEffort("orchestra:xhigh") == "xhigh")
        // The legacy sentinel takes a qualifier too, so an old session that gets re-saved is fine.
        #expect(OrchestrationMode.isOrchestra("ultracode:max"))
        #expect(OrchestrationMode.resolvedEffort("ultracode:max") == "max")
    }

    /// `xhigh` — what Orchestra always ran at — stores bare, so the default selection produces
    /// exactly the value it always did and nothing downstream sees a new shape.
    @Test func defaultSupervisorEffortStoresTheBareSentinel() {
        #expect(OrchestrationMode.qualifiedEffortSentinel("xhigh") == "orchestra")
        #expect(OrchestrationMode.qualifiedEffortSentinel(nil) == "orchestra")
        #expect(OrchestrationMode.qualifiedEffortSentinel("max") == "orchestra:max")
        // Round-trips: what we store resolves back to what was chosen.
        for effort in ["low", "medium", "high", "xhigh", "max"] {
            let stored = OrchestrationMode.qualifiedEffortSentinel(effort)
            #expect(OrchestrationMode.isOrchestra(stored))
            #expect(OrchestrationMode.resolvedEffort(stored) == effort)
        }
    }

    /// A qualifier that isn't a real effort resolves to xhigh rather than being forwarded — a
    /// hand-edited or future-versioned store value must never reach a backend as a bogus `--effort`.
    @Test func unknownQualifierFallsBackToXhigh() {
        #expect(OrchestrationMode.resolvedEffort("orchestra:bogus") == "xhigh")
        #expect(OrchestrationMode.resolvedEffort("orchestra:") == "xhigh")
        #expect(OrchestrationMode.qualifiedEffortSentinel("bogus") == "orchestra")
        // And a non-Orchestra effort that merely contains a colon is left alone.
        #expect(OrchestrationMode.isOrchestra("high:max") == false)
        #expect(OrchestrationMode.resolvedEffort("high:max") == "high:max")
    }

    // MARK: - Intelligent worker sentinel

    /// The Intelligent sentinel is not a SKU: it must be recognizable, and it must not be mistaken
    /// for a model (it is resolved to a real one before any spawn).
    @Test func intelligentWorkerSentinelIsRecognizedAndIsNotASKU() {
        #expect(OrchestrationMode.isIntelligentWorkerModel(OrchestrationMode.intelligentWorkerModel))
        #expect(OrchestrationMode.isIntelligentWorkerModel("  Nucleic-Intelligent  "))
        #expect(OrchestrationMode.isIntelligentWorkerModel("claude-sonnet-5") == false)
        #expect(OrchestrationMode.isIntelligentWorkerModel(nil) == false)
        // No provider claims it — which is exactly why it must never be passed through as `--model`.
        #expect(BackendID.forModel(OrchestrationMode.intelligentWorkerModel) == nil)
    }

    @Test func passesThroughRealEffortLevels() {
        for level in ["low", "medium", "high", "xhigh", "max"] {
            #expect(OrchestrationMode.resolvedEffort(level) == level)
        }
        #expect(OrchestrationMode.resolvedEffort(nil) == nil)
    }

    @Test func appendsRolePromptOnlyForAnOrchestraRole() {
        // No role: nothing to append when there's no environment guidance either.
        #expect(OrchestrationMode.combinedSystemPrompt(environment: nil, role: nil) == nil)
        // Supervisor → the consent text; worker → the worker briefing.
        #expect(
            OrchestrationMode.combinedSystemPrompt(environment: nil, role: .supervisor)
                == OrchestrationMode.consentPrompt)
        #expect(
            OrchestrationMode.combinedSystemPrompt(environment: nil, role: .worker)
                == OrchestrationMode.workerBriefing)
    }

    @Test func mergesEnvironmentGuidanceWithRolePrompt() {
        let environment = "Build environment: you are running inside a Linux container."
        // No role → only the environment guidance survives.
        #expect(
            OrchestrationMode.combinedSystemPrompt(environment: environment, role: nil) == environment)
        // Supervisor → both, environment first, consent as its own paragraph.
        #expect(
            OrchestrationMode.combinedSystemPrompt(environment: environment, role: .supervisor)
                == environment + "\n\n" + OrchestrationMode.consentPrompt)
        // Worker → environment first, briefing as its own paragraph.
        #expect(
            OrchestrationMode.combinedSystemPrompt(environment: environment, role: .worker)
                == environment + "\n\n" + OrchestrationMode.workerBriefing)
    }

    @Test func consentFramesSupervisorRoleAndDriveLoop() {
        // The contract that makes orchestra work: standing consent to fan out through Nucleic, and
        // the supervisor role framing + drive-loop tools.
        let consent = OrchestrationMode.consentPrompt.lowercased()
        #expect(consent.contains("standing consent"))
        #expect(consent.contains("supervisor"))
        #expect(consent.contains("nucleic_subagent"))
        #expect(consent.contains("nucleic_supervise"))
        #expect(consent.contains("nucleic_reply_to_worker"))
        // The supervisor escalates genuinely user-facing decisions to the human directly — through
        // Nucleic's own question tool, which exists on every provider (Claude's built-in
        // AskUserQuestion does not, so naming that one would strand a Codex/Grok supervisor).
        #expect(consent.contains("nucleic_ask_user"))
        // Orchestra routes fan-out through Nucleic's managed workers, not the provider's native
        // Task/Agent spawn — the consent must steer the model off those blocked tools.
        #expect(consent.contains("do not use native task/agent"))
    }

    /// The upgraded loop: take the user's request, clarify intent before committing, submit a batched
    /// delegation plan as a structured tool call, then act on batches as they land and re-plan.
    @Test func consentDirectsPlanFirstDelegationLoop() {
        let consent = OrchestrationMode.consentPrompt.lowercased()
        // Plan submission is the primary dispatch path, and it is a real tool call, not prose.
        #expect(consent.contains("nucleic_delegate_plan"))
        #expect(consent.contains("plan"))
        // Clarify *before* planning — the ordering is the point, not merely that asking is allowed.
        #expect(consent.contains("before you commit to a plan"))
        // Batches: groups whose results are consumed early, so the supervisor keeps working while the
        // rest of the fan-out runs.
        #expect(consent.contains("batch"))
        #expect(consent.contains("keep running"))
        // And the loop closes: supervise → act → plan → delegate, until nothing remains.
        #expect(consent.contains("re-plan"))
        #expect(consent.contains("no workers remain running"))
    }

    // MARK: - Plan parsing (the orchestrator's structured output)

    /// The plan tool's input schema is the structured-output contract, so parsing must be forgiving
    /// about presentation and strict only about what a worker actually needs: an unnamed batch is
    /// numbered rather than dropped, a task with no label gets a generic one, and a task with no
    /// prompt is dropped (a worker can't be given nothing to do).
    @Test func planParsingIsTolerantButDropsPromptlessTasks() {
        let call = MCPApprovalServer.parsePlanCall(
            .object([
                "plan": .string("first look, then build"),
                "batches": .array([
                    .object([
                        "purpose": .string("research"),
                        "tasks": .array([
                            .object(["task": .string("read A"), "prompt": .string("read file A")]),
                            .object(["prompt": .string("read file B")]),  // no label
                            .object(["task": .string("ghost")]),  // no prompt — dropped
                        ]),
                    ]),
                    .object([  // no tasks at all — dropped entirely
                        "name": .string("empty"), "tasks": .array([]),
                    ]),
                    .object([
                        "name": .string("  implement  "),
                        "tasks": .array([
                            .object(["task": .string("build"), "prompt": .string("write the code")])
                        ]),
                    ]),
                ]),
            ]))

        #expect(call.plan == "first look, then build")
        #expect(call.batches.count == 2)
        // Unnamed batch is numbered by its position in the submitted plan.
        #expect(call.batches[0].name == "batch-1")
        #expect(call.batches[0].purpose == "research")
        #expect(call.batches[0].tasks.count == 2)
        #expect(call.batches[0].tasks[1].task == "Delegated task")
        // Names are trimmed, so the supervisor's later `nucleic_supervise {batch:"implement"}` matches.
        #expect(call.batches[1].name == "implement")
        #expect(call.batches[1].tasks.first?.prompt == "write the code")
    }

    /// A plan with nothing delegable yields no batches, which the app reports as a denial rather than
    /// letting the supervisor wait on workers that were never created.
    @Test func planParsingYieldsNoBatchesForAnEmptyPlan() {
        #expect(MCPApprovalServer.parsePlanCall(.object([:])).batches.isEmpty)
        #expect(
            MCPApprovalServer.parsePlanCall(
                .object(["plan": .string("hmm"), "batches": .array([])])
            ).batches.isEmpty)
        // Whitespace-only prompts are not work.
        #expect(
            MCPApprovalServer.parsePlanCall(
                .object([
                    "batches": .array([
                        .object([
                            "name": .string("b"),
                            "tasks": .array([.object(["prompt": .string("   ")])]),
                        ])
                    ])
                ])
            ).batches.isEmpty)
    }

    /// The dispatch ack tells the supervisor exactly what exists per batch — worker ids to track, and
    /// which tasks were rejected, so a partially-dispatched plan isn't silently short.
    @Test func planResultWireJSONReportsWorkersAndRejections() {
        let result = OrchestraPlanResult.dispatched(batches: [
            OrchestraDispatchedBatch(
                name: "explore",
                workers: [OrchestraDispatchedWorker(workerID: SessionID(rawValue: "w1"), task: "read")],
                rejected: [OrchestraRejectedTask(task: "bad", message: "prompt required")])
        ])
        let json = result.wireJSON()
        #expect(json.contains(#""total_spawned":1"#))
        #expect(json.contains(#""batch":"explore""#))
        #expect(json.contains(#""worker_id":"w1""#))
        #expect(json.contains(#""message":"prompt required""#))

        #expect(
            OrchestraPlanResult.denied(message: "nope").wireJSON()
                == #"{"denied":true,"message":"nope"}"#)
    }

    // MARK: - Cross-provider ask-user answers

    /// The user's picks come back inside the approval decision's `updatedInput` (the same channel the
    /// Claude CLI expects), keyed by exact question text: a string for single-select, an array for
    /// multi-select. Unanswered questions are omitted rather than reported as empty picks.
    @Test func askUserAnswersReadsSingleAndMultiSelectPicks() {
        let questions = AskUserQuestion.questions(
            from: .object([
                "questions": .array([
                    .object([
                        "question": .string("Which backend?"),
                        "options": .array([
                            .object(["label": .string("Claude")]),
                            .object(["label": .string("Codex")]),
                        ]),
                    ]),
                    .object([
                        "question": .string("Which features?"),
                        "multiSelect": .bool(true),
                        "options": .array([
                            .object(["label": .string("Batches")]),
                            .object(["label": .string("Plans")]),
                        ]),
                    ]),
                    .object([
                        "question": .string("Skipped?"),
                        "options": .array([.object(["label": .string("A")])]),
                    ]),
                ])
            ]))!

        let answers = ClaudeCodeBackend.askUserAnswers(
            from: .object([
                "answers": .object([
                    "Which backend?": .string("Codex"),
                    "Which features?": .array([.string("Batches"), .string("Plans")]),
                    "Skipped?": .string(""),
                ])
            ]),
            questions: questions)

        // Order follows the questions as asked, not the dictionary's.
        #expect(answers.count == 2)
        #expect(answers[0].question == "Which backend?")
        #expect(answers[0].labels == ["Codex"])
        #expect(answers[1].labels == ["Batches", "Plans"])
    }

    /// No `answers` object at all (the user allowed the card without choosing) reads as no answers, so
    /// the caller reports `declined` and the agent proceeds instead of acting on a phantom choice.
    @Test func askUserAnswersIsEmptyWithoutRecordedSelections() {
        let questions = AskUserQuestion.questions(
            from: .object([
                "questions": .array([
                    .object([
                        "question": .string("Go?"),
                        "options": .array([.object(["label": .string("Yes")])]),
                    ])
                ])
            ]))!
        #expect(ClaudeCodeBackend.askUserAnswers(from: .object([:]), questions: questions).isEmpty)
        #expect(
            ClaudeCodeBackend.askUserAnswers(
                from: .object(["answers": .object([:])]), questions: questions
            ).isEmpty)
    }

    /// The result the model reads: answered carries both keyed and positional forms; a declined card
    /// says so explicitly so the agent knows to fall back to its own judgment.
    @Test func askUserResultWireJSONShape() {
        #expect(
            AskUserResult.answered(answers: [(question: "Go?", labels: ["Yes"])]).wireJSON()
                == #"{"answered":[{"labels":["Yes"],"question":"Go?"}],"answers":{"Go?":"Yes"},"ok":true}"#)
        let declined = AskUserResult.declined(message: "dismissed").wireJSON()
        #expect(declined.contains(#""declined":true"#))
        #expect(declined.contains(#""ok":false"#))
        let unavailable = AskUserResult.unavailable(message: "no user").wireJSON()
        #expect(unavailable.contains(#""user_unavailable":true"#))
    }

    @Test func workerBriefingFramesWorkerRoleAndUpwardChannel() {
        let briefing = OrchestrationMode.workerBriefing.lowercased()
        #expect(briefing.contains("worker"))
        #expect(briefing.contains("supervisor"))
        // Workers reach up via nucleic_ask_supervisor and must NOT try to reach the user directly.
        #expect(briefing.contains("nucleic_ask_supervisor"))
        #expect(briefing.contains("do not use `askuserquestion`"))
    }
}
