// SPEC: PJ2026-01060703 CI/CD branch follower debug steps.
// Responsibility: bounded single-step debugging for branch follower state and decision paths.
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { runCommand, type CommandResult } from "./command";
import { repoRoot, rootPath } from "./config";
import type { AdapterSummary, BranchFollowerDebugStep, BranchFollowerRegistry, FollowerSpec, FollowerState, K8sStateRead, ParsedOptions } from "./cicd-types";
import { taskRunItems } from "./cicd-taskruns";
import { redactText } from "./platform-infra-ops-library";

type KubeScriptRunner = (registry: BranchFollowerRegistry, options: ParsedOptions, script: string, input: string, timeoutMs: number) => CommandResult;

export interface CicdDebugDeps {
  selectFollowers(registry: BranchFollowerRegistry, options: ParsedOptions, opts: { includeDisabled: boolean }): FollowerSpec[];
  readK8sState(registry: BranchFollowerRegistry, options: ParsedOptions): K8sStateRead;
  readAdapterStatus(registry: BranchFollowerRegistry, follower: FollowerSpec, options: ParsedOptions): Promise<AdapterSummary>;
  decideAndMaybeTrigger(registry: BranchFollowerRegistry, follower: FollowerSpec, previous: Record<string, unknown>, live: AdapterSummary, options: ParsedOptions): Promise<FollowerState>;
  writeFollowerState(registry: BranchFollowerRegistry, state: FollowerState, options: ParsedOptions): CommandResult;
  runKubeScript: KubeScriptRunner;
}

export async function buildDebugStep(registry: BranchFollowerRegistry, options: ParsedOptions, deps: CicdDebugDeps): Promise<Record<string, unknown>> {
  const step = options.debugStep ?? "state-read";
  if (options.followerId === null) throw new Error("debug-step requires --follower <id>");

  const selected = deps.selectFollowers(registry, options, { includeDisabled: true });
  if (selected.length !== 1) throw new Error("debug-step operates on exactly one follower");
  const follower = selected[0] as FollowerSpec;
  const before = deps.readK8sState(registry, options);
  const previous = before.stateByFollower[follower.id] ?? {};
  let live: AdapterSummary | null = null;
  let decided: FollowerState | null = null;
  let write: Record<string, unknown> | null = null;
  let after: K8sStateRead | null = null;
  let controllerSource: Record<string, unknown> | null = null;

  if (step === "controller-source") {
    controllerSource = controllerSourceSnapshot(registry);
  }
  if (step === "status-read" || step === "decide") {
    live = await deps.readAdapterStatus(registry, follower, options);
  }
  if (step === "decide") {
    decided = await deps.decideAndMaybeTrigger(registry, follower, previous, live as AdapterSummary, debugDecisionOptions(options));
  }
  if (step === "state-write") {
    const writeInput = stateWriteInput(previous);
    if (writeInput === null) {
      return {
        ok: false,
        action: "debug-step",
        step,
        follower: follower.id,
        execution: options.inCluster ? "k8s-native-in-cluster" : "operator-readonly",
        dryRun: !options.confirm,
        stateBefore: stateSnapshot(before, follower.id),
        stateWrite: { ok: false, skipped: true, reason: "stored-state-missing" },
        parsedDownstreamCliOutput: false,
      };
    }
    if (options.confirm) {
      const result = deps.writeFollowerState(registry, writeInput, options);
      write = stateWriteResult(follower.id, result);
      after = deps.readK8sState(registry, options);
    } else {
      write = { ok: true, skipped: true, reason: "dry-run-requires-confirm", input: "stored-state" };
    }
  }

  return {
    ok: write === null ? before.ok && (live === null || live.ok) : write.ok === true,
    action: "debug-step",
    step,
    follower: follower.id,
    execution: options.inCluster ? "k8s-native-in-cluster" : "operator-readonly",
    dryRun: !options.confirm,
    stateBefore: stateSnapshot(before, follower.id),
    controllerSource,
    status: live === null ? null : compactAdapterStatus(live),
    decision: decided === null ? null : compactFollowerDecision(decided),
    stateWrite: write,
    stateAfter: after === null ? null : stateSnapshot(after, follower.id),
    parsedDownstreamCliOutput: false,
    next: debugNext(follower.id),
  };
}

