/**
 * RunCoordinator — WP-09.
 *
 * Separates long-lived Host Session (external platform context) from
 * per-doorbell Run (one execution). One Host Session may carry many Runs
 * over time; at most one *active* Run may hold the Host Session claim.
 *
 * Does not rewire SessionManager / TaskDispatcher production paths.
 */

import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { join } from "node:path";

import type { HostSessionRef } from "../contracts/HostSession.ts";
import type {
  ContinuationMode,
  RunRecord,
  RunRecordState,
} from "../contracts/RunRecord.ts";
import type { HostFailure } from "../contracts/HostFailure.ts";
import { AtomicFile } from "../atomic/AtomicFile.ts";
import { SwitchboardPaths } from "../paths/SwitchboardPaths.ts";
import {
  HostClaimStore,
  type HostClaimRecord,
} from "../store/HostClaimStore.ts";
import { RunStore } from "../store/RunStore.ts";

export function hostSessionKey(host: HostSessionRef): string {
  return `${host.hostId}::${host.externalSessionId}`;
}

export interface BeginRunInput {
  workerId: string;
  doorbellId: string;
  bindingId: string;
  host: HostSessionRef;
  deliveryAttemptId?: string;
  taskId?: string;
  sourceFactRefs?: string[];
  continuationMode?: ContinuationMode;
}

export type BeginRunResult =
  | {
      status: "started";
      run: RunRecord;
      claimPath: string;
      sessionKey: string;
    }
  | {
      status: "claim_conflict";
      reason: string;
      heldBy?: string;
      activeRunId?: string;
    };

export interface RunEvidenceEvent {
  eventId: string;
  at: string;
  type: string;
  payload?: unknown;
}

export interface HostSessionRunIndexEntry {
  runId: string;
  workerId: string;
  hostSessionKey: string;
}

export interface RunCoordinatorOptions {
  paths: SwitchboardPaths;
  runs?: RunStore;
  claims?: HostClaimStore;
  now?: () => Date;
  idFactory?: () => string;
}

const ACTIVE_STATES: ReadonlySet<RunRecordState> = new Set([
  "created",
  "starting",
  "running",
]);

export class RunCoordinator {
  readonly #paths: SwitchboardPaths;
  readonly #runs: RunStore;
  readonly #claims: HostClaimStore;
  readonly #now: () => Date;
  readonly #idFactory: () => string;

  constructor(opts: RunCoordinatorOptions) {
    this.#paths = opts.paths;
    this.#runs = opts.runs ?? new RunStore(opts.paths);
    this.#claims = opts.claims ?? new HostClaimStore(opts.paths);
    this.#now = opts.now ?? (() => new Date());
    this.#idFactory = opts.idFactory ?? (() => randomUUID());
  }

  get paths(): SwitchboardPaths {
    return this.#paths;
  }

  get runs(): RunStore {
    return this.#runs;
  }

  get claims(): HostClaimStore {
    return this.#claims;
  }

  /**
   * Begin a new Run on an existing Host Session.
   * Exclusive claim: cross-worker races and concurrent active runs conflict.
   */
  async beginRun(input: BeginRunInput): Promise<BeginRunResult> {
    const sessionKey = hostSessionKey(input.host);
    const existingClaim = await this.#claims.get(
      input.host.hostId,
      sessionKey,
    );
    if (
      existingClaim?.workerId &&
      existingClaim.workerId !== input.workerId
    ) {
      return {
        status: "claim_conflict",
        reason: `host session claimed by ${existingClaim.workerId}`,
        heldBy: existingClaim.workerId,
        activeRunId: existingClaim.runId,
      };
    }

    const active = await this.listActiveRunsForHostSession(input.host);
    if (active.length > 0) {
      const blocker = active[0]!;
      return {
        status: "claim_conflict",
        reason: `active run already in flight: ${blocker.runId}`,
        heldBy: blocker.workerId,
        activeRunId: blocker.runId,
      };
    }

    const runId = `run-${this.#idFactory()}`;
    const deliveryAttemptId =
      input.deliveryAttemptId?.trim() || `attempt-${this.#idFactory()}`;
    const run: RunRecord = {
      schema: "codeflowmu.run/v1",
      runId,
      workerId: input.workerId,
      doorbellId: input.doorbellId,
      bindingId: input.bindingId,
      hostId: input.host.hostId,
      deliveryAttemptId,
      state: "starting",
      taskId: input.taskId,
      sourceFactRefs: [...(input.sourceFactRefs ?? [])],
      continuationMode: input.continuationMode ?? "same_session",
      startedAt: this.#now().toISOString(),
    };

    const claim: HostClaimRecord = {
      schema: "codeflowmu.host-claim/v1",
      hostId: input.host.hostId,
      sessionKey,
      workerId: input.workerId,
      runId,
      acquiredAt: this.#now().toISOString(),
      metadata: {
        externalSessionId: input.host.externalSessionId,
        doorbellId: input.doorbellId,
      },
    };

