import type { JsonValue } from "./types";

export type RunnerFailureScope =
  | "runner-local"
  | "provider-gateway"
  | "registry"
  | "k3s"
  | "scheduler"
  | "external-provider"
  | "unknown";

export type RunnerFailureDisposition =
  | "runner-local-observation-gap"
  | "retryable-transient"
  | "service-degraded"
  | "provider-degraded"
  | "global-blocker";

export interface RunnerErrorClassification {
  scope: RunnerFailureScope;
  disposition: RunnerFailureDisposition;
  failureKind: string;
  retryable: boolean;
  globalBlocker: boolean;
  singlePathObservation: boolean;
  externalProvider429: boolean;
  staleBadResume: boolean;
  reason: string;
  evidence: string[];
  recommendedTriageCommand: string;
  backoffHint: {
    strategy: "exponential-jitter";
    minimumDelayMs: number;
    maxDelayMs: number;
    jitter: string;
    commanderAction: string;
  } | null;
  contract: {
    singlePathFailureIsGlobalBlocker: false;
    globalBlockerRequiresMultiSignalTriage: true;
    externalProvider429IsInfrastructureOutage: false;
  };
}

function unique(items: string[]): string[] {
  return Array.from(new Set(items.filter((item) => item.length > 0)));
}

function collectEvidence(text: string, patterns: RegExp[], limit = 4): string[] {
  const evidence: string[] = [];
  for (const pattern of patterns) {
    for (const match of text.matchAll(pattern)) {
      const raw = String(match[0] ?? "").trim().replace(/\s+/gu, " ");
      if (raw.length > 0) evidence.push(raw);
      if (evidence.length >= limit) return unique(evidence);
    }
  }
  return unique(evidence);
}

