import fs from "node:fs";
import path from "node:path";

/**
 * On-disk state for the PR Resolver. Single JSON file, mirrors the Python
 * service's shape so the two can read each other's state if you ever run
 * them side-by-side during migration. Schema is intentionally flat and
 * documented inline — promote to SQLite later if cross-query becomes
 * useful.
 */

export type ProcessedThreadStatus = "fixed" | "failed" | "build_blocked";

export interface ProcessedThreadEntry {
  pr_number: number;
  processed_at: string;
  status: ProcessedThreadStatus;
  commit_sha?: string;
  path?: string;
  instruction_preview?: string;
  resolution_summary?: string;
  error?: string;
  /**
   * GraphQL node id of the trigger comment that produced this entry. Used
   * for per-trigger-comment dedup in `filterTriggeredThreads` — a fresh
   * trigger reply in the same thread has a different id, so the new comment
   * is picked up instead of being silently skipped under per-thread dedup.
   * Optional for backward compat with state.json entries written before
   * this schema landed.
   */
  last_trigger_comment_id?: string;
}

export interface BuildFailureEntry {
  branch: string;
  head_sha: string;
  failed_at: string;
  error: string;
}

/**
 * Per-PR state machine. Drives the new flow that holds local commits on the
 * worktree until the user approves them via the dashboard (or auto-mode
 * skips the wait). One row per PR currently in flight or terminal.
 */
export type PrMachineStatus =
  | "noticed"
  | "preparing"
  | "resolving"
  | "verifying"
  | "awaiting_approval"
  | "committing"
  | "pushed"
  | "rejected"
  | "failed";

/** Legacy flat-command result shape — kept for back-compat reads of old state. */
export interface GrpcTestResultRecord {
  command: string;
  ok: boolean;
  exitCode: number | null;
  stdout: string;
  stderr: string;
  durationMs: number;
  timedOut: boolean;
}

/** Per-step result from a structured test plan. */
export interface GrpcTestStepResult {
  name: string;
  method: string;
  command: string;
  ok: boolean;
  skipped: boolean;
  skipReason?: string;
  exitCode: number | null;
  stdout: string;
  stderr: string;
  durationMs: number;
  timedOut: boolean;
  captures: Record<string, unknown>;
  expectMisses: string[];
  renderedHeaders: Record<string, string>;
  renderedBody: unknown;
}

export interface PrMachine {
  prNumber: number;
  branch: string;
  status: PrMachineStatus;
  threadIds: string[];
  /**
   * Trigger comment IDs parallel to `threadIds` (same length, same order).
   * Carried through approval / rejection so we can write the per-trigger
   * dedup key into `processed_threads.last_trigger_comment_id`.
   */
  triggerCommentIds: string[];
  connectors: string[];
  /** origin/<branch> SHA at the moment the resolver picked the PR up. */
  remoteSha?: string;
  /** Local HEAD after our commits — what we'd push if approved. */
  localSha?: string;
  /** `git diff origin/<branch>..HEAD` snapshot for the approval view. Capped. */
  diffPreview?: string;
  /** Resolver's final summary for the PR (concat of per-connector summaries). */
  summary?: string;
  /** Reason if rejected/failed. */
  reason?: string;
  /**
   * Phase B v2: structured test plan (Claude-generated). The list of steps
   * with method/headers/body/depends_on/captures/expect.
   */
  testPlan?: { tests: unknown[] };
  /** Phase B v2: per-step results from runTestPlan, in execution order. */
  testStepResults?: GrpcTestStepResult[];
  /**
   * Raw Claude reply from the plan-generation call. Stored for the panel
   * to render when the JSON parser couldn't extract a plan.
   */
  testGenerationReply?: string;
  /** Legacy flat-command commands array — retained for old state. */
  testCommands?: string[];
  /** Legacy flat-command results array — retained for old state. */
  testResults?: GrpcTestResultRecord[];
  /** Legacy. New code uses testPlan + testStepResults. */
  testCommandsSource?: "extracted" | "generated" | "none";
  /**
   * Reviewer feedback captured from a "Request changes" action while the
   * machine sat in `awaiting_approval`. Set when the user requests revisions,
   * consumed (and cleared) by the next resolve pass for this PR.
   */
  revisionFeedback?: string;
  /** How many "Request changes" round-trips this PR has been through. */
  revisionCount?: number;
  /**
   * Reviewer-facing summary generated by a small Claude call after cargo + grpc
   * pass. Populated asynchronously so the approval gate opens immediately and
   * the reviewer can start reading the diff while we generate.
   */
  reviewSummary?: string;
  /** Lifecycle of the async review-summary call. */
  reviewSummaryStatus?: "generating" | "ready" | "failed";
  /** Error message when `reviewSummaryStatus === "failed"`. */
  reviewSummaryError?: string;
  /**
   * Which worktree pool slot is currently leased for this PR. Persisted so
   * the supervisor can re-attach a pinned slot to its awaiting_approval PR
   * on restart, instead of orphaning the local commits the user is about
   * to approve.
   */
  workerSlot?: number;
  startedAt: string;
  updatedAt: string;
}

