// ---------------------------------------------------------------------------
// LangSmith run reshaping
//
// Pure transform from LangSmith's flat `Run[]` into the
// `WorkflowTestCaseResult[][]` shape the aggregator + HTML report expect.
// Extracted from cli/index.ts so the verdict-reattachment logic is
// unit-testable on its own (index.ts runs main() at import time).
// ---------------------------------------------------------------------------

import type {
	InstanceAiEvalAgentExecutionResult,
	InstanceAiEvalExecutionResult,
	InstanceAiRunDebugResponse,
} from '@n8n/api-types';
import type { Run } from 'langsmith/schemas';
import { z } from 'zod';

import { CHECK_DIMENSIONS, type CheckOutcome } from '../binaryChecks/types';
import type { WorkflowResponse } from '../clients/n8n-client';
import type { WorkflowTestCaseWithFile } from '../data/workflows';
import { EVAL_ATTRIBUTIONS, type EvalAttribution } from '../harness/attribution';
import { BUILD_ONLY_SCENARIO_NAME } from '../langsmith/dataset-sync';
import type {
	BuildTrace,
	BuildExpectationResult,
	ExecutionScenarioResult,
	TranscriptTurn,
	WorkflowTestCaseResult,
} from '../types';

const checkOutcomeSchema = z.object({
	name: z.string(),
	description: z.string(),
	kind: z.enum(['deterministic', 'llm']),
	dimension: z.enum(CHECK_DIMENSIONS),
	status: z.enum(['pass', 'fail', 'n_a', 'error']),
	comment: z.string().optional(),
});

/** Per-expectation verdicts embedded in run outputs — mirrors `BuildExpectationResult`
 *  so baseline fetches can score expectations alongside scenarios. */
export const expectationResultsSchema = z.array(
	z.object({
		expectation: z.string(),
		pass: z.boolean(),
		reason: z.string().default(''),
		incomplete: z.boolean().optional(),
		/** Stamped in case-pipeline's `stampExpectations` — zod strips unknown
		 *  keys, so an omission here silently drops it from `eval-results.json`. */
		attribution: z.enum(EVAL_ATTRIBUTIONS).optional(),
	}),
);

const targetOutputSchema = z.object({
	buildSuccess: z.boolean().default(false),
	passed: z.boolean().default(false),
	score: z.number().default(0),
	reasoning: z.string().default(''),
	workflowId: z.string().optional(),
	scenarioWorkflowId: z.string().optional(),
	/** Set when the scenario ran against a built first-class Agent instead of a workflow. */
	agentId: z.string().optional(),
	failureCategory: z.string().optional(),
	/** The harness's own verdict on who owns the failure — what lang-tracer
	 *  stores. `failureCategory` stays alongside it for readers on the legacy
	 *  contract (TRUST-375). */
	attribution: z.enum(EVAL_ATTRIBUTIONS).optional(),
	rootCause: z.string().optional(),
	/** Verifier returned no verdict — run is excluded from scoring but stays visible. */
	incomplete: z.boolean().optional(),
	// `.catch` so one malformed field can't void the whole row in `safeParse`.
	expectationResults: expectationResultsSchema.optional().catch(undefined),
	execErrors: z.array(z.string()).default([]),
	evalResult: z.unknown().optional(),
	agentEvalResult: z.unknown().optional(),
	/** Rendered agent config + skills — attached to every agent row, deduped on reshape (first write wins). */
	agentContext: z.string().optional(),
	/** Only set on the scenario that initiated the build. */
	buildDurationMs: z.number().optional(),
	/** `claude` build spend in USD (--build-via-mcp only). Repeats on every row
	 *  of the case's build — dedupe per (iteration, case) when summing. */
	buildCostUsd: z.number().optional(),
	/** Assistant turns across the `claude` build's attempts (--build-via-mcp only). */
	buildTurns: z.number().optional(),
	execDurationMs: z.number().default(0),
	nodeCount: z.number().default(0),
	/** The thread id used during the build — keys the LangSmith trace lookup. */
	threadId: z.string().optional(),
	workflowChecks: z.array(checkOutcomeSchema).optional(),
	workflowJson: z.unknown().optional(),
	buildTrace: z.unknown().optional(),
	/** Plan rejections the proxy issued — deterministic conversation counter. Multi-turn only. */
	planRejections: z.number().optional(),
});

