/**
 * ShadowDispatchRecorder — WP-06 Shadow Doorbell / Run / Session / Event.
 *
 * Mirrors legacy (real) dispatch facts into file-native switchboard stores.
 * NEVER calls AgentSdkAdapter.send — recording only; no second delivery.
 * mode=off stops new writes (rollback) while preserving existing evidence.
 */

import { createHash } from "node:crypto";

import { AtomicFile } from "../atomic/AtomicFile.ts";
import type { ShadowSwitchboardMode } from "../binding/WorkerBindingService.ts";
import {
  makeShadowExternalSessionId,
} from "../binding/WorkerBindingService.ts";
import type { DoorbellMessage } from "../contracts/DoorbellMessage.ts";
import {
  toHostEventEnvelope,
  type HostEventEnvelope,
} from "../contracts/HostEvent.ts";
import type { RunRecord } from "../contracts/RunRecord.ts";
import { SwitchboardPaths } from "../paths/SwitchboardPaths.ts";
import {
  DoorbellMailbox,
  type EnqueueResult,
} from "../store/DoorbellMailbox.ts";
import {
  HostClaimStore,
  type HostClaimRecord,
} from "../store/HostClaimStore.ts";
import { RunStore } from "../store/RunStore.ts";

export const SHADOW_EVIDENCE_SCHEMA = "codeflowmu.shadow-evidence/v1" as const;
export const SHADOW_ALIGNMENT_SCHEMA = "codeflowmu.shadow-alignment/v1" as const;

export interface LegacyDispatchFact {
  /** Stable id of the real (legacy) dispatch — one fact → at most one shadow doorbell. */
  dispatchId: string;
  workerId: string;
  summary: string;
  dedupeKey: string;
  sourceFactRefs: string[];
  hostId: string;
  bindingId: string;
  taskId?: string;
  topic?: string;
  priority?: DoorbellMessage["priority"];
  requestedBy?: string;
  externalSessionId?: string;
  continuationMode?: RunRecord["continuationMode"];
  events?: Array<{
    type: string;
    payload?: unknown;
    at?: string;
    eventId?: string;
  }>;
}

export interface ShadowEvidenceRecord {
  schema: typeof SHADOW_EVIDENCE_SCHEMA;
  deliveryAttemptId: string;
  dispatchId: string;
  doorbellId: string;
  runId: string;
  workerId: string;
  hostId: string;
  createdAt: string;
  mode: ShadowSwitchboardMode;
  invokedSdkSend: false;
  openedLiveSession: false;
  paths: {
    doorbell?: string;
    run?: string;
    sessionClaim?: string;
    events: string[];
    evidence: string;
  };
  digest: string;
}

export interface AlignmentRow {
  dispatchId: string;
  doorbellId: string;
  runId: string;
  deliveryAttemptId: string;
  workerId: string;
  eventCount: number;
  doorbellPresent: boolean;
  runPresent: boolean;
  aligned: boolean;
}

export interface AlignmentLedger {
  schema: typeof SHADOW_ALIGNMENT_SCHEMA;
  updatedAt: string;
  mode: ShadowSwitchboardMode;
  legacyDispatchCount: number;
  shadowDoorbellCount: number;
  rows: AlignmentRow[];
  aligned: boolean;
  notes: string[];
}

export interface ShadowRecordResult {
  dispatchId: string;
  doorbellId: string;
  runId: string;
  deliveryAttemptId: string;
  enqueue: EnqueueResult;
  runPath: string;
  sessionClaimPath: string;
  eventPaths: string[];
  evidence: ShadowEvidenceRecord;
  evidencePath: string;
  invokedSdkSend: false;
  mode: ShadowSwitchboardMode;
  duplicate: boolean;
}

export interface ShadowDispatchRecorderOptions {
  paths: SwitchboardPaths;
  mode?: ShadowSwitchboardMode;
  mailbox?: DoorbellMailbox;
  runs?: RunStore;
  claims?: HostClaimStore;
}

function digestOf(parts: unknown): string {
  return createHash("sha256")
    .update(JSON.stringify(parts))
    .digest("hex")
    .slice(0, 16);
}

/**
 * Records Shadow artefacts for observed legacy dispatches.
 * Constructor intentionally omits AgentSdkAdapter / AgentHostAdapter.
 */
export class ShadowDispatchRecorder {
  readonly #paths: SwitchboardPaths;
  readonly #mailbox: DoorbellMailbox;
  readonly #runs: RunStore;
  readonly #claims: HostClaimStore;
  #mode: ShadowSwitchboardMode;
  #sdkSendAttempts = 0;