export function renderDebugStepHuman(payload: Record<string, unknown>): string {
  const before = asOptionalRecord(payload.stateBefore);
  const after = asOptionalRecord(payload.stateAfter);
  const write = asOptionalRecord(payload.stateWrite);
  const status = asOptionalRecord(payload.status);
  const decision = asOptionalRecord(payload.decision);
  const controllerSource = asOptionalRecord(payload.controllerSource);
  const target = asOptionalRecord(payload.target);
  const next = asOptionalRecord(payload.next);
  const rows = [[
    payload.follower ?? "-",
    payload.step ?? "-",
    payload.execution ?? "-",
    payload.dryRun === true ? "true" : "false",
    before?.phase ?? "-",
    after?.phase ?? decision?.phase ?? status?.phase ?? "-",
    shortSha(stringOrNull(before?.observedSha)),
    shortSha(stringOrNull(after?.observedSha) ?? stringOrNull(decision?.observedSha) ?? stringOrNull(status?.observedSha)),
  ]];
  const writeRows = write === null ? [] : [[
    write.ok === true ? "ok" : "failed",
    write.skipped === true ? "skipped" : "executed",
    write.input ?? "-",
    asOptionalRecord(write.patch)?.beforeResourceVersion ?? "-",
    asOptionalRecord(write.patch)?.afterResourceVersion ?? "-",
    write.exitCode ?? "-",
    write.message ?? write.reason ?? "-",
  ]];
  return [
    `CI/CD BRANCH-FOLLOWER DEBUG-STEP (${payload.ok === false ? "failed" : "ok"})`,
    "",
    table(["FOLLOWER", "STEP", "EXECUTION", "DRY_RUN", "BEFORE", "AFTER", "BEFORE_SHA", "AFTER_SHA"], rows),
    controllerSource === null ? "" : `\nCONTROLLER SOURCE\n${table(["HEAD", "BRANCH", "REGISTRY", "REREAD_MARKER", "FILE_SHA"], [[shortSha(stringOrNull(controllerSource.head)), controllerSource.branch ?? "-", shortSha(stringOrNull(controllerSource.registrySha256)), controllerSource.closeoutRereadMarker === true ? "present" : "missing", shortSha(stringOrNull(asOptionalRecord(controllerSource.branchFollowerFile)?.sha256))]])}`,
    target === null ? "" : `\nTARGET JOB\n${table(["JOB", "EXIT", "TIMED_OUT", "PARSED"], [[target.name ?? "-", target.exitCode ?? "-", target.timedOut ?? "-", target.parsed === true ? "yes" : "no"]])}`,
    writeRows.length === 0 ? "" : `\nSTATE WRITE\n${table(["STATUS", "MODE", "INPUT", "BEFORE_RV", "AFTER_RV", "EXIT", "MESSAGE"], writeRows)}`,
    "",
    "NEXT",
    `state-read: ${next?.stateRead ?? "-"}`,
    `controller-source: ${next?.controllerSource ?? "-"}`,
    `status-read: ${next?.statusRead ?? "-"}`,
    `decide: ${next?.decide ?? "-"}`,
    "",
  ].filter((line) => line !== "").join("\n");
}

function controllerSourceSnapshot(registry: BranchFollowerRegistry): Record<string, unknown> {
  const head = commandText(["git", "rev-parse", "HEAD"]);
  const branch = commandText(["git", "symbolic-ref", "--short", "HEAD"]);
  const file = fileSnapshot("scripts/src/cicd-branch-follower.ts");
  const text = file.text;
  const errors = [
    head.ok ? null : `git head: ${head.error}`,
    branch.ok ? null : `git branch: ${branch.error}`,
    file.ok ? null : `branch follower file: ${file.error}`,
  ].filter((item): item is string => item !== null);
  return {
    ok: errors.length === 0,
    repository: registry.controller.source.repository,
    branch: branch.value,
    head: head.value,
    registrySha256: registry.rawSha256,
    controllerConfigMap: registry.controller.configMapName,
    closeoutRereadMarker: text.includes("post-closeout status re-read") && text.includes("automaticCloseoutAccelerated"),
    branchFollowerFile: {
      path: file.path,
      present: file.present,
      bytes: file.bytes,
      sha256: file.sha256,
    },
    statusAuthority: "target-controller-checkout",
    parsedDownstreamCliOutput: false,
    errors,
  };
}

