export type Message = {
  id: string;
  timestamp: number;
  role: 'user' | 'assistant' | 'tool';
  text?: string;
  model?: string;
  cost?: MessageCost;
  toolCallId?: string;
  toolName?: string;
  toolInput?: Record<string, unknown>;
  output?: string;
  isError?: boolean;
  /** The Task tool call this came from, when a subagent produced it. See `Parented` below. */
  parentToolUseId?: string;
};

export type MessageCost = {
  inputTokens: number;
  outputTokens: number;
  totalUSD: number;
};

export type SessionMeta = {
  id: string;
  title: string;
  model: string;
  cwd: string;
  createdAt: number;
  updatedAt: number;
  messageCount: number;
  cost: MessageCost;
  groupSlug?: string | null;
  context?: string;
  contextId?: string;
};

export type GroupMeta = {
  name: string;
  slug: string;
  description?: string;
  createdAt: number;
  updatedAt: number;
  sessionCount: number;
};

export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';

/**
 * An image pasted or attached in the composer, already split out of its data URL by the client.
 *
 * `/upload` also writes the file to disk, but the on-disk copy is for the attachment list and history —
 * what the model actually reads is these bytes, carried through to a native image content block. Naming
 * is camelCase on the wire and renamed to the Anthropic `media_type` at the last step.
 */
export type PromptImage = { mediaType: string; data: string };

export type ClientMessage =
  | {
      type: 'chat';
      prompt: string;
      displayText?: string;
      sessionId?: string;
      model?: string;
      cwd?: string;
      cwdRoot?: string;
      groupSlug?: string;
      attachmentIds?: string[];
      images?: PromptImage[];
      thinking?: ThinkingLevel;
      context?: string;
      contextId?: string;
      resumeSummary?: string;
    }
  | {
      type: 'resume';
      sessionId: string;
      cwd?: string;
      cwdRoot?: string;
    }
  | {
      type: 'stop';
    }
  | {
      // Tear down the whole session (kill any in-flight turn + drop the in-memory session), not just
      // the current turn. Frees the session so its transcript can be resumed elsewhere.
      type: 'disconnect';
    }
  | {
      // Sent on (re)connect when the client knows only Claude's transcript uuid — which, after a page
      // refresh, is the ONLY id it has: officer's `sessionId` lived in React state and died with the
      // page, while the uuid is in the URL. Officer reverse-maps it through the agent sidecar's
      // on-disk session map and re-binds this socket to the live session, so a turn that kept running
      // while the browser was away resumes delivering instead of stranding the user on a dead page.
      type: 'attach';
      claudeSessionId: string;
    }
  | {
      // Sent on (re)connect: re-bind this socket to the session and replay every durable event queued
      // since `cursor` (the last seq the client saw). Powers transparent reconnect without losing
      // background task notifications that landed while disconnected.
      type: 'resume-cursor';
      sessionId: string;
      cursor: number;
      // Echoed back from the `session:init` this client already received, so a session orphaned by an
      // officer restart can be re-adopted with the harness and working directory it actually had. The
      // client is the only party that still remembers them — officer's copy died with the process.
      model?: string;
      cwd?: string;
      /**
       * The client still shows a turn in flight. Officer cannot know this on its own after a restart, and
       * it is the one claim worth checking against the agent — see `endTurnIfAgentIsGone`.
       */
      generating?: boolean;
    };

// The `Task` tool's id, when this piece of output came from a subagent rather than the agent you are
// talking to. The harness stamps every nested message with `parent_tool_use_id`; nothing used to read it,
// so a subagent's prose and tool calls were spliced into the main transcript as if the main agent had
// produced them — and its deltas were appended to the main agent's text buffer, interleaving two voices
// inside one bubble. Attributing them lets the UI file them under the Task row that spawned them.
type Parented = { parentToolUseId?: string };

/** A background task with no terminal notification yet. Same fields as `task:started`, by construction. */
export type RunningTask = { taskId: string; description: string; taskType?: string };

