/**
 * Mock gateways implementing every UI port in-memory, with no backend. They let
 * the frontend run and be tested fully offline (ARCHITECTURE §1.3, §11) and are
 * selected by the DI provider when `VITE_USE_MOCK` is set.
 */

import type {
  Agent,
  AgentDrift,
  AppExitWorkGuardState,
  AgentProfile,
  DiagnosticWarning,
  DomainEvent,
  EmbedderEngines,
  EmbedderProfile,
  EmbeddedServerStatus,
  FirstRunState,
  GatewayError,
  GitBranches,
  GitCommit,
  GitFileStatus,
  GraphCommit,
  HealthReport,
  LayoutInfo,
  LayoutKind,
  LayoutList,
  LayoutOperation,
  LayoutTree,
  LocalModelServerConfig,
  ModelServerCommandPreview,
  Memory,
  MemoryIndexEntry,
  MemoryLink,
  MemoryType,
  McpToolCatalogue,
  McpToolPolicy,
  EffectivePermissions,
  PairedDevice,
  PairingCode,
  PermissionSet,
  Project,
  ProjectMcpToolPermissions,
  ProjectPermissions,
  ProjectWorkState,
  ProfileAvailability,
  ResumableAgent,
  ServerExposurePreview,
  ServerExposureSettings,
  Skill,
  ReplyChunk,
  SkillScope,
  Sprint,
  Template,
  TerminalSession,
  Ticket,
  TicketCarnet,
  TicketChat,
  TicketLink,
  TicketLinkKind,
  TicketList,
  TicketPriority,
  TicketStatus,
  TurnPage,
  TurnView,
  Unsubscribe,
} from "@/domain";
import type {
  AgentGateway,
  ConversationGateway,
  ConversationPageRequest,
  ConversationDetails,
  CloneOpenCodeProfileFromSeedInput,
  CreateAgentInput,
  CreateMemoryInput,
  CreateSkillInput,
  DesktopServerGateway,
  DeviceGateway,
  EmbedderGateway,
  CreateTemplateInput,
  Gateways,
  InputGateway,
  LiveAgent,
  MemoryGateway,
  GitGateway,
  LayoutGateway,
  ModelServerGateway,
  OpenTerminalOptions,
  ProfileGateway,
  ProjectGateway,
  PermissionGateway,
  ReattachResult,
  RemoteGateway,
  SkillGateway,
  StoppedLiveAgent,
  SystemGateway,
  TemplateGateway,
  TerminalGateway,
  TerminalHandle,
  TicketGateway,
  TicketListQuery,
  CreateTicketInput,
  UpdateTicketInput,
  UiPreferencesGateway,
  ViewWindowClosed,
  ViewWindowSnapshot,
  WindowGateway,
  FocusedProject,
  FocusedProjectGateway,
  WorkStateGateway,
} from "@/ports";
import { normalizeProjectWorkState } from "../workStateNormalization";
import { applyOperation, singleLeafTree } from "@/features/layout/layout";

const TICKET_PRIORITY_RANK: Record<TicketPriority, number> = {
  low: 0,
  medium: 1,
  high: 2,
  critical: 3,
};

const TICKET_STATUS_RANK: Record<TicketStatus, number> = {
  open: 0,
  inProgress: 1,
  QA: 2,
  closed: 3,
};

function sortTickets(
  rows: Ticket[],
  sort: NonNullable<TicketListQuery["sort"]>,
): Ticket[] {
  return rows.sort((a, b) => {
    let fieldOrder = 0;
    if (sort.field === "number") {
      fieldOrder = a.number - b.number;
    } else if (sort.field === "priority") {
      fieldOrder =
        TICKET_PRIORITY_RANK[a.priority] - TICKET_PRIORITY_RANK[b.priority];
    } else if (sort.field === "status") {
      fieldOrder = TICKET_STATUS_RANK[a.status] - TICKET_STATUS_RANK[b.status];
    } else {
      fieldOrder =
        a.title.localeCompare(b.title, undefined, { sensitivity: "base" }) ||
        a.title.localeCompare(b.title);
    }

    const directed = sort.direction === "desc" ? -fieldOrder : fieldOrder;
    return directed || a.number - b.number;
  });
}

export class MockSystemGateway implements SystemGateway {
  private listeners = new Set<(e: DomainEvent) => void>();

  async health(note?: string): Promise<HealthReport> {
    const report: HealthReport = {
      version: "0.1.0-mock",
      alive: true,
      timeMillis: Date.now(),
      correlationId: `mock-${Math.random().toString(36).slice(2, 10)}`,
      note: note ?? null,
    };
    // Emit a smoke event so subscribers can be exercised offline too.
    this.emit({ type: "projectCreated", projectId: report.correlationId });
    return report;
  }

  async onDomainEvent(
    handler: (event: DomainEvent) => void,
  ): Promise<Unsubscribe> {
    this.listeners.add(handler);
    return () => {
      this.listeners.delete(handler);
    };
  }

  /** Test/dev helper to push an event to all subscribers. */
  emit(event: DomainEvent): void {
    for (const l of this.listeners) l(event);
  }

  /** Returns a deterministic fake path — never opens a native dialog. */
  async pickFolder(): Promise<string | null> {
    return "/home/user/mock-project";
  }

  private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
  /** Count of `confirmAppExit()` calls, for test assertions. */
  confirmAppExitCallCount = 0;

  async onAppExitWorkGuard(
    handler: (state: AppExitWorkGuardState) => void,
  ): Promise<Unsubscribe> {
    this.exitGuardListeners.add(handler);
    return () => {
      this.exitGuardListeners.delete(handler);
    };
  }

  /** Test/dev helper to push an app-exit work guard state to all subscribers. */
  emitAppExitWorkGuard(state: AppExitWorkGuardState): void {
    for (const l of this.exitGuardListeners) l(state);
  }

  async confirmAppExit(): Promise<void> {
    this.confirmAppExitCallCount += 1;
  }
}

/**
 * Slugifies a display name into a safe file stem (`[a-z0-9-]`), collapsing
 * runs of non-alphanumeric characters into a single dash, mirroring the
 * backend `slugify` logic.
 */
function slugify(name: string): string {
  let out = "";
  let prevDash = false;
  for (const ch of name.trim()) {
    if (/[a-zA-Z0-9]/.test(ch)) {
      out += ch.toLowerCase();
      prevDash = false;
    } else if (!prevDash) {
      out += "-";
      prevDash = true;
    }
  }
  return out.replace(/^-+|-+$/g, "");
}

/**
 * A live in-memory mock PTY session: it retains a scrollback (everything written
 * to `onData`) and tracks the *current* output sink so a view can detach (sink
 * cleared, session stays alive) and later re-attach (new sink, scrollback
 * replayed). Only `close` ends the session — mirroring the backend's decoupling
 * of PTY lifecycle from view lifecycle.
 */
class MockPtySession {
  /** Accumulated output (the scrollback ring; unbounded in the mock — fine for tests). */
  private scrollback: number[] = [];
  /** Current view sink; `null` while detached. */
  private sink: ((bytes: Uint8Array) => void) | null = null;
  /** Whether the session was explicitly closed (PTY killed). */
  closed = false;

  constructor(
    readonly sessionId: string,
    sink: (bytes: Uint8Array) => void,
  ) {
    this.sink = sink;
  }

  /** Records output into the scrollback and forwards it to the current sink. */
  emit(bytes: Uint8Array): void {
    if (this.closed) return;
    for (const b of bytes) this.scrollback.push(b);
    this.sink?.(bytes);
  }

  /** Detaches the current view: stop delivering, keep the session alive. */
  detach(): void {
    this.sink = null;
  }

  /** Re-attaches a new view, returning the retained scrollback to repaint. */
  reattach(sink: (bytes: Uint8Array) => void): Uint8Array {
    this.sink = sink;
    return Uint8Array.from(this.scrollback);
  }
}

/**
 * Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
 * `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
 * `LaunchAgent` use cases, keyed per `projectId` (ARCHITECTURE §11).
 *
 * Exported so tests can instantiate it directly (same pattern as
 * {@link MockProjectGateway}).
 */
export class MockAgentGateway implements AgentGateway {
  /** Agents indexed by projectId. */
  private agents = new Map<string, Agent[]>();
  /** Context content indexed by `${projectId}::${agentId}`. */
  private contexts = new Map<string, string>();
  /** Monotonic session counter for deterministic session ids in tests. */
  private sessionSeq = 0;
  /** Live agent PTY sessions, kept across detach so reattach can find them. */
  private sessions = new Map<string, MockPtySession>();
  /**
   * The cell each live agent currently runs in (`agentId → nodeId`). Mirrors the
   * backend "one live session per agent" invariant: launching an agent already
   * present here in a *different* node is refused; closing its session clears it.
   * Only populated when the caller supplies a `nodeId`.
   */
  private liveByAgent = new Map<string, string>();
  /** Live PTY session id per agent (`agentId → sessionId`). */
  private liveSessionByAgent = new Map<string, string>();

  private getAgents(projectId: string): Agent[] {
    if (!this.agents.has(projectId)) this.agents.set(projectId, []);
    return this.agents.get(projectId)!;
  }

  private contextKey(projectId: string, agentId: string): string {
    return `${projectId}::${agentId}`;
  }

  async listAgents(projectId: string): Promise<Agent[]> {
    return structuredClone(this.getAgents(projectId));
  }

  async listLiveAgents(projectId: string): Promise<LiveAgent[]> {
    // Only report agents that belong to this project (ids are disjoint across
    // projects in the mock, mirroring the backend's per-project agent ids).
    const known = new Set(this.getAgents(projectId).map((a) => a.id));
    return [...this.liveByAgent.entries()]
      .filter(([agentId]) => known.has(agentId))
      .map(([agentId, nodeId]) => ({
        agentId,
        nodeId,
        sessionId: this.liveSessionByAgent.get(agentId)!,
        kind: "pty" as const,
      }));
  }

  /**
   * Resumable agents per project, seeded by tests via
   * {@link _setResumableAgents}. Empty by default (no panel on open).
   */
  private resumable = new Map<string, ResumableAgent[]>();

  /** Seeds the resumable inventory returned for a project (deterministic tests). */
  _setResumableAgents(projectId: string, list: ResumableAgent[]): void {
    this.resumable.set(projectId, list);
  }

  async listResumableAgents(projectId: string): Promise<ResumableAgent[]> {
    return structuredClone(this.resumable.get(projectId) ?? []);
  }