export type TargetOutput = Omit<
	z.infer<typeof targetOutputSchema>,
	'evalResult' | 'agentEvalResult' | 'workflowJson' | 'buildTrace'
> & {
	evalResult?: InstanceAiEvalExecutionResult;
	agentEvalResult?: InstanceAiEvalAgentExecutionResult;
	workflowJson?: WorkflowResponse;
	buildTrace?: BuildTrace;
};

export function isPlainObject(v: unknown): v is Record<string, unknown> {
	return typeof v === 'object' && v !== null && !Array.isArray(v);
}

function isEvalResult(v: unknown): v is InstanceAiEvalExecutionResult {
	if (!isPlainObject(v)) return false;
	return (
		typeof v.nodeResults === 'object' &&
		v.nodeResults !== null &&
		Array.isArray(v.errors) &&
		typeof v.hints === 'object' &&
		v.hints !== null
	);
}

function isAgentEvalResult(v: unknown): v is InstanceAiEvalAgentExecutionResult {
	if (!isPlainObject(v)) return false;
	return (
		typeof v.runId === 'string' &&
		Array.isArray(v.toolCalls) &&
		Array.isArray(v.modelTurns) &&
		isPlainObject(v.seed)
	);
}

function isWorkflowResponse(v: unknown): v is WorkflowResponse {
	if (!isPlainObject(v)) return false;
	return (
		typeof v.id === 'string' &&
		typeof v.name === 'string' &&
		typeof v.active === 'boolean' &&
		typeof v.versionId === 'string' &&
		Array.isArray(v.nodes) &&
		isPlainObject(v.connections)
	);
}

function isBuildTrace(v: unknown): v is BuildTrace {
	if (!isPlainObject(v)) return false;
	return (
		typeof v.finalText === 'string' &&
		Array.isArray(v.toolCalls) &&
		Array.isArray(v.agentActivities)
	);
}

/** Safe-parse a run's outputs. Returns `undefined` if the row is malformed
 *  or missing, so callers can skip it instead of treating it as a genuine
 *  failed evaluation. Every field in the schema has a default, so an empty
 *  or nullish raw value would otherwise parse successfully into a "failed"
 *  shape (passed:false, score:0) — masking infra errors as builder regressions.
 */
export function parseTargetOutput(raw: unknown): TargetOutput | undefined {
	if (!isPlainObject(raw) || Object.keys(raw).length === 0) return undefined;
	const parsed = targetOutputSchema.safeParse(raw);
	if (!parsed.success) return undefined;
	return {
		...parsed.data,
		evalResult: isEvalResult(parsed.data.evalResult) ? parsed.data.evalResult : undefined,
		agentEvalResult: isAgentEvalResult(parsed.data.agentEvalResult)
			? parsed.data.agentEvalResult
			: undefined,
		workflowJson: isWorkflowResponse(parsed.data.workflowJson)
			? parsed.data.workflowJson
			: undefined,
		buildTrace: isBuildTrace(parsed.data.buildTrace) ? parsed.data.buildTrace : undefined,
	};
}

/**
 * Derive the `__build_only__` sentinel row's outcome from the case's expectation
 * verdicts — the judge IS the whole test for a scenario-less case. All evaluated
 * expectations passing ⇒ passed; no evaluated verdicts (judge dead) ⇒ `incomplete`
 * so the row stays out of scoring instead of reading as a permanent failure.
 */