export type ServerMessage =
  | {
      type: 'session:init';
      sessionId: string;
      model: string;
      cwd: string;
      context?: string;
      contextId?: string;
    }
  | {
      // Claude's transcript uuid, forwarded the moment the harness reports it (its `system.init`) rather
      // than at the end of the turn with `result`. The client writes it straight into the address bar, so
      // the chat is addressable — and therefore recoverable after a refresh — from the first second of the
      // first turn instead of only once the turn has finished. Live-only: anyone replaying the durable log
      // reached it by this id already.
      type: 'session:claude';
      claudeSessionId: string;
    }
  | ({
      type: 'assistant:text';
      text: string;
    } & Parented)
  | ({
      type: 'assistant:delta';
      text: string;
    } & Parented)
  | ({
      type: 'tool:start';
      toolCallId: string;
      toolName: string;
      toolInput: Record<string, unknown>;
    } & Parented)
  | ({
      type: 'tool:result';
      toolCallId: string;
      output: string;
      isError: boolean;
    } & Parented)
  | {
      type: 'result';
      sessionId: string;
      cost: MessageCost;
      // Claude's OWN session uuid — the name of the transcript file under ~/.claude/projects/<slug>/.
      // Distinct from `sessionId`, which is the key officer made up. Until this crossed the wire the
      // platform had no way to address a session it had started: the mapping was captured inside the
      // sidecar (claude-manager's setClaudeSession) and never left it. Absent if the harness didn't
      // report one.
      claudeSessionId?: string;
    }
  | {
      type: 'sync:messages';
      sessionId: string;
      messages: Message[];
      isGenerating: boolean;
      streamingText: string;
    }
  | {
      // Answer to a client `attach`: this socket is now bound to the live session. Deliberately carries
      // no messages. The client has just loaded the transcript over HTTP and the harness writes that file
      // as it goes, so the past is already on screen; what it cannot have is the part of the turn still
      // being written. Sending both records of the same messages is the one thing guaranteed to produce
      // duplicates — there is no shared id to reconcile them by — so attach hands over the *future* of
      // the turn plus the half-written paragraph, and nothing else.
      type: 'sync:live';
      sessionId: string;
      isGenerating: boolean;
      streamingText: string;
      /**
       * The session's newest durable cursor, so the client starts from the head rather than from zero.
       * Not an optimisation: this socket now holds officer's session key, so the *next* drop goes down
       * the `resume-cursor` path — and a cursor of 0 there would replay the entire session on top of the
       * transcript the client already loaded over HTTP, turning one reconnect into a duplicated
       * conversation.
       */
      cursor: number;
      /**
       * Background tasks that were started and have not reported a terminal status. The one exception to
       * "carries no messages", and for a concrete reason: a task row is officer's own invention — it is
       * synthesised from the harness's `system.task_started`, and nothing corresponding to it is ever
       * written to Claude's transcript. So unlike every other message, rebuilding the transcript cannot
       * produce it, and a refresh mid-task left the tray empty while the work was still running.
       * Finished tasks are deliberately excluded: replaying those would resurrect rows the user has
       * already seen resolve, and only the running ones are still telling you anything.
       */
      runningTasks: RunningTask[];
    }
  | {
      type: 'error';
      message: string;
      errorCode?: string;
    }
  | {
      type: 'stopped';
    }
  | {
      // The agent process went away mid-turn (almost always `pm2 restart officer-agent`). Its own type
      // rather than an `error` because it is recoverable and the recovery is one click: the conversation
      // is on disk and the next turn resumes it, so the UI offers to send the same prompt again.
      type: 'cut-off';
    }
  | {
      // Ack for a client 'disconnect': the session was torn down server-side.
      type: 'disconnected';
    }
  | { type: 'task:started'; taskId: string; description: string; taskType?: string }
  | { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string }
  // Context compaction, start and end. Worth its own pair of messages because compaction is the one thing
  // the harness does that produces no output at all while it runs — and it can run for minutes. Without
  // these the stream simply stops, which is indistinguishable from a hung turn, a dead socket or a
  // crashed agent; the honest response to that is to restart the server, which is what actually happened.
  | { type: 'compact:start'; trigger: 'manual' | 'auto' }
  | { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number };

// The turn-output subset of ServerMessage — everything the agent sidecar produces on its own. The
// remaining members (session:init, sync:messages, disconnected, connection-level errors) are officer's:
// they describe the browser's connection, not the turn.
//
// The sidecar builds these, commits them to chat_session_events, and hands officer a finished message
// plus its cursor id; officer relays it verbatim. `prevSeq` is the writer's continuity claim — the
// cursor of the previous durable message in the same session — which lets a reconnecting client tell a
// contiguous replay from one with a hole in it. Absent when the writer cannot vouch for it.
export type TurnMessageType =
  | 'session:claude'
  | 'assistant:delta'
  | 'assistant:text'
  | 'tool:start'
  | 'tool:result'
  | 'result'
  | 'error'
  | 'stopped'
  | 'task:started'
  | 'task:notification'
  | 'compact:start'
  | 'compact:done';