  async attachLiveAgent(
    projectId: string,
    agentId: string,
    nodeId: string,
  ): Promise<LiveAgent> {
    const list = this.getAgents(projectId);
    if (!list.some((a) => a.id === agentId)) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent ${agentId} not found in project ${projectId}`,
      };
      throw err;
    }
    const sessionId = this.liveSessionByAgent.get(agentId);
    if (!sessionId || !this.sessions.has(sessionId)) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent ${agentId} has no live session`,
      };
      throw err;
    }
    this.liveByAgent.set(agentId, nodeId);
    return { agentId, nodeId, sessionId, kind: "pty" };
  }

  async stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
    const list = this.getAgents(projectId);
    if (!list.some((a) => a.id === agentId)) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent ${agentId} not found in project ${projectId}`,
      };
      throw err;
    }
    const sessionId = this.liveSessionByAgent.get(agentId);
    if (!sessionId) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent ${agentId} has no live session`,
      };
      throw err;
    }
    const session = this.sessions.get(sessionId);
    if (session) session.closed = true;
    this.sessions.delete(sessionId);
    this.liveSessionByAgent.delete(agentId);
    this.liveByAgent.delete(agentId);
    return { agentId, sessionId, kind: "pty" };
  }

  async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
    const list = this.getAgents(projectId);
    const slug = slugify(input.name) || "agent";
    // Derive a unique agents/<slug>.md path, disambiguating with -2, -3, …
    const existingPaths = new Set(list.map((a) => a.contextPath));
    let candidate = `agents/${slug}.md`;
    let n = 2;
    while (existingPaths.has(candidate)) {
      candidate = `agents/${slug}-${n}.md`;
      n += 1;
    }
    const agent: Agent = {
      id: `mock-agent-${Math.random().toString(36).slice(2, 10)}`,
      name: input.name,
      contextPath: candidate,
      profileId: input.profileId,
      origin: { type: "scratch" },
      synchronized: false,
      skills: [],
    };
    list.push(agent);
    this.contexts.set(
      this.contextKey(projectId, agent.id),
      input.initialContent ?? "",
    );
    return structuredClone(agent);
  }

  async readContext(projectId: string, agentId: string): Promise<string> {
    const list = this.getAgents(projectId);
    if (!list.some((a) => a.id === agentId)) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent ${agentId} not found in project ${projectId}`,
      };
      throw err;
    }
    return this.contexts.get(this.contextKey(projectId, agentId)) ?? "";
  }

  async updateContext(
    projectId: string,
    agentId: string,
    content: string,
  ): Promise<void> {
    const list = this.getAgents(projectId);
    if (!list.some((a) => a.id === agentId)) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent ${agentId} not found in project ${projectId}`,
      };
      throw err;
    }
    this.contexts.set(this.contextKey(projectId, agentId), content);
  }

  async deleteAgent(projectId: string, agentId: string): Promise<void> {
    const list = this.getAgents(projectId);
    const idx = list.findIndex((a) => a.id === agentId);
    if (idx === -1) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent ${agentId} not found in project ${projectId}`,
      };
      throw err;
    }
    list.splice(idx, 1);
    this.contexts.delete(this.contextKey(projectId, agentId));
  }

  async changeAgentProfile(
    projectId: string,
    agentId: string,
    profileId: string,
    rows: number,
    cols: number,
  ): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }> {
    const list = this.getAgents(projectId);
    const idx = list.findIndex((a) => a.id === agentId);
    if (idx === -1) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent ${agentId} not found in project ${projectId}`,
      };
      throw err;
    }
    // Mutate the agent's runtime profile in place (the rest of the record —
    // origin, synchronized, skills — is untouched).
    list[idx] = { ...list[idx], profileId };
    const agent = structuredClone(list[idx]);

    // Hot-swap path: when the agent owns a live session, the engine restarts —
    // the old PTY is killed (history abandoned, per the product decision) and a
    // fresh session is minted in the same cell, surfaced for the caller to
    // rebind. No live session ⇒ nothing to relaunch.
    const nodeId = this.liveByAgent.get(agentId);
    const oldSessionId = this.liveSessionByAgent.get(agentId);
    if (!nodeId || !oldSessionId) {
      return { agent };
    }
    // Kill the old session.
    const old = this.sessions.get(oldSessionId);
    if (old) old.closed = true;
    this.sessions.delete(oldSessionId);

    // Mint a fresh, detached session (no view sink yet; the cell reattaches).
    this.sessionSeq += 1;
    const sessionId = `mock-agent-session-${this.sessionSeq}`;
    const session = new MockPtySession(sessionId, () => {});
    session.detach();
    this.sessions.set(sessionId, session);
    this.liveSessionByAgent.set(agentId, sessionId);
    this.liveByAgent.set(agentId, nodeId);

    return {
      agent,
      relaunchedSession: {
        sessionId,
        cwd: `/home/user/mock-project`,
        rows,
        cols,
      },
    };
  }

  // ── Internal helpers for MockTemplateGateway (same-package use only) ──

  /**
   * Inserts a pre-built agent record into the registry.
   * Used by `MockTemplateGateway.createAgentFromTemplate` so both gateways
   * share the same in-memory store.
   */
  _insertAgent(projectId: string, agent: Agent, context: string): void {
    const list = this.getAgents(projectId);
    list.push(agent);
    this.contexts.set(this.contextKey(projectId, agent.id), context);
  }

  /**
   * Updates an agent record in-place (origin + synchronized flag).
   * Used by `MockTemplateGateway.syncAgent`.
   */
  _updateAgent(
    projectId: string,
    agentId: string,
    patch: Partial<Pick<Agent, "origin" | "synchronized">>,
    newContext?: string,
  ): void {
    const list = this.getAgents(projectId);
    const idx = list.findIndex((a) => a.id === agentId);
    if (idx === -1) return;
    list[idx] = { ...list[idx], ...patch };
    if (newContext !== undefined) {
      this.contexts.set(this.contextKey(projectId, agentId), newContext);
    }
  }

  /**
   * Returns a **live** (not cloned) reference to the agent list so
   * `MockTemplateGateway` can read agent origins without triggering async overhead.
   */
  _rawAgents(projectId: string): Agent[] {
    return this.getAgents(projectId);
  }

  /**
   * Replaces an agent's assigned skills in-place.
   * Used by `MockSkillGateway.assignSkill` / `unassignSkill` so both gateways
   * share the same in-memory store.
   */
  _setSkills(projectId: string, agentId: string, skills: Agent["skills"]): void {
    const list = this.getAgents(projectId);
    const idx = list.findIndex((a) => a.id === agentId);
    if (idx === -1) return;
    list[idx] = { ...list[idx], skills };
  }

  async launchAgent(
    projectId: string,
    agentId: string,
    options: OpenTerminalOptions,
    onData: (bytes: Uint8Array) => void,
  ): Promise<TerminalHandle> {
    const list = this.getAgents(projectId);
    if (!list.some((a) => a.id === agentId)) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent ${agentId} not found in project ${projectId}`,
      };
      throw err;
    }
    // Singleton invariant: refuse a launch when the agent is already live in a
    // *different* cell (mirrors the backend `AGENT_ALREADY_RUNNING`). The same
    // node is allowed (idempotent re-launch of the very same cell).
    const liveNode = this.liveByAgent.get(agentId);
    if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) {
      const err: GatewayError = {
        code: "AGENT_ALREADY_RUNNING",
        message: `agent ${agentId} is already running in cell ${liveNode}`,
      };
      throw err;
    }
    this.sessionSeq += 1;
    const sessionId = `mock-agent-session-${this.sessionSeq}`;
    const cwd = options.cwd;

    // Record liveness for `listLiveAgents` + the guard above (only when the
    // caller pins a node).
    if (options.nodeId) {
      this.liveByAgent.set(agentId, options.nodeId);
      this.liveSessionByAgent.set(agentId, sessionId);
    }
    const clearLive = () => {
      if (this.liveByAgent.get(agentId) === options.nodeId) {
        this.liveByAgent.delete(agentId);
        this.liveSessionByAgent.delete(agentId);
      }
    };

    // Every agent cell is a raw PTY (Option 1 — Terminal + MCP). The former
    // structured "chat" cell routing is retired.
    const enc = new TextEncoder();
    const session = new MockPtySession(sessionId, onData);
    this.sessions.set(sessionId, session);
    // Greet so something is visible immediately (mirrors MockTerminalGateway).
    queueMicrotask(() =>
      session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
    );
    const handle: TerminalHandle = makeMockHandle(session, () => {
      this.sessions.delete(sessionId);
      clearLive();
    });

    // Simulate session assignment (T4b): a fresh cell (no conversation id) gets a
    // newly-minted id surfaced on the handle so the caller persists it; a cell
    // that already carries an id resumes it and nothing new is assigned.
    if (!options.conversationId) {
      return {
        ...handle,
        assignedConversationId: `mock-conversation-${sessionId}`,
      };
    }
    return handle;
  }

  async reattach(
    sessionId: string,
    onData: (bytes: Uint8Array) => void,
  ): Promise<ReattachResult> {
    const session = this.sessions.get(sessionId);
    if (!session || session.closed) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent session ${sessionId} is not alive`,
      };
      throw err;
    }
    const scrollback = session.reattach(onData);
    return {
      handle: makeMockHandle(session, () => {
        this.sessions.delete(sessionId);
        for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
          if (liveSessionId === sessionId) {
            this.liveSessionByAgent.delete(agentId);
            this.liveByAgent.delete(agentId);
          }
        }
      }),
      scrollback,
    };
  }

  /**
   * Best-effort conversation details, keyed by conversation id (T7). Empty by
   * default (degraded mode); a test seeds enriched details via
   * {@link _setConversationDetails}.
   */
  private conversationDetails = new Map<string, ConversationDetails>();

  /** Seeds the (best-effort) details returned for a given conversation id. */
  _setConversationDetails(
    conversationId: string,
    details: ConversationDetails,
  ): void {
    this.conversationDetails.set(conversationId, details);
  }

  async inspectConversation(
    _projectId: string,
    _agentId: string,
    conversationId: string,
  ): Promise<ConversationDetails> {
    // Best-effort: an unknown conversation yields empty details (degraded mode),
    // never an error — mirroring the backend contract.
    return structuredClone(this.conversationDetails.get(conversationId) ?? {});
  }
}

/**
 * Builds a {@link TerminalHandle} over a {@link MockPtySession}. `write` echoes
 * (cooked-terminal CRLF translation) through the session so the scrollback
 * records it; `detach` keeps the session alive; `close` ends it and unregisters.
 */
function makeMockHandle(
  session: MockPtySession,
  unregister: () => void,
): TerminalHandle {
  return {
    sessionId: session.sessionId,
    async write(data: Uint8Array): Promise<void> {
      if (session.closed) return;
      const out: number[] = [];
      for (const b of data) {
        if (b === 0x0d) out.push(0x0d, 0x0a);
        else out.push(b);
      }
      session.emit(Uint8Array.from(out));
    },
    async resize(): Promise<void> {},
    detach(): void {
      session.detach();
    },
    async close(): Promise<void> {
      session.closed = true;
      unregister();
    },
  };
}

/**
 * In-memory fake terminal: a shell-less PTY that **echoes** whatever is written
 * back to `onData` (so the xterm wrapper renders typed input) and greets on
 * open. Lets the terminal feature run and be tested fully offline.
 */
export class MockTerminalGateway implements TerminalGateway {
  private seq = 0;
  /** Live sessions kept across detach so reattach can find them. */
  private sessions = new Map<string, MockPtySession>();

