#!/usr/bin/env node
/* global AbortController, clearTimeout, console, fetch, process, setTimeout */

import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const DEFAULT_TIMEOUT_MS = 180000;
const DEFAULT_CONTRACT_SCENARIO_ID = 'convert-to-doc';
const FAILURE_CONTRACT_IDS = ['missing-artifact', 'tool-failure', 'quality-gate-failure', 'forbidden-output'];
const DEFAULT_CONTRACT_IDS = ['success', ...FAILURE_CONTRACT_IDS];
const SENTINEL = 'SIT_SCENARIO_SENTINEL_0_1_8';
const SCENARIO_MANIFEST_API_PATH = '/api/scenario-manifests';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, '../..');
const DEFAULT_SCENARIO_DIR = resolve(REPO_ROOT, 'sit/scenarios');

const HEADER_NAMES = ['x-tenant-key', 'x-user-id', 'x-session-id', 'x-conversation-id', 'x-trace-id', 'x-request-id'];

const args = new Set(process.argv.slice(2).filter((arg) => arg !== '--'));
const baseUrl = stripTrailingSlash(readEnv('LARKPAL_SIT_BASE_URL', 'http://127.0.0.1:3000'));
const timeoutMs = Number(readEnv('LARKPAL_SIT_TIMEOUT_MS', String(DEFAULT_TIMEOUT_MS)));
const runId = readEnv(
  'LARKPAL_SIT_RUN_ID',
  new Date()
    .toISOString()
    .replace(/[-:.TZ]/g, '')
    .slice(0, 14),
);

const selected = {
  headers: args.size === 0 || args.has('--headers') || args.has('--all'),
  failure: args.has('--failure') || args.has('--all') || readBooleanEnv('LARKPAL_SIT_RUN_FAILURE'),
  scenario: args.has('--scenario') || args.has('--all') || readBooleanEnv('LARKPAL_SIT_RUN_SCENARIO'),
  missingScenario:
    args.has('--missing-scenario') || args.has('--all') || readBooleanEnv('LARKPAL_SIT_RUN_MISSING_SCENARIO'),
  contracts: args.has('--contracts') || args.has('--all') || readBooleanEnv('LARKPAL_SIT_RUN_CONTRACTS'),
};

if (args.has('--help') || args.has('-h')) {
  printHelp();
  process.exit(0);
}

if (
  ![...args].every((arg) =>
    ['--headers', '--failure', '--scenario', '--missing-scenario', '--contracts', '--all', '--help', '-h'].includes(
      arg,
    ),
  )
) {
  console.error('Unknown argument. Run with --help for usage.');
  process.exit(2);
}

const results = [];

try {
  if (selected.headers) {
    results.push(...(await runHeaderProbe()));
  }
  if (selected.failure) {
    results.push(...(await runFailureProbes()));
  }
  if (selected.scenario) {
    results.push(await runScenarioSmoke());
  }
  if (selected.missingScenario) {
    results.push(await runMissingScenarioProbe());
  }
  if (selected.contracts) {
    results.push(...(await runContractExampleProbes()));
  }
} catch (error) {
  results.push({
    name: 'sit-runner',
    status: 'FAIL',
    error: error instanceof Error ? error.message : String(error),
  });
}

printSummary(results);

if (results.some((result) => result.status === 'FAIL')) {
  process.exit(1);
}