export interface PrResolverState {
  version: 1;
  processed_threads: Record<string, ProcessedThreadEntry>;
  build_failures: Record<string, BuildFailureEntry>;
  pr_machines: Record<string, PrMachine>;
  last_poll: string | null;
}

function freshState(): PrResolverState {
  return {
    version: 1,
    processed_threads: {},
    build_failures: {},
    pr_machines: {},
    last_poll: null,
  };
}

export class PrResolverStateManager {
  private state: PrResolverState = freshState();

  constructor(private readonly stateFile: string) {}

  // ─── Persistence ─────────────────────────────────────────────────────

  load(): PrResolverState {
    if (!fs.existsSync(this.stateFile)) {
      fs.mkdirSync(path.dirname(this.stateFile), { recursive: true });
      this.state = freshState();
      this.save();
      return this.state;
    }
    try {
      const raw = fs.readFileSync(this.stateFile, "utf-8");
      const parsed = JSON.parse(raw) as Partial<PrResolverState>;
      this.state = {
        version: 1,
        processed_threads: parsed.processed_threads ?? {},
        build_failures: parsed.build_failures ?? {},
        pr_machines: parsed.pr_machines ?? {},
        last_poll: parsed.last_poll ?? null,
      };
    } catch (err) {
      // eslint-disable-next-line no-console
      console.warn(
        `[pr-resolver:state] Failed to load ${this.stateFile}: ${err instanceof Error ? err.message : String(err)} — starting fresh`
      );
      this.state = freshState();
      this.save();
    }
    return this.state;
  }

  save(): void {
    fs.mkdirSync(path.dirname(this.stateFile), { recursive: true });
    // Write to a sibling temp file then rename — `rename(2)` on the same FS
    // is atomic on POSIX, so a SIGKILL mid-flight can never leave a torn
    // JSON file for the next supervisor boot to choke on. Concurrent
    // PrMachine upserts from N workers are still safe because JS
    // single-threading runs each `save()` to completion before the next
    // one starts.
    const tmp = `${this.stateFile}.tmp`;
    fs.writeFileSync(tmp, JSON.stringify(this.state, null, 2), "utf-8");
    fs.renameSync(tmp, this.stateFile);
  }

  snapshot(): PrResolverState {
    // Deep clone so consumers can't mutate our state via the returned object.
    return JSON.parse(JSON.stringify(this.state)) as PrResolverState;
  }

  // ─── Queries ─────────────────────────────────────────────────────────

  isProcessed(threadId: string): boolean {
    return threadId in this.state.processed_threads;
  }

  /**
   * Thread IDs the poll loop should skip. Excludes `build_blocked` — those
   * need to be re-evaluated each cycle because the build may have been
   * fixed by a new commit (see {@link shouldSkipBuild}).
   *
   * @deprecated Prefer {@link getProcessedFilters} so the filter can
   * distinguish per-trigger-comment dedup (new) from legacy per-thread.
   * Retained because external callers/tests may still use it.
   */
  getProcessedIds(): Set<string> {
    const out = new Set<string>();
    for (const [tid, entry] of Object.entries(this.state.processed_threads)) {
      if (entry.status !== "build_blocked") out.add(tid);
    }
    return out;
  }