  async openTerminal(
    options: OpenTerminalOptions,
    onData: (bytes: Uint8Array) => void,
  ): Promise<TerminalHandle> {
    this.seq += 1;
    const sessionId = `mock-session-${this.seq}`;
    const enc = new TextEncoder();
    const session = new MockPtySession(sessionId, onData);
    this.sessions.set(sessionId, session);
    // Greet so something is visible immediately.
    queueMicrotask(() =>
      session.emit(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
    );
    return makeMockHandle(session, () => this.sessions.delete(sessionId));
  }

  async reattach(
    sessionId: string,
    onData: (bytes: Uint8Array) => void,
  ): Promise<ReattachResult> {
    const session = this.sessions.get(sessionId);
    if (!session || session.closed) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `terminal session ${sessionId} is not alive`,
      };
      throw err;
    }
    const scrollback = session.reattach(onData);
    return {
      handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
      scrollback,
    };
  }

  async closeTerminal(sessionId: string): Promise<void> {
    // Best-effort / idempotent: kill the PTY by id (mirrors the handle's close)
    // so a later reattach to it fails. No-op if the session is already gone.
    const session = this.sessions.get(sessionId);
    if (session) {
      session.closed = true;
      this.sessions.delete(sessionId);
    }
  }
}

export class MockProjectGateway implements ProjectGateway {
  private projects: Project[] = [];
  private contexts = new Map<string, string>();

  async listProjects(): Promise<Project[]> {
    return [...this.projects];
  }

  async createProject(name: string, root: string): Promise<Project> {
    if (this.projects.some((p) => p.root === root)) {
      const err: GatewayError = {
        code: "INVALID",
        message: `a project already exists at ${root} for this remote`,
      };
      throw err;
    }
    const project: Project = {
      id: `mock-project-${Math.random().toString(36).slice(2, 10)}`,
      name,
      root,
      remote: { kind: "local" },
      createdAt: Date.now(),
    };
    this.projects.push(project);
    return project;
  }

  async openProject(projectId: string): Promise<Project> {
    const project = this.projects.find((p) => p.id === projectId);
    if (!project) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `project ${projectId} not found`,
      };
      throw err;
    }
    return project;
  }

  async closeProject(): Promise<void> {}

  async readProjectContext(projectId: string): Promise<string> {
    if (!this.projects.some((p) => p.id === projectId)) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `project ${projectId} not found`,
      };
      throw err;
    }
    return this.contexts.get(projectId) ?? "";
  }

  async updateProjectContext(projectId: string, content: string): Promise<void> {
    if (!this.projects.some((p) => p.id === projectId)) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `project ${projectId} not found`,
      };
      throw err;
    }
    this.contexts.set(projectId, content);
  }
}

/** Internal per-project layout store entry. */
interface MockLayoutEntry {
  id: string;
  name: string;
  kind: LayoutKind;
  tree: LayoutTree;
}

/** Internal per-project layout state: list of named layouts + active id. */
interface MockProjectLayouts {
  activeId: string;
  layouts: MockLayoutEntry[];
}

/**
 * In-memory layout store: keeps multiple named {@link LayoutTree}s per project,
 * applying operations with the same pure logic as the backend (`applyOperation`).
 * Lets the grid feature run and be tested fully offline.
 *
 * Each project starts with a single "Default" layout. The old `loadLayout` /
 * `mutateLayout` signatures (no `layoutId`) default to the active layout,
 * preserving backward-compat with existing tests.
 */
export class MockLayoutGateway implements LayoutGateway {
  private store = new Map<string, MockProjectLayouts>();

  private getProjectLayouts(projectId: string): MockProjectLayouts {
    if (!this.store.has(projectId)) {
      const defaultId = `layout-default-${Math.random().toString(36).slice(2, 8)}`;
      this.store.set(projectId, {
        activeId: defaultId,
        layouts: [{ id: defaultId, name: "Default", kind: "terminal", tree: singleLeafTree() }],
      });
    }
    return this.store.get(projectId)!;
  }

  private getActiveTree(projectId: string): LayoutTree {
    const ps = this.getProjectLayouts(projectId);
    const entry = ps.layouts.find((l) => l.id === ps.activeId);
    return entry ? entry.tree : singleLeafTree();
  }

  private getTree(projectId: string, layoutId?: string): LayoutTree {
    if (!layoutId) return this.getActiveTree(projectId);
    const ps = this.getProjectLayouts(projectId);
    const entry = ps.layouts.find((l) => l.id === layoutId);
    return entry ? entry.tree : singleLeafTree();
  }

  private setTree(projectId: string, tree: LayoutTree, layoutId?: string): void {
    const ps = this.getProjectLayouts(projectId);
    const id = layoutId ?? ps.activeId;
    const entry = ps.layouts.find((l) => l.id === id);
    if (entry) entry.tree = tree;
  }

  async loadLayout(projectId: string, layoutId?: string): Promise<LayoutTree> {
    const tree = this.getTree(projectId, layoutId);
    return structuredClone(tree);
  }

  async mutateLayout(
    projectId: string,
    operation: LayoutOperation,
    layoutId?: string,
  ): Promise<LayoutTree> {
    const current = this.getTree(projectId, layoutId);
    const next = applyOperation(current, operation);
    this.setTree(projectId, next, layoutId);
    return structuredClone(next);
  }

  async listLayouts(projectId: string): Promise<LayoutList> {
    const ps = this.getProjectLayouts(projectId);
    const layouts: LayoutInfo[] = ps.layouts.map((l) => ({ id: l.id, name: l.name, kind: l.kind }));
    return { layouts, activeId: ps.activeId };
  }

  async createLayout(projectId: string, name: string, kind: LayoutKind = "terminal"): Promise<{ layoutId: string }> {
    const ps = this.getProjectLayouts(projectId);
    const layoutId = `layout-${Math.random().toString(36).slice(2, 10)}`;
    ps.layouts.push({ id: layoutId, name, kind, tree: singleLeafTree() });
    return { layoutId };
  }

  async renameLayout(projectId: string, layoutId: string, name: string): Promise<void> {
    const ps = this.getProjectLayouts(projectId);
    const entry = ps.layouts.find((l) => l.id === layoutId);
    if (entry) entry.name = name;
  }

  async deleteLayout(
    projectId: string,
    layoutId: string,
  ): Promise<{ activeId: string }> {
    const ps = this.getProjectLayouts(projectId);
    const idx = ps.layouts.findIndex((l) => l.id === layoutId);
    if (idx !== -1) ps.layouts.splice(idx, 1);
    // If the deleted layout was active, switch to the first remaining layout.
    if (ps.activeId === layoutId && ps.layouts.length > 0) {
      ps.activeId = ps.layouts[0].id;
    }
    return { activeId: ps.activeId };
  }

  async setActiveLayout(
    projectId: string,
    layoutId: string,
  ): Promise<{ activeId: string }> {
    const ps = this.getProjectLayouts(projectId);
    // Self-heal (I4): only adopt the requested id when it exists; otherwise keep
    // the current active id. Either way the returned id is authoritative.
    if (ps.layouts.some((l) => l.id === layoutId)) {
      ps.activeId = layoutId;
    }
    return { activeId: ps.activeId };
  }
}

/** Per-project git state kept in the mock. */
interface MockGitProjectState {
  files: Map<string, boolean>; // path → staged
  branches: string[];
  current: string;
  log: GitCommit[];
  commitSeq: number;
}

/**
 * A small demo DAG that exercises branches, a merge commit, and a tag.
 *
 * Topology (newest first, as git log returns):
 *
 *   e (main, HEAD) — merge commit from feature
 *   ├─ d (feature)
 *   │  └─ c
 *   └─ b
 *      └─ a (tag: v1.0)
 *
 * In list form (parents reference earlier hashes):
 *   e  parents=[b,d]
 *   d  parents=[c]
 *   c  parents=[a]     (feature branch diverges from a)
 *   b  parents=[a]
 *   a  parents=[]      (initial commit, tag v1.0)
 */
const DEMO_GRAPH_COMMITS: GraphCommit[] = [
  {
    hash: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
    summary: "Merge feature into main",
    parents: [
      "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
      "dddddddddddddddddddddddddddddddddddddddd",
    ],
    refs: ["main", "HEAD"],
    author: "Alice",
    timestamp: 1_717_200_000,
  },
  {
    hash: "dddddddddddddddddddddddddddddddddddddddd",
    summary: "Implement feature (step 2)",
    parents: ["cccccccccccccccccccccccccccccccccccccccc"],
    refs: ["feature"],
    author: "Bob",
    timestamp: 1_717_100_000,
  },
  {
    hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    summary: "Hotfix on main",
    parents: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
    refs: [],
    author: "Alice",
    timestamp: 1_717_090_000,
  },
  {
    hash: "cccccccccccccccccccccccccccccccccccccccc",
    summary: "Implement feature (step 1)",
    parents: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
    refs: [],
    author: "Bob",
    timestamp: 1_717_080_000,
  },
  {
    hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    summary: "Initial commit",
    parents: [],
    refs: ["tag: v1.0"],
    author: "Alice",
    timestamp: 1_717_000_000,
  },
];

/**
 * Stateful in-memory git gateway — simulates a git repository per project
 * (keyed by projectId). Seeded with demo files so the panel renders something
 * on first render.
 *
 * Exported so tests can instantiate it directly.
 */
export class MockGitGateway implements GitGateway {
  private projects = new Map<string, MockGitProjectState>();

  private getState(projectId: string): MockGitProjectState {
    if (!this.projects.has(projectId)) {
      this.projects.set(projectId, this._seedState());
    }
    return this.projects.get(projectId)!;
  }

  private _seedState(): MockGitProjectState {
    const files = new Map<string, boolean>();
    files.set("src/main.rs", false);
    files.set("README.md", false);
    return {
      files,
      branches: ["main"],
      current: "main",
      log: [],
      commitSeq: 0,
    };
  }

  async init(projectId: string): Promise<void> {
    if (!this.projects.has(projectId)) {
      this.projects.set(projectId, this._seedState());
    }
  }

  async status(projectId: string): Promise<GitFileStatus[]> {
    const state = this.getState(projectId);
    return Array.from(state.files.entries()).map(([path, staged]) => ({
      path,
      staged,
    }));
  }

  async stage(projectId: string, path: string): Promise<void> {
    const state = this.getState(projectId);
    if (state.files.has(path)) {
      state.files.set(path, true);
    }
  }

  async unstage(projectId: string, path: string): Promise<void> {
    const state = this.getState(projectId);
    if (state.files.has(path)) {
      state.files.set(path, false);
    }
  }

  async commit(projectId: string, message: string): Promise<GitCommit> {
    const state = this.getState(projectId);
    state.commitSeq += 1;
    const gitCommit: GitCommit = {
      hash: `mock-${state.commitSeq}`,
      summary: message.split("\n")[0],
    };
    // Remove staged files from the working tree status.
    for (const [path, staged] of Array.from(state.files.entries())) {
      if (staged) state.files.delete(path);
    }
    // Push commit to the top of the log.
    state.log.unshift(gitCommit);
    return gitCommit;
  }

  async branches(projectId: string): Promise<GitBranches> {
    const state = this.getState(projectId);
    return { branches: [...state.branches], current: state.current };
  }

  async checkout(projectId: string, branch: string): Promise<void> {
    const state = this.getState(projectId);
    if (!state.branches.includes(branch)) {
      state.branches.push(branch);
    }
    state.current = branch;
  }

  async log(projectId: string, limit: number): Promise<GitCommit[]> {
    const state = this.getState(projectId);
    return state.log.slice(0, limit);
  }

  async graph(_projectId: string, limit: number): Promise<GraphCommit[]> {
    return DEMO_GRAPH_COMMITS.slice(0, limit);
  }
}