async function runHeaderProbe() {
  const probes = [
    buildExecuteProbe({
      label: 'headers-tenant-a',
      tenantKey: readEnv('LARKPAL_SIT_TENANT_A', 'sit-tenant-a'),
      userId: readEnv('LARKPAL_SIT_USER_A', 'sit-user-a'),
      sessionId: `sit-session-a-${runId}`,
      conversationId: `sit-conv-a-${runId}`,
      traceId: `sit-trace-a-${runId}`,
      requestId: `sit-req-a-${runId}`,
      prompt: readEnv(
        'LARKPAL_SIT_TOOL_LIST_PROMPT',
        'You must call the MCP tool list-scenario-manifests exactly once before answering. After the tool result, reply with SIT_HEADER_PROBE_DONE. Do not answer without the tool call.',
      ),
      maxTurns: Number(readEnv('LARKPAL_SIT_HEADER_MAX_TURNS', '8')),
    }),
    buildExecuteProbe({
      label: 'headers-tenant-b',
      tenantKey: readEnv('LARKPAL_SIT_TENANT_B', 'sit-tenant-b'),
      userId: readEnv('LARKPAL_SIT_USER_B', 'sit-user-b'),
      sessionId: `sit-session-b-${runId}`,
      conversationId: `sit-conv-b-${runId}`,
      traceId: `sit-trace-b-${runId}`,
      requestId: `sit-req-b-${runId}`,
      prompt: readEnv(
        'LARKPAL_SIT_TOOL_LIST_PROMPT',
        'You must call the MCP tool list-scenario-manifests exactly once before answering. After the tool result, reply with SIT_HEADER_PROBE_DONE. Do not answer without the tool call.',
      ),
      maxTurns: Number(readEnv('LARKPAL_SIT_HEADER_MAX_TURNS', '8')),
    }),
  ];

  const responses = await Promise.all(probes.map((probe) => postExecute(probe)));
  return responses.map((response) => {
    const transportOk = response.ok && response.json.status !== 'failed';
    return {
      name: response.probe.label,
      status: transportOk ? 'MANUAL' : 'FAIL',
      check: 'Compare expectedMcpHeaders with MCP Server whitelist logs for this request.',
      httpStatus: response.httpStatus,
      taskId: response.json.task_id,
      responseStatus: response.json.status,
      resultSubtype: response.json.result?.subtype,
      resultIsError: response.json.result?.isError,
      expectedMcpHeaders: expectedHeaders(response.probe),
      responsePreview: preview(response.json),
    };
  });
}

async function runFailureProbes() {
  const cases = [
    {
      label: 'failure-ok-false',
      mode: 'ok-false',
      envName: 'LARKPAL_SIT_FAILURE_PROMPT_OK_FALSE',
      defaultPrompt:
        'Call the MCP tool diagnostic.fail exactly once with JSON arguments {"mode":"ok-false"}. Do not call any other tool. Do not recover the error.',
    },
    {
      label: 'failure-exit-code',
      mode: 'exit-code',
      envName: 'LARKPAL_SIT_FAILURE_PROMPT_EXIT_CODE',
      defaultPrompt:
        'Call the MCP tool diagnostic.fail exactly once with JSON arguments {"mode":"exit-code","exitCode":2}. Do not call any other tool. Do not recover the error.',
    },
    {
      label: 'failure-is-error',
      mode: 'is-error',
      envName: 'LARKPAL_SIT_FAILURE_PROMPT_IS_ERROR',
      defaultPrompt:
        'Call the MCP tool diagnostic.fail exactly once with JSON arguments {"mode":"is-error"}. Do not call any other tool. Do not recover the error.',
    },
  ];

  const responses = [];
  for (const testCase of cases) {
    const probe = buildExecuteProbe({
      label: testCase.label,
      tenantKey: readEnv('LARKPAL_SIT_FAILURE_TENANT', 'sit-tenant-failure'),
      userId: readEnv('LARKPAL_SIT_FAILURE_USER', 'sit-user-failure'),
      sessionId: `sit-session-${testCase.mode}-${runId}`,
      conversationId: `sit-conv-${testCase.mode}-${runId}`,
      traceId: `sit-trace-${testCase.mode}-${runId}`,
      requestId: `sit-req-${testCase.mode}-${runId}`,
      prompt: readEnv(testCase.envName, testCase.defaultPrompt),
      maxTurns: Number(readEnv('LARKPAL_SIT_FAILURE_MAX_TURNS', '8')),
    });
    responses.push(await postExecute(probe));
  }

  return responses.map((response) => {
    const failedFinal =
      response.json.status === 'failed' ||
      response.json.result?.isError === true ||
      response.json.result?.subtype === 'error_during_execution';
    const pass =
      response.ok &&
      response.json.result?.isError === true &&
      response.json.result?.subtype === 'error_during_execution';

    return {
      name: response.probe.label,
      status: pass ? 'PASS' : 'FAIL',
      check:
        'Final result must be error_during_execution/isError=true. Confirm tool-result summary fields in larkpal audit logs.',
      httpStatus: response.httpStatus,
      taskId: response.json.task_id,
      responseStatus: response.json.status,
      resultSubtype: response.json.result?.subtype,
      resultIsError: response.json.result?.isError,
      failedFinal,
      expectedMcpHeaders: expectedHeaders(response.probe),
      responsePreview: preview(response.json),
    };
  });
}