function commandText(command: string[]): { ok: boolean; value: string | null; error: string | null } {
  const result = runCommand(command, repoRoot, { timeoutMs: 5_000 });
  const value = result.exitCode === 0 ? result.stdout.trim() : null;
  return {
    ok: result.exitCode === 0 && value !== null && value.length > 0,
    value: value === null || value.length === 0 ? null : value,
    error: result.exitCode === 0 ? null : redactText(tailText(result.stderr || result.stdout, 300)),
  };
}

function fileSnapshot(path: string): { ok: boolean; path: string; present: boolean; bytes: number | null; sha256: string | null; text: string; error: string | null } {
  const absolute = rootPath(path);
  if (!existsSync(absolute)) return { ok: false, path, present: false, bytes: null, sha256: null, text: "", error: "missing" };
  const text = readFileSync(absolute, "utf8");
  return {
    ok: true,
    path,
    present: true,
    bytes: Buffer.byteLength(text),
    sha256: createHash("sha256").update(text).digest("hex"),
    text,
    error: null,
  };
}

function debugDecisionOptions(options: ParsedOptions): ParsedOptions {
  return { ...options, confirm: false, dryRun: true, wait: false, recordState: false };
}

function stateWriteInput(previous: Record<string, unknown>): FollowerState | null {
  if (stringOrNull(previous.id) === null) return null;
  if (asOptionalRecord(previous.source) === null || asOptionalRecord(previous.target) === null || asOptionalRecord(previous.timings) === null) return null;
  return previous as unknown as FollowerState;
}

function stateWriteResult(followerId: string, result: CommandResult): Record<string, unknown> {
  const parsed = parseLastJsonObject(result.stdout);
  return {
    ok: result.exitCode === 0 && parsed?.ok !== false,
    follower: followerId,
    exitCode: result.exitCode,
    timedOut: result.timedOut,
    input: "stored-state",
    patch: parsed,
    message: result.exitCode === 0 ? "state patch command completed" : redactText(tailText(result.stderr || result.stdout, 500)),
    parsedDownstreamCliOutput: false,
  };
}

function stateSnapshot(read: K8sStateRead, followerId: string): Record<string, unknown> {
  const stateByFollower = asOptionalRecord((read as unknown as Record<string, unknown>).stateByFollower) ?? {};
  const valueBytesByFollower = asOptionalRecord((read as unknown as Record<string, unknown>).stateValueBytes) ?? {};
  const errors = Array.isArray((read as unknown as Record<string, unknown>).errors) ? (read as unknown as Record<string, unknown>).errors as unknown[] : [];
  const state = asOptionalRecord(stateByFollower[followerId]) ?? {};
  const source = asOptionalRecord(state.source);
  const target = asOptionalRecord(state.target);
  const timings = asOptionalRecord(state.timings);
  return {
    present: read.stateConfigMapPresent,
    ok: read.ok,
    errors: errors.map(String).slice(0, 8),
    metadata: read.stateMetadata,
    valueBytes: numberOrNull(valueBytesByFollower[followerId]),
    phase: stringOrNull(state.phase),
    observedSha: stringOrNull(source?.observedSha),
    targetSha: stringOrNull(target?.targetSha),
    lastTriggeredSha: stringOrNull(state.lastTriggeredSha),
    lastSucceededSha: stringOrNull(state.lastSucceededSha),
    pipelineRun: stringOrNull(state.pipelineRun),
    inFlightJob: stringOrNull(state.inFlightJob),
    timingStatus: stringOrNull(timings?.totalStatus),
    totalSeconds: numberOrNull(timings?.totalSeconds),
    startedAt: stringOrNull(timings?.startedAt),
    updatedAt: stringOrNull(state.updatedAt),
    rawStateDiagnostic: asOptionalRecord(state.rawStateDiagnostic),
  };
}