class MockRemoteGateway implements RemoteGateway {
  async connect(): Promise<void> {}
}

/**
 * In-memory {@link WindowGateway} for tests/dev (#23, panel-only in #47). Tracks
 * which views are "detached" in a set keyed by `panel` alone (a panel-only
 * window follows the focused project, so no project id is part of the key), and
 * lets tests simulate the OS closing a window via {@link simulateOsClose}.
 * `closeViewWindow` also emits the close event, mirroring the backend (a
 * programmatic close still notifies).
 */
export class MockWindowGateway implements WindowGateway {
  /** Currently-open detached windows, keyed by `panel`. */
  readonly open = new Set<string>();
  private readonly listeners = new Set<(e: ViewWindowClosed) => void>();

  async openViewWindow(panel: string): Promise<void> {
    this.open.add(panel);
  }

  async closeViewWindow(panel: string): Promise<void> {
    if (this.open.delete(panel)) {
      this.emit({ panel });
    }
  }

  async listOpenViewWindows(): Promise<ViewWindowSnapshot[]> {
    // Mirror the backend enumeration: every currently-open detached window is
    // reported visible, with a stable label derived from its panel id.
    return [...this.open].map((panel) => ({
      panel,
      label: panel,
      visible: true,
    }));
  }

  async onViewWindowClosed(
    handler: (event: ViewWindowClosed) => void,
  ): Promise<Unsubscribe> {
    this.listeners.add(handler);
    return () => {
      this.listeners.delete(handler);
    };
  }

  /** Test hook: simulate the user closing the OS window (fires the event). */
  simulateOsClose(panel: string): void {
    this.open.delete(panel);
    this.emit({ panel });
  }

  private emit(event: ViewWindowClosed): void {
    for (const l of this.listeners) l(event);
  }
}

/**
 * In-memory {@link FocusedProjectGateway} for tests/dev (#47). Holds the current
 * focus in memory and fans changes out to subscribers — the mock counterpart to
 * the backend's shared focused-project state + `focused-project://changed`
 * event. `setFocusedProject` always notifies (mirrors the backend emit), so a
 * detached-window test can drive a panel window's focus transitions.
 */
export class MockFocusedProjectGateway implements FocusedProjectGateway {
  private focused: FocusedProject | null = null;
  private readonly listeners = new Set<
    (project: FocusedProject | null) => void
  >();

  async setFocusedProject(project: FocusedProject | null): Promise<void> {
    this.focused = project;
    for (const l of this.listeners) l(project);
  }

  async getFocusedProject(): Promise<FocusedProject | null> {
    return this.focused;
  }

  async onFocusedProjectChanged(
    handler: (project: FocusedProject | null) => void,
  ): Promise<Unsubscribe> {
    this.listeners.add(handler);
    return () => {
      this.listeners.delete(handler);
    };
  }
}

/**
 * The pre-filled reference catalogue the mock serves — mirror of the backend's
 * **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode
 * (Claude + Codex) are offered to selection/creation. Gemini/Aider stay in the
 * backend's raw catalogue data but are filtered out of the selection path, so the
 * mock must not serve them either.
 */
export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
  {
    id: "mock-claude",
    name: "Claude Code",
    command: "claude",
    args: [],
    contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
    detect: "claude --version",
    cwdTemplate: "{projectRoot}",
  },
  {
    id: "mock-codex",
    name: "OpenAI Codex CLI",
    command: "codex",
    args: [],
    contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
    detect: "codex --version",
    cwdTemplate: "{projectRoot}",
  },
  {
    id: "mock-opencode",
    name: "OpenCode + llama.cpp",
    command: "opencode",
    args: [],
    contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
    detect: "opencode --version",
    cwdTemplate: "{projectRoot}",
    structuredAdapter: "openCode",
    opencode: {
      baseURL: "http://localhost:8080/v1",
      apiKey: "sk-no-key",
      model: "qwen3-coder-30b",
    },
  },
];

/**
 * In-memory profiles gateway. Tracks configured profiles and a first-run flag so
 * the wizard can be driven and tested fully offline. By default it reports the
 * first run as *not done* until {@link configureProfiles} is called. Detection
 * marks a fixed subset (claude) as installed so ✓/✗ rendering is exercised.
 */
export class MockProfileGateway implements ProfileGateway {
  private profiles: AgentProfile[] = [];
  private configured = false;
  /** Monotonic suffix so cloned OpenCode profiles get distinct ids/names. */
  private cloneCounter = 0;

  async firstRunState(): Promise<FirstRunState> {
    return {
      isFirstRun: !this.configured,
      referenceProfiles: structuredClone(MOCK_REFERENCE_PROFILES),
    };
  }

  async referenceProfiles(): Promise<AgentProfile[]> {
    return structuredClone(MOCK_REFERENCE_PROFILES);
  }

  async detectProfiles(
    candidates: AgentProfile[],
  ): Promise<ProfileAvailability[]> {
    // Pretend only `claude` is installed, so the wizard shows a mix of ✓/✗.
    return candidates.map((profile) => ({
      profile,
      available: profile.command === "claude",
    }));
  }

  async listProfiles(): Promise<AgentProfile[]> {
    return structuredClone(this.profiles);
  }

  async saveProfile(profile: AgentProfile): Promise<AgentProfile> {
    const i = this.profiles.findIndex((p) => p.id === profile.id);
    if (i >= 0) this.profiles[i] = profile;
    else this.profiles.push(profile);
    this.configured = true;
    return structuredClone(profile);
  }

  async deleteProfile(profileId: string): Promise<void> {
    this.profiles = this.profiles.filter((p) => p.id !== profileId);
  }

  async configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
    this.profiles = structuredClone(profiles);
    this.configured = true;
    return structuredClone(profiles);
  }

  async cloneOpenCodeProfileFromSeed(
    input: CloneOpenCodeProfileFromSeedInput = {},
  ): Promise<AgentProfile> {
    const seed = MOCK_REFERENCE_PROFILES.find(
      (p) => p.structuredAdapter === "openCode",
    );
    if (!seed) throw new Error("no OpenCode seed profile");
    this.cloneCounter += 1;
    return structuredClone({
      ...seed,
      id: `mock-opencode-clone-${this.cloneCounter}`,
      name: input.name ?? `${seed.name} (copy ${this.cloneCounter})`,
      opencode: input.opencode ?? seed.opencode,
    });
  }
}

/**
 * In-memory local model server registry (F35). Tracks declared servers and can
 * be told which ids are still referenced by a profile, so `deleteModelServer`
 * reproduces the backend `model_server_in_use` rejection offline.
 */
export class MockModelServerGateway implements ModelServerGateway {
  private servers: LocalModelServerConfig[] = [];
  private readonly inUse = new Set<string>();

  async listModelServers(): Promise<LocalModelServerConfig[]> {
    return structuredClone(this.servers);
  }

  async saveModelServer(
    config: LocalModelServerConfig,
  ): Promise<LocalModelServerConfig> {
    const i = this.servers.findIndex((s) => s.id === config.id);
    if (i >= 0) this.servers[i] = structuredClone(config);
    else this.servers.push(structuredClone(config));
    return structuredClone(config);
  }

  async deleteModelServer(serverId: string): Promise<void> {
    if (this.inUse.has(serverId)) {
      const err: GatewayError = {
        code: "model_server_in_use",
        message: "A profile still references this server.",
      };
      throw err;
    }
    this.servers = this.servers.filter((s) => s.id !== serverId);
  }

  async previewModelServerCommand(
    config: LocalModelServerConfig,
  ): Promise<ModelServerCommandPreview> {
    // Mirror of the backend `LlamaCppRuntime::build_argv` (order matters), kept
    // deliberately simple: it exists so the UI preview flow is testable, never
    // as the source of truth (production uses the real backend command).
    if (!config.modelSource) {
      const err: GatewayError = {
        code: "INVALID",
        message: "model.source missing",
      };
      throw err;
    }
    const command =
      config.binaryPath && config.binaryPath.trim().length > 0
        ? config.binaryPath
        : "llama-server";
    const args: string[] = [];
    if (config.modelSource.type === "localPath") {
      args.push("--model", config.modelSource.path);
    } else {
      args.push("-hf", config.modelSource.repo);
    }
    args.push("--port", String(config.port), "--host", config.host);
    if (config.gpuLayers !== undefined) {
      args.push("-ngl", String(config.gpuLayers));
    }
    if (config.contextSize !== undefined) {
      args.push("-c", String(config.contextSize));
    }
    if (config.jinja) args.push("--jinja");
    args.push(...config.args);
    const display = [command, ...args]
      .map((part) => (/\s/.test(part) ? `'${part}'` : part))
      .join(" ");
    return { command, args, display };
  }

  /** Test hook: mark a server id as still referenced (delete then rejects). */
  markInUse(serverId: string): void {
    this.inUse.add(serverId);
  }
}

/** Mirror of the backend `default_settings()` (ticket #68). */
const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = {
  mode: "localOnly",
  port: 17373,
  trustedProxies: [],
};

/** Fixed LAN candidates so the offline UI has something to pick from. */
const MOCK_LAN_ADDRESSES = ["192.168.1.42", "10.0.0.17"];

function invalid(message: string): never {
  const err: GatewayError = { code: "INVALID", message };
  throw err;
}

/**
 * In-memory embedded-server gateway (ticket #68).
 *
 * Mirrors the backend `validate_settings` / `preview_settings` closely enough
 * that the Deployment UI — including its rejection paths — is testable offline.
 * It is never the source of truth: production runs the real Tauri commands.
 * `candidateLanAddresses` is fixed data here precisely because the UI must take
 * addresses from the gateway rather than deriving them.
 */
export class MockDesktopServerGateway implements DesktopServerGateway {
  private settings: ServerExposureSettings = structuredClone(
    DEFAULT_EXPOSURE_SETTINGS,
  );
  private current: EmbeddedServerStatus = { state: "stopped" };
  private readonly handlers = new Set<(s: EmbeddedServerStatus) => void>();

  async getExposureSettings(): Promise<ServerExposureSettings> {
    return structuredClone(this.settings);
  }