  constructor(opts: ShadowDispatchRecorderOptions) {
    this.#paths = opts.paths;
    this.#mailbox = opts.mailbox ?? new DoorbellMailbox(opts.paths);
    this.#runs = opts.runs ?? new RunStore(opts.paths);
    this.#claims = opts.claims ?? new HostClaimStore(opts.paths);
    this.#mode = opts.mode ?? "shadow";
  }

  get mode(): ShadowSwitchboardMode {
    return this.#mode;
  }

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

  /** Always 0 — no SDK send path exists on this recorder. */
  get sdkSendAttempts(): number {
    return this.#sdkSendAttempts;
  }

  /** Rollback switch: stop new Shadow writes; keep existing evidence on disk. */
  setMode(mode: ShadowSwitchboardMode): void {
    if (mode !== "off" && mode !== "shadow") {
      throw new Error(
        `ShadowDispatchRecorder: unsupported mode "${String(mode)}" (Active forbidden in WP-06)`,
      );
    }
    this.#mode = mode;
  }

  /**
   * Mirror one legacy dispatch into Doorbell + Run + Session claim + Events + Evidence.
   * Does not call sdk.send. Duplicate dedupeKey / already-recorded dispatchId is idempotent.
   */
  async recordFromLegacyDispatch(
    fact: LegacyDispatchFact,
  ): Promise<ShadowRecordResult> {
    if (this.#mode === "off") {
      throw new Error(
        "ShadowDispatchRecorder.recordFromLegacyDispatch: mode=off (rolled back)",
      );
    }

    const workerId = String(fact.workerId ?? "").trim();
    const dispatchId = String(fact.dispatchId ?? "").trim();
    const hostId = String(fact.hostId ?? "").trim();
    const bindingId = String(fact.bindingId ?? "").trim();
    if (!workerId || !dispatchId || !hostId || !bindingId) {
      throw new Error(
        "recordFromLegacyDispatch: workerId, dispatchId, hostId, bindingId required",
      );
    }

    const ledger = await this.#readLedger();
    const existingRow = ledger.rows.find((r) => r.dispatchId === dispatchId);
    if (existingRow) {
      const evidencePath = this.#paths.evidenceFile(existingRow.deliveryAttemptId);
      const evidence = (await AtomicFile.readJson(
        evidencePath,
      )) as ShadowEvidenceRecord;
      const enqueue = await this.#mailbox.findByDedupeKey(
        workerId,
        fact.dedupeKey,
      );
      return {
        dispatchId,
        doorbellId: existingRow.doorbellId,
        runId: existingRow.runId,
        deliveryAttemptId: existingRow.deliveryAttemptId,
        enqueue: enqueue
          ? {
              status: "duplicate",
              path: enqueue.path,
              bucket: enqueue.bucket,
            }
          : {
              status: "duplicate",
              path: evidence.paths.doorbell ?? "",
              bucket: "pending",
            },
        runPath: evidence.paths.run ?? "",
        sessionClaimPath: evidence.paths.sessionClaim ?? "",
        eventPaths: evidence.paths.events,
        evidence,
        evidencePath,
        invokedSdkSend: false,
        mode: this.#mode,
        duplicate: true,
      };
    }

    const doorbellId = `db-${dispatchId}`;
    const runId = `run-${dispatchId}`;
    const deliveryAttemptId = `attempt-${dispatchId}`;
    const now = new Date().toISOString();

    const doorbell: DoorbellMessage = {
      schema: "codeflowmu.doorbell/v1",
      doorbellId,
      recipientWorkerId: workerId,
      topic: fact.topic ?? "task.ready",
      sourceFactRefs: [...fact.sourceFactRefs],
      summary: fact.summary,
      priority: fact.priority ?? "P1",
      dedupeKey: fact.dedupeKey,
      createdAt: now,
      ...(fact.requestedBy ? { requestedBy: fact.requestedBy } : {}),
      extension: {
        shadow: true,
        dispatchId,
        switchboard_mode: this.#mode,
        legacy_mirror: true,
      },
    };

