/**
 * Orchestrator — the chat channel runtime.
 *
 * One **channel** per task. A channel holds an `AgentSession` per roster
 * agent and emits protocol events for the cloud side to persist, stream,
 * and route. It is the in-process stand-in for what becomes the host
 * agent process in the hosted product (docs/hosted-architecture.md).
 *
 * Responsibilities:
 *   - Lazily build a channel: load the project roster, spawn sessions.
 *   - Deliver cloud-routed messages into live agent sessions.
 *   - Emit typed HostEvents for agent output, tools, permissions, exits.
 *
 * Module singleton, stashed on globalThis so Next.js HMR reuses it
 * instead of leaking a second orchestrator.
 */

import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

import { inArray } from "drizzle-orm";

import { getDb, schema } from "./db";
import { mockAgentFactory } from "./agents/mock";
import { realAgentFactory } from "./agents/factory";
import {
  type AgentEvent,
  type AgentSession,
  type AgentSessionFactory,
  type Roster,
  type RosterAgent,
} from "./agents/types";
import { ACTIVE_STATUSES } from "./task-status";
import { getHostTask, upsertHostTask } from "./runtime-state";
import { setupTaskGithub } from "./github-tokens";
import { setupTaskGitIdentity } from "./git-identity";
import { dockerCli } from "./docker-exec";
import {
  containerSkillFile,
  installedSkillPath,
  installPackageSkills,
  writeAgentSkills,
} from "./skills";
import {
  CONTAINER_CLI_PATH,
  agentCliEnv,
  apiUrlFromCloudUrl,
  loadTaskCliSecret,
  writeAgentCli,
} from "./agent-cli";
import { env } from "./env";
import type { ChannelEnsureInput, HostEvent } from "../src/protocol";

export type HostEventSubscriber = (event: HostEvent) => void;

// ---------------------------------------------------------------------------
// Channel — one task's live conversation.
// ---------------------------------------------------------------------------

interface Channel {
  taskId: string;
  roster: Roster;
  sessions: Map<string, AgentSession>;
  /** Session-spawn inputs, kept so sessions can be started lazily. */
  containerName: string;
  /** Per-agent system preamble (channel briefing + assembled persona). */
  preambles: Map<string, string>;
  /** Per-agent first-turn message — only agents whose `initialPrompt` is
   *  non-empty have an entry. Delivered once at container-ready. */
  firstTurns: Map<string, string>;
  /** In-flight (or completed) session start. A memoized promise — not a
   *  boolean flag — so a concurrent deliver() awaits actual readiness instead
   *  of observing "started" while the sessions map is still empty (the start
   *  awaits docker work before populating it). Reset to null on failure so the
   *  next ensure retries. */
  sessionsReady: Promise<boolean> | null;
  /** Agents with turn output already streamed for the current turn — i.e. the
   *  cloud has buffered content for them (see ChannelRouter.turnBuffer). */
  openTurns: Set<string>;
  /** Agents whose current turn was interrupted (ESC) — their next
   *  turn_complete is flagged `aborted` so the cloud DISCARDS the buffered
   *  half-turn instead of delivering it to @-mentioned peers. */
  interrupted: Set<string>;
  /** Per-agent respawn counter — bounded so a broken agent can't
   *  loop forever rewriting its config. */
  respawns: Map<string, number>;
}

/** Hard cap on automatic respawns per agent per channel lifetime. */
const MAX_RESPAWNS_PER_AGENT = 5;

/** Substrings in an agent's error output that mean "config was
 *  unlinked between runs" — repair-and-respawn covers the common
 *  Docker-Desktop-macOS race where Claude's atomic writes briefly
 *  leave .claude.json missing. */
const CLAUDE_CONFIG_MISSING_PATTERNS = [
  /Claude configuration file not found/i,
  /\/\.claude\.json/i,
];

class Orchestrator {
  private readonly channels = new Map<string, Channel>();
  private readonly channelSpecs = new Map<string, ChannelEnsureInput>();
  private readonly hostSubscribers = new Set<HostEventSubscriber>();

  constructor(private readonly factory: AgentSessionFactory) {}

  // -- subscriptions --------------------------------------------------------

  subscribeHostEvents(fn: HostEventSubscriber): () => void {
    this.hostSubscribers.add(fn);
    return () => this.hostSubscribers.delete(fn);
  }

  /** Emit a host-originated system note into a task's channel (ADR-027). */
  emitSystemNote(taskId: string, text: string): void {
    this.emitHost({ kind: "system.note", taskId, text });
  }

  private emitHost(event: HostEvent): void {
    for (const fn of this.hostSubscribers) {
      try {
        fn(event);
      } catch {
        // A broken cloud subscriber must not take the channel down.
      }
    }
  }

  // -- channel lifecycle ----------------------------------------------------

  registerChannelSpec(spec: ChannelEnsureInput): void {
    this.channelSpecs.set(spec.taskId, spec);
  }