  /**
   * Returns the two dedup structures `filterTriggeredThreads` consults:
   *  - `triggerCommentIds`: any trigger comment id we've recorded as
   *    fixed/failed. A NEW trigger comment in the same thread has a
   *    different id → not in the set → picked up.
   *  - `legacyThreadProcessedAt`: thread id → `processed_at` epoch ms.
   *    Used for entries that pre-date the per-trigger-comment schema. The
   *    filter compares the trigger comment's `createdAt` against this
   *    timestamp — a strictly newer comment is treated as fresh work,
   *    everything else is skipped. As fixes flow through the new code
   *    path, each entry gets `last_trigger_comment_id` populated and
   *    graduates out of this bucket.
   *
   * `build_blocked` entries are excluded from both — they're meant to be
   * re-evaluated each cycle until a new HEAD SHA arrives.
   */
  getProcessedFilters(): {
    triggerCommentIds: Set<string>;
    legacyThreadProcessedAt: Map<string, number>;
  } {
    const triggerCommentIds = new Set<string>();
    const legacyThreadProcessedAt = new Map<string, number>();
    for (const [tid, entry] of Object.entries(this.state.processed_threads)) {
      if (entry.status === "build_blocked") continue;
      if (entry.last_trigger_comment_id) {
        triggerCommentIds.add(entry.last_trigger_comment_id);
      } else {
        const ts = Date.parse(entry.processed_at);
        legacyThreadProcessedAt.set(tid, Number.isNaN(ts) ? 0 : ts);
      }
    }
    return { triggerCommentIds, legacyThreadProcessedAt };
  }

  getProcessedThreads(): Record<string, ProcessedThreadEntry> {
    return this.state.processed_threads;
  }

  getBuildFailures(): Record<string, BuildFailureEntry> {
    return this.state.build_failures;
  }

  /**
   * Forget a cached baseline-build-failure for `prNumber`. The next cycle
   * will re-run the build instead of short-circuiting on the stale SHA.
   * Used by `retryPr` so manual retries genuinely retry.
   */
  clearBuildFailure(prNumber: number): boolean {
    if (this.state.build_failures[String(prNumber)]) {
      delete this.state.build_failures[String(prNumber)];
      this.save();
      return true;
    }
    return false;
  }

  getLastPoll(): string | null {
    return this.state.last_poll;
  }

  // ─── Mutations ───────────────────────────────────────────────────────

  retry(threadId: string): boolean {
    if (threadId in this.state.processed_threads) {
      delete this.state.processed_threads[threadId];
      this.save();
      return true;
    }
    return false;
  }

  retryAllFailed(): number {
    let removed = 0;
    for (const [tid, entry] of Object.entries(this.state.processed_threads)) {
      if (entry.status === "failed") {
        delete this.state.processed_threads[tid];
        removed++;
      }
    }
    if (removed > 0) this.save();
    return removed;
  }

  markFixed(input: {
    threadId: string;
    prNumber: number;
    commitSha: string;
    path: string;
    instruction: string;
    resolutionSummary?: string;
    /** Trigger comment id; required for per-trigger-comment dedup to work. */
    triggerCommentId?: string;
  }): void {
    this.state.processed_threads[input.threadId] = {
      pr_number: input.prNumber,
      processed_at: new Date().toISOString(),
      status: "fixed",
      commit_sha: input.commitSha,
      path: input.path,
      instruction_preview: input.instruction.slice(0, 200),
      resolution_summary: (input.resolutionSummary ?? "").slice(0, 500),
      last_trigger_comment_id: input.triggerCommentId,
    };
    this.save();
  }

  markFailed(
    threadId: string,
    prNumber: number,
    error: string,
    triggerCommentId?: string
  ): void {
    this.state.processed_threads[threadId] = {
      pr_number: prNumber,
      processed_at: new Date().toISOString(),
      status: "failed",
      error: error.slice(0, 3000),
      last_trigger_comment_id: triggerCommentId,
    };
    this.save();
  }

  updateLastPoll(): void {
    this.state.last_poll = new Date().toISOString();
    this.save();
  }

  /**
   * Record a baseline build failure for `prNumber@headSha`. Affected
   * threads are marked `build_blocked` so they show up in the dashboard
   * but auto-clear on the next cycle if a new commit arrives.
   */
  markBuildFailed(input: {
    prNumber: number;
    branch: string;
    headSha: string;
    error: string;
    threadIds?: string[];
  }): void {
    this.state.build_failures[String(input.prNumber)] = {
      branch: input.branch,
      head_sha: input.headSha,
      failed_at: new Date().toISOString(),
      error: input.error.slice(0, 3000),
    };
    if (input.threadIds) {
      for (const tid of input.threadIds) {
        this.state.processed_threads[tid] = {
          pr_number: input.prNumber,
          processed_at: new Date().toISOString(),
          status: "build_blocked",
          error:
            "Branch build broken — will auto-retry when new commits are pushed",
        };
      }
    }
    this.save();
  }