  async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
    this.validate(settings);
    this.settings = structuredClone(settings);
  }

  async previewExposure(
    settings: ServerExposureSettings,
  ): Promise<ServerExposurePreview> {
    // The real `preview_server_exposure_settings` validates *before* previewing,
    // so an incomplete draft is rejected rather than previewed. Mirrored here —
    // the UI relies on it as its validation authority.
    this.validate(settings);
    const warnings: DiagnosticWarning[] = [];
    if (
      settings.mode === "remoteProxyOtherMachine" &&
      settings.trustedProxies.length === 0
    ) {
      // Mirrors the backend's `missingTrustedProxy` warning — which the backend
      // itself cannot currently reach, since `validate_settings` rejects an
      // empty `trustedProxies` for this mode first. Kept aligned on purpose: if
      // the backend reorders, the mock already matches.
      warnings.push({
        code: "missingTrustedProxy",
        message:
          "Add the IP address or CIDR of the reverse proxy that connects to this server.",
      });
    }
    const bindAddress =
      settings.mode === "remoteProxyOtherMachine"
        ? settings.lanBindAddress
        : "127.0.0.1";
    const upstreamUrl =
      settings.mode !== "localOnly" && bindAddress
        ? `http://${bindAddress}:${settings.port}`
        : undefined;
    return {
      candidateLanAddresses: [...MOCK_LAN_ADDRESSES],
      upstreamUrl,
      warnings,
    };
  }

  async status(): Promise<EmbeddedServerStatus> {
    return structuredClone(this.current);
  }

  async start(): Promise<EmbeddedServerStatus> {
    // The backend re-validates on start; a bad config fails there, not silently.
    this.validate(this.settings);
    const preview = await this.previewExposure(this.settings);
    this.emit({
      state: "running",
      localUrl: `http://127.0.0.1:${this.settings.port}`,
      publicUrl:
        this.settings.mode === "localOnly"
          ? undefined
          : this.settings.publicOrigin,
      upstreamUrl: preview.upstreamUrl,
    });
    return structuredClone(this.current);
  }

  async stop(): Promise<EmbeddedServerStatus> {
    this.emit({ state: "stopped" });
    return structuredClone(this.current);
  }

  async onStatusChanged(
    handler: (status: EmbeddedServerStatus) => void,
  ): Promise<Unsubscribe> {
    this.handlers.add(handler);
    return () => this.handlers.delete(handler);
  }

  /** Test hook: force a status (e.g. a `failed` state) and notify subscribers. */
  setStatus(status: EmbeddedServerStatus): void {
    this.emit(status);
  }

  private emit(status: EmbeddedServerStatus): void {
    this.current = status;
    for (const handler of this.handlers) handler(structuredClone(status));
  }

  private validate(settings: ServerExposureSettings): void {
    if (settings.mode !== "localOnly") {
      const origin = settings.publicOrigin;
      if (!origin) invalid("remote exposure requires publicOrigin");
      if (!origin.startsWith("https://")) {
        invalid("remote exposure requires publicOrigin to start with https://");
      }
      if (origin.includes("*")) invalid("publicOrigin must be exact, not a wildcard");
      if (origin.endsWith("/")) invalid("publicOrigin must not end with '/'");
    }
    if (settings.mode === "remoteProxyOtherMachine") {
      if (!settings.lanBindAddress) {
        invalid("remoteProxyOtherMachine requires lanBindAddress");
      }
      if (settings.trustedProxies.length === 0) {
        invalid("remoteProxyOtherMachine requires at least one trustedProxies entry");
      }
    }
  }
}

/**
 * Stateful in-memory template gateway.
 *
 * Shares the `MockAgentGateway` instance passed at construction time so that
 * `createAgentFromTemplate` / `detectDrift` / `syncAgent` operate on the same
 * agent registry as the rest of the UI (ARCHITECTURE §11).
 *
 * Exported so tests can instantiate it directly and inject a shared
 * `MockAgentGateway`.
 */
export class MockTemplateGateway implements TemplateGateway {
  private templates: Template[] = [];
  private seq = 0;

  constructor(private readonly agentGateway: MockAgentGateway) {}

  async listTemplates(): Promise<Template[]> {
    return structuredClone(this.templates);
  }

  async createTemplate(input: CreateTemplateInput): Promise<Template> {
    this.seq += 1;
    const template: Template = {
      id: `mock-template-${this.seq}`,
      name: input.name,
      contentMd: input.content,
      version: 1,
      defaultProfileId: input.defaultProfileId,
    };
    this.templates.push(template);
    return structuredClone(template);
  }

  async updateTemplate(templateId: string, content: string): Promise<Template> {
    const idx = this.templates.findIndex((t) => t.id === templateId);
    if (idx === -1) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `template ${templateId} not found`,
      };
      throw err;
    }
    this.templates[idx] = {
      ...this.templates[idx],
      contentMd: content,
      version: this.templates[idx].version + 1,
    };
    return structuredClone(this.templates[idx]);
  }

  async deleteTemplate(templateId: string): Promise<void> {
    const idx = this.templates.findIndex((t) => t.id === templateId);
    if (idx === -1) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `template ${templateId} not found`,
      };
      throw err;
    }
    this.templates.splice(idx, 1);
  }

  async createAgentFromTemplate(
    projectId: string,
    templateId: string,
    opts?: { name?: string; synchronized?: boolean },
  ): Promise<Agent> {
    const template = this.templates.find((t) => t.id === templateId);
    if (!template) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `template ${templateId} not found`,
      };
      throw err;
    }

    const synchronized = opts?.synchronized ?? true;
    const name = opts?.name ?? template.name;
    const slug = slugify(name) || "agent";

    // Derive unique contextPath (same logic as MockAgentGateway)
    const existingPaths = new Set(
      this.agentGateway._rawAgents(projectId).map((a) => a.contextPath),
    );
    let candidate = `agents/${slug}.md`;
    let n = 2;
    while (existingPaths.has(candidate)) {
      candidate = `agents/${slug}-${n}.md`;
      n += 1;
    }

    const agent: Agent = {
      id: `mock-agent-${Math.random().toString(36).slice(2, 10)}`,
      name,
      contextPath: candidate,
      profileId: template.defaultProfileId,
      origin: {
        type: "fromTemplate",
        templateId,
        syncedTemplateVersion: template.version,
      },
      synchronized,
      skills: [],
    };

    this.agentGateway._insertAgent(projectId, agent, template.contentMd);
    return structuredClone(agent);
  }

  async detectDrift(projectId: string): Promise<AgentDrift[]> {
    const agents = this.agentGateway._rawAgents(projectId);
    const drifts: AgentDrift[] = [];
    for (const agent of agents) {
      if (!agent.synchronized) continue;
      if (agent.origin.type !== "fromTemplate") continue;
      const { templateId, syncedTemplateVersion } = agent.origin;
      const template = this.templates.find((t) => t.id === templateId);
      if (!template) continue;
      if (template.version > syncedTemplateVersion) {
        drifts.push({
          agentId: agent.id,
          from: syncedTemplateVersion,
          to: template.version,
        });
      }
    }
    return drifts;
  }

  async syncAgent(
    projectId: string,
    agentId: string,
  ): Promise<{ synced: boolean; version: number | null }> {
    const agents = this.agentGateway._rawAgents(projectId);
    const agent = agents.find((a) => a.id === agentId);
    if (!agent || !agent.synchronized || agent.origin.type !== "fromTemplate") {
      return { synced: false, version: null };
    }

    const { templateId } = agent.origin;
    const template = this.templates.find((t) => t.id === templateId);
    if (!template) {
      return { synced: false, version: null };
    }

    this.agentGateway._updateAgent(
      projectId,
      agentId,
      {
        origin: {
          type: "fromTemplate",
          templateId,
          syncedTemplateVersion: template.version,
        },
      },
      template.contentMd,
    );

    return { synced: true, version: template.version };
  }
}

/**
 * Stateful in-memory skill gateway (L12).
 *
 * Skills are keyed by `${scope}` (project scope is further partitioned by
 * `projectId`, mirroring the disjoint on-disk roots). Assignment mutates the
 * agent record held by the injected {@link MockAgentGateway}, so both gateways
 * share one store — exactly like {@link MockTemplateGateway}.
 */
export class MockSkillGateway implements SkillGateway {
  // Global skills are project-independent; project skills live under their id.
  private global: Skill[] = [];
  private byProject = new Map<string, Skill[]>();
  private seq = 0;

  constructor(private readonly agentGateway: MockAgentGateway) {}

  private bucket(projectId: string, scope: SkillScope): Skill[] {
    if (scope === "global") return this.global;
    let list = this.byProject.get(projectId);
    if (!list) {
      list = [];
      this.byProject.set(projectId, list);
    }
    return list;
  }