  private async getOrCreateChannel(taskId: string): Promise<Channel | null> {
    const existing = this.channels.get(taskId);
    if (existing) return existing;

    const spec = this.channelSpecs.get(taskId);
    if (!spec) return null;

    const roster = spec.agents;
    const preambles = new Map<string, string>();
    const firstTurns = new Map<string, string>();
    for (const agent of roster) {
      preambles.set(
        agent.id,
        buildSystemPreamble(
          roster,
          agent,
          spec.projects,
          spec.globalContext,
          spec.workspacePath,
          spec.branch,
        ),
      );
      // ADR-046: materialise this agent's skills to its per-agent SKILL.md in
      // the workspace (bind-mounted into the container). Best-effort.
      writeAgentSkills(taskId, agent);
      // ADR-022: any agent with a non-empty initialPrompt opens a first
      // turn at container-ready. The rest stay silent until addressed.
      const firstTurn = assembleFirstTurnPrompt(
        spec.projects,
        spec.globalContext,
        agent,
      );
      if (firstTurn !== null) firstTurns.set(agent.id, firstTurn);
    }

    const channel: Channel = {
      taskId,
      roster,
      sessions: new Map(),
      containerName: `task-${taskId.toLowerCase()}-app-1`,
      preambles,
      firstTurns,
      sessionsReady: null,
      openTurns: new Set(),
      interrupted: new Set(),
      respawns: new Map(),
    };
    this.channels.set(taskId, channel);
    return channel;
  }

  /**
   * Spawn the channel's agent sessions, lazily and exactly once.
   *
   * The real adapters `docker exec` into the task container, which only
   * exists after `task-up` has run `docker compose up`. So sessions are
   * gated on the task being `running` — calling this earlier is a no-op
   * that returns `false`. Once the task is running, the first call
   * spawns every roster agent's session.
   *
   * Returns whether sessions are ready.
   */
  private ensureSessions(channel: Channel): Promise<boolean> {
    // Memoized: every caller awaits the SAME in-flight start, so a concurrent
    // deliver() can't observe "ready" while the sessions map is still empty
    // (startSessions awaits docker work before populating it — the old boolean
    // flag flipped before that await and opened exactly that window).
    if (!channel.sessionsReady) {
      channel.sessionsReady = this.startSessions(channel);
      // A failed/aborted start must not poison the channel — reset so the
      // next ensure retries (e.g. the task wasn't `running` yet).
      channel.sessionsReady.then(
        (ok) => {
          if (!ok) channel.sessionsReady = null;
        },
        () => {
          channel.sessionsReady = null;
        },
      );
    }
    return channel.sessionsReady;
  }

  private async startSessions(channel: Channel): Promise<boolean> {
    const task = getHostTask(channel.taskId);
    if (!task || task.statusMirror !== "running") return false;

    // Set the task creator's git author identity in the container (ADR-029).
    // The SSH key itself is installed earlier by task-up.sh (host clone +
    // container), using the creator's per-user key. Best-effort. Awaited but
    // async (docker exec via dockerCli) so it doesn't block the event loop —
    // this runs on every channel ensure, including each post-restart reconnect.
    await setupTaskGitIdentity(channel.taskId, task.ownerName, task.ownerEmail);

    // GitHub auth for `gh` (ADR-027): mint + inject the access token and
    // (re)start its refresh schedule. Done here — not only at task-up — so it
    // survives a host restart: recovery recreates the channel and re-runs
    // ensureSessions, whereas the in-memory refresh timer from the original
    // task-up is gone. Guarded + best-effort + non-blocking; the token lands
    // before the agents (spawned just below) make their first `gh` call.
    if (task.ownerUserId) {
      void setupTaskGithub(channel.taskId, task.ownerUserId);
    }

    // ADR-047: install any package skills (native Claude Agent Skills) into the
    // container BEFORE spawning agents, so they're discoverable on the first
    // turn. Idempotent + best-effort (returns fast when there are none); a slow
    // clone/install briefly delays start, which is acceptable for skill-bearing
    // tasks. Never throws.
    await installPackageSkills(channel.taskId, channel.roster);

    // ADR-048: write the in-container `uai` CLI (apiUrl only, no token) into the
    // workspace. Each agent's OWN task token — carrying only ITS permissions — is
    // injected per-agent via its docker exec env below, so per-persona permissions
    // are actually enforced. Best-effort, host-side.
    const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
    const cliSecret = loadTaskCliSecret(channel.taskId);
    writeAgentCli(channel.taskId, channel.roster, apiUrl);

    for (const agent of channel.roster) {
      const session = await this.factory.create({
        taskId: channel.taskId,
        agent,
        containerName: channel.containerName,
        systemPreamble: channel.preambles.get(agent.id) ?? "",
        agentEnv: agentCliEnv(channel.taskId, agent, task.ownerUserId, apiUrl, cliSecret),
      });
      channel.sessions.set(agent.id, session);
      session.onEvent((event) => {
        void this.handleAgentEvent(channel, agent.id, event);
      });
    }

    // First-turn delivery is owned by the CLOUD (channel-router.ensureStarted),
    // which fires each agent's initialPrompt exactly once per task — guarded by
    // a non-empty message history (its durable "already started" check). The
    // host must NOT deliver it here: this path re-runs on every session spawn,
    // so it both duplicated the opening turn AND re-injected it on a host
    // restart, making agents redo the first turn mid-task. The persona/mission
    // an agent needs live in the always-on system preamble, so nothing is lost.
    return true;
  }

  /**
   * Ensure the channel exists and, if the task is `running`, its agent
   * sessions are spawned. Idempotent — the task detail page's poll
   * calls this so agents auto-start the moment the task is running,
   * without the human having to send a message first.
   */
  async ensureStarted(taskId: string): Promise<void> {
    const channel = await this.getOrCreateChannel(taskId);
    if (channel) await this.ensureSessions(channel);
  }

  // -- inbound: a human (or routed peer) message ----------------------------