    let claimPath: string;
    try {
      claimPath = await this.#claims.acquire(claim);
    } catch (err) {
      return {
        status: "claim_conflict",
        reason: err instanceof Error ? err.message : String(err),
      };
    }

    await this.#runs.put(run);
    await this.#appendSessionIndex(input.host, {
      runId,
      workerId: input.workerId,
      hostSessionKey: sessionKey,
    });
    return { status: "started", run, claimPath, sessionKey };
  }

  async markRunning(
    workerId: string,
    runId: string,
    externalRunId?: string,
  ): Promise<RunRecord | null> {
    const found = await this.#runs.get(workerId, runId);
    if (!found) return null;
    const next: RunRecord = {
      ...found.record,
      state: "running",
      externalRunId: externalRunId ?? found.record.externalRunId,
    };
    await this.#runs.put(next);
    return next;
  }

  async settleRun(
    workerId: string,
    runId: string,
    state: "completed" | "failed" | "cancelled" | "delivery_unknown" = "completed",
    failure?: HostFailure,
  ): Promise<RunRecord | null> {
    const found = await this.#runs.get(workerId, runId);
    if (!found) return null;
    const next: RunRecord = {
      ...found.record,
      state,
      settledAt: this.#now().toISOString(),
      ...(failure ? { failure } : {}),
    };
    await this.#runs.put(next);

    const hostId = found.record.hostId;
    const listed = await this.#claims.list(hostId);
    for (const c of listed) {
      if (c.runId === runId) {
        await this.#claims.release(hostId, c.sessionKey);
      }
    }
    return next;
  }

  async appendEvidence(
    workerId: string,
    runId: string,
    event: Omit<RunEvidenceEvent, "eventId" | "at"> &
      Partial<Pick<RunEvidenceEvent, "eventId" | "at">>,
  ): Promise<string> {
    const dir = join(this.#paths.root, "evidence", "runs", workerId, runId);
    await fs.mkdir(dir, { recursive: true });
    const full: RunEvidenceEvent = {
      eventId: event.eventId?.trim() || `evt-${this.#idFactory()}`,
      at: event.at?.trim() || this.#now().toISOString(),
      type: event.type,
      payload: event.payload,
    };
    const path = join(dir, `${full.eventId}.json`);
    await AtomicFile.writeJson(path, full);
    return path;
  }

  async listEvidence(
    workerId: string,
    runId: string,
  ): Promise<RunEvidenceEvent[]> {
    const dir = join(this.#paths.root, "evidence", "runs", workerId, runId);
    let names: string[] = [];
    try {
      names = await fs.readdir(dir);
    } catch {
      return [];
    }
    const out: RunEvidenceEvent[] = [];
    for (const name of names.sort()) {
      if (!name.endsWith(".json") || name.includes(".tmp")) continue;
      out.push(await AtomicFile.readJson<RunEvidenceEvent>(join(dir, name)));
    }
    return out;
  }

  async listRunsForHostSession(host: HostSessionRef): Promise<RunRecord[]> {
    const index = await this.readSessionIndex(host);
    const out: RunRecord[] = [];
    for (const entry of index) {
      const found = await this.#runs.get(entry.workerId, entry.runId);
      if (found) out.push(found.record);
    }
    return out;
  }

  async listActiveRunsForHostSession(
    host: HostSessionRef,
  ): Promise<RunRecord[]> {
    const all = await this.listRunsForHostSession(host);
    return all.filter((r) => ACTIVE_STATES.has(r.state));
  }

  async getRun(
    workerId: string,
    runId: string,
  ): Promise<RunRecord | null> {
    const found = await this.#runs.get(workerId, runId);
    return found?.record ?? null;
  }

  async readSessionIndex(
    host: HostSessionRef,
  ): Promise<HostSessionRunIndexEntry[]> {
    const path = this.sessionIndexPath(host);
    if (!(await AtomicFile.exists(path))) return [];
    try {
      const raw = await AtomicFile.readJson<{
        runs?: HostSessionRunIndexEntry[];
      }>(path);
      return Array.isArray(raw.runs) ? raw.runs : [];
    } catch {
      return [];
    }
  }

  sessionIndexPath(host: HostSessionRef): string {
    const safe = hostSessionKey(host).replace(/[^a-zA-Z0-9._-]+/g, "_");
    return join(this.#paths.root, "host-sessions", `${safe}.runs.json`);
  }

  async #appendSessionIndex(
    host: HostSessionRef,
    entry: HostSessionRunIndexEntry,
  ): Promise<void> {
    const path = this.sessionIndexPath(host);
    const existing = await this.readSessionIndex(host);
    if (!existing.some((e) => e.runId === entry.runId)) {
      existing.push(entry);
    }
    await AtomicFile.writeJson(path, {
      schema: "codeflowmu.host-session-runs/v1",
      hostSessionKey: hostSessionKey(host),
      hostId: host.hostId,
      externalSessionId: host.externalSessionId,
      runs: existing,
      updatedAt: this.#now().toISOString(),
    });
  }
}