function providerTriageCommand(providerId: string | null | undefined, message: string, scope: RunnerFailureScope): string {
  const safeProviderId = /^[A-Za-z0-9_.-]{1,64}$/u.test(String(providerId ?? "")) ? String(providerId) : "<providerId>";
  const compactMessage = message.replace(/\s+/gu, " ").trim().slice(0, 500).replace(/'/gu, "'\\''");
  return `bun scripts/cli.ts provider triage ${safeProviderId} --observed-scope ${scope} --observed-error '${compactMessage}'`;
}

export function classifyRunnerError(message: string, providerId?: string | null): RunnerErrorClassification {
  const raw = String(message || "");
  const normalized = raw.toLowerCase();
  const providerOfflineEvidence = collectEvidence(normalized, [
    /provider is not online/gu,
    /provider is offline/gu,
    /provider went offline/gu,
    /provider .*not online/gu,
    /provider .*offline/gu,
  ]);
  const providerGatewayEvidence = collectEvidence(normalized, [
    /provider[- ]gateway/gu,
    /provider gateway/gu,
    /provider-gateway-online/gu,
    /websocket|ws\/provider/gu,
    /provider .*disconnected/gu,
    /provider .*heartbeat/gu,
    /tunnel failed|http tunnel|egress proxy/gu,
  ]);
  const registryEvidence = collectEvidence(normalized, [
    /artifact[- ]registry/gu,
    /registry \/v2/gu,
    /\bregistry\b/gu,
    /manifest unknown|docker push|docker pull/gu,
    /127\.0\.0\.1:5000/gu,
  ]);
  const k3sEvidence = collectEvidence(normalized, [
    /\bk3s\b/gu,
    /\bk3sctl\b/gu,
    /kubectl/gu,
    /kubernetes/gu,
    /containerd/gu,
    /pod .*failed|deployment .*unavailable/gu,
  ]);
  const schedulerEvidence = collectEvidence(normalized, [
    /code[- ]queue scheduler/gu,
    /\bscheduler\b/gu,
    /runner skills?/gu,
    /skills projection/gu,
    /required[- ]target[- ]skills[- ]missing/gu,
    /forbidden[- ]skills[- ]path[- ]configured/gu,
    /active run/gu,
    /runtime-preflight/gu,
    /database claim/gu,
    /postgres|postgresql/gu,
    /app-server closed|opencode exited|codex .*failed/gu,
  ]);
  const externalProvider429Evidence = collectEvidence(normalized, [
    /429 too many requests/gu,
    /too many requests/gu,
    /\bstatus:?\s*429\b/gu,
    /\bhttp\s*429\b/gu,
    /\b429\b/gu,
    /rate[- ]?limit(?:ed|ing)?/gu,
    /quota exceeded/gu,
    /exceeded retry limit/gu,
    /tokens per min|requests per min/gu,
  ]);
  const staleBadResumeEvidence = collectEvidence(normalized, [
    /no rollout found for thread id [a-z0-9._:-]+/gu,
    /no rollout found for thread id/gu,
    /rollout .*not found .*thread/gu,
    /missing .*rollout .*thread/gu,
  ]);
  const runnerLocalEvidence = collectEvidence(normalized, [
    /spawn .*enoent/gu,
    /command not found/gu,
    /no such file or directory/gu,
    /permission denied/gu,
    /app-server is already closed/gu,
    /no .*activity .*stopping/gu,
    /etimedout|timed out|timeout/gu,
  ]);

  let scope: RunnerFailureScope = "unknown";
  let disposition: RunnerFailureDisposition = "retryable-transient";
  let failureKind = "unknown-runner-failure";
  let reason = "The runner failure needs provider triage before it can be treated as a global blocker.";
  let evidence: string[] = [];
  let externalProvider429 = false;
  let staleBadResume = false;

  if (staleBadResumeEvidence.length > 0) {
    scope = "scheduler";
    disposition = "service-degraded";
    failureKind = "stale-bad-resume-thread-rollout-missing";
    staleBadResume = true;
    reason = "Codex app-server rejected resume because the saved thread id has no rollout; the scheduler should stop reusing that stale resume thread and reopen bounded recovery on a fresh thread.";
    evidence = staleBadResumeEvidence;
  } else if (externalProvider429Evidence.length > 0) {
    scope = "external-provider";
    disposition = "retryable-transient";
    failureKind = "external-provider-rate-limit";
    externalProvider429 = true;
    reason = "The failure matches an external model provider rate limit/transient 429; keep the task in retry_wait with conservative jittered backoff while scheduler heartbeat remains fresh.";
    evidence = externalProvider429Evidence;
  } else if (providerOfflineEvidence.length > 0) {
    scope = "runner-local";
    disposition = "runner-local-observation-gap";
    failureKind = "provider-offline-single-path";
    reason = "A single runner-local provider offline observation is not independent evidence of a global D601/provider outage.";
    evidence = providerOfflineEvidence;
  } else if (registryEvidence.length > 0) {
    scope = "registry";
    disposition = "service-degraded";
    failureKind = "registry-path-failure";
    reason = "The failure is registry-scoped; cross-check provider gateway, SSH, k3s and scheduler before escalating globally.";
    evidence = registryEvidence;
  } else if (k3sEvidence.length > 0) {
    scope = "k3s";
    disposition = "service-degraded";
    failureKind = "k3s-path-failure";
    reason = "The failure is k3s-scoped unless provider gateway and SSH cross-checks also fail.";
    evidence = k3sEvidence;
  } else if (providerGatewayEvidence.length > 0) {
    scope = "provider-gateway";
    disposition = "provider-degraded";
    failureKind = "provider-gateway-path-failure";
    reason = "The failure points at provider-gateway/proxy transport and requires independent SSH/scheduler checks.";
    evidence = providerGatewayEvidence;
  } else if (schedulerEvidence.length > 0) {
    scope = "scheduler";
    disposition = "retryable-transient";
    failureKind = "scheduler-runner-failure";
    reason = "The failure is scheduler/runner scoped and should be retried or triaged with runtime preflight.";
    evidence = schedulerEvidence;
  } else if (runnerLocalEvidence.length > 0) {
    scope = "runner-local";
    disposition = "runner-local-observation-gap";
    failureKind = "runner-local-observation";
    reason = "The failure was observed locally by the runner and is not enough to declare provider-global outage.";
    evidence = runnerLocalEvidence;
  }

  return {
    scope,
    disposition,
    failureKind,
    retryable: true,
    globalBlocker: false,
    singlePathObservation: true,
    externalProvider429,
    staleBadResume,
    reason,
    evidence,
    recommendedTriageCommand: providerTriageCommand(providerId, raw, scope),
    backoffHint: externalProvider429
      ? {
        strategy: "exponential-jitter",
        minimumDelayMs: 30_000,
        maxDelayMs: 10 * 60 * 1000,
        jitter: "stable positive jitter keyed by task id and completed attempt count",
        commanderAction: "wait for retry_wait to return to running while heartbeat remains fresh; do not duplicate the task unless retry attempts exhaust or heartbeat/state machine stalls",
      }
      : null,
    contract: {
      singlePathFailureIsGlobalBlocker: false,
      globalBlockerRequiresMultiSignalTriage: true,
      externalProvider429IsInfrastructureOutage: false,
    },
  };
}

export function runnerErrorClassificationJson(classification: RunnerErrorClassification): JsonValue {
  return classification as unknown as JsonValue;
}