  /**
   * Deliver a cloud-routed message into one live agent session.
   */
  async deliver(
    taskId: string,
    agentId: string,
    text: string,
  ): Promise<{ ok: true } | { ok: false; error: string }> {
    // Slash command: re-run GitHub auth setup for this task (ADR-027). Handled
    // before sessions so it works even when an agent isn't ready.
    if (text.trim() === "/retry-gh") {
      void this.handleRetryGh(taskId);
      return { ok: true };
    }

    const channel = await this.getOrCreateChannel(taskId);
    if (!channel) return { ok: false, error: "task or project not found" };

    const ready = await this.ensureSessions(channel);
    if (!ready) {
      return { ok: false, error: "task is not running" };
    }

    const session = channel.sessions.get(agentId);
    if (!session) return { ok: false, error: `no such agent: ${agentId}` };

    void session.send(rewriteAttachmentRefs(text));
    return { ok: true };
  }

  /** Interrupt an agent's current turn (ESC). No-op when the task/agent has no
   *  live session (nothing to stop). */
  async interrupt(
    taskId: string,
    agentId: string,
  ): Promise<{ ok: true } | { ok: false; error: string }> {
    const channel = this.channels.get(taskId);
    const session = channel?.sessions.get(agentId);
    if (!channel || !session) return { ok: false, error: "no active session" };
    // An interrupted turn is a half-turn — its eventual turn_complete must not
    // hand the buffered fragment to @-mentioned peers. Flag it so the boundary
    // goes out `aborted` and the cloud discards the buffer. Only when the turn
    // actually streamed output (openTurns): an idle-agent ESC must not mark
    // the NEXT legitimate turn as aborted.
    if (channel.openTurns.has(agentId)) channel.interrupted.add(agentId);
    void session.interrupt();
    this.emitSystemNote(taskId, `Stopped @${agentId}.`);
    return { ok: true };
  }

  /** `/retry-gh` handler: re-mint + inject the task owner's GitHub token. */
  private async handleRetryGh(taskId: string): Promise<void> {
    const owner = getHostTask(taskId)?.ownerUserId;
    if (!owner) {
      this.emitSystemNote(taskId, "gh: no owner recorded for this task.");
      return;
    }
    this.emitSystemNote(taskId, "gh: retrying authentication…");
    const ok = await setupTaskGithub(taskId, owner);
    this.emitSystemNote(
      taskId,
      ok
        ? "gh: authentication restored."
        : "gh: still not connected — reconnect GitHub on Account, then try again.",
    );
  }

  // -- outbound: events from an agent session -------------------------------

  private async handleAgentEvent(
    channel: Channel,
    agentId: string,
    event: AgentEvent,
  ): Promise<void> {
    switch (event.type) {
      case "message_delta": {
        channel.openTurns.add(agentId);
        this.emitHost({
          kind: "agent.message_delta",
          taskId: channel.taskId,
          agentId,
          chunk: event.text,
        });
        break;
      }
      case "message_complete": {
        channel.openTurns.add(agentId);
        this.emitHost({
          kind: "agent.message_complete",
          taskId: channel.taskId,
          agentId,
          fullText: event.text,
          mentions: parseMentions(event.text, channel.roster),
        });
        break;
      }
      case "tool_call": {
        this.emitHost({
          kind: "agent.tool_call",
          taskId: channel.taskId,
          agentId,
          tool: event.title,
          meta: { detail: event.detail, toolId: event.id },
        });
        break;
      }
      case "permission_request": {
        this.emitHost({
          kind: "agent.permission_request",
          taskId: channel.taskId,
          agentId,
          requestId: event.id,
          meta: {
            title: event.title,
            detail: event.detail,
            requestId: event.id,
          },
        });
        break;
      }
      case "peer_message": {
        const text = `@${event.toAgentId} ${event.text}`;
        this.emitHost({
          kind: "agent.message_complete",
          taskId: channel.taskId,
          agentId,
          fullText: text,
          mentions: [event.toAgentId],
        });
        break;
      }
      case "error": {
        // The turn died with the session — drop its turn-state flags so a
        // respawned session starts clean.
        channel.openTurns.delete(agentId);
        channel.interrupted.delete(agentId);
        // Claude under load (especially Docker Desktop macOS) occasionally
        // unlinks ~/.claude.json mid-write during atomic config rewrites,
        // and a concurrent claude spawn lands during the gap and exits 0
        // with "Claude configuration file not found". When we recognise
        // that pattern, repair the in-container config + respawn the
        // session so the user keeps working instead of staring at a dead
        // chat.
        const agent = channel.roster.find((a) => a.id === agentId);
        const isClaude = agent?.kind === "claude";
        const configMissing = CLAUDE_CONFIG_MISSING_PATTERNS.some((re) =>
          re.test(event.message),
        );
        if (isClaude && configMissing) {
          await this.recoverClaudeAgent(channel, agentId);
          break;
        }
        this.emitHost({
          kind: "agent.exit",
          taskId: channel.taskId,
          agentId,
          reason: event.message,
        });
        break;
      }
      case "turn_complete": {
        // Turn boundary — the cloud flushes any @-mentions buffered across this
        // turn's messages and wakes the mentioned peers with the full turn.
        // An interrupted (ESC'd) turn goes out `aborted`: it's a half-turn, so
        // the cloud discards the buffer instead of handing it to peers.
        const aborted = channel.interrupted.delete(agentId);
        channel.openTurns.delete(agentId);
        this.emitHost({
          kind: "agent.turn_complete",
          taskId: channel.taskId,
          agentId,
          aborted,
        });
        break;
      }
      case "exit":
        break;
    }
  }

  // -- session recovery -----------------------------------------------------