    const enqueue = await this.#mailbox.enqueue(doorbell);
    // Deduped against a prior unrelated write still counts as one doorbell for this fact.
    const effectiveDoorbellId =
      enqueue.status === "duplicate"
        ? (await this.#mailbox.findByDedupeKey(workerId, fact.dedupeKey))!
            .message.doorbellId
        : doorbellId;

    const run: RunRecord = {
      schema: "codeflowmu.run/v1",
      runId,
      workerId,
      doorbellId: effectiveDoorbellId,
      bindingId,
      hostId,
      deliveryAttemptId,
      state: "completed",
      sourceFactRefs: [...fact.sourceFactRefs],
      continuationMode: fact.continuationMode ?? "same_session",
      startedAt: now,
      settledAt: now,
      ...(fact.taskId ? { taskId: fact.taskId } : {}),
    };
    const runPath = await this.#runs.put(run);

    const sessionKey =
      fact.externalSessionId?.trim() ||
      makeShadowExternalSessionId(`sess-${dispatchId}`);
    const claim: HostClaimRecord = {
      schema: "codeflowmu.host-claim/v1",
      hostId,
      sessionKey,
      workerId,
      runId,
      acquiredAt: now,
      metadata: {
        shadow: "true",
        switchboard_mode: this.#mode,
        dispatchId,
        opens_live_session: "false",
      },
    };
    const sessionClaimPath = await this.#claims.acquire(claim);

    const eventPaths: string[] = [];
    const eventInputs = fact.events?.length
      ? fact.events
      : [{ type: "host.run.completed", payload: { shadow: true } }];
    for (const [i, ev] of eventInputs.entries()) {
      const eventId = ev.eventId?.trim() || `evt-${dispatchId}-${i + 1}`;
      const envelope: HostEventEnvelope = toHostEventEnvelope({
        eventId,
        at: ev.at ?? now,
        hostId,
        workerId,
        runId,
        type: ev.type,
        payload: {
          ...(typeof ev.payload === "object" && ev.payload
            ? (ev.payload as object)
            : { value: ev.payload }),
          deliveryAttemptId,
          dispatchId,
          shadow: true,
        },
      });
      const eventPath = this.#paths.eventFile(runId, eventId);
      await AtomicFile.writeJson(eventPath, envelope);
      eventPaths.push(eventPath);
    }

    const evidencePath = this.#paths.evidenceFile(deliveryAttemptId);
    const evidence: ShadowEvidenceRecord = {
      schema: SHADOW_EVIDENCE_SCHEMA,
      deliveryAttemptId,
      dispatchId,
      doorbellId: effectiveDoorbellId,
      runId,
      workerId,
      hostId,
      createdAt: now,
      mode: this.#mode,
      invokedSdkSend: false,
      openedLiveSession: false,
      paths: {
        doorbell: enqueue.path,
        run: runPath,
        sessionClaim: sessionClaimPath,
        events: eventPaths,
        evidence: evidencePath,
      },
      digest: digestOf({
        dispatchId,
        doorbellId: effectiveDoorbellId,
        runId,
        deliveryAttemptId,
        eventCount: eventPaths.length,
      }),
    };
    await AtomicFile.writeJson(evidencePath, evidence);

    const row: AlignmentRow = {
      dispatchId,
      doorbellId: effectiveDoorbellId,
      runId,
      deliveryAttemptId,
      workerId,
      eventCount: eventPaths.length,
      doorbellPresent: true,
      runPresent: true,
      aligned: true,
    };
    await this.#appendLedgerRow(row);

    return {
      dispatchId,
      doorbellId: effectiveDoorbellId,
      runId,
      deliveryAttemptId,
      enqueue,
      runPath,
      sessionClaimPath,
      eventPaths,
      evidence,
      evidencePath,
      invokedSdkSend: false,
      mode: this.#mode,
      duplicate: enqueue.status === "duplicate",
    };
  }