async function runScenarioSmoke() {
  const scenarioDir = readEnv('LARKPAL_SIT_SCENARIO_DIR', DEFAULT_SCENARIO_DIR);
  const manifestPath = resolve(scenarioDir, 'sit-smoke.scenario.yaml');
  const referencePath = resolve(scenarioDir, 'sit-smoke.reference.md');

  if (!existsSync(manifestPath) || !existsSync(referencePath)) {
    return {
      name: 'scenario-smoke',
      status: 'FAIL',
      error: `Missing scenario fixture. Expected ${manifestPath} and ${referencePath}.`,
    };
  }

  const probe = buildExecuteProbe({
    label: 'scenario-smoke',
    tenantKey: readEnv('LARKPAL_SIT_SCENARIO_TENANT', 'sit-tenant-scenario'),
    userId: readEnv('LARKPAL_SIT_SCENARIO_USER', 'sit-user-scenario'),
    sessionId: `sit-session-scenario-${runId}`,
    conversationId: `sit-conv-scenario-${runId}`,
    traceId: `sit-trace-scenario-${runId}`,
    requestId: `sit-req-scenario-${runId}`,
    scenarioId: 'sit-smoke',
    prompt: readEnv(
      'LARKPAL_SIT_SCENARIO_PROMPT',
      'Return exactly the sentinel value from the loaded scenario reference. If the scenario reference is unavailable, reply with MISSING_SCENARIO_CONTEXT.',
    ),
    maxTurns: Number(readEnv('LARKPAL_SIT_SCENARIO_MAX_TURNS', '6')),
  });
  const response = await postExecute(probe);
  const responseText = JSON.stringify(response.json);
  const hasScenarioError =
    responseText.includes('SCENARIO_NOT_FOUND') || responseText.includes('SCENARIO_SKILL_NOT_FOUND');
  const pass = response.ok && !hasScenarioError && responseText.includes(SENTINEL);

  return {
    name: 'scenario-smoke',
    status: pass ? 'PASS' : 'FAIL',
    check: 'Server must be started with LARKPAL_AGENT_SCENARIO_DIRS pointing at the fixture directory.',
    scenarioDir,
    requiredServerEnv: `LARKPAL_AGENT_SCENARIO_DIRS=${scenarioDir}`,
    httpStatus: response.httpStatus,
    taskId: response.json.task_id,
    responseStatus: response.json.status,
    resultSubtype: response.json.result?.subtype,
    resultIsError: response.json.result?.isError,
    containsSentinel: responseText.includes(SENTINEL),
    hasScenarioError,
    expectedMcpHeaders: expectedHeaders(response.probe),
    responsePreview: preview(response.json),
  };
}