  /**
   * Re-copy the host's `~/.claude.json` into the task container and
   * respawn the agent's session. Bounded by MAX_RESPAWNS_PER_AGENT so
   * a permanently-broken setup doesn't loop forever.
   */
  private async recoverClaudeAgent(
    channel: Channel,
    agentId: string,
  ): Promise<void> {
    const tries = (channel.respawns.get(agentId) ?? 0) + 1;
    channel.respawns.set(agentId, tries);

    if (tries > MAX_RESPAWNS_PER_AGENT) {
      this.emitHost({
        kind: "agent.exit",
        taskId: channel.taskId,
        agentId,
        reason:
          `Claude exited with "configuration file not found" ${tries - 1} ` +
          "times in a row. Giving up automatic recovery — recreate the " +
          "task to start fresh.",
      });
      return;
    }

    const restored = repairClaudeConfigInContainer(channel.containerName);

    // Tear the dead session down and build a fresh one in its place.
    const old = channel.sessions.get(agentId);
    if (old) {
      try {
        await old.close();
      } catch {
        // Already exited — close is idempotent for our adapters.
      }
    }

    const agent = channel.roster.find((a) => a.id === agentId);
    if (!agent) return;
    const session = await this.factory.create({
      taskId: channel.taskId,
      agent,
      containerName: channel.containerName,
      systemPreamble: channel.preambles.get(agentId) ?? "",
    });
    channel.sessions.set(agentId, session);
    session.onEvent((event) => {
      void this.handleAgentEvent(channel, agentId, event);
    });

    const message = restored
      ? `${agentId} restarted — config file was missing in the container, restored from the host.`
      : `${agentId} restarted — config file was missing in the container (host copy not found; spawned anyway).`;
    this.emitHost({
      kind: "agent.exit",
      taskId: channel.taskId,
      agentId,
      reason: message,
    });
  }

  // -- permission resolution ------------------------------------------------

  async resolvePermission(
    taskId: string,
    agentId: string,
    requestId: string,
    decision: "accept" | "decline",
  ): Promise<boolean> {
    const channel = this.channels.get(taskId);
    const session = channel?.sessions.get(agentId);
    if (!channel || !session) return false;
    await session.resolvePermission(requestId, decision);
    return true;
  }

  /** Tear a channel down (task killed). */
  async closeChannel(taskId: string): Promise<void> {
    const ch = this.channels.get(taskId);
    if (!ch) return;
    for (const session of ch.sessions.values()) await session.close();
    this.channels.delete(taskId);
  }
}

// ---------------------------------------------------------------------------
// `@mention` addressing.
// ---------------------------------------------------------------------------

/**
 * Extract the roster agents explicitly `@mentioned` in `text`, in order
 * of first appearance, de-duplicated. Returns [] when none are
 * mentioned. A single space after `@` is tolerated (`@ codex`) since
 * agents sometimes emit that.
 */
export function parseMentions(text: string, roster: Roster): string[] {
  const ids = new Set(roster.map((a) => a.id));
  const mentioned: string[] = [];
  const re = /@ ?([A-Za-z0-9_-]+)/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(text)) !== null) {
    const id = m[1];
    if (id && ids.has(id) && !mentioned.includes(id)) mentioned.push(id);
  }
  return mentioned;
}

/**
 * Resolve who an inbound human message is addressed to: the explicit
 * `@mentions`, else `fallbackAgentId`, else the first roster agent.
 */
/**
 * Rewrite cloud attachment URLs (`…/api/tasks/<id>/uploads/<file>`) to the
 * in-container workspace path so the agent reads the file directly with its own
 * tools (ADR-015 attachments live in the workspace, mounted at /workspace). The
 * cloud URL would be Clerk-gated and unreachable from inside the container.
 */
export function rewriteAttachmentRefs(text: string): string {
  return text.replace(
    /(?:https?:\/\/[^/\s)\]]+)?\/api\/tasks\/[^/\s)\]]+\/uploads\/([A-Za-z0-9._-]+)/g,
    (_m, filename: string) => `/workspace/.uai/attachments/${filename}`,
  );
}

export function resolveAddressing(
  text: string,
  roster: Roster,
  fallbackAgentId?: string,
): string[] {
  const mentioned = parseMentions(text, roster);
  if (mentioned.length > 0) return mentioned;
  if (fallbackAgentId && roster.some((a) => a.id === fallbackAgentId)) {
    return [fallbackAgentId];
  }
  const first = roster[0];
  return first ? [first.id] : [];
}

/**
 * Re-copy the host's `~/.claude.json` into the task container at
 * `/home/node/.claude.json` and chown it back to `node`. Used when an
 * agent exited with "configuration file not found" — Claude under
 * Docker Desktop macOS sometimes unlinks its own config mid-write,
 * and a concurrent spawn lands in the gap. Restoring from the host is
 * the same recovery `task-up.sh` does at first launch.
 *
 * Returns true when the file actually made it into the container.
 */