export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };

export type ChatEvent =
  | { type: 'session'; claudeSessionId: string }
  | ({ type: 'text'; text: string } & Parented)
  | ({ type: 'delta'; text: string } & Parented)
  | ({
      type: 'tool:start';
      toolCallId: string;
      toolName: string;
      toolInput: Record<string, unknown>;
    } & Parented)
  | ({
      type: 'tool:result';
      toolCallId: string;
      output: string;
      isError: boolean;
    } & Parented)
  | {
      type: 'result';
      cost: MessageCost;
      claudeSessionId?: string;
    }
  | { type: 'error'; message: string }
  | { type: 'stopped' }
  // Background-task lifecycle (run_in_background / Monitor), delivered in-stream by the persistent
  // session — including AFTER the turn's `result`, which is the whole point of the persistent worker.
  | { type: 'task:started'; taskId: string; description: string; taskType?: string }
  | { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string }
  // Compaction, start and end. The start half has no message in the harness's output stream at all — it
  // comes from the `PreCompact` hook, which is why it is a sidecar concern and not the parser's.
  | { type: 'compact:start'; trigger: 'manual' | 'auto' }
  | { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number };

export type UserSession = {
  sessionId: string;
  email: string;
  userId?: number;
  cwd: string;
  model: string;
  piProcess: any | null;
  /**
   * Every socket watching this conversation, not the most recent one. Two panes in one window, or a
   * laptop and an iPad on the same chat, are both ordinary now that a tab holds several panes — and a
   * single `ws` field meant the newest attach silently stole the turn from everyone else, while any one
   * of them closing set it to null and killed delivery for the rest.
   */
  sockets: Set<any>;
  lastActivity: number;
  idleTimer: Timer | null;
  streamBuffer: string;
  isGenerating: boolean;
  systemContextSent: boolean;
  messages: Message[];
  meta: SessionMeta;
  _sidecarUnsub?: () => void;
  _claudeKill?: () => void;
};

export type ModelInfo = {
  id: string;
  name: string;
  provider: string;
  contextWindow: number;
  maxTokens: number;
  reasoning?: boolean;
  images?: boolean;
};

// ── Pi-native JSONL types ──────────────────────────────────────────────

export type JnlSessionHeader = {
  type: 'session';
  version: 3;
  id: string;
  timestamp: string;
  cwd: string;
};

export type JnlEntryBase = {
  type: string;
  id: string;
  parentId?: string;
  timestamp: string;
};

export type JnlTextContent = {
  type: 'text';
  text: string;
};

export type JnlToolCall = {
  type: 'tool_use';
  id: string;
  name: string;
  input: Record<string, unknown>;
};

export type JnlUserMessage = JnlEntryBase & {
  type: 'message';
  message: {
    role: 'user';
    content: string | Array<JnlTextContent>;
  };
};

export type JnlAssistantMessage = JnlEntryBase & {
  type: 'message';
  message: {
    role: 'assistant';
    content: Array<JnlTextContent | JnlToolCall>;
  };
};

export type JnlToolResultMessage = JnlEntryBase & {
  type: 'message';
  message: {
    role: 'toolResult';
    toolCallId: string;
    toolName: string;
    content: Array<JnlTextContent>;
    isError?: boolean;
  };
};

export type JnlMessageEntry = JnlUserMessage | JnlAssistantMessage | JnlToolResultMessage;

export type JnlSessionInfoEntry = JnlEntryBase & {
  type: 'session_info';
  name: string;
  officer?: {
    cost: MessageCost;
    model: string;
    groupSlug?: string | null;
    messageCount: number;
    createdAt: number;
    updatedAt: number;
    context?: string;
    contextId?: string;
  };
};

export type JnlEntry = JnlMessageEntry | JnlSessionInfoEntry;

// ── Session Index ──────────────────────────────────────────────────────

export type SessionIndexEntry = {
  file: string;
  title: string;
  model: string;
  cwd: string;
  createdAt: number;
  updatedAt: number;
  messageCount: number;
  cost: MessageCost;
  groupSlug?: string | null;
  context?: string;
  contextId?: string;
};

export type SessionIndex = Record<string, SessionIndexEntry>;
