import {
  Clock,
  HttpFault,
  Rng,
  Transport,
  faultsFromRng,
  type FaultConfig,
} from "./sim";
import { SOCKET_TTL_MS, SWEEP_INTERVAL_MS, SimServer, type SimUser } from "./server";
import { SimClient, assertInvariants } from "./client";
import { channelView } from "../invariants";

export type Action =
  | { type: "send"; client: number; kind: "plain" | "mention" | "here" }
  | { type: "reply"; client: number; broadcast: boolean }
  | { type: "edit"; client: number }
  | { type: "delete"; client: number }
  | { type: "react"; client: number }
  | { type: "ack"; client: number }
  | { type: "markUnread"; client: number }
  | { type: "ackThread"; client: number }
  | { type: "disconnect"; client: number; graceful: boolean }
  | { type: "reconnect"; client: number }
  | { type: "partition"; client: number; on: boolean }
  | { type: "tick" }
  | { type: "sweep" };

const USERS: SimUser[] = [
  { id: 1, username: "alice" },
  { id: 2, username: "bob" },
  { id: 3, username: "cara" },
];

const TEXTS = {
  plain: "hello",
  mention: "hey @bob",
  here: "ping @here",
} as const;

export function generateActions(rng: Rng, length: number): Action[] {
  const actions: Action[] = [];
  for (let i = 0; i < length; i++) {
    const client = rng.int(0, 1);
    const roll = rng.next();
    if (roll < 0.28) {
      actions.push({ type: "send", client, kind: rng.pick(["plain", "mention", "here"] as const) });
    }
    else if (roll < 0.4) actions.push({ type: "reply", client, broadcast: rng.chance(0.4) });
    else if (roll < 0.48) actions.push({ type: "edit", client });
    else if (roll < 0.54) actions.push({ type: "delete", client });
    else if (roll < 0.62) actions.push({ type: "react", client });
    else if (roll < 0.72) actions.push({ type: "ack", client });
    else if (roll < 0.76) actions.push({ type: "markUnread", client });
    else if (roll < 0.8) actions.push({ type: "ackThread", client });
    else if (roll < 0.86) actions.push({ type: "disconnect", client, graceful: rng.chance(0.6) });
    else if (roll < 0.92) actions.push({ type: "reconnect", client });
    else if (roll < 0.95) actions.push({ type: "partition", client, on: rng.chance(0.5) });
    else if (roll < 0.98) actions.push({ type: "tick" });
    else actions.push({ type: "sweep" });
  }
  return actions;
}

export interface TraceOptions {
  seed: number;
  actions?: Action[];
  faults?: FaultConfig;
  actionCount?: number;
}

export async function runTrace(opts: TraceOptions): Promise<void> {
  const rng = new Rng(opts.seed);
  const clock = new Clock();
  const faults = opts.faults ?? faultsFromRng(rng);
  const transport = new Transport(clock, rng, faults);
  const server = new SimServer(USERS, clock, transport, rng, faults);
  const clients = [
    new SimClient(1, server, 1),
    new SimClient(2, server, 2),
  ];

  await clients[0]!.open();
  await clients[1]!.open();
  await drain(clock, transport, clients);

  const actions = opts.actions ?? generateActions(rng, opts.actionCount ?? rng.int(8, 28));
  for (const action of actions) {
    await apply(action, clients, server, transport, clock);
    clock.advance(5);
    await deliver(transport, clients);
  }

  await quiesce(faults, clock, transport, server, clients);
  assertInvariants(server, clients);
  await assertPresenceI9(clock, server, clients, transport);
}

async function apply(
  action: Action,
  clients: SimClient[],
  server: SimServer,
  transport: Transport,
  clock: Clock,
): Promise<void> {
  if (action.type === "tick") {
    clock.advance(25_000);
    for (const c of clients) c.heartbeat();
    return;
  }
  if (action.type === "sweep") {
    server.sweep();
    return;
  }
  const client = clients[action.client];
  if (!client) return;

  switch (action.type) {
    case "send":
      if (!client.connected) return;
      await client.send(TEXTS[action.kind]);
      break;
    case "reply": {
      if (!client.connected) return;
      const root = server.allMessages().find((m) => m.parentId == null && m.deletedAt == null);
      if (!root) {
        await client.send("root");
        break;
      }
      await client.send("reply", { parentId: Number(root.id), isBroadcast: action.broadcast });
      break;
    }
    case "edit": {
      if (!client.connected) return;
      const own = server.allMessages().find((m) => Number(m.authorId) === client.userId && m.deletedAt == null);
      if (own) server.edit(client.userId, Number(own.id), `${own.text}!`);
      break;
    }
    case "delete": {
      if (!client.connected) return;
      const own = server.allMessages().find((m) => Number(m.authorId) === client.userId && m.deletedAt == null);
      if (own) server.delete(Number(own.id));
      break;
    }
    case "react": {
      if (!client.connected) return;
      const any = server.allMessages().find((m) => m.deletedAt == null);
      if (any) server.react(client.userId, Number(any.id), "thumbsup");
      break;
    }
    case "ack":
      client.ackUpToLatest();
      break;
    case "markUnread":
      client.markLatestUnread();
      break;
    case "ackThread":
      client.ackThreads();
      break;
    case "disconnect":
      if (client.connected) client.disconnect(action.graceful);
      break;
    case "reconnect":
      if (!client.connected) {
        try {
          await client.reconnect();
        } catch (err) {
          if (!(err instanceof HttpFault)) throw err;
        }
      }
      break;
    case "partition":
      if (action.on) transport.partitioned.add(client.userId);
      else transport.partitioned.delete(client.userId);
      break;
  }
}