async function runMissingScenarioProbe() {
  const probe = buildExecuteProbe({
    label: 'scenario-missing-fail-closed',
    tenantKey: readEnv('LARKPAL_SIT_SCENARIO_TENANT', 'sit-tenant-scenario'),
    userId: readEnv('LARKPAL_SIT_SCENARIO_USER', 'sit-user-scenario'),
    sessionId: `sit-session-missing-scenario-${runId}`,
    conversationId: `sit-conv-missing-scenario-${runId}`,
    traceId: `sit-trace-missing-scenario-${runId}`,
    requestId: `sit-req-missing-scenario-${runId}`,
    scenarioId: readEnv('LARKPAL_SIT_MISSING_SCENARIO_ID', 'sit-missing-scenario'),
    prompt:
      'This request intentionally uses a missing scenarioId. The runtime must fail closed and must not continue as ordinary chat.',
    maxTurns: Number(readEnv('LARKPAL_SIT_SCENARIO_MAX_TURNS', '6')),
  });
  const response = await postExecute(probe);
  const responseText = JSON.stringify(response.json);
  const hasScenarioNotFound = responseText.includes('SCENARIO_NOT_FOUND');
  const failedClosed =
    !response.ok ||
    response.json.status === 'failed' ||
    response.json.result?.isError === true ||
    response.json.result?.subtype === 'error_during_execution';
  const pass = failedClosed && response.json.status !== 'completed';

  return {
    name: 'scenario-missing-fail-closed',
    status: pass ? 'PASS' : 'FAIL',
    check: 'Missing scenarioId must not fall back to ordinary chat success.',
    httpStatus: response.httpStatus,
    taskId: response.json.task_id,
    responseStatus: response.json.status,
    resultSubtype: response.json.result?.subtype,
    resultIsError: response.json.result?.isError,
    hasScenarioNotFound,
    expectedMcpHeaders: expectedHeaders(response.probe),
    responsePreview: preview(response.json),
  };
}

async function runContractExampleProbes() {
  const providerBaseUrl = getScenarioProviderBaseUrl();
  if (!providerBaseUrl) {
    return [
      {
        name: 'contract-examples',
        status: 'FAIL',
        error:
          'Missing scenario provider base URL. Set LARKPAL_SIT_SCENARIO_PROVIDER_BASE_URL or LARKPAL_AGENT_SCENARIO_PROVIDER_BASE_URL.',
      },
    ];
  }

  const scenarioId = readEnv('LARKPAL_SIT_CONTRACT_SCENARIO_ID', DEFAULT_CONTRACT_SCENARIO_ID);
  const contractIds = readListEnv('LARKPAL_SIT_CONTRACT_IDS', DEFAULT_CONTRACT_IDS);
  const results = [];

  for (const exampleId of contractIds) {
    const example = await fetchContractExample(providerBaseUrl, scenarioId, exampleId);
    const prompt = extractContractPrompt(example, exampleId, scenarioId);
    const probe = buildExecuteProbe({
      label: `contract-${exampleId}`,
      tenantKey: readEnv('LARKPAL_SIT_CONTRACT_TENANT', 'sit-tenant-contract'),
      userId: readEnv('LARKPAL_SIT_CONTRACT_USER', 'sit-user-contract'),
      sessionId: `sit-session-contract-${exampleId}-${runId}`,
      conversationId: `sit-conv-contract-${exampleId}-${runId}`,
      traceId: `sit-trace-contract-${exampleId}-${runId}`,
      requestId: `sit-req-contract-${exampleId}-${runId}`,
      scenarioId,
      prompt,
      maxTurns: Number(readEnv('LARKPAL_SIT_CONTRACT_MAX_TURNS', '30')),
    });
    const response = await postExecute(probe);
    const finalSuccess = response.ok && response.json.status === 'completed' && response.json.result?.isError === false;
    const expectSuccess = exampleId === 'success';
    const pass = expectSuccess ? finalSuccess : !finalSuccess;

    results.push({
      name: `contract-${exampleId}`,
      status: pass ? 'PASS' : 'FAIL',
      check: expectSuccess
        ? 'success example must be allowed to complete successfully by verifier gate.'
        : 'negative example must not complete as success after verifier/artifact/failure gate.',
      scenarioId,
      exampleId,
      providerBaseUrl,
      httpStatus: response.httpStatus,
      taskId: response.json.task_id,
      responseStatus: response.json.status,
      resultSubtype: response.json.result?.subtype,
      resultIsError: response.json.result?.isError,
      expectedMcpHeaders: expectedHeaders(response.probe),
      responsePreview: preview(response.json),
    });
  }

  return results;
}