export function sentinelOutcomeFromVerdicts(verdicts: BuildExpectationResult[] | undefined): {
	passed: boolean;
	score: number;
	reasoning: string;
	incomplete?: boolean;
	/** Only on non-passing outcomes — without a category the feedback extractor
	 *  files the row under 'unknown' in the LangSmith failure_category column. */
	failureCategory?: 'expectations_failed' | 'verification_failure';
	/** The bucket lang-tracer stores. A missed expectation is a builder miss —
	 *  same stance the verifier prompt takes; no verdicts at all is unmeasured. */
	attribution?: EvalAttribution;
} {
	const evaluated = (verdicts ?? []).filter((v) => !v.incomplete);
	if (evaluated.length === 0) {
		return {
			passed: false,
			score: 0,
			reasoning: 'Build-only case — no expectation verdicts (judge incomplete)',
			incomplete: true,
			failureCategory: 'verification_failure',
			attribution: 'verification_gap',
		};
	}
	const failed = evaluated.filter((v) => !v.pass);
	const passed = failed.length === 0;
	return {
		passed,
		score: (evaluated.length - failed.length) / evaluated.length,
		reasoning: passed
			? `Build-only case — all ${String(evaluated.length)} expectations passed`
			: `Build-only case — failed expectations: ${failed.map((v) => v.expectation).join('; ')}`,
		...(passed
			? {}
			: { failureCategory: 'expectations_failed' as const, attribution: 'builder_issue' as const }),
	};
}

const runInputsSchema = z
	.object({
		testCaseFile: z.string().default(''),
		scenarioName: z.string().default(''),
		/** 0-based iteration index; injected during multi-run expansion. */
		_iteration: z.number().int().nonnegative().default(0),
	})
	.passthrough();

/**
 * Convert LangSmith's flat `Run[]` into the `WorkflowTestCaseResult[][]` shape
 * the aggregator expects (outer: runs, inner: test cases). Groups by
 * (testCaseFile, scenarioName), then reconstructs per-iteration test case
 * results. Scenarios with no matching run get a build_failure stub.
 */
/** The two Run fields reshape reads. LangSmith's `{run: Run}` rows satisfy it,
 *  and the direct driver builds it from plain row inputs/outputs — no
 *  LangSmith dependency required to reshape. */
export interface ReshapeRunRow {
	run: Pick<Run, 'inputs' | 'outputs'>;
}