function compactAdapterStatus(live: AdapterSummary): Record<string, unknown> {
  const payload = asOptionalRecord(live.payload);
  return {
    ok: live.ok,
    phase: live.phase,
    observedSha: live.observedSha,
    targetSha: live.targetSha,
    aligned: live.aligned,
    pipelineRun: live.pipelineRun,
    inFlightJob: live.inFlightJob,
    message: live.message,
    gates: compactStatusGates(payload),
  };
}

function compactStatusGates(payload: Record<string, unknown> | null): Record<string, unknown> | null {
  if (payload === null) return null;
  const gitMirror = asOptionalRecord(payload.gitMirror);
  const reuseConfig = asOptionalRecord(payload.reuseConfig);
  const tekton = asOptionalRecord(payload.tekton);
  const taskRuns = asOptionalRecord(payload.taskRuns);
  const argo = asOptionalRecord(payload.argo);
  const runtime = asOptionalRecord(payload.runtime);
  return {
    gitMirror: gitMirror === null ? null : {
      ok: gitMirror.ok === true,
      sourceSnapshotReady: gitMirror.sourceSnapshotReady === true,
      pendingFlush: gitMirror.pendingFlush === true,
      githubInSync: gitMirror.githubInSync === true,
      localSource: stringOrNull(gitMirror.localSource),
      githubSource: stringOrNull(gitMirror.githubSource),
      localGitops: stringOrNull(gitMirror.localGitops),
      githubGitops: stringOrNull(gitMirror.githubGitops),
    },
    reuseConfig: reuseConfig === null ? null : {
      ok: reuseConfig.ok === true,
      present: reuseConfig.present === true,
      path: stringOrNull(reuseConfig.path),
      sourceCommit: stringOrNull(reuseConfig.sourceCommit),
      stageRef: stringOrNull(reuseConfig.stageRef),
      sha256: stringOrNull(reuseConfig.sha256),
      serviceCount: numberOrNull(reuseConfig.serviceCount),
      serviceIds: arrayStrings(reuseConfig.serviceIds).slice(0, 16),
      errors: Array.isArray(reuseConfig.errors) ? reuseConfig.errors.map(String).slice(0, 5) : [],
    },
    tekton: tekton === null ? null : {
      name: stringOrNull(tekton.name),
      pipelineRefName: stringOrNull(tekton.pipelineRefName),
      succeeded: tekton.succeeded === true ? true : tekton.succeeded === false ? false : null,
      reason: stringOrNull(tekton.reason),
      startTime: stringOrNull(tekton.startTime),
      completionTime: stringOrNull(tekton.completionTime),
      durationSeconds: numberOrNull(tekton.durationSeconds),
    },
    taskRuns: taskRuns === null ? null : {
      count: numberOrNull(taskRuns.count),
      failedCount: numberOrNull(taskRuns.failedCount),
      activeCount: numberOrNull(taskRuns.activeCount),
      failedItems: taskRunItems(taskRuns, "failed"),
      activeItems: taskRunItems(taskRuns, "active"),
      slowItems: taskRunItems(taskRuns, "slow"),
    },
    argo: argo === null ? null : {
      syncStatus: stringOrNull(argo.syncStatus),
      healthStatus: stringOrNull(argo.healthStatus),
      healthMessage: stringOrNull(argo.healthMessage),
      revision: stringOrNull(argo.revision),
      operationPhase: stringOrNull(argo.operationPhase),
      operationMessage: stringOrNull(argo.operationMessage),
      operationStartedAt: stringOrNull(argo.operationStartedAt),
      operationFinishedAt: stringOrNull(argo.operationFinishedAt),
      operationDurationSeconds: numberOrNull(argo.operationDurationSeconds),
      conditions: Array.isArray(argo.conditions) ? argo.conditions.slice(0, 5) : [],
      nonReadyResources: Array.isArray(argo.nonReadyResources) ? argo.nonReadyResources.slice(0, 5) : [],
      ready: argo.ready === true,
    },
    pipeline: compactPipeline(payload.pipeline),
    refreshEvidence: compactRefreshEvidence(payload.refreshEvidence),
    runtime: runtime === null ? null : {
      ready: runtime.ready === true,
      targetSha: stringOrNull(runtime.targetSha),
      expectedSha: stringOrNull(runtime.expectedSha),
      aligned: runtime.aligned === true ? true : runtime.aligned === false ? false : null,
    },
    errors: Array.isArray(payload.errors) ? payload.errors.map(String).slice(0, 5) : [],
  };
}