  async listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
    return structuredClone(this.bucket(projectId, scope));
  }

  async createSkill(input: CreateSkillInput): Promise<Skill> {
    this.seq += 1;
    const skill: Skill = {
      id: `mock-skill-${this.seq}`,
      name: input.name,
      contentMd: input.content,
      scope: input.scope,
    };
    this.bucket(input.projectId, input.scope).push(skill);
    return structuredClone(skill);
  }

  async updateSkill(
    projectId: string,
    scope: SkillScope,
    skillId: string,
    content: string,
  ): Promise<Skill> {
    const list = this.bucket(projectId, scope);
    const idx = list.findIndex((s) => s.id === skillId);
    if (idx === -1) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `skill ${skillId} not found`,
      };
      throw err;
    }
    list[idx] = { ...list[idx], contentMd: content };
    return structuredClone(list[idx]);
  }

  async deleteSkill(
    projectId: string,
    scope: SkillScope,
    skillId: string,
  ): Promise<void> {
    const list = this.bucket(projectId, scope);
    const idx = list.findIndex((s) => s.id === skillId);
    if (idx === -1) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `skill ${skillId} not found`,
      };
      throw err;
    }
    list.splice(idx, 1);
  }

  async assignSkill(
    projectId: string,
    agentId: string,
    skillId: string,
    scope: SkillScope,
  ): Promise<void> {
    const agent = this.agentGateway
      ._rawAgents(projectId)
      .find((a) => a.id === agentId);
    if (!agent) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent ${agentId} not found in project ${projectId}`,
      };
      throw err;
    }
    if (agent.skills.some((s) => s.skillId === skillId)) return; // idempotent
    this.agentGateway._setSkills(projectId, agentId, [
      ...agent.skills,
      { skillId, scope },
    ]);
  }

  async unassignSkill(
    projectId: string,
    agentId: string,
    skillId: string,
  ): Promise<void> {
    const agent = this.agentGateway
      ._rawAgents(projectId)
      .find((a) => a.id === agentId);
    if (!agent) {
      const err: GatewayError = {
        code: "NOT_FOUND",
        message: `agent ${agentId} not found in project ${projectId}`,
      };
      throw err;
    }
    this.agentGateway._setSkills(
      projectId,
      agentId,
      agent.skills.filter((s) => s.skillId !== skillId),
    );
  }
}

/**
 * Stateful in-memory memory gateway (L14).
 *
 * Notes are partitioned by `projectId` and keyed by their immutable **slug**
 * (derived from the name on create). `readIndex` projects the notes into the
 * recall index; `resolveLinks` scans `[[slug]]` wikilinks in a note's content
 * and keeps only those targeting an existing note; `recall` returns a simple
 * truncation of the index (no embeddings in the mock).
 */
export class MockMemoryGateway implements MemoryGateway {
  private byProject = new Map<string, Map<string, Memory>>();

  private bucket(projectId: string): Map<string, Memory> {
    let store = this.byProject.get(projectId);
    if (!store) {
      store = new Map();
      this.byProject.set(projectId, store);
    }
    return store;
  }

  private notFound(slug: string, projectId: string): GatewayError {
    return {
      code: "NOT_FOUND",
      message: `memory ${slug} not found in project ${projectId}`,
    };
  }

  async listMemories(projectId: string): Promise<Memory[]> {
    return structuredClone([...this.bucket(projectId).values()]);
  }

  async getMemory(projectId: string, slug: string): Promise<Memory> {
    const note = this.bucket(projectId).get(slug);
    if (!note) throw this.notFound(slug, projectId);
    return structuredClone(note);
  }

  async createMemory(input: CreateMemoryInput): Promise<Memory> {
    const note: Memory = {
      name: input.name,
      description: input.description,
      type: input.type,
      content: input.content,
    };
    this.bucket(input.projectId).set(slugify(input.name), note);
    return structuredClone(note);
  }

  async updateMemory(
    projectId: string,
    slug: string,
    description: string,
    type: MemoryType,
    content: string,
  ): Promise<Memory> {
    const store = this.bucket(projectId);
    const note = store.get(slug);
    if (!note) throw this.notFound(slug, projectId);
    const updated: Memory = { ...note, description, type, content };
    store.set(slug, updated);
    return structuredClone(updated);
  }

  async deleteMemory(projectId: string, slug: string): Promise<void> {
    const store = this.bucket(projectId);
    if (!store.delete(slug)) throw this.notFound(slug, projectId);
  }

  async readIndex(projectId: string): Promise<MemoryIndexEntry[]> {
    return [...this.bucket(projectId).entries()].map(([slug, note]) => ({
      slug,
      title: note.name,
      hook: note.description,
      type: note.type,
    }));
  }

  async resolveLinks(projectId: string, slug: string): Promise<MemoryLink[]> {
    const store = this.bucket(projectId);
    const note = store.get(slug);
    if (!note) throw this.notFound(slug, projectId);
    const targets = new Set<MemoryLink>();
    for (const match of note.content.matchAll(/\[\[([^\]]+)\]\]/g)) {
      const target = match[1].trim();
      if (target !== slug && store.has(target)) targets.add(target);
    }
    return [...targets];
  }

  async recall(
    projectId: string,
    _text: string,
    tokenBudget: number,
  ): Promise<MemoryIndexEntry[]> {
    const index = await this.readIndex(projectId);
    const limit = Math.max(0, Math.floor(tokenBudget / 16));
    return index.slice(0, limit);
  }
}

/**
 * In-memory {@link EmbedderGateway} (L14 / lot C2). Seeds a curated set of
 * recommended ONNX engines (e5-small recommended) with both feature flags on by
 * default; tests can pass overrides to simulate a build where a strategy is not
 * compiled in.
 */
export class MockEmbedderGateway implements EmbedderGateway {
  private profiles = new Map<string, EmbedderProfile>();
  private engines: EmbedderEngines;

  constructor(
    engines?: Partial<EmbedderEngines>,
    seedProfiles: EmbedderProfile[] = [],
  ) {
    this.engines = {
      recommendedOnnx: [
        {
          id: "e5-small",
          displayName: "E5 Small (multilingual)",
          dimension: 384,
          approxSizeMb: 120,
          recommended: true,
        },
        {
          id: "bge-small",
          displayName: "BGE Small",
          dimension: 384,
          approxSizeMb: 130,
          recommended: false,
        },
      ],
      ollamaDetected: false,
      onnxCachedModels: [],
      vectorHttpEnabled: true,
      vectorOnnxEnabled: true,
      ...engines,
    };
    for (const p of seedProfiles) this.profiles.set(p.id, p);
  }

  async listEmbedderProfiles(): Promise<EmbedderProfile[]> {
    return structuredClone([...this.profiles.values()]);
  }

  async saveEmbedderProfile(
    profile: EmbedderProfile,
  ): Promise<EmbedderProfile> {
    if (!profile.id.trim() || !profile.name.trim()) {
      const err: GatewayError = {
        code: "INVALID",
        message: "embedder profile id/name must not be empty",
      };
      throw err;
    }
    if (profile.dimension <= 0) {
      const err: GatewayError = {
        code: "INVALID",
        message: "embedder profile dimension must be > 0",
      };
      throw err;
    }
    this.profiles.set(profile.id, structuredClone(profile));
    return structuredClone(profile);
  }

  async deleteEmbedderProfile(embedderId: string): Promise<void> {
    this.profiles.delete(embedderId);
  }

  async describeEmbedderEngines(): Promise<EmbedderEngines> {
    return structuredClone(this.engines);
  }
}

/** One recorded agent-input control call (for test assertions). */
export interface MockInputCall {
  projectId: string;
  agentId: string;
  /** Present for `delegationDelivered`, absent for `interrupt`. */
  ticket?: string;
}

/**
 * In-memory {@link InputGateway} (ARCHITECTURE §20.3). Records every
 * `interrupt`/`delegationDelivered` so tests can assert the component routed
 * through the port with the right args — no backend.
 *
 * Exported so tests can instantiate it directly (same pattern as the other mocks).
 */
export class MockInputGateway implements InputGateway {
  /** All recorded interrupt calls, in order. */
  readonly interrupts: MockInputCall[] = [];
  /** All recorded delegation-delivered acks, in order. */
  readonly delivered: MockInputCall[] = [];
  /** All recorded front-attached reports, in order. */
  readonly frontAttached: { agentId: string; attached: boolean }[] = [];
  /** All recorded cancel-resume calls, in order. */
  readonly cancelledResumes: string[] = [];
  /** All recorded set-resume-at armings (human net, level 3), in order. */
  readonly resumeArmings: { agentId: string; resetsAtMs: number }[] = [];
  /**
   * What {@link cancelResume} resolves to (mirrors the backend `bool`: whether a
   * pending resume was disarmed). Tests can flip it to exercise the "already
   * fired / none armed" path.
   */
  cancelResumeResult = true;

  async interrupt(projectId: string, agentId: string): Promise<void> {
    this.interrupts.push({ projectId, agentId });
  }

  async delegationDelivered(
    projectId: string,
    agentId: string,
    ticket: string,
  ): Promise<void> {
    this.delivered.push({ projectId, agentId, ticket });
  }

  async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
    this.frontAttached.push({ agentId, attached });
  }

  async cancelResume(agentId: string): Promise<boolean> {
    this.cancelledResumes.push(agentId);
    return this.cancelResumeResult;
  }

  async setResumeAt(agentId: string, resetsAtMs: number): Promise<void> {
    this.resumeArmings.push({ agentId, resetsAtMs });
  }
}

/**
 * In-memory paired-device gateway (ticket #77) — stands in for B1/B2/B3 while
 * the backend lands, and drives the `VITE_USE_MOCK` dev surface.
 *
 * Models the parts of the frozen contract the UI can actually observe: a device
 * list with exactly one `isCurrentDevice`, single-use codes with a 600 s TTL
 * where generating invalidates the previous one, and revocation. It does **not**
 * model the wire (no HTTP, no cookie): that is the HTTP gateway's job.
 */
export class MockDeviceGateway implements DeviceGateway {
  private devices: PairedDevice[] = [];
  private issued = 0;

  /** Seeds the device list (tests/dev). */
  _setDevices(devices: PairedDevice[]): void {
    this.devices = structuredClone(devices);
  }

  /** Every code handed out so far, newest last — the previous ones are dead. */
  _issuedCodes: string[] = [];

  async listDevices(): Promise<PairedDevice[]> {
    return structuredClone(this.devices);
  }

  async createPairingCode(): Promise<PairingCode> {
    // Deterministic 8-char uppercase hex, same shape as the server's.
    const code = (0x10000000 + this.issued++)
      .toString(16)
      .toUpperCase()
      .slice(0, 8);
    this._issuedCodes.push(code);
    const ttlSeconds = 600;
    return {
      code,
      // Epoch ms, exactly as the server sends it — a mock that invents a
      // friendlier encoding is how the ISO/epoch mismatch stayed green (#77).
      expiresAtMs: Date.now() + ttlSeconds * 1000,
      ttlSeconds,
    };
  }

  async renameDevice(deviceId: string, name: string): Promise<void> {
    const device = this.devices.find((d) => d.deviceId === deviceId);
    if (!device) {
      const err: GatewayError = { code: "NOT_FOUND", message: `unknown device ${deviceId}` };
      throw err;
    }
    device.name = name;
  }

  async revokeDevice(deviceId: string): Promise<void> {
    this.devices = this.devices.filter((d) => d.deviceId !== deviceId);
  }

  async revokeAllDevices(): Promise<void> {
    this.devices = [];
  }
}

/** In-memory permissions gateway. */
export class MockPermissionGateway implements PermissionGateway {
  private docs = new Map<string, ProjectPermissions>();

  private doc(projectId: string): ProjectPermissions {
    if (!this.docs.has(projectId)) {
      this.docs.set(projectId, { version: 1, agents: [] });
    }
    return this.docs.get(projectId)!;
  }

  async getProjectPermissions(projectId: string): Promise<ProjectPermissions> {
    return structuredClone(this.doc(projectId));
  }

  async updateProjectPermissions(
    projectId: string,
    permissions: PermissionSet | null,
  ): Promise<ProjectPermissions> {
    const doc = this.doc(projectId);
    if (permissions) doc.projectDefaults = structuredClone(permissions);
    else delete doc.projectDefaults;
    return structuredClone(doc);
  }

  async updateAgentPermissions(
    projectId: string,
    agentId: string,
    permissions: PermissionSet | null,
  ): Promise<ProjectPermissions> {
    const doc = this.doc(projectId);
    const agents = (doc.agents ?? []).filter((entry) => entry.agentId !== agentId);
    if (permissions) agents.push({ agentId, permissions: structuredClone(permissions) });
    doc.agents = agents;
    return structuredClone(doc);
  }

  async resolveAgentPermissions(
    projectId: string,
    agentId: string,
  ): Promise<EffectivePermissions | null> {
    const doc = this.doc(projectId);
    const project = doc.projectDefaults;
    const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions;
    if (!project && !agent) return null;
    return {
      rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
      fallback: mostRestrictive(project?.fallback, agent?.fallback),
    };
  }

  // ── MCP tool permissions (ticket #82) — mirrors the backend catalogue in
  // `crates/infrastructure/src/orchestrator/mcp/tools.rs`, a separate durable
  // document from the file/command permissions above. ─────────────────────
  private mcpCatalogue: McpToolCatalogue = {
    readOnlyTools: [
      "idea_list_agents",
      "idea_context_read",
      "idea_memory_read",
      "idea_skill_read",
      "idea_workstate_read",
      "idea_ticket_read",
      "idea_ticket_list",
      "idea_ticket_read_carnet",
      "idea_sprint_list",
    ],
    writeActionTools: [
      "idea_ask_agent",
      "idea_run_in_background",
      "idea_launch_agent",
      "idea_stop_agent",
      "idea_update_context",
      "idea_context_propose",
      "idea_memory_write",
      "idea_workstate_set",
      "idea_create_skill",
      "idea_ticket_create",
      "idea_ticket_update",
      "idea_ticket_update_status",
      "idea_ticket_update_priority",
      "idea_ticket_update_carnet",
      "idea_ticket_link",
      "idea_ticket_unlink",
    ],
  };

  private mcpDocs = new Map<string, ProjectMcpToolPermissions>();

  private mcpDoc(projectId: string): ProjectMcpToolPermissions {
    if (!this.mcpDocs.has(projectId)) {
      this.mcpDocs.set(projectId, {
        version: 1,
        catalogue: this.mcpCatalogue,
        projectDefault: null,
        agents: [],
      });
    }
    return this.mcpDocs.get(projectId)!;
  }

  async getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions> {
    return structuredClone(this.mcpDoc(projectId));
  }

  async updateProjectMcpToolPermissions(
    projectId: string,
    policy: McpToolPolicy | null,
  ): Promise<ProjectMcpToolPermissions> {
    const doc = this.mcpDoc(projectId);
    doc.projectDefault = policy ? structuredClone(policy) : null;
    return structuredClone(doc);
  }

  async updateAgentMcpToolPermissions(
    projectId: string,
    agentId: string,
    policy: McpToolPolicy | null,
  ): Promise<ProjectMcpToolPermissions> {
    const doc = this.mcpDoc(projectId);
    doc.agents = doc.agents.filter((entry) => entry.agentId !== agentId);
    if (policy) doc.agents.push({ agentId, policy: structuredClone(policy) });
    return structuredClone(doc);
  }
}

export class MockWorkStateGateway implements WorkStateGateway {
  private states = new Map<string, ProjectWorkState>();

  /** Seeds the read-model returned for a project (deterministic tests/dev). */
  _setProjectWorkState(projectId: string, state: unknown): void {
    this.states.set(projectId, normalizeProjectWorkState(state));
  }

  async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
    return structuredClone(
      this.states.get(projectId) ?? { agents: [], conversations: [] },
    );
  }

  async cancelBackgroundTask(_taskId: string): Promise<void> {
    // No-op in the mock; real refresh is driven by domain events.
  }

  async retryBackgroundTask(_taskId: string): Promise<void> {
    // No-op in the mock; real refresh is driven by domain events.
  }
}

/**
 * In-memory {@link ConversationGateway} with **real** pagination, mirroring the
 * LS6 backend slicing (`crates/infrastructure/.../conversation_log/mod.rs::page`
 * + `domain::clamp_page_limit`): turns stored oldest→newest; anchor pagination
 * is **strict** (turns strictly before/after the anchor); `limit` defaults to 50
 * and is clamped to `[1, 200]`; `hasMore` is true when more turns exist in the
 * travel direction; `nextAnchor` is the **last** (most recent) turn of the page.
 * Full turn text is preserved (never truncated).
 */
export class MockConversationGateway implements ConversationGateway {
  private threads = new Map<string, TurnView[]>();

  private key(projectId: string, conversationId: string): string {
    return `${projectId}::${conversationId}`;
  }

  /** Seeds the turns of a conversation (oldest→newest) for deterministic tests. */
  _setTurns(
    projectId: string,
    conversationId: string,
    turns: TurnView[],
  ): void {
    this.threads.set(
      this.key(projectId, conversationId),
      turns.map((t) => structuredClone(t)),
    );
  }

  async readPage(
    projectId: string,
    conversationId: string,
    request?: ConversationPageRequest,
  ): Promise<TurnPage> {
    const all = this.threads.get(this.key(projectId, conversationId)) ?? [];
    const len = all.length;
    const raw = request?.limit;
    const limit = Math.min(Math.max(raw && raw > 0 ? raw : 50, 1), 200);
    const direction = request?.direction ?? "backward";
    const anchor = request?.anchor;

    let start: number;
    let end: number;
    let hasMore: boolean;
    if (anchor === undefined) {
      if (direction === "forward") {
        start = 0;
        end = Math.min(limit, len);
        hasMore = end < len;
      } else {
        start = Math.max(len - limit, 0);
        end = len;
        hasMore = start > 0;
      }
    } else {
      const pos = all.findIndex((t) => t.id === anchor);
      if (pos === -1) {
        start = 0;
        end = 0;
        hasMore = false;
      } else if (direction === "forward") {
        start = pos + 1;
        end = Math.min(start + limit, len);
        hasMore = end < len;
      } else {
        end = pos;
        start = Math.max(end - limit, 0);
        hasMore = start > 0;
      }
    }

    const turns = all.slice(start, end).map((t) => structuredClone(t));
    const last = turns.at(-1);
    return {
      turns,
      hasMore,
      ...(last ? { nextAnchor: last.id } : {}),
    };
  }
}

/**
 * In-memory {@link TicketGateway} with real optimistic-concurrency semantics:
 * every mutation checks `expectedVersion` against the stored version and rejects
 * a stale write with an `INVALID` {@link GatewayError} whose message contains
 * `"version conflict"` (mirroring the backend `IssueStoreError::VersionConflict`
 * → `AppError::Invalid` mapping). Mutations emit the matching `Issue*` domain
 * event through the injected {@link MockSystemGateway} so event-driven refresh
 * can be exercised offline.
 */
export class MockTicketGateway implements TicketGateway {
  private tickets = new Map<string, Map<string, Ticket>>();
  private carnets = new Map<string, Map<string, string>>();
  private counters = new Map<string, number>();
  private sprints = new Map<string, Map<string, Sprint>>();
  private sprintCounters = new Map<string, number>();
  /** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
  private chatSessions = new Map<string, string>();
  private chatCounter = 0;

  constructor(private readonly system?: MockSystemGateway) {}

  /** Seeds a ticket for deterministic tests; returns the stored clone. */
  _seedTicket(projectId: string, ticket: Ticket): Ticket {
    const stored = structuredClone(ticket);
    if (stored.sprintId === undefined) stored.sprintId = null;
    this.projectTickets(projectId).set(stored.ref, stored);
    this.counters.set(
      projectId,
      Math.max(this.counters.get(projectId) ?? 0, stored.number),
    );
    return structuredClone(stored);
  }

  /** Seeds a sprint for deterministic tests; returns the stored clone. */
  _seedSprint(
    projectId: string,
    sprint: Pick<Sprint, "id" | "order" | "name"> &
      Partial<Pick<Sprint, "status" | "ticketCount" | "version">>,
  ): Sprint {
    const stored: Sprint = {
      status: "planned",
      ticketCount: 0,
      version: 1,
      ...sprint,
    };
    this.projectSprints(projectId).set(stored.id, stored);
    return structuredClone(stored);
  }

  private projectSprints(projectId: string): Map<string, Sprint> {
    let map = this.sprints.get(projectId);
    if (!map) {
      map = new Map();
      this.sprints.set(projectId, map);
    }
    return map;
  }

  /** Recomputes `ticketCount` for every sprint from the current tickets. */
  private recountSprints(projectId: string): void {
    const sprints = this.projectSprints(projectId);
    const counts = new Map<string, number>();
    for (const t of this.projectTickets(projectId).values()) {
      if (t.sprintId) counts.set(t.sprintId, (counts.get(t.sprintId) ?? 0) + 1);
    }
    for (const s of sprints.values()) s.ticketCount = counts.get(s.id) ?? 0;
  }

  private projectTickets(projectId: string): Map<string, Ticket> {
    let map = this.tickets.get(projectId);
    if (!map) {
      map = new Map();
      this.tickets.set(projectId, map);
    }
    return map;
  }

  private projectCarnets(projectId: string): Map<string, string> {
    let map = this.carnets.get(projectId);
    if (!map) {
      map = new Map();
      this.carnets.set(projectId, map);
    }
    return map;
  }

  private require(projectId: string, ref: string): Ticket {
    const ticket = this.projectTickets(projectId).get(ref);
    if (!ticket) {
      throw { code: "NOT_FOUND", message: `ticket ${ref} not found` } as GatewayError;
    }
    return ticket;
  }

  private guard(ticket: Ticket, expectedVersion: number): void {
    if (ticket.version !== expectedVersion) {
      throw {
        code: "INVALID",
        message: `issue version conflict: expected ${expectedVersion}, actual ${ticket.version}`,
      } as GatewayError;
    }
  }

  async create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
    const number = (this.counters.get(projectId) ?? 0) + 1;
    this.counters.set(projectId, number);
    const now = Date.now();
    const ticket: Ticket = {
      id: `mock-ticket-${projectId}-${number}`,
      ref: `#${number}`,
      number,
      title: input.title,
      description: input.description ?? "",
      status: input.status ?? "open",
      priority: input.priority ?? "medium",
      sprintId: null,
      links: [],
      assignedAgentIds: [...(input.assignedAgentIds ?? [])],
      createdBy: { kind: "user" },
      updatedBy: { kind: "user" },
      createdAt: now,
      updatedAt: now,
      version: 1,
    };
    this.projectTickets(projectId).set(ticket.ref, ticket);
    this.system?.emit({
      type: "issueCreated",
      issueId: ticket.id,
      issueRef: ticket.ref,
    });
    return structuredClone(ticket);
  }

  async read(
    projectId: string,
    ref: string,
    includeCarnet = false,
  ): Promise<Ticket> {
    const ticket = structuredClone(this.require(projectId, ref));
    if (includeCarnet) {
      ticket.carnet = this.projectCarnets(projectId).get(ref) ?? "";
    }
    return ticket;
  }

  async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
    const q = query ?? {};
    const text = q.text?.trim().toLowerCase();
    // Multi-select facets (ticket #12): OR within a facet, AND across facets; an
    // empty/absent set means "no constraint" (all values pass).
    const statuses = q.statuses ?? [];
    const priorities = q.priorities ?? [];
    let rows = [...this.projectTickets(projectId).values()]
      .filter((t) => statuses.length === 0 || statuses.includes(t.status))
      .filter((t) => priorities.length === 0 || priorities.includes(t.priority))
      .filter(
        (t) =>
          !q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId),
      )
      .filter(
        (t) =>
          !text ||
          t.title.toLowerCase().includes(text) ||
          t.description.toLowerCase().includes(text),
      );
    rows = q.sort
      ? sortTickets(rows, q.sort)
      : rows.sort((a, b) => b.number - a.number);
    const start = Number.parseInt(q.cursor ?? "0", 10) || 0;
    const limit = Math.min(Math.max(q.limit ?? 100, 1), 500);
    const end = Math.min(rows.length, start + limit);
    return {
      items: rows.slice(start, end).map((t) => ({
        ref: t.ref,
        path: `.ideai/tickets/${t.number}.md`,
        title: t.title,
        status: t.status,
        priority: t.priority,
        sprintId: t.sprintId ?? null,
        assignedAgentIds: [...t.assignedAgentIds],
        updatedAt: t.updatedAt,
      })),
      ...(end < rows.length ? { nextCursor: String(end) } : {}),
    };
  }

  private bump(ticket: Ticket): void {
    ticket.version += 1;
    ticket.updatedAt = Date.now();
    ticket.updatedBy = { kind: "user" };
  }

  async update(
    projectId: string,
    ref: string,
    input: UpdateTicketInput,
  ): Promise<Ticket> {
    const ticket = this.require(projectId, ref);
    this.guard(ticket, input.expectedVersion);
    if (input.title !== undefined) ticket.title = input.title;
    if (input.description !== undefined) ticket.description = input.description;
    if (input.status !== undefined) ticket.status = input.status;
    if (input.priority !== undefined) ticket.priority = input.priority;
    if (input.assignedAgentIds !== undefined) {
      ticket.assignedAgentIds = [...input.assignedAgentIds];
    }
    this.bump(ticket);
    this.system?.emit({
      type: "issueUpdated",
      issueRef: ref,
      version: ticket.version,
    });
    return structuredClone(ticket);
  }

  async delete(projectId: string, ref: string): Promise<void> {
    // NotFound if absent (mirrors the backend `AppError::NotFound`).
    const ticket = this.require(projectId, ref);
    const freedSprint = ticket.sprintId ?? null;
    this.projectTickets(projectId).delete(ref);
    this.projectCarnets(projectId).delete(ref);
    // Releasing the ticket updates its former sprint's count.
    if (freedSprint) this.recountSprints(projectId);
    this.system?.emit({
      type: "issueDeleted",
      projectId,
      issueRef: ref,
      freedSprint,
    });
  }

  async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
    const ticket = this.require(projectId, ref);
    return {
      ref,
      carnet: this.projectCarnets(projectId).get(ref) ?? "",
      version: ticket.version,
    };
  }

  async updateCarnet(
    projectId: string,
    ref: string,
    carnet: string,
    expectedVersion: number,
  ): Promise<Ticket> {
    const ticket = this.require(projectId, ref);
    this.guard(ticket, expectedVersion);
    this.projectCarnets(projectId).set(ref, carnet);
    this.bump(ticket);
    this.system?.emit({
      type: "issueCarnetUpdated",
      issueRef: ref,
      version: ticket.version,
    });
    return structuredClone(ticket);
  }

  async link(
    projectId: string,
    ref: string,
    targetRef: string,
    kind: TicketLinkKind,
    expectedVersion: number,
  ): Promise<Ticket> {
    const ticket = this.require(projectId, ref);
    this.guard(ticket, expectedVersion);
    const exists = ticket.links.some(
      (l: TicketLink) => l.targetRef === targetRef && l.kind === kind,
    );
    if (!exists) ticket.links.push({ targetRef, kind });
    this.bump(ticket);
    this.system?.emit({
      type: "issueLinked",
      issueRef: ref,
      target: targetRef,
      kind,
      version: ticket.version,
    });
    return structuredClone(ticket);
  }

  async unlink(
    projectId: string,
    ref: string,
    targetRef: string,
    expectedVersion: number,
    kind?: TicketLinkKind,
  ): Promise<Ticket> {
    const ticket = this.require(projectId, ref);
    this.guard(ticket, expectedVersion);
    ticket.links = ticket.links.filter(
      (l: TicketLink) =>
        l.targetRef !== targetRef || (kind !== undefined && l.kind !== kind),
    );
    this.bump(ticket);
    this.system?.emit({
      type: "issueUnlinked",
      issueRef: ref,
      target: targetRef,
      kind: kind ?? "relatesTo",
      version: ticket.version,
    });
    return structuredClone(ticket);
  }

  async assign(
    projectId: string,
    ref: string,
    agentId: string,
    assigned: boolean,
    expectedVersion: number,
  ): Promise<Ticket> {
    const ticket = this.require(projectId, ref);
    this.guard(ticket, expectedVersion);
    const has = ticket.assignedAgentIds.includes(agentId);
    if (assigned && !has) ticket.assignedAgentIds.push(agentId);
    if (!assigned && has) {
      ticket.assignedAgentIds = ticket.assignedAgentIds.filter(
        (id) => id !== agentId,
      );
    }
    this.bump(ticket);
    this.system?.emit({
      type: assigned ? "issueAgentAssigned" : "issueAgentUnassigned",
      issueRef: ref,
      agentId,
      version: ticket.version,
    });
    return structuredClone(ticket);
  }

  async listSprints(projectId: string): Promise<Sprint[]> {
    this.recountSprints(projectId);
    return [...this.projectSprints(projectId).values()]
      .sort((a, b) => a.order - b.order)
      .map((s) => structuredClone(s));
  }

  async setTicketSprint(
    projectId: string,
    ref: string,
    sprintId: string | null,
    expectedVersion: number,
  ): Promise<Ticket> {
    const ticket = this.require(projectId, ref);
    this.guard(ticket, expectedVersion);
    if (sprintId !== null && !this.projectSprints(projectId).has(sprintId)) {
      throw {
        code: "NOT_FOUND",
        message: `sprint ${sprintId} not found`,
      } as GatewayError;
    }
    const from = ticket.sprintId ?? null;
    ticket.sprintId = sprintId;
    this.bump(ticket);
    this.recountSprints(projectId);
    this.system?.emit({
      type: "issueSprintChanged",
      issueRef: ref,
      from,
      to: sprintId,
      version: ticket.version,
    });
    return structuredClone(ticket);
  }

  async createSprint(projectId: string, name: string): Promise<Sprint> {
    const sprints = this.projectSprints(projectId);
    const seq = (this.sprintCounters.get(projectId) ?? 0) + 1;
    this.sprintCounters.set(projectId, seq);
    const order =
      sprints.size === 0
        ? 1
        : Math.max(...[...sprints.values()].map((s) => s.order)) + 1;
    const sprint: Sprint = {
      id: `mock-sprint-${projectId}-${seq}`,
      order,
      name,
      status: "planned",
      ticketCount: 0,
      version: 1,
    };
    sprints.set(sprint.id, sprint);
    this.system?.emit({
      type: "sprintCreated",
      sprintId: sprint.id,
      order: sprint.order,
    });
    return structuredClone(sprint);
  }

  async renameSprint(
    projectId: string,
    sprintId: string,
    name: string,
    expectedVersion: number,
  ): Promise<Sprint> {
    const sprint = this.requireSprint(projectId, sprintId);
    this.guardSprint(sprint, expectedVersion);
    sprint.name = name;
    sprint.version += 1;
    this.system?.emit({
      type: "sprintRenamed",
      sprintId,
      name,
      version: sprint.version,
    });
    return structuredClone(sprint);
  }

  async reorderSprints(
    projectId: string,
    orderedIds: string[],
  ): Promise<Sprint[]> {
    const sprints = this.projectSprints(projectId);
    for (const id of orderedIds) this.requireSprint(projectId, id);
    // Assign 1-based order following the provided id sequence; ids omitted from
    // `orderedIds` keep their relative order after the listed ones.
    const rest = [...sprints.values()]
      .filter((s) => !orderedIds.includes(s.id))
      .sort((a, b) => a.order - b.order)
      .map((s) => s.id);
    [...orderedIds, ...rest].forEach((id, index) => {
      const sprint = sprints.get(id);
      if (sprint) {
        sprint.order = index + 1;
        this.system?.emit({
          type: "sprintReordered",
          sprintId: id,
          order: sprint.order,
          version: sprint.version,
        });
      }
    });
    return this.listSprints(projectId);
  }

  async deleteSprint(projectId: string, sprintId: string): Promise<void> {
    this.requireSprint(projectId, sprintId);
    // Delete unassigns the sprint's tickets (it does NOT delete them): they fall
    // back to the "no sprint" bucket.
    for (const ticket of this.projectTickets(projectId).values()) {
      if (ticket.sprintId === sprintId) {
        ticket.sprintId = null;
        this.bump(ticket);
        this.system?.emit({
          type: "issueSprintChanged",
          issueRef: ticket.ref,
          from: sprintId,
          to: null,
          version: ticket.version,
        });
      }
    }
    this.projectSprints(projectId).delete(sprintId);
    this.system?.emit({ type: "sprintDeleted", sprintId });
  }

  async openTicketChat(
    projectId: string,
    issueRef: string,
    profileId: string,
  ): Promise<TicketChat> {
    // Ensure the ticket exists (mirrors the backend resolving the ref).
    this.require(projectId, issueRef);
    const sessionId = `mock-ticket-chat-${++this.chatCounter}`;
    this.chatSessions.set(issueRef, sessionId);
    this.system?.emit({ type: "ticketAssistantOpened", issueRef, profileId });
    return { sessionId, requester: "ticket-assistant", issueRef };
  }

  async closeTicketChat(_projectId: string, issueRef: string): Promise<void> {
    if (this.chatSessions.delete(issueRef)) {
      this.system?.emit({ type: "ticketAssistantClosed", issueRef });
    }
  }

  async sendTicketChat(
    sessionId: string,
    message: string,
    onChunk: (chunk: ReplyChunk) => void,
  ): Promise<void> {
    if (![...this.chatSessions.values()].includes(sessionId)) {
      throw {
        code: "NOT_FOUND",
        message: `chat session ${sessionId} not found`,
      } as GatewayError;
    }
    // Simulate a streamed assistant turn: a couple of text deltas, then the
    // deterministic `final` chunk. Delivered across microtasks (like a real
    // Channel), so a consumer that awaits sees the whole turn.
    const content = `Assistant: reçu « ${message} ».`;
    const chunks: ReplyChunk[] = [
      { kind: "textDelta", text: "Assistant: reçu " },
      { kind: "textDelta", text: `« ${message} ».` },
      { kind: "final", content },
    ];
    for (const chunk of chunks) {
      await Promise.resolve();
      onChunk(chunk);
    }
  }

  private requireSprint(projectId: string, sprintId: string): Sprint {
    const sprint = this.projectSprints(projectId).get(sprintId);
    if (!sprint) {
      throw {
        code: "NOT_FOUND",
        message: `sprint ${sprintId} not found`,
      } as GatewayError;
    }
    return sprint;
  }

  private guardSprint(sprint: Sprint, expectedVersion: number): void {
    if (sprint.version !== expectedVersion) {
      throw {
        code: "INVALID",
        message: `sprint version conflict: expected ${expectedVersion}, actual ${sprint.version}`,
      } as GatewayError;
    }
  }
}