export function reshapeLangSmithRuns(
	rows: ReshapeRunRow[],
	testCasesWithFiles: WorkflowTestCaseWithFile[],
	numIterations: number,
	transcriptByThreadId: Map<string, TranscriptTurn[]>,
	/** Keyed by the build-cache key (`iteration:fileSlug`), not threadId, so prebuilt
	 *  builds (no threadId) still attach their outcome-expectation verdicts. */
	buildExpectationsByKey: Map<string, BuildExpectationResult[]>,
	n8nBaseUrl: string | undefined,
	runDebugByThreadId: Map<string, InstanceAiRunDebugResponse[]> = new Map(),
): WorkflowTestCaseResult[][] {
	// Index runs by (iteration, testCaseFile, scenarioName) using the `_iteration`
	// we injected in expandExamplesForIterations. Falls back to 0 for single-run.
	const byKey = new Map<string, ReshapeRunRow['run']>();
	for (const { run } of rows) {
		const inputs = runInputsSchema.safeParse(run.inputs ?? {});
		if (!inputs.success) continue;
		const key = `${String(inputs.data._iteration)}/${inputs.data.testCaseFile}/${inputs.data.scenarioName}`;
		byKey.set(key, run);
	}

	const allRunResults: WorkflowTestCaseResult[][] = [];
	for (let iter = 0; iter < numIterations; iter++) {
		const runResults: WorkflowTestCaseResult[] = [];
		for (const { testCase, fileSlug } of testCasesWithFiles) {
			const executionScenarioResults: ExecutionScenarioResult[] = [];
			let workflowBuildSuccess = false;
			let workflowId: string | undefined;
			let agentId: string | undefined;
			let agentArtifactContext: string | undefined;
			let buildError: string | undefined;
			let threadId: string | undefined;
			let workflowChecks: CheckOutcome[] | undefined;
			let workflowJson: WorkflowResponse | undefined;
			let buildTrace: BuildTrace | undefined;
			let buildCostUsd: number | undefined;
			let buildTurns: number | undefined;

			for (const scenario of testCase.executionScenarios ?? []) {
				const run = byKey.get(`${String(iter)}/${fileSlug}/${scenario.name}`);
				const output = run ? parseTargetOutput(run.outputs) : undefined;
				if (!run || !output) {
					executionScenarioResults.push({
						scenario,
						success: false,
						score: 0,
						reasoning: run ? 'Malformed run output — skipped' : 'No run result for this scenario',
						// The row never produced a verdict, so nobody owns it. Without this
						// it reaches lang-tracer category-less and defaults to a product
						// failure.
						attribution: 'verification_gap',
						// …and unowned has to mean unscored too, or the row is visible as a
						// gap while still counting against the builder. Same pairing as
						// `sentinelOutcomeFromVerdicts` and `attributionFor`.
						incomplete: true,
					});
					continue;
				}
				if (output.buildSuccess) workflowBuildSuccess = true;
				if (output.workflowId) workflowId = output.workflowId;
				if (output.agentId && !agentId) agentId = output.agentId;
				if (output.agentContext && !agentArtifactContext)
					agentArtifactContext = output.agentContext;
				if (output.threadId) threadId = output.threadId;
				if (!output.buildSuccess && output.reasoning) buildError = output.reasoning;
				if (output.workflowChecks && !workflowChecks) workflowChecks = output.workflowChecks;
				if (output.workflowJson && !workflowJson) workflowJson = output.workflowJson;
				if (output.buildTrace && !buildTrace) buildTrace = output.buildTrace;
				// Every row of the case repeats the build's spend — first defined wins.
				buildCostUsd ??= output.buildCostUsd;
				buildTurns ??= output.buildTurns;
				executionScenarioResults.push({
					scenario,
					success: output.passed,
					evalResult: output.evalResult,
					...(output.agentEvalResult ? { agentEvalResult: output.agentEvalResult } : {}),
					workflowId: output.scenarioWorkflowId ?? output.workflowId,
					...(output.agentId ? { agentId: output.agentId } : {}),
					score: output.score,
					reasoning: output.reasoning,
					failureCategory: output.failureCategory,
					attribution: output.attribution,
					rootCause: output.rootCause,
					...(output.incomplete ? { incomplete: true } : {}),
				});
			}

			// Build-only case (0 scenarios): pull build fields from the sentinel row; no scenario unit is recorded.
			if ((testCase.executionScenarios?.length ?? 0) === 0) {
				const run = byKey.get(`${String(iter)}/${fileSlug}/${BUILD_ONLY_SCENARIO_NAME}`);
				const output = run ? parseTargetOutput(run.outputs) : undefined;
				if (output) {
					workflowBuildSuccess = output.buildSuccess;
					workflowId = output.workflowId;
					agentId = output.agentId;
					agentArtifactContext = output.agentContext;
					threadId = output.threadId;
					if (!output.buildSuccess && output.reasoning) buildError = output.reasoning;
					workflowChecks = output.workflowChecks;
					workflowJson = output.workflowJson;
					buildTrace = output.buildTrace;
					buildCostUsd = output.buildCostUsd;
					buildTurns = output.buildTurns;
				}
			}

			const transcript = threadId ? transcriptByThreadId.get(threadId) : undefined;
			const buildExpectationResults = buildExpectationsByKey.get(`${String(iter)}:${fileSlug}`);
			runResults.push({
				testCase,
				fileSlug,
				workflowBuildSuccess,
				workflowId,
				agentId,
				agentArtifactContext,
				executionScenarioResults,
				buildError,
				threadId,
				transcript,
				buildExpectationResults,
				workflowChecks,
				workflowJson,
				buildTrace,
				buildCostUsd,
				buildTurns,
				n8nBaseUrl,
				runDebug: threadId ? runDebugByThreadId.get(threadId) : undefined,
			});
		}
		allRunResults.push(runResults);
	}
	return allRunResults;
}