  /**
   * Returns true if `prNumber` has a recorded build failure at exactly
   * `currentSha`. When the SHA differs the failure is cleared (and any
   * blocked threads unblocked) so the cycle retries on the new commit.
   */
  shouldSkipBuild(prNumber: number, currentSha: string): boolean {
    const entry = this.state.build_failures[String(prNumber)];
    if (!entry) return false;
    if (entry.head_sha !== currentSha) {
      delete this.state.build_failures[String(prNumber)];
      for (const [tid, t] of Object.entries(this.state.processed_threads)) {
        if (t.pr_number === prNumber && t.status === "build_blocked") {
          delete this.state.processed_threads[tid];
        }
      }
      this.save();
      return false;
    }
    return true;
  }

  // ─── PR machines ─────────────────────────────────────────────────────

  getPrMachine(prNumber: number): PrMachine | null {
    return this.state.pr_machines[String(prNumber)] ?? null;
  }

  listPrMachines(): PrMachine[] {
    return Object.values(this.state.pr_machines);
  }

  listPrMachinesByStatus(status: PrMachineStatus): PrMachine[] {
    return this.listPrMachines().filter((m) => m.status === status);
  }

  upsertPrMachine(
    input: Partial<PrMachine> & { prNumber: number; branch?: string; status?: PrMachineStatus }
  ): PrMachine {
    const key = String(input.prNumber);
    const existing = this.state.pr_machines[key];
    const now = new Date().toISOString();
    const next: PrMachine = {
      prNumber: input.prNumber,
      branch: input.branch ?? existing?.branch ?? "",
      status: input.status ?? existing?.status ?? "noticed",
      threadIds: input.threadIds ?? existing?.threadIds ?? [],
      triggerCommentIds:
        input.triggerCommentIds ?? existing?.triggerCommentIds ?? [],
      connectors: input.connectors ?? existing?.connectors ?? [],
      remoteSha: input.remoteSha ?? existing?.remoteSha,
      localSha: input.localSha ?? existing?.localSha,
      diffPreview: input.diffPreview ?? existing?.diffPreview,
      summary: input.summary ?? existing?.summary,
      reason: input.reason ?? existing?.reason,
      testPlan: input.testPlan ?? existing?.testPlan,
      testStepResults: input.testStepResults ?? existing?.testStepResults,
      testCommands: input.testCommands ?? existing?.testCommands,
      testResults: input.testResults ?? existing?.testResults,
      testCommandsSource: input.testCommandsSource ?? existing?.testCommandsSource,
      testGenerationReply: input.testGenerationReply ?? existing?.testGenerationReply,
      // revisionFeedback uses hasOwn merge so callers can explicitly clear
      // it by passing `revisionFeedback: undefined` after consuming it.
      revisionFeedback: Object.hasOwn(input, "revisionFeedback")
        ? input.revisionFeedback
        : existing?.revisionFeedback,
      revisionCount: input.revisionCount ?? existing?.revisionCount,
      // Same hasOwn merge for review-summary fields — generateReviewSummary
      // explicitly clears `reviewSummary` on retry, and we want that to take.
      reviewSummary: Object.hasOwn(input, "reviewSummary")
        ? input.reviewSummary
        : existing?.reviewSummary,
      reviewSummaryStatus: Object.hasOwn(input, "reviewSummaryStatus")
        ? input.reviewSummaryStatus
        : existing?.reviewSummaryStatus,
      reviewSummaryError: Object.hasOwn(input, "reviewSummaryError")
        ? input.reviewSummaryError
        : existing?.reviewSummaryError,
      workerSlot: Object.hasOwn(input, "workerSlot")
        ? input.workerSlot
        : existing?.workerSlot,
      startedAt: existing?.startedAt ?? now,
      updatedAt: now,
    };
    this.state.pr_machines[key] = next;
    this.save();
    return next;
  }

  removePrMachine(prNumber: number): void {
    if (delete this.state.pr_machines[String(prNumber)]) {
      this.save();
    }
  }

  cleanupOldEntries(maxAgeDays = 30): number {
    const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
    let removed = 0;
    for (const [tid, entry] of Object.entries(this.state.processed_threads)) {
      const ts = Date.parse(entry.processed_at);
      if (!Number.isNaN(ts) && ts < cutoff) {
        delete this.state.processed_threads[tid];
        removed++;
      }
    }
    if (removed > 0) this.save();
    return removed;
  }
}