async function deliver(transport: Transport, clients: SimClient[]): Promise<void> {
  const due = transport.deliverDue();
  for (const envelope of due) {
    const client = clients.find((c) => c.userId === envelope.toUserId);
    if (client) await client.onEvent(envelope.event);
  }
}

async function drain(clock: Clock, transport: Transport, clients: SimClient[]): Promise<void> {
  for (let i = 0; i < 8; i++) {
    clock.advance(50);
    await deliver(transport, clients);
    if (transport.remaining() === 0) break;
  }
}

async function quiesce(
  faults: FaultConfig,
  clock: Clock,
  transport: Transport,
  server: SimServer,
  clients: SimClient[],
): Promise<void> {
  faults.dropRate = 0;
  faults.duplicateRate = 0;
  faults.reorder = false;
  faults.maxDelayMs = 0;
  faults.crashBeforeCommitRate = 0;
  faults.crashAfterCommitRate = 0;
  faults.httpFailRate = 0;
  transport.partitioned.clear();

  for (const client of clients) {
    if (!client.connected) await client.reconnect();
  }
  for (let i = 0; i < 6; i++) {
    for (const client of clients) await client.outbox.replay();
    await drain(clock, transport, clients);
  }
  await assertCatchupI7(server, clients, clock, transport);
  for (const client of clients) await client.fullReconcile();
  for (const client of clients) client.heartbeat();
  await drain(clock, transport, clients);

  for (const client of clients) {
    const pending = await client.outboxStorage.load();
    if (pending.length > 0) {
      throw new Error(`I2: outbox still holds ${pending.length} unacked send(s) after quiesce`);
    }
  }
}

async function assertCatchupI7(
  server: SimServer,
  clients: SimClient[],
  clock: Clock,
  transport: Transport,
): Promise<void> {
  const alice = clients[0]!;
  const bob = clients[1]!;
  if (!alice.connected) await alice.reconnect();
  if (!bob.connected) await bob.reconnect();
  const cursor = alice.sync.getCursor();
  alice.disconnect(true);
  await bob.send("i7-probe");
  await drain(clock, transport, clients);
  await alice.reconnect();
  const expected = channelView(server.allMessages()).filter((m) => m.seq > cursor && m.deletedAt == null);
  const have = new Set(alice.sync.getMessages().map((m) => m.seq));
  for (const message of expected) {
    if (!have.has(message.seq)) {
      throw new Error(`I7: after catch-up from ${cursor}, missing seq ${message.seq}`);
    }
  }
}

/**
 * I9 dual-cursor analogue: one user, two sockets; closing one must not
 * go offline; ungraceful death + TTL + sweep must.
 */
async function assertPresenceI9(
  clock: Clock,
  server: SimServer,
  clients: SimClient[],
  transport: Transport,
): Promise<void> {
  const alice = clients[0]!;
  const bob = clients[1]!;
  if (server.liveSocketCount(alice.userId) === 0) await alice.reconnect();
  if (server.displayedPresence.get(alice.userId) === "offline") {
    throw new Error("I9: connected alice shown offline before dual-socket check");
  }

  alice.addExtraSocket();
  if (server.liveSocketCount(alice.userId) < 2) throw new Error("I9: extra socket did not register");
  alice.dropExtraSocket(true);
  if (server.displayedPresence.get(alice.userId) === "offline") {
    throw new Error("I9: closing one of two sockets marked alice offline");
  }

  bob.disconnect(false);
  clock.advance(SOCKET_TTL_MS + SWEEP_INTERVAL_MS);
  server.sweep();
  await drain(clock, transport, clients);
  if (server.displayedPresence.get(bob.userId) !== "offline") {
    throw new Error("I9: bob still shown online >60s after ungraceful disconnect");
  }
  if (server.liveSocketCount(alice.userId) > 0 && server.displayedPresence.get(alice.userId) === "offline") {
    throw new Error("I9: alice flipped offline while still holding a live socket");
  }
  await bob.reconnect();
}
