import * as crypto from "crypto";
import { AnalysisScope, isDiff, } from "../../types/RepositoryAnalysis.js";
import { WorkspaceAuthType, getDefaultAuthHeader } from "../../utils/workspaceAuth.js";
import { logger } from "../../utils/logger.js";
import { buildArchitectPreamble, buildContextFetchingGuidance, buildReasoningProtocol, buildToolWorkflows, buildFewShotExamples, buildVerificationChecklist, getAuthSnippets, MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, } from "./recommendationSections.js";
import { CATEGORY_PRIORITY } from "../../types/TestRecommendation.js";
import { buildScopeAssessmentSection, isFrontendFile } from "./scopeAssessment.js";
import { buildExecutionPlan, EXEC_STEP_CODE_REVIEW } from "./diffExecutionPlan.js";
import { buildFullRepoRecommendations } from "./fullRepoCatalog.js";
import { ANALYSIS_STEP_EXTRACT } from "./analysisOutputPrompt.js";
import { TASK_GENERATE, buildExternalCoverageSet, externalDedupKey, isAttackSurfaceSecurityBoundary, taskRef, } from "./recommendationShared.js";
// Re-export for backward compatibility (tests and external callers import these from this module)
export { buildExternalCoverageSet, externalDedupKey };
function formatTestLocations(locs) {
    const entries = Object.entries(locs || {});
    if (entries.length === 0)
        return "";
    const rows = entries.map(([type, files]) => `| ${type.padEnd(12)} | ${files} |`).join("\n");
    return ("\n**Existing test coverage (Skyramp + external):**\n" +
        "| Test type    | File (covers: endpoints)                                |\n" +
        "|--------------|---------------------------------------------------------|\n" +
        rows + "\n\n" +
        "**Deduplication rule (apply this table before generating anything):**\n" +
        "- `[external]` tests: if a resource is covered by an `[external]` test, do NOT create a new test for the same HTTP method + resource + test type (e.g. an external integration test covering `POST /orders` blocks any new `POST` integration test on the `orders` resource). Do NOT attempt to UPDATE, REGENERATE, or DELETE external tests — they are user-maintained.\n" +
        "- `[skyramp]` contract test: if the HTTP method + path already appears in a `[skyramp]` `covers:` entry of type `contract` → UPDATE that file, do NOT create a new one.\n" +
        "- `[skyramp]` integration test: if the primary (last mutating) step's method + path already appears in a `[skyramp]` `covers:` entry of type `integration` → UPDATE, do NOT create a new one.\n" +
        "- UI/E2E test: always create a new file — traces are distinct recordings.\n" +
        "For `[skyramp]` contract and integration tests: if in doubt, prefer UPDATE over creating a duplicate.");
}
// ── Priority-tier ordering (replaces numeric CATEGORY_WEIGHTS) ──
// Categories map to HIGH / MEDIUM / LOW tiers.
// Within a tier, novelty (new > modified > existing) breaks ties,
// then cross-resource, step count, and finally the deterministic SHA-256 seed.
// CATEGORY_PRIORITY and PriorityTier imported from ../../types/TestRecommendation.js
const PRIORITY_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
const NOVELTY_ORDER = { new: 3, modified: 2, existing: 1 };
function classifyNovelty(scenario, diffContext) {
    if (!diffContext)
        return "existing";
    const paths = scenario.steps.map(s => s.path);
    const newPaths = new Set((diffContext.newEndpoints || []).map(ep => ep.path));
    const modPaths = new Set((diffContext.modifiedEndpoints || []).map(ep => ep.path));
    const removedPaths = new Set((diffContext.removedEndpoints || []).map(ep => ep.path));
    if (paths.some(p => newPaths.has(p)))
        return "new";
    if (paths.some(p => modPaths.has(p) || removedPaths.has(p)))
        return "modified";
    return "existing";
}
function prioritiseCandidate(scenario, diffContext) {
    const priority = isAttackSurfaceSecurityBoundary(scenario)
        ? "CRITICAL"
        : CATEGORY_PRIORITY[scenario.category] ?? "LOW";
    const novelty = classifyNovelty(scenario, diffContext);
    return { priority, novelty };
}
function computeTiebreakerSeed(endpoints, diffFiles) {
    const canonical = [...endpoints].sort().join("|") + "::" + [...diffFiles].sort().join("|");
    return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 8);
}
// ── Execution Plan (replaces pre-ranked + scenarios + heuristic sections) ──
export function buildRecommendationPrompt(analysis, analysisScope = AnalysisScope.FullRepo, topN = MAX_RECOMMENDATIONS, prContext, workspaceAuthHeader, workspaceAuthType, workspaceAuthScheme, maxGenerateOverride, sessionId) {
    const isDiffScope = isDiff(analysisScope);
    const diffContext = analysis.branchDiffContext;
    const openApiSpec = analysis.artifacts?.openApiSpecs?.[0];
    // ── Filter out bot-generated test files from changedFiles ──
    // Prevents bot-committed test files from being treated as application changes
    // on subsequent testbot runs on the same PR.
    const SKYRAMP_TEST_FILE_PATTERN = /(?:_test|_smoke|_contract|_fuzz|_integration|_load|_e2e|_ui)\.[^/]+$|scenario_[^/]+\.json$/;
    const filteredChangedFiles = diffContext
        ? diffContext.changedFiles.filter(f => !SKYRAMP_TEST_FILE_PATTERN.test(f))
        : [];
    // ── Frontend / UI change detection ──
    // Used for isUIOnlyPR detection and mode preamble text.
    // The actual UI vs backend test split is determined by LLM judgment via the
    // scope assessment embedded in buildExecutionPlan (see scopeAssessment.ts).
    const hasFrontendChanges = isDiffScope && diffContext
        ? filteredChangedFiles.some(f => isFrontendFile(f))
        : false;
    const hasApiChanges = isDiffScope && diffContext
        ? (diffContext.newEndpoints.length > 0 || diffContext.modifiedEndpoints.length > 0 || (diffContext.removedEndpoints?.length ?? 0) > 0)
        : false;
    const isUIOnlyPR = hasFrontendChanges && !hasApiChanges;
    const hasTraces = (analysis.artifacts?.traceFiles?.length ?? 0) > 0 ||
        (analysis.artifacts?.playwrightRecordings?.length ?? 0) > 0;
    // ── Mode preamble ──
    const modePreamble = isDiffScope
        ? `You are in **PR mode**. Maximize test coverage for the branch changes.
Focus on tests that validate the changed fields, endpoints, and their interactions.
${isUIOnlyPR ? `\n**UI-only PR** — no backend changes. UI and E2E tests are most relevant.`
            : hasFrontendChanges ? `\n**Mixed PR** — both frontend and backend changes detected. Backend and E2E/UI tests are both required.`
                : ``}
Output should be concise and immediately actionable.`
        : `You are in **Repo mode**. Comprehensive test strategy across all endpoints.`;
    // ── UI rec authoring rules ──
    // Anchors every UI recommendation regardless of whether the agent ran
    // skyramp_ui_analyze_changes / browser_blueprint before this call. When the
    // agent has prior captures in its own tool-result history, those serve as
    // grounding; when it doesn't, recommendations fall back to source-grounded
    // prose. Either way, inventing elements / leaking jargon / duplicating UI
    // tests are wrong, so the rules fire unconditionally.
    const uiRecRulesSection = `

<ui_recommendation_authoring_rules>
**Blueprints inform *how* you describe UI tests, not *which* tests to recommend.** The recommendation catalog derives from the same change-signals listed elsewhere in this prompt (new endpoints, schema/field changes, security boundaries, business-logic modifications, frontend route or component additions, layout additions, etc.) — refer to those signals for the source-of-truth list, not this section.

**Do not generate near-duplicate UI tests of the same surface;** one well-targeted UI test per surface is enough.

For UI recommendations you *do* emit, ground the \`reasoning\` field in elements you have actually observed via \`browser_blueprint\` calls earlier in this session. If a recommendation's target element is not in any blueprint you have observed, either rephrase the recommendation around an element that IS observed, or describe the test target in higher-level terms. Do not invent element names from the PR description, source diff, or component name. If you have not captured any blueprints yet (e.g. backend-only PR, or pre-flight skipped), UI recommendations fall back to source-grounded prose drawn from the diff alone — that is a legitimate outcome, not a reason to invent.

Write UI recommendation \`reasoning\` fields in **natural prose** that names elements as a human would describe them (e.g. "the Notifications heading", "the disabled Mark all as read button"). **Do NOT mention "blueprint", "captured blueprint", "DOM analyzer", or any other internal MCP terminology in the reasoning text.** The reader of the report is a developer reviewing test recommendations on a PR; they don't know what a blueprint is and shouldn't have to. Phrases like "the captured blueprint shows X" or "visible from the captured blueprint" leak builder internals — instead just describe what the test verifies in plain product terms ("the disabled mark-all-read button in the empty state"). Likewise do NOT use internal-identifier syntax like \`role=button, accessibleName='X', logicalName=...\`.
</ui_recommendation_authoring_rules>
`;
    // ── Endpoint listing ──
    const allEndpoints = analysis.apiEndpoints.endpoints;
    // In PR mode, identify which endpoints were changed so we can partition the listing.
    const changedEndpointKeys = new Set();
    if (isDiffScope && diffContext) {
        for (const ep of [...(diffContext.newEndpoints || []), ...(diffContext.modifiedEndpoints || []), ...(diffContext.removedEndpoints || [])]) {
            for (const m of (ep.methods ?? [])) {
                changedEndpointKeys.add(`${m.method} ${ep.path}`);
            }
        }
    }
    const fmtEndpoint = (m, ep) => `  ${m.method} ${ep.path}${m.authRequired ? " [auth]" : ""} (${(m.interactions ?? []).length} interactions)`;
    let endpointLines;
    if (isDiffScope && changedEndpointKeys.size > 0) {
        const changedLines = [];
        const otherLines = [];
        for (const ep of allEndpoints) {
            for (const m of (ep.methods ?? [])) {
                const line = fmtEndpoint(m, ep);
                if (changedEndpointKeys.has(`${m.method} ${ep.path}`)) {
                    changedLines.push(line);
                }
                else {
                    otherLines.push(line);
                }
            }
        }
        // Removed endpoints no longer exist in allEndpoints (current catalog), so they
        // would be silently absent from changedLines. Append them explicitly with a
        // [removed] marker so the LLM knows to generate verify-404/deprecation tests.
        for (const ep of (diffContext?.removedEndpoints || [])) {
            for (const m of (ep.methods ?? [])) {
                changedLines.push(`  ${m.method} ${ep.path} [removed]`);
            }
        }
        endpointLines = `**Likely changed in this PR (from static file→endpoint mapping — verify against diff in Step ${ANALYSIS_STEP_EXTRACT}):**\n${changedLines.join("\n") || "  none"}\n\n**Other endpoints (reference only):**\n${otherLines.join("\n") || "  none"}`;
    }
    else {
        endpointLines = allEndpoints
            .flatMap((ep) => (ep.methods ?? []).map((m) => fmtEndpoint(m, ep)))
            .join("\n");
    }
    const authMethod = analysis.authentication.method || "unknown";
    const authTypeValue = workspaceAuthType ?? "";
    let authHeaderValue;
    if (authTypeValue === WorkspaceAuthType.None) {
        authHeaderValue = "";
    }
    else if (workspaceAuthHeader !== undefined) {
        // workspace.yml authHeader takes precedence (may be inferred from authType in analyzeChangesTool)
        authHeaderValue = workspaceAuthHeader;
    }
    else {
        // No workspace authHeader — derive from authType using the canonical mapping.
        authHeaderValue = getDefaultAuthHeader(authTypeValue);
        // apiKey requires an explicit authHeader; getDefaultAuthHeader returns "" for it.
        // Surface a warning so the LLM can tell the user to add authHeader to workspace.yml.
        if (authTypeValue === WorkspaceAuthType.ApiKey && !authHeaderValue) {
            logger.warning("workspace.yml has authType: apiKey but no authHeader is set — " +
                "generated tests will have no auth header and will likely receive 401/403. " +
                "Add authHeader: <X-Your-Key-Header> to .skyramp/workspace.yml.");
        }
    }
    const { authSchemeSnippet } = getAuthSnippets(authHeaderValue, authTypeValue, workspaceAuthScheme);
    const sourcePriority = `
## Source Priority
When information conflicts, prefer: **Traces** (actual behavior) > **Code** (implemented behavior) > **Spec/Docs** (documented behavior).
`;
    // Compact fingerprint of tests already covering endpoints in this repo (Skyramp + external).
    // Re-derived fresh each run from test files on disk — no separate persistence needed.
    const testLocations = analysis.existingTests?.testLocations ?? {};
    const testFingerprint = (() => {
        const entries = Object.entries(testLocations);
        if (entries.length === 0)
            return "";
        // Each value is a comma-joined list of "file (covers: ep1, ep2)" entries — one per file.
        // Count files by splitting on "), " boundaries (each entry ends with ")").
        let totalFiles = 0;
        let externalCount = 0;
        const byType = new Map();
        for (const [type, fileList] of entries) {
            // Use matchAll to extract covers from ALL files of this type, not just the first.
            const allEndpoints = [...fileList.matchAll(/covers:\s*([^)]+)/g)].map(m => m[1].trim());
            // Count files: strip "[external]", "[skyramp]", and "(covers: ...)" clauses then split on ", " to count entries,
            // correctly handling both files-with-covers and files-without-covers (e.g. UI tests).
            const strippedList = fileList.replace(/\s*\[(?:external|skyramp)\]/g, "").replace(/\s*\(covers:[^)]*\)/g, "");
            totalFiles += strippedList.split(", ").filter(s => s.trim().length > 0).length;
            // Count external files from [external] annotations
            externalCount += (fileList.match(/\[external\]/g) || []).length;
            if (!byType.has(type))
                byType.set(type, []);
            byType.get(type).push(...allEndpoints);
        }
        const lines = [...byType.entries()]
            .filter(([, eps]) => eps.length > 0)
            .map(([type, eps]) => `  ${type}: ${[...new Set(eps)].join(", ")}`);
        const skyrampCount = totalFiles - externalCount;
        const breakdown = externalCount > 0
            ? `${skyrampCount} Skyramp + ${externalCount} external`
            : `${totalFiles} files`;
        return `\nTests already covering endpoints in this repo (${breakdown}):\n${lines.join("\n")}\n(Use this to focus on coverage gaps. External tests block new recommendations but cannot be updated.)`;
    })();
    const repoContext = `
Repository: ${analysis.metadata.repositoryName}
Framework: ${analysis.projectClassification.primaryFramework} (${analysis.projectClassification.primaryLanguage})
Project type: ${analysis.projectClassification.projectType}
Auth: ${authMethod} (header: ${authHeaderValue}${authTypeValue ? `, type: ${authTypeValue}` : ""})
Base URL: ${analysis.apiEndpoints.baseUrl}
Candidate endpoints from static scan — unverified, confirm paths against spec or source before use (${analysis.apiEndpoints.totalCount}):
${endpointLines}${testFingerprint}
`.trim();
    // ── Branch diff ──
    let diffSection = "";
    if (isDiffScope && diffContext) {
        const fmtEps = (eps, detail) => eps.flatMap((ep) => (ep.methods ?? []).map((m) => `  ${m.method} ${ep.path} (${detail(m)})`)).join("\n") || "  none";
        diffSection = `
## Branch Diff Context
Branch: \`${diffContext.currentBranch}\` → base: \`${diffContext.baseBranch}\`
Changed files: ${filteredChangedFiles.join(", ")}
New endpoints:
${fmtEps(diffContext.newEndpoints, (m) => `${m.sourceFile}, ${m.interactionCount} interactions`)}
Modified endpoints:
${fmtEps(diffContext.modifiedEndpoints, (m) => `${m.sourceFile}, ${m.changeType}`)}
Removed endpoints:
${fmtEps(diffContext.removedEndpoints ?? [], (m) => `${m.sourceFile}, removed`)}
Affected services: ${diffContext.affectedServices.join(", ") || "N/A"}

Focus on tests that validate these changes and how they interact with existing resources.
For removed endpoints: verify they now return 404 or the appropriate deprecation status code.
Allocate your test budget to endpoints listed under "Likely changed in this PR". Use other endpoints only as setup steps (e.g. creating a resource before testing its deletion).
`;
    }
    // ── Interactions ──
    let interactionSection = "";
    {
        const diffEndpointKeys = new Set();
        if (isDiffScope && diffContext) {
            for (const ep of [...(diffContext.newEndpoints || []), ...(diffContext.modifiedEndpoints || [])]) {
                for (const m of (ep.methods ?? []))
                    diffEndpointKeys.add(`${m.method} ${ep.path}`);
            }
        }
        const summaryLines = allEndpoints
            .flatMap((ep) => (ep.methods ?? []).flatMap((m) => (m.interactions ?? []).map((i) => `  ${m.method} ${ep.path} → ${i.response.statusCode} (${i.type}): ${i.description}`)))
            .join("\n");
        const detailEndpoints = isDiffScope
            ? allEndpoints.filter((ep) => (ep.methods ?? []).some((m) => diffEndpointKeys.has(`${m.method} ${ep.path}`)))
            : allEndpoints;
        const detailBlocks = detailEndpoints
            .flatMap((ep) => (ep.methods ?? []).flatMap((m) => (m.interactions ?? []).map((i) => {
            const reqBody = i.request.body ? `\n    requestBody: ${JSON.stringify(i.request.body)} (pass as JSON string when calling tools)` : "";
            const qParams = i.request.queryParams ? `\n    queryParams: ${JSON.stringify(i.request.queryParams)}` : "";
            const resBody = i.response.body ? `\n    responseBody: ${JSON.stringify(i.response.body)}` : "";
            const headers = i.request.headers ? `\n    headers: ${JSON.stringify(i.request.headers)}` : "";
            return `  ${m.method} ${ep.path} → ${i.response.statusCode} (${i.type}): ${i.description}${reqBody}${qParams}${resBody}${headers}`;
        })))
            .join("\n");
        interactionSection = `
## Endpoint Interactions
${summaryLines}

### Detailed (request/response bodies)
${isDiffScope ? "Changed endpoints only. " : ""}Use source code schemas (Zod/Pydantic/DTOs) for actual request bodies and query parameters.
${detailBlocks}
`;
    }
    // ── Scoring ──
    const endpointCount = allEndpoints.reduce((acc, ep) => acc + (ep.methods ?? []).length, 0);
    const baseMaxGen = Math.min(Math.max(maxGenerateOverride ?? (isDiffScope ? MAX_TESTS_TO_GENERATE : topN), 0), topN);
    const maxGen = isUIOnlyPR ? Math.max(baseMaxGen, 1) : baseMaxGen;
    const scenarios = analysis.businessContext.draftedScenarios;
    let scored = [];
    let seed = "";
    if (!isUIOnlyPR && scenarios.length > 0) {
        const diffFiles = filteredChangedFiles; // use filtered list so bot-committed test files don't shift the seed
        const endpointPaths = allEndpoints.map(ep => ep.path);
        seed = computeTiebreakerSeed(endpointPaths, diffFiles);
        scored = scenarios.map(s => {
            const result = prioritiseCandidate(s, diffContext ?? undefined);
            return { scenario: s, ...result };
        });
        scored.sort((a, b) => {
            const pa = PRIORITY_ORDER[a.priority], pb = PRIORITY_ORDER[b.priority];
            if (pb !== pa)
                return pb - pa;
            const na = NOVELTY_ORDER[a.novelty], nb = NOVELTY_ORDER[b.novelty];
            if (nb !== na)
                return nb - na;
            const crossA = a.scenario.steps.length > 2 ? 1 : 0;
            const crossB = b.scenario.steps.length > 2 ? 1 : 0;
            if (crossB !== crossA)
                return crossB - crossA;
            if (b.scenario.steps.length !== a.scenario.steps.length)
                return b.scenario.steps.length - a.scenario.steps.length;
            const errorA = a.scenario.steps.some(s => s.interactionType === "error" || s.interactionType === "edge-case") ? 1 : 0;
            const errorB = b.scenario.steps.some(s => s.interactionType === "error" || s.interactionType === "edge-case") ? 1 : 0;
            if (errorB !== errorA)
                return errorB - errorA;
            // Use locale-independent comparison to avoid runtime-locale non-determinism
            const nameA = a.scenario.scenarioName;
            const nameB = b.scenario.scenarioName;
            if (nameA < nameB)
                return -1;
            if (nameA > nameB)
                return 1;
            const hashA = parseInt(crypto.createHash("sha256").update(seed + a.scenario.scenarioName).digest("hex").slice(0, 8), 16);
            const hashB = parseInt(crypto.createHash("sha256").update(seed + b.scenario.scenarioName).digest("hex").slice(0, 8), 16);
            return hashA - hashB;
        });
    }
    // ── Main section: execution plan, UI-only guidance, or draft-your-own ──
    let mainSection;
    if (!isDiffScope && scored.length > 0) {
        const projectType = analysis.projectClassification.projectType;
        const isFrontendProject = projectType === "full-stack" || projectType === "frontend";
        const isFrontendOnlyProject = projectType === "frontend";
        const externalCoverageFullRepo = buildExternalCoverageSet(testLocations);
        if (externalCoverageFullRepo.size > 0) {
            logger.info(`External test coverage keys (full-repo): ${[...externalCoverageFullRepo].join(", ")}`);
        }
        mainSection = buildFullRepoRecommendations(scored, topN, analysis.apiEndpoints.baseUrl, authHeaderValue, authSchemeSnippet, authTypeValue, isFrontendProject, isFrontendOnlyProject, externalCoverageFullRepo);
    }
    else if (isDiffScope) {
        // Always use the full execution plan in diff scope — even when scored.length === 0
        // (no pre-ranked scenarios). The execution plan includes the dynamic Code Review step,
        // bug-catching insertion, and the dynamic bug-coverage gate
        // that are critical for catching seeded bugs in new endpoints.
        const externalCoverage = buildExternalCoverageSet(testLocations);
        if (externalCoverage.size > 0) {
            logger.info(`External test coverage keys: ${[...externalCoverage].join(", ")}`);
        }
        mainSection = buildExecutionPlan(scored, maxGen, topN, analysis.apiEndpoints.baseUrl, authHeaderValue, authSchemeSnippet, authTypeValue, seed, endpointCount, isUIOnlyPR, hasFrontendChanges, hasTraces, externalCoverage, analysis.existingTests.relevantExternalTestPaths ?? []);
    }
    else {
        // Full-repo scope with no scored items — rare fallback
        mainSection = `
## Draft Your Execution Plan

No pre-drafted scenarios available.

${buildScopeAssessmentSection(topN, maxGen)}

Draft tests from the endpoint interactions and source code above, following the same tool pipeline described in Tool Workflows below. Prioritize critical categories: security_boundary > data_integrity > business_rule > workflow > crud.

For each test: pick the highest-impact endpoint(s), draft a realistic scenario with actual request/response shapes from source code, and execute the generation tool pipeline.

**Honor your Budget Plan: produce exactly the total you committed to (GENERATE + ADDITIONAL). No fewer, no padding with low-value tests.**

## Recommendation Stability
- **Carry forward** previous additionalRecommendations that still apply — match by scenarioName (multi-step) or endpoint (single-endpoint). Re-derive category and priority from test content.
- **Only drop** a previous recommendation if its target endpoint was removed, its business logic changed, or it is now covered by a generated test.
- **Only add** new recommendations for code paths introduced since the last run.
- Do not churn recommendations without cause.
`;
    }
    // ── OpenAPI spec note ──
    const specNote = openApiSpec
        ? `\n**OpenAPI Spec available**: \`${openApiSpec.path}\`
Use it for contract tests (\`apiSchema: "${openApiSpec.path}"\`) and to extract request/response shapes.
`
        : "";
    // ── PR History ──
    let prHistorySection = "";
    if (prContext && prContext.previousRecommendations.length > 0) {
        const implemented = prContext.previousRecommendations.filter(r => r.status === "implemented");
        const recommended = prContext.previousRecommendations.filter(r => r.status === "recommended");
        let historyBody = "";
        if (implemented.length > 0) {
            const implLines = implemented
                .map((r) => `  - ${r.testType} — ${r.endpoint}`)
                .join("\n");
            const fileLines = prContext.implementedTestFiles.length > 0
                ? `\nExisting test files (in working tree):\n${prContext.implementedTestFiles.map(f => "  - \`" + f + "\`").join("\n")}\n`
                : "";
            historyBody += `### Previously Generated Tests
${implLines}${fileLines}`;
        }
        if (prContext.executionResults.length > 0) {
            const resultLines = prContext.executionResults
                .map((r) => `  - \`${r.testFile}\` — ${r.status}`)
                .join("\n");
            historyBody += `### Execution Results from Prior Run
${resultLines}
If a test failed previously, check whether the failure was environmental or a real bug,
and adjust the test approach if needed.
`;
        }
        if (recommended.length > 0) {
            // De-duplicate: the parser creates one entry per step-endpoint for
            // multi-step scenarios. Collapse them so the LLM sees one line per
            // unique (testType, scenarioName) — or (testType, endpoint) when no
            // scenarioName exists.
            const seen = new Set();
            const dedupedRecs = recommended.filter((r) => {
                const key = r.scenarioName
                    ? `${r.testType}::${r.scenarioName}`
                    : `${r.testType}::${r.endpoint}`;
                if (seen.has(key))
                    return false;
                seen.add(key);
                return true;
            });
            const recLines = dedupedRecs
                .map((r) => `  - ${r.testType} — ${r.endpoint}${r.scenarioName ? ` (scenario: ${r.scenarioName})` : ""}`)
                .join("\n");
            historyBody += `
### Previously Recommended (not generated)
${recLines}
**Stability rule**: If a previously recommended test still applies to the current code
(the endpoint exists, the business logic hasn't changed), carry it forward in your
additionalRecommendations — match by scenarioName (for multi-step scenarios) or by
endpoint (for single-endpoint tests). Re-derive category and priority from the test
content. Do NOT drop a previous recommendation unless the underlying code was removed
or the test is now covered by a generated test.
Only add NEW recommendations for code paths introduced in the latest commit.
`;
        }
        prHistorySection = `
## PR History (PR #${prContext.prNumber})
Tests from prior bot runs are still in the working tree — the maintenance pipeline
(${taskRef(TASK_GENERATE)}) keeps them up to date. Use the history below to **avoid duplicating** existing
coverage and to fill gaps:
- **Do NOT re-recommend** tests listed under "Previously Generated Tests" — they already
  exist and are maintained automatically.
- **Carry forward** previously recommended-but-not-generated tests unchanged in
  additionalRecommendations if they still apply. Promote the highest-priority ones
  into generation slots if capacity allows.
- **Add new** recommendations only for endpoints or code paths introduced in the latest
  commit that aren't covered by existing tests or previous recommendations.
- **Stability**: When the code diff between commits is small, the recommendation set
  should be mostly stable. Do not churn recommendations without cause.
- If prior execution results show failures, note the issue but do not re-recommend
  the test — ${taskRef(TASK_GENERATE)} handles fixes for existing tests.
${historyBody}`;
    }
    // ── Compose all sections ──
    // Long-context best practice: all data at top, all instructions + query at bottom.
    // See: https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#long-context-prompting
    const scopeNote = isDiffScope
        ? "Scoped to current branch changes."
        : "Covers the full repository.";
    return `
${buildArchitectPreamble(isDiffScope)}

${modePreamble}

Scope: ${scopeNote}

${sourcePriority}
${uiRecRulesSection}
<repository_context>
## Repository Context

${repoContext}
${specNote}
</repository_context>
${diffSection ? `<branch_diff>\n${diffSection}\n</branch_diff>` : ""}
<endpoint_interactions>
${interactionSection}
</endpoint_interactions>

<existing_tests>
## Existing Tests

**Two categories of test files (identified by tag):**
- \`[skyramp]\` — generated by Skyramp tools. You may UPDATE these when the covered endpoint changes.
- \`[external]\` — user-written tests (pytest, jest, junit, etc.) maintained outside Skyramp. Treat as read-only: use them to determine existing coverage but NEVER update, regenerate, or delete them.

- Frameworks: ${analysis.existingTests.frameworks.join(", ") || "none"}
${formatTestLocations(analysis.existingTests.testLocations)}
</existing_tests>
${prHistorySection ? `<pr_history>\n${prHistorySection}\n</pr_history>` : ""}

<instructions>

${buildContextFetchingGuidance(sessionId)}

${buildReasoningProtocol()}

${isDiffScope ? buildFewShotExamples() : ""}

${isDiffScope
        ? (isUIOnlyPR
            ? `## How to Generate Tests — Tool Workflows

**For UI tests (AI-driven recording):**
1. \`browser_navigate({ url: "<frontend_url>" })\`
2. Interact with changed components (\`browser_click\`, \`browser_type\`, \`browser_fill_form\`, etc.)
3. \`browser_snapshot()\` after each interaction
4. \`skyramp_export_zip({ outputPath: "<repositoryPath>/.skyramp/<component>_trace.zip" })\` — absolute path
5. \`skyramp_ui_test_generation({ playwrightInput: "<absolute_path_to_zip>" })\`

**For E2E tests:** Same browser recording flow, then call \`skyramp_e2e_test_generation\` with the zip.`
            : buildToolWorkflows(authHeaderValue, authTypeValue, workspaceAuthScheme))
        : ""}

${mainSection}

${isDiffScope ? buildVerificationChecklist(topN, maxGen, EXEC_STEP_CODE_REVIEW) : ""}
</instructions>
`;
}