async function fetchContractExample(providerBaseUrl, scenarioId, exampleId) {
  const url = `${stripTrailingSlash(providerBaseUrl)}/api/scenario-manifests/${encodeURIComponent(
    scenarioId,
  )}/examples/${encodeURIComponent(exampleId)}`;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const response = await fetch(url, {
      signal: controller.signal,
      headers: {
        accept: 'application/json',
      },
    });
    const rawText = await response.text();
    const json = parseJson(rawText);
    if (!response.ok || !json) {
      throw new Error(`Failed to read contract example ${exampleId}: HTTP ${response.status} ${rawText.slice(0, 200)}`);
    }
    return json;
  } finally {
    clearTimeout(timer);
  }
}

function extractContractPrompt(example, exampleId, scenarioId) {
  const candidates = [
    example?.prompt,
    example?.input?.prompt,
    example?.request?.prompt,
    example?.body?.prompt,
    example?.messages?.find?.((message) => message?.role === 'user')?.content,
  ];
  const prompt = candidates.find((candidate) => typeof candidate === 'string' && candidate.trim().length > 0);
  if (prompt) {
    return prompt;
  }

  return [
    `Run the ${scenarioId} scenario using AI_knowledge_dev contract example ${exampleId}.`,
    'Use the loaded scenario manifest, required skills, capabilities, artifacts, quality gates, failure policy, and verifier contract.',
    `Contract example payload:\n${JSON.stringify(example)}`,
  ].join('\n\n');
}

function buildExecuteProbe(input) {
  return {
    label: input.label,
    tenantKey: input.tenantKey,
    userId: input.userId,
    sessionId: input.sessionId,
    conversationId: input.conversationId,
    traceId: input.traceId,
    requestId: input.requestId,
    body: {
      session_id: input.sessionId,
      conversationId: input.conversationId,
      traceId: input.traceId,
      requestId: input.requestId,
      ...(input.scenarioId ? { scenarioId: input.scenarioId } : {}),
      prompt: input.prompt,
      mode: 'sync',
      max_turns: input.maxTurns,
    },
  };
}

async function postExecute(probe) {
  const url = `${baseUrl}/api/execute`;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      method: 'POST',
      signal: controller.signal,
      headers: {
        'content-type': 'application/json',
        'x-tenant-key': probe.tenantKey,
        'x-user-id': probe.userId,
        'x-trace-id': probe.traceId,
        'x-request-id': probe.requestId,
      },
      body: JSON.stringify(probe.body),
    });
    const rawText = await response.text();
    const json = parseJson(rawText);
    return {
      probe,
      ok: response.ok,
      httpStatus: response.status,
      json: json ?? { rawText },
    };
  } catch (error) {
    return {
      probe,
      ok: false,
      httpStatus: 0,
      json: {
        status: 'failed',
        error: error instanceof Error ? error.message : String(error),
      },
    };
  } finally {
    clearTimeout(timer);
  }
}

function expectedHeaders(probe) {
  return {
    'x-tenant-key': probe.tenantKey,
    'x-user-id': probe.userId,
    'x-session-id': probe.sessionId,
    'x-conversation-id': probe.conversationId,
    'x-trace-id': probe.traceId,
    'x-request-id': probe.requestId,
  };
}

function preview(value) {
  const text = JSON.stringify(value);
  if (text.length <= 1200) return value;
  return {
    truncated: true,
    length: text.length,
    text: `${text.slice(0, 1200)}...`,
  };
}