function repairClaudeConfigInContainer(containerName: string): boolean {
  // Operator can override the source path via UAI_OWNER_HOME so the
  // server doesn't have to guess. Useful when Next.js's dev process
  // env diverges from `os.homedir()` for any reason.
  const home = process.env.UAI_OWNER_HOME || homedir();
  const hostConfig = join(home, ".claude.json");
  if (!existsSync(hostConfig)) {
    console.error(
      `[orchestrator] repair: host .claude.json not found at ${hostConfig} ` +
        `(homedir=${homedir()}, UAI_OWNER_HOME=${
          process.env.UAI_OWNER_HOME ?? "<unset>"
        })`,
    );
    return false;
  }

  // Why not `docker cp`: it does unlink + create on the destination,
  // and when a stuck claude process still has the in-container file
  // open we get `Error response from daemon: unlinkat …: device or
  // resource busy` and the new bytes never land.
  //
  // Instead, pipe the host file's bytes into a `cat >` inside the
  // container. That truncates the existing inode in place — no
  // unlink, no rename — so the file's bytes are replaced and any
  // open fd just sees the new content on its next read. Permissions
  // and ownership of the inode are preserved (it was already owned
  // by `node`, which is what claude needs).
  let content: Buffer;
  try {
    content = readFileSync(hostConfig);
  } catch (err) {
    console.error(
      `[orchestrator] repair: read host .claude.json failed: ${
        err instanceof Error ? err.message : String(err)
      }`,
    );
    return false;
  }
  // Clear the existing dentry before writing. Docker Desktop macOS
  // occasionally ends up with a stale dentry where `ls -la` shows
  // `-????????? ?` and `open(O_CREAT)` fails with "Directory
  // nonexistent" — the file appears to be there but every syscall
  // bounces. `rm -f` punches through it (or no-ops on a genuine
  // missing file). Then `cat >` creates the fresh inode. Both steps
  // run in one `sh -c` so a partial failure leaves a clean state.
  const write = spawnSync(
    "docker",
    [
      "exec",
      "-i",
      containerName,
      "sh",
      "-c",
      "rm -f /home/node/.claude.json && cat > /home/node/.claude.json",
    ],
    { input: content, encoding: "buffer" },
  );
  if (write.status !== 0) {
    const stderr =
      write.stderr instanceof Buffer
        ? write.stderr.toString("utf8")
        : String(write.stderr ?? "");
    console.error(
      `[orchestrator] repair: in-place write to .claude.json failed: ${stderr.trim()}`,
    );
    return false;
  }
  return true;
}

/** A project as carried in a channel spec — one repo + its prompt. */
type ChannelProject = { slug: string; defaultPrompt: string };

/**
 * Concatenate each selected project's `defaultPrompt` in position order
 * (the spec already orders them), dropping empties, joined by a blank
 * line. The leading layer of the ADR-022 prompt-assembly chain.
 */
function concatProjectPrompts(projects: ChannelProject[]): string {
  return projects
    .map((p) => p.defaultPrompt.trim())
    .filter((p) => p.length > 0)
    .join("\n\n");
}

/**
 * Assemble one agent's first-turn message (ADR-022 prompt-assembly
 * chain):
 *
 *   concat(project.defaultPrompt in position order)
 *     + "\n\n" + globalContext         (task-level mission, if present)
 *     + "\n\n" + agent.defaultPrompt   (the agent's persona, if present)
 *     + "\n\n" + agent.initialPrompt   (this-turn instructions)
 *
 * Returns `null` when the agent has no non-empty `initialPrompt` — those
 * agents stay silent until addressed. There is no "first speaker" /
 * coder rule; any agent with an `initialPrompt` opens a first turn.
 */
export function assembleFirstTurnPrompt(
  projects: ChannelProject[],
  globalContext: string | undefined,
  agent: RosterAgent,
): string | null {
  const initial = (agent.initialPrompt ?? "").trim();
  if (initial.length === 0) return null;

  const parts = [
    concatProjectPrompts(projects),
    (globalContext ?? "").trim(),
    (agent.defaultPrompt ?? "").trim(),
    initial,
  ].filter((p) => p.length > 0);
  return parts.join("\n\n");
}

/**
 * Build the system preamble an agent gets on session start. It opens
 * with how uai's channel works — agents must know to hand off via
 * `@mention`, since there is no `peer` command / shared tmux any more
 * (ADR-008) — then appends the project context and the agent's persona.
 *
 * The persona / mission layers (project defaultPrompts, globalContext,
 * agent.defaultPrompt) live in the always-on system prompt so they apply
 * to every turn; only `initialPrompt` is delivered as the first user
 * turn (see `assembleFirstTurnPrompt`).
 */