  /** Recompute alignment from durable files (also used after "runtime restart"). */
  async buildAlignment(workerIds?: string[]): Promise<AlignmentLedger> {
    const ledger = await this.#readLedger();
    const workers =
      workerIds?.length
        ? workerIds
        : [...new Set(ledger.rows.map((r) => r.workerId))];

    let shadowDoorbellCount = 0;
    for (const workerId of workers) {
      for (const bucket of ["pending", "claimed", "settled", "failed"] as const) {
        shadowDoorbellCount += (await this.#mailbox.list(workerId, bucket)).length;
      }
    }

    const rows: AlignmentRow[] = [];
    for (const row of ledger.rows) {
      const doorbellHit = await this.#findDoorbell(row.workerId, row.doorbellId);
      const runHit = await this.#runs.get(row.workerId, row.runId);
      let eventCount = 0;
      try {
        const { promises: fs } = await import("node:fs");
        const names = await fs.readdir(this.#paths.eventsDir(row.runId));
        eventCount = names.filter(
          (n) => n.endsWith(".json") && !n.includes(".tmp"),
        ).length;
      } catch {
        eventCount = 0;
      }
      const aligned = Boolean(doorbellHit && runHit && eventCount > 0);
      rows.push({
        ...row,
        eventCount,
        doorbellPresent: Boolean(doorbellHit),
        runPresent: Boolean(runHit),
        aligned,
      });
    }

    const legacyDispatchCount = rows.length;
    const allAligned =
      rows.every((r) => r.aligned) &&
      shadowDoorbellCount === legacyDispatchCount;

    const next: AlignmentLedger = {
      schema: SHADOW_ALIGNMENT_SCHEMA,
      updatedAt: new Date().toISOString(),
      mode: this.#mode,
      legacyDispatchCount,
      shadowDoorbellCount,
      rows,
      aligned: allAligned,
      notes: [
        "Shadow mirrors legacy dispatch facts only; sdk.send is never invoked.",
        "aligned requires doorbellCount == legacyDispatchCount and each row present.",
        this.#mode === "off"
          ? "mode=off: new records rejected (rollback); evidence retained."
          : "mode=shadow: recording enabled; Active path unchanged.",
      ],
    };
    await AtomicFile.writeJson(this.#paths.alignmentLedgerFile(), next);
    return next;
  }

  /** Simulate runtime restart: new recorder instance reading same persist root. */
  static reopen(
    paths: SwitchboardPaths,
    mode?: ShadowSwitchboardMode,
  ): ShadowDispatchRecorder {
    return new ShadowDispatchRecorder({ paths, mode });
  }

  async #findDoorbell(
    workerId: string,
    doorbellId: string,
  ): Promise<DoorbellMessage | null> {
    for (const bucket of ["pending", "claimed", "settled", "failed"] as const) {
      const list = await this.#mailbox.list(workerId, bucket);
      const hit = list.find((d) => d.doorbellId === doorbellId);
      if (hit) return hit;
    }
    return null;
  }

  async #readLedger(): Promise<AlignmentLedger> {
    const file = this.#paths.alignmentLedgerFile();
    if (!(await AtomicFile.exists(file))) {
      return {
        schema: SHADOW_ALIGNMENT_SCHEMA,
        updatedAt: new Date().toISOString(),
        mode: this.#mode,
        legacyDispatchCount: 0,
        shadowDoorbellCount: 0,
        rows: [],
        aligned: true,
        notes: [],
      };
    }
    return AtomicFile.readJson<AlignmentLedger>(file);
  }

  async #appendLedgerRow(row: AlignmentRow): Promise<void> {
    const ledger = await this.#readLedger();
    ledger.rows = [...ledger.rows.filter((r) => r.dispatchId !== row.dispatchId), row];
    ledger.legacyDispatchCount = ledger.rows.length;
    ledger.updatedAt = new Date().toISOString();
    ledger.mode = this.#mode;
    await AtomicFile.writeJson(this.#paths.alignmentLedgerFile(), ledger);
  }
}

/** Convenience: write a human-readable shadow report markdown. */
export async function writeShadowAlignmentReport(
  paths: SwitchboardPaths,
  ledger: AlignmentLedger,
  dateStamp?: string,
): Promise<string> {
  const stamp =
    dateStamp ??
    new Date().toISOString().slice(0, 10).replace(/-/g, "");
  const file = paths.shadowReportFile(stamp);
  const lines = [
    `# Shadow Alignment Report`,
    "",
    `- updatedAt: ${ledger.updatedAt}`,
    `- mode: ${ledger.mode}`,
    `- legacyDispatchCount: ${ledger.legacyDispatchCount}`,
    `- shadowDoorbellCount: ${ledger.shadowDoorbellCount}`,
    `- aligned: ${ledger.aligned}`,
    "",
    `| dispatchId | doorbellId | runId | deliveryAttemptId | events | ok |`,
    `|---|---|---|---|---:|:---:|`,
    ...ledger.rows.map(
      (r) =>
        `| ${r.dispatchId} | ${r.doorbellId} | ${r.runId} | ${r.deliveryAttemptId} | ${r.eventCount} | ${r.aligned ? "Y" : "N"} |`,
    ),
    "",
    "## Notes",
    ...ledger.notes.map((n) => `- ${n}`),
    "",
  ];
  await AtomicFile.writeText(file, lines.join("\n"));
  return file;
}