function parseJson(text) {
  try {
    return JSON.parse(text);
  } catch {
    return undefined;
  }
}

function printSummary(results) {
  console.log(
    JSON.stringify(
      {
        baseUrl,
        runId,
        selected,
        mcpLogWhitelist: HEADER_NAMES,
        scenarioFixtureDir: DEFAULT_SCENARIO_DIR,
        results,
      },
      null,
      2,
    ),
  );
}

function printHelp() {
  console.log(`larkpal-agent 0.1.9 P0/P1/P2 SIT runner

Usage:
  pnpm sit:larkpal-agent
  pnpm sit:larkpal-agent -- --all
  pnpm sit:larkpal-agent -- --headers --failure
  pnpm sit:larkpal-agent -- --scenario --missing-scenario
  pnpm sit:larkpal-agent -- --contracts

Default:
  Runs the concurrent /api/execute header probe only.

Flags:
  --headers            Run two concurrent RunContext/MCP header probes.
  --failure            Run diagnostic.fail semantics probes.
  --scenario           Run sit-smoke local scenario loader probe.
  --missing-scenario   Run missing scenario fail-closed probe.
  --contracts          Run AI_knowledge_dev convert-to-doc contract examples.
  --all                Run all probes.

Key environment variables:
  LARKPAL_SIT_BASE_URL=http://127.0.0.1:3000
  LARKPAL_SIT_TIMEOUT_MS=${DEFAULT_TIMEOUT_MS}
  LARKPAL_SIT_SCENARIO_DIR=${DEFAULT_SCENARIO_DIR}
  LARKPAL_SIT_SCENARIO_PROVIDER_BASE_URL=<ai-knowledge-dev-base-url>
  LARKPAL_SIT_CONTRACT_IDS=${DEFAULT_CONTRACT_IDS.join(',')}

Server-side prerequisite for scenario probes:
  LARKPAL_AGENT_SCENARIO_DIRS=${DEFAULT_SCENARIO_DIR}

Server-side prerequisite for P2 HTTP provider probes:
  LARKPAL_AGENT_SCENARIO_PROVIDER_TYPE=http
  LARKPAL_AGENT_SCENARIO_PROVIDER_BASE_URL=<ai-knowledge-dev-base-url>

MCP Server/API Server log whitelist:
  ${HEADER_NAMES.join(', ')}
`);
}

function readEnv(name, fallback) {
  const value = process.env[name];
  return typeof value === 'string' && value.length > 0 ? value : fallback;
}

function readBooleanEnv(name) {
  const value = process.env[name];
  return value === '1' || value === 'true' || value === 'yes';
}

function readListEnv(name, fallback) {
  const value = process.env[name];
  if (!value) return fallback;
  return value
    .split(',')
    .map((entry) => entry.trim())
    .filter(Boolean);
}

function getScenarioProviderBaseUrl() {
  const baseUrlValue =
    process.env.LARKPAL_SIT_SCENARIO_PROVIDER_BASE_URL ||
    process.env.LARKPAL_AGENT_SCENARIO_PROVIDER_BASE_URL ||
    process.env.AI_KNOWLEDGE_SCENARIO_PROVIDER_BASE_URL;
  return baseUrlValue ? normalizeScenarioProviderBaseUrl(baseUrlValue) : undefined;
}

function normalizeScenarioProviderBaseUrl(baseUrlValue) {
  const trimmed = stripTrailingSlash(baseUrlValue.trim());
  if (trimmed.toLowerCase().endsWith(SCENARIO_MANIFEST_API_PATH)) {
    return trimmed.slice(0, -SCENARIO_MANIFEST_API_PATH.length) || trimmed;
  }
  return trimmed;
}

function stripTrailingSlash(value) {
  return value.endsWith('/') ? value.slice(0, -1) : value;
}