export function buildSystemPreamble(
  roster: Roster,
  agent: RosterAgent,
  projects: ChannelProject[],
  globalContext: string | undefined,
  workspacePath: string,
  taskBranch: string,
): string {
  const channelList = roster
    .map((a) =>
      a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
    )
    .join(", ");
  const projectLines =
    projects.length === 0
      ? ["(none mounted)"]
      : projects.map(
          (p) =>
            `- \`${workspacePath}/${p.slug}\` — git worktree on \`${taskBranch}\``,
        );
  const comms = [
    "## uai task channel",
    "",
    "You are one agent in a uai task chat channel, shared with the human",
    "and the other agents. To hand work to or ask another agent, mention",
    "it by id at the start of a line — e.g. `@codex please review the",
    "diff`. uai routes that message into that agent's input.",
    "",
    `**You are @${agent.id}** — your name in this channel is **${agent.label}**.`,
    `Introduce and refer to yourself as ${agent.label}, not as a generic`,
    "assistant or model name.",
    "",
    `Agents in this channel: ${channelList}.`,
    "",
    "The human you're working with is **@you**. @-mentioning them sends a",
    "NOTIFICATION, so use it sparingly — only when you actually need them: a",
    "decision you can't make, a blocker, an approval, or you've finished your",
    "work and are handing it back for them to act on (e.g. `@you which`",
    "`approach do you prefer?` or `@you done — PR is up for review`). For",
    "everything else — status updates, thinking out loud, a direct reply to",
    "something they just asked, acknowledgments — post in the channel WITHOUT",
    "@-mentioning @you; they can read the channel and don't need a ping for",
    "every message. Do NOT reflexively end messages with @you.",
    "",
    "An agent only receives a message when it is explicitly @-mentioned",
    "(or addressed by the human) — so always @-mention the agent (or @you)",
    "you mean. There is NO `peer` command and no shared tmux session;",
    "hand-offs are just @-mentions in your replies.",
    "",
    "Collaborate with your peers — divide up the work, review each other's",
    "changes, share concrete ideas, and debate approach decisions by",
    "@-mentioning them. That is how the team gets things done, and you",
    "should do it freely whenever it moves the work forward. But mentioning",
    "a peer WAKES it and costs a turn, so make each one count: a message to",
    "a peer should ADVANCE the work — a real proposal, a question you need",
    "answered, a hand-off, or a review with specific findings. Do NOT",
    "@-mention a peer just to greet, thank, agree, acknowledge, or say",
    "you're ready — content-free replies wake them for nothing and spiral",
    "into endless back-and-forth. If you have nothing substantive to add,",
    "don't @-mention back. And when there's no active task yet (intros, or",
    "you're waiting on the human), answer briefly and then wait — you don't",
    "need to @-mention anyone (including @you); they can see the channel.",
    "",
    "When a message already @-mentions several participants at once (the",
    "human asking the whole group, or a peer addressing multiple agents),",
    "it's a group broadcast — this is a GROUP CHAT and everyone named has",
    "ALREADY been notified and will answer for themselves. Just answer for",
    "YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
    "question to them, or wait on them — no `I'll let @x speak`, `@x your",
    "turn`, or `still waiting on @x`. Re-mentioning someone who already got",
    "the message only wakes them again and spirals into duplicate replies.",
    "Say your piece and stop.",
    "",
    "Because your input is only what you're addressed, you may be missing",
    "context from messages between the human and the other agents. The full",
    "channel transcript — every message + who wrote it (no tool calls) — is",
    "logged at `/workspace/.uai/chat.md`. Read it whenever you need that",
    "context (e.g. the human shared a file or instruction with another",
    "agent); it's appended live, so re-read it for the latest.",
    "",
    "Hand off when you finish your part of the work. When you've made",
    "and committed your changes, or completed a review, end your reply by",
    "@-mentioning the agent who should act next and telling them what you",
    "did and what you need (e.g. `@codex changes committed on <branch> —",
    "please review`, or `@claude review done, N issues to fix`). Don't",
    "abandon unfinished work silently — but once your part is done and no",
    "peer needs to act, it's fine to stop; only @-mention @you if you need",
    "their input or are handing back finished work for them to act on. Don't",
    "prolong an agent-to-agent exchange just to fill silence.",
    "",
    "## Workspace layout",
    "",
    `Your shell starts in \`${workspacePath}\` (the task workspace).`,
    "That directory is **not** itself a git repo — it holds one git",
    "worktree per project this task spans. Every project below is on the",
    "same task branch. To run git commands, **`cd` into one of the",
    "project directories first**:",
    "",
    ...projectLines,
    "",
    `The task branch is \`${taskBranch}\`. Push with \`git push -u origin`,
    `${taskBranch}\` from inside the project, then open a PR with \`gh pr`,
    "create` (the container has gh authenticated). For multi-project",
    "tasks, each project's PR is independent — open one per project whose",
    "worktree you actually changed.",
    "",
    "The `.uai/` directory under each task is uai's own scaffolding",
    "(rendered Dockerfile, compose file, container scripts) — it is NOT",
    "part of the project. Never review, edit, stage, commit, or flag it;",
    "treat it as ignored, even though git may show it as untracked.",
    "",
    // ADR-046: link/document skills are materialised to a per-agent SKILL.md.
    // Point the agent at its own file (package skills are handled below).
    ...((agent.skills ?? []).some((s) => s.type !== "package")
      ? [
          "## Your skills",
          "",
          "Reference material (links + documents) has been prepared for you at",
          `\`${containerSkillFile(agent.id)}\`. Read it when a task touches what`,
          "it covers. It's yours — other agents have their own.",
          "",
        ]
      : []),
    // ADR-047: package skills are native Claude Agent Skills installed into the
    // container's skills dir (Claude-only). List them so the agent knows they're
    // available even if headless auto-discovery is unreliable.
    ...(agent.kind === "claude" &&
    (agent.skills ?? []).some((s) => s.type === "package")
      ? [
          "## Installed skills",
          "",
          "These Claude Agent Skills are installed for this task — invoke them",
          "when a task touches what they cover:",
          ...(agent.skills ?? [])
            .filter((s) => s.type === "package")
            .map((s) => `- **${s.name}** (\`${installedSkillPath(s)}\`)`),
          "",
        ]
      : []),
    // ADR-048: tell agents with permissions about their `uai` CLI.
    ...((agent.permissions?.length ?? 0) > 0
      ? [
          "## The uai CLI",
          "",
          `You can query and plan through Uai by running \`node ${CONTAINER_CLI_PATH}`,
          "<command>` in the shell. Your permissions:",
          `\`${(agent.permissions ?? []).join(", ")}\`. Commands: \`projects list\`,`,
          "`people list`, `task list`" +
            (agent.permissions?.includes("tasks.create")
              ? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]`"
              : "") +
            (agent.permissions?.includes("memory.write")
              ? ", `memory save <text>`"
              : "") +
            (agent.permissions?.includes("memory.read")
              ? ", `memory search <query>`"
              : "") +
            ".",
          ...(agent.permissions?.includes("tasks.create")
            ? [
                "**Creating a task in Uai:** when a human asks you to create/file/",
                "queue a Uai task (or plan a backlog of them), you MUST run the",
                `shell command \`node ${CONTAINER_CLI_PATH} task create …\`. Do NOT`,
                "use your built-in TaskCreate / task tools for this — those are just",
                "your own private to-do notes and are invisible to the human in Uai.",
                "Uai tasks you create are DRAFTS — a human reviews and starts them;",
                "nothing runs (or spends) until then. Discover options first with",
                "`projects list` / `people list`.",
              ]
            : []),
          ...(agent.permissions?.includes("memory.read") ||
          agent.permissions?.includes("memory.write")
            ? [
                `Your memory handle is \`${agent.id}\` — pass \`--as ${agent.id}\` so`,
                "memories are attributed to you. Recall relevant past work with",
                "`memory search` before you start; save durable learnings with",
                "`memory save` when you finish something worth remembering.",
              ]
            : []),
          "",
        ]
      : []),
    "## Commit policy",
    "",
    "Commits are SSH-signed automatically (git is configured for it) — do",
    "not disable or override signing. Do NOT add any `Co-Authored-By:`",
    "trailers to commit messages, and do NOT add 'Generated with …' or any",
    "tool/agent attribution footer to commit messages or PR/issue bodies.",
    "Write commit messages and PR descriptions plainly, as the author, with",
    "no agent attribution.",
  ].join("\n");

  // Persona / mission layers, always-on so they apply to every turn:
  // project context + task globalContext + this agent's persona. The
  // per-turn instructions (initialPrompt) are delivered separately.
  const context = [
    concatProjectPrompts(projects),
    (globalContext ?? "").trim(),
    (agent.defaultPrompt ?? "").trim(),
  ]
    .filter((p) => p.length > 0)
    .join("\n\n");

  return context.length > 0 ? `${comms}\n\n---\n\n${context}` : comms;
}