/**
 * In-memory {@link UiPreferencesGateway} for tests/dev (ticket #29). Mirrors the
 * localStorage adapter's best-effort JSON round-trip without touching the DOM,
 * so persisted-filter behaviour can be exercised offline.
 */
export class MockUiPreferencesGateway implements UiPreferencesGateway {
  private readonly store = new Map<string, string>();

  read(key: string): unknown {
    const raw = this.store.get(key);
    if (raw === undefined) return null;
    try {
      return JSON.parse(raw);
    } catch {
      this.store.delete(key);
      return null;
    }
  }

  write(key: string, value: unknown): void {
    try {
      this.store.set(key, JSON.stringify(value));
    } catch {
      /* best-effort */
    }
  }

  remove(key: string): void {
    this.store.delete(key);
  }
}

function mostRestrictive(
  project?: PermissionSet["fallback"],
  agent?: PermissionSet["fallback"],
): PermissionSet["fallback"] {
  const rank = { allow: 0, ask: 1, deny: 2 } as const;
  const fallback = project ?? agent ?? "ask";
  if (!agent) return fallback;
  return rank[agent] >= rank[fallback] ? agent : fallback;
}

/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
  const agentGateway = new MockAgentGateway();
  const systemGateway = new MockSystemGateway();
  return {
    system: systemGateway,
    agent: agentGateway,
    input: new MockInputGateway(),
    terminal: new MockTerminalGateway(),
    project: new MockProjectGateway(),
    layout: new MockLayoutGateway(),
    git: new MockGitGateway(),
    remote: new MockRemoteGateway(),
    profile: new MockProfileGateway(),
    modelServer: new MockModelServerGateway(),
    desktopServer: new MockDesktopServerGateway(),
    template: new MockTemplateGateway(agentGateway),
    skill: new MockSkillGateway(agentGateway),
    memory: new MockMemoryGateway(),
    embedder: new MockEmbedderGateway(),
    device: new MockDeviceGateway(),
    permission: new MockPermissionGateway(),
    workState: new MockWorkStateGateway(),
    conversation: new MockConversationGateway(),
    ticket: new MockTicketGateway(systemGateway),
    window: new MockWindowGateway(),
    focusedProject: new MockFocusedProjectGateway(),
    uiPreferences: new MockUiPreferencesGateway(),
  };
}

// Re-export GatewayError so tests can reference the thrown shape.
export type { GatewayError };