function compactPipeline(value: unknown): Record<string, unknown> | null {
  const pipeline = asOptionalRecord(value);
  if (pipeline === null) return null;
  return {
    metadata: asOptionalRecord(pipeline.metadata),
    spec: asOptionalRecord(pipeline.spec),
  };
}

function compactRefreshEvidence(value: unknown): Record<string, unknown> | null {
  const refresh = asOptionalRecord(value);
  if (refresh === null) return null;
  return {
    jobName: stringOrNull(refresh.jobName),
    namespace: stringOrNull(refresh.namespace),
    status: stringOrNull(refresh.status),
    pipeline: stringOrNull(refresh.pipeline) ?? stringOrNull(asOptionalRecord(refresh.apply)?.pipelineName) ?? stringOrNull(asOptionalRecord(refresh.render)?.pipelineName),
    sourceCommit: stringOrNull(refresh.sourceCommit),
    sourceStageRef: stringOrNull(refresh.sourceStageRef),
    elapsedMs: numberOrNull(refresh.elapsedMs),
    render: compactRefreshRender(asOptionalRecord(refresh.render)),
    apply: compactRefreshApply(asOptionalRecord(refresh.apply)),
    sourceAuthority: stringOrNull(refresh.sourceAuthority),
    statusAuthority: stringOrNull(refresh.statusAuthority),
    parsedDownstreamCliOutput: false,
  };
}

function compactRefreshRender(value: Record<string, unknown> | null): Record<string, unknown> | null {
  if (value === null) return null;
  return {
    pipelineName: stringOrNull(value.pipelineName),
    taskCount: numberOrNull(value.taskCount),
    runtimeReadyTask: compactRefreshRuntimeReady(asOptionalRecord(value.runtimeReadyTask)),
  };
}

function compactRefreshApply(value: Record<string, unknown> | null): Record<string, unknown> | null {
  if (value === null) return null;
  return {
    pipelineName: stringOrNull(value.pipelineName),
    namespace: stringOrNull(value.namespace),
    resourceVersion: stringOrNull(value.resourceVersion),
    annotations: compactStringMap(asOptionalRecord(value.annotations)),
    labels: compactStringMap(asOptionalRecord(value.labels)),
    degradedReason: stringOrNull(value.degradedReason),
  };
}

function compactRefreshRuntimeReady(value: Record<string, unknown> | null): Record<string, unknown> | null {
  if (value === null) return null;
  return {
    present: booleanOrNull(value.present),
    name: stringOrNull(value.name),
    runAfter: compactStringArray(value.runAfter, 4),
    when: compactWhenList(value.when, 4),
  };
}

function compactWhenList(value: unknown, limit: number): Array<Record<string, unknown>> {
  return Array.isArray(value)
    ? value
      .map((item) => asOptionalRecord(item))
      .filter((item): item is Record<string, unknown> => item !== null)
      .slice(0, limit)
      .map((item) => ({
        input: stringOrNull(item.input),
        operator: stringOrNull(item.operator),
        values: compactStringArray(item.values, 4),
      }))
    : [];
}

function compactStringMap(value: Record<string, unknown> | null): Record<string, string> | null {
  if (value === null) return null;
  const output: Record<string, string> = {};
  for (const [key, item] of Object.entries(value).slice(0, 8)) {
    const text = stringOrNull(item);
    if (text !== null) output[key] = text;
  }
  return Object.keys(output).length === 0 ? null : output;
}

function arrayRecords(value: unknown): Record<string, unknown>[] {
  return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item)) : [];
}