// ---------------------------------------------------------------------------
// Singleton — survives Next.js HMR by living on globalThis.
// ---------------------------------------------------------------------------

const globalForOrchestrator = globalThis as unknown as {
  __uaiOrchestrator?: Orchestrator;
  __uaiRecoverRan?: boolean;
};

export function getOrchestrator(): Orchestrator {
  if (!globalForOrchestrator.__uaiOrchestrator) {
    // `UAI_AGENTS=real` drives the real Claude/Codex CLIs inside the
    // task containers; anything else uses the mock (no Docker / no CLI
    // needed — the default until the adapters are verified on a host).
    const factory =
      process.env.UAI_AGENTS === "real" ? realAgentFactory : mockAgentFactory;
    globalForOrchestrator.__uaiOrchestrator = new Orchestrator(factory);
  }
  if (!globalForOrchestrator.__uaiRecoverRan) {
    globalForOrchestrator.__uaiRecoverRan = true;
    // Fire-and-forget — the orchestrator is usable while recovery runs.
    void recoverRunningTasks();
  }
  return globalForOrchestrator.__uaiOrchestrator;
}

// ---------------------------------------------------------------------------
// Boot-time recovery
//
// When uai starts after a machine reboot (or a hard `pnpm dev` restart)
// task containers are gone but the DB still claims status='running'.
// Walk the active rows and reconcile each against actual Docker state:
//
//   container up                  → re-discover the host port, keep
//                                   the row at `running`.
//   container exited (preserved)  → `docker start` it, re-run uai-init,
//                                   discover the fresh port, keep
//                                   `running`. AI sessions respawn on
//                                   the next ensureSessions() call.
//   container gone, worktree on   → mark `stopped`. User hits Resume,
//   disk                            which runs task-up against the same
//                                   id (task-up is idempotent).
//   container gone, worktree gone → mark `error`. Data loss.
//
// Idempotent per process — guarded by `__uaiRecoverRan` on globalThis
// so a Next.js HMR rebuild doesn't trigger it twice.
// ---------------------------------------------------------------------------

/**
 * docker ps --all output entry — Names is unique per task because we
 * scope by compose-project label (set via `-p task-<id>` at task-up).
 */
interface DockerPs {
  Names: string;
  State: string; // "running" | "exited" | "created" | "paused"
}

/**
 * Returns the task's containers, [] when docker reports none, or NULL when
 * docker itself was unreachable/timed out (status null = spawn failure or our
 * SIGKILL). The distinction matters: recovery treats [] as "container gone"
 * and DOWNGRADES the task to stopped/error — doing that because a `docker ps`
 * timed out under load would destroy a perfectly recoverable task.
 */
async function dockerListContainersByLabel(
  label: string,
): Promise<DockerPs[] | null> {
  const res = await dockerCli(
    ["ps", "--all", "--filter", `label=${label}`, "--format", "{{json .}}"],
    { timeoutMs: 60_000 },
  );
  if (res.status === null) return null; // docker state UNKNOWN — don't act on it
  if (res.status !== 0) return [];
  return res.stdout
    .split("\n")
    .map((l) => l.trim())
    .filter(Boolean)
    .map((l) => {
      try {
        return JSON.parse(l) as DockerPs;
      } catch {
        return null;
      }
    })
    .filter((x): x is DockerPs => x !== null);
}

async function dockerStart(containerName: string): Promise<boolean> {
  // Starting a big dev container can legitimately take a while — give it far
  // more than dockerCli's 30s default before declaring the task stopped.
  const res = await dockerCli(["start", containerName], { timeoutMs: 120_000 });
  if (res.status !== 0) {
    console.error(
      `[orchestrator] docker start ${containerName} failed: ${res.stderr.trim()}`,
    );
    return false;
  }
  return true;
}

async function dockerPort(
  containerName: string,
  containerPort: number,
): Promise<number | null> {
  const res = await dockerCli(["port", containerName, String(containerPort)]);
  if (res.status !== 0) return null;
  // Output: "127.0.0.1:32785\n"
  const firstLine = res.stdout.split("\n")[0]?.trim() ?? "";
  const colonIdx = firstLine.lastIndexOf(":");
  if (colonIdx === -1) return null;
  const port = Number(firstLine.slice(colonIdx + 1));
  return Number.isFinite(port) ? port : null;
}

async function dockerExec(
  containerName: string,
  cmd: string[],
  timeoutMs?: number,
): Promise<boolean> {
  const res = await dockerCli(
    ["exec", containerName, ...cmd],
    timeoutMs === undefined ? {} : { timeoutMs },
  );
  return res.status === 0;
}

async function recoverRunningTasks(): Promise<void> {
  try {
    const db = getDb();
    const rows = db
      .select()
      .from(schema.hostTasks)
      .where(inArray(schema.hostTasks.statusMirror, [...ACTIVE_STATUSES]))
      .all();
    if (rows.length === 0) return;
    console.log(
      `[orchestrator] recovery: scanning ${rows.length} active task row(s)`,
    );
    for (const task of rows) {
      try {
        await recoverOneTask(task);
      } catch (err) {
        console.error(
          `[orchestrator] recovery: ${task.taskId} failed:`,
          err instanceof Error ? err.message : err,
        );
      }
    }
  } catch (err) {
    console.error(
      "[orchestrator] recovery: top-level failure",
      err instanceof Error ? err.message : err,
    );
  }
}

async function recoverOneTask(
  task: typeof schema.hostTasks.$inferSelect,
): Promise<void> {
  const composeProject = task.composeProject;
  if (!composeProject) {
    // We never wrote a compose project name for this row — must be
    // a row stuck in `queued`/`starting` from before task-up got past
    // step 2. Leave it; the user can recreate.
    return;
  }
  const containerName = `${composeProject}-app-1`;
  const containers = await dockerListContainersByLabel(
    `com.docker.compose.project=${composeProject}`,
  );
  if (containers === null) {
    // Docker unreachable / timed out — actual state unknown. Leave the row
    // alone; the next recovery pass (or the next ensure) will see the truth.
    console.warn(
      `[orchestrator] recovery: ${task.taskId} skipped — docker did not answer`,
    );
    return;
  }

  if (containers.length === 0) {
    // Container gone. Worktree state determines whether resume is
    // viable.
    const workspaceOnDisk = task.worktreePath && existsSync(task.worktreePath);
    const next = workspaceOnDisk ? "stopped" : "error";
    db_setStatus(task.taskId, next, {
      codeServerPort: null,
      previewPorts: "[]",
      composeProject: next === "error" ? null : composeProject,
    });
    console.log(
      `[orchestrator] recovery: ${task.taskId} -> ${next} (container gone, ` +
        `worktree ${workspaceOnDisk ? "present" : "gone"})`,
    );
    return;
  }

  const running = containers.some((c) => c.State === "running");
  if (running) {
    // Container is up. Re-discover the host port in case Docker
    // remapped it across restarts (it usually does for ephemeral
    // bindings).
    const port = await dockerPort(containerName, 8080);
    if (port && port !== task.codeServerPort) {
      db_setRuntime(task.taskId, {
        codeServerPort: port,
      });
      console.log(`[orchestrator] recovery: ${task.taskId} ports refreshed`);
    }
    // Re-establish gh proactively (ADR-027): the restart killed the in-memory
    // refresh timers, so an idle task's access token would silently expire
    // ~8h later. Cheap with the per-user access-token cache — one exchange
    // per user, every task re-injects from it. Best-effort.
    if (task.ownerUserId) {
      void setupTaskGithub(task.taskId, task.ownerUserId);
    }
    return;
  }

  // Container exists but exited. Bring it back up + re-launch the
  // in-container init (deps + code-server).
  console.log(
    `[orchestrator] recovery: ${task.taskId} starting exited container ${containerName}`,
  );
  if (!(await dockerStart(containerName))) {
    db_setStatus(task.taskId, "stopped", {
      codeServerPort: null,
      previewPorts: "[]",
    });
    return;
  }
  // uai-init reinstalls workspace deps (pnpm/npm install) — minutes on a big
  // repo. dockerCli's 30s default would SIGKILL it mid-install.
  if (
    !(await dockerExec(containerName, ["/usr/local/bin/uai-init"], 10 * 60_000))
  ) {
    console.warn(
      `[orchestrator] recovery: ${task.taskId} uai-init failed; container is up but Editor may be down`,
    );
  }
  const port = (await dockerPort(containerName, 8080)) ?? null;
  db_setRuntime(task.taskId, {
    codeServerPort: port,
  });
  // Same gh re-establish as the running branch — the restarted container's gh
  // config is whatever it held when it exited.
  if (task.ownerUserId) {
    void setupTaskGithub(task.taskId, task.ownerUserId);
  }
  console.log(
    `[orchestrator] recovery: ${task.taskId} resumed (port ${port ?? "?"})`,
  );
}

function db_setStatus(
  taskId: string,
  status: string,
  extras: Partial<typeof schema.hostTasks.$inferInsert>,
): void {
  upsertHostTask(taskId, { ...extras, statusMirror: status });
}

function db_setRuntime(
  taskId: string,
  extras: Partial<typeof schema.hostTasks.$inferInsert>,
): void {
  upsertHostTask(taskId, extras);
}

export type { Orchestrator };