function arrayStrings(value: unknown): string[] {
  return Array.isArray(value) ? value.map(String) : [];
}

function compactFollowerDecision(state: FollowerState): Record<string, unknown> {
  return {
    phase: state.phase,
    observedSha: state.source.observedSha,
    targetSha: state.target.targetSha,
    lastTriggeredSha: state.lastTriggeredSha,
    lastSucceededSha: state.lastSucceededSha,
    pipelineRun: state.pipelineRun,
    inFlightJob: state.inFlightJob,
    decision: state.decision,
    timings: compactDebugTimings(state.timings),
  };
}

function compactDebugTimings(timings: FollowerState["timings"]): Record<string, unknown> {
  return {
    budgetSeconds: timings.budgetSeconds,
    totalSeconds: timings.totalSeconds,
    totalStatus: timings.totalStatus,
    totalSource: timings.totalSource,
    sourceCommit: timings.sourceCommit,
    startedAt: timings.startedAt,
    finishedAt: timings.finishedAt,
    overBudget: timings.overBudget,
    stages: timings.stages.slice(0, 8).map((stage) => ({
      stage: stage.stage,
      status: stage.status,
      seconds: stage.seconds,
      budgetSeconds: stage.budgetSeconds,
      source: stage.source,
      object: stage.object,
    })),
  };
}

function debugNext(followerId: string): Record<string, string> {
  return {
    stateRead: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step state-read`,
    controllerSource: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step controller-source`,
    statusRead: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step status-read`,
    decide: `bun scripts/cli.ts cicd branch-follower debug-step --follower ${followerId} --step decide`,
  };
}

function parseLastJsonObject(text: string): Record<string, unknown> | null {
  const starts: number[] = [];
  let offset = 0;
  for (const line of text.split(/\r?\n/u)) {
    if (line.trimStart().startsWith("{")) starts.push(offset + line.indexOf("{"));
    offset += line.length + 1;
  }
  const end = text.lastIndexOf("}");
  if (end < 0) return null;
  for (let index = starts.length - 1; index >= 0; index -= 1) {
    const start = starts[index] ?? -1;
    if (start < 0 || start >= end) continue;
    try {
      const parsed = JSON.parse(text.slice(start, end + 1)) as unknown;
      const record = asOptionalRecord(parsed);
      if (record !== null) return record;
    } catch {
      // Try the previous JSON-looking line.
    }
  }
  return null;
}

function asOptionalRecord(value: unknown): Record<string, unknown> | null {
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}

function stringOrNull(value: unknown): string | null {
  return typeof value === "string" && value.length > 0 ? value : null;
}

function numberOrNull(value: unknown): number | null {
  return typeof value === "number" && Number.isFinite(value) ? value : null;
}

function booleanOrNull(value: unknown): boolean | null {
  return value === true ? true : value === false ? false : null;
}

function compactStringArray(value: unknown, limit: number): string[] {
  return Array.isArray(value) ? value.map((item) => stringOrNull(item)).filter((item): item is string => item !== null).slice(0, limit) : [];
}

function shortSha(value: string | null): string {
  if (value === null) return "-";
  return value.length > 12 ? value.slice(0, 12) : value;
}

function table(headers: readonly string[], rows: readonly (readonly unknown[])[]): string {
  const normalized = rows.map((row) => headers.map((_, index) => cell(row[index])));
  const widths = headers.map((header, index) => Math.max(header.length, ...normalized.map((row) => row[index]?.length ?? 0)));
  const format = (row: readonly string[]) => row.map((value, index) => value.padEnd(widths[index] ?? 0)).join("  ").trimEnd();
  return [format(headers), format(headers.map((header) => "-".repeat(header.length))), ...normalized.map(format)].join("\n");
}

function cell(value: unknown): string {
  if (value === null || value === undefined || value === "") return "-";
  const text = String(value).replace(/\s+/gu, " ");
  return text.length > 96 ? `${text.slice(0, 93)}...` : text;
}

function tailText(text: string, maxChars: number): string {
  return text.length <= maxChars ? text : text.slice(text.length - maxChars);
}
