// =============================================================================
// session_api.ts — Shared API client for the OpenCode Server at localhost:4096
//
// Part of: .opencode/skills/auto-agents-system/scripts/
//
// Importable module only — not a standalone CLI.
//
// Imported by:
//   session_list.ts, session_start.ts, session_find_by_tag.ts,
//   session_find_by_prefix.ts, session_messages.ts, session_stop.ts,
//   session_delete.ts, session_health_data.ts
//
// Provides typed wrappers around every localhost:4096 endpoint used by the
// async-agent-util operations, plus shared formatting and CLI-parsing helpers.
//
// Zero external npm dependencies — uses only Node.js built-ins and the global
// `fetch` API (stable since Node.js 21, available as unflagged since 18).
// =============================================================================

// ─────────────────────────────────────────────────────────────────────────────
// CONSTANTS
// ─────────────────────────────────────────────────────────────────────────────

export const DEFAULT_SERVER = 'http://localhost:4096';

/** Delay schedule for HTTP retries: 2 s, 5 s, 10 s */
const RETRY_DELAYS_MS = [2_000, 5_000, 10_000];

// ─────────────────────────────────────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────────────────────────────────────

/** A session object as returned by GET /session or POST /session */
export interface Session {
  id: string;
  title: string;
  time: { created: number; updated: number };
  directory: string;
  version: string;
}

/** The status of a single session */
export interface SessionStatus {
  type: 'busy' | 'idle';
}

/** Map of session_id → SessionStatus, as returned by GET /session/status */
export type AllStatuses = Record<string, SessionStatus>;

/** Session enriched with its current status and last-activity timestamp */
export interface SessionWithStatus {
  id: string;
  title: string;
  tag: string | null;          // extracted from "[TAG] display-name", or null
  status: 'busy' | 'idle';
  last_active: number;         // epoch ms — mirrors session.time.updated
}

/** Metadata block attached to each message */
export interface MessageInfo {
  id: string;
  role: 'user' | 'assistant';
  agent?: string;
  time: { created: number };
  modelID?: string;
}

// ── Raw API message part types ────────────────────────────────────────────────

/** Visible text output from the model */
export interface TextPart {
  type: 'text';
  text: string;
  time?: { start: number; end: number };
  id?: string;
  sessionID?: string;
  messageID?: string;
}

/**
 * Internal chain-of-thought / extended thinking.
 * Present when the model runs with reasoning enabled.
 * The `metadata.anthropic.signature` field is an opaque Anthropic verification
 * token — preserved verbatim in formatted output.
 */
export interface ReasoningPart {
  type: 'reasoning';
  text: string;
  time?: { start: number; end: number };
  metadata?: { anthropic?: { signature?: string } };
  id?: string;
  sessionID?: string;
  messageID?: string;
}

/** State of a completed, running, or errored tool invocation */
export interface ToolPartState {
  status: 'completed' | 'error' | 'running' | string;
  input?: Record<string, unknown>;
  output?: string;
  title?: string;
  time?: { start: number; end: number };
  error?: unknown;
  metadata?: Record<string, unknown>;
}

/**
 * A tool call with its result.
 * The API combines both the call and the result in one part (no split).
 */
export interface ToolPart {
  type: 'tool';
  tool: string;
  callID: string;
  state: ToolPartState;
  metadata?: Record<string, unknown>;
  id?: string;
  sessionID?: string;
  messageID?: string;
}

/**
 * File-change snapshot emitted alongside Edit/Write tool calls.
 * Records which files were modified and the snapshot hash at that point.
 */
export interface PatchPart {
  type: 'patch';
  hash: string;      // snapshot / git hash at the point of the change
  files: string[];   // absolute paths of all modified files
  id?: string;
  sessionID?: string;
  messageID?: string;
}

/** Marks the start of one LLM inference step (one generation round-trip) */
export interface StepStartPart {
  type: 'step-start';
  snapshot?: string;
  id?: string;
  sessionID?: string;
  messageID?: string;
}

/**
 * Marks the end of an inference step.
 * Carries per-step token counts and cost — useful for spotting context bloat
 * or max-token truncation (reason === "max-tokens").
 */
export interface StepFinishPart {
  type: 'step-finish';
  reason?: string;
  snapshot?: string;
  tokens?: {
    total?: number;
    input?: number;
    output?: number;
    reasoning?: number;
    cache?: { write?: number; read?: number };
  };
  cost?: number;
  id?: string;
  sessionID?: string;
  messageID?: string;
}

/** Union of all known raw API part types */
export type MessagePart =
  | TextPart
  | ReasoningPart
  | ToolPart
  | PatchPart
  | StepStartPart
  | StepFinishPart
  | { type: string; [key: string]: unknown };   // forward-compat catch-all

/** Full message as returned by GET /session/{id}/message */
export interface Message {
  info: MessageInfo;
  parts: MessagePart[];
}

// ── Formatted output types ────────────────────────────────────────────────────
//
// These represent the structured, human- and LLM-readable form of a message.
// Every known part type is preserved; nothing is dropped or truncated.
// The ordering of parts within a message is maintained (step_start → reasoning
// → text → tool → patch → step_finish).

/** text part in formatted output */
export interface FmtText {
  type: 'text';
  content: string;
}

/**
 * reasoning part in formatted output.
 * Full text, no truncation.  Clearly separate from text parts so callers
 * can distinguish the model's internal thinking from its visible output.
 */
export interface FmtReasoning {
  type: 'reasoning';
  content: string;
}

/** tool call + result in formatted output */
export interface FmtTool {
  type: 'tool';
  tool: string;
  call_id: string;
  status: string;
  has_error: boolean;
  input: Record<string, unknown> | undefined;  // full input object, not serialised
  output: string;
  error?: unknown;
}

/** patch / file-change snapshot in formatted output */
export interface FmtPatch {
  type: 'patch';
  hash: string;
  files: string[];
}

/** step boundary (start) in formatted output */
export interface FmtStepStart {
  type: 'step_start';  // underscore for JSON key convention
  snapshot: string | undefined;
}

/** step boundary (finish) in formatted output */
export interface FmtStepFinish {
  type: 'step_finish';
  reason: string | undefined;
  snapshot: string | undefined;
  tokens: StepFinishPart['tokens'];
  cost: number | undefined;
}

/** Union of all formatted part types */
export type FmtPart =
  | FmtText
  | FmtReasoning
  | FmtTool
  | FmtPatch
  | FmtStepStart
  | FmtStepFinish;

/**
 * A message formatted for display and LLM consumption.
 * All part types are preserved in their original order.
 */
export interface FormattedMessage {
  role: string;
  agent: string | undefined;
  time: number;       // epoch ms
  parts: FmtPart[];   // ordered: step_start → reasoning → text → tool → patch → step_finish
}

/**
 * Compact summary of a single tool invocation.
 * Useful when you want a condensed view of tool calls without the full
 * input object or complete output string.  Produced by `toToolCallSummary()`.
 */
export interface ToolCallSummary {
  tool: string;
  call_id: string;
  status: string;
  has_error: boolean;
  input_preview: string;   // JSON-serialised input, truncated to inputMaxChars
  output_preview: string;  // output string, truncated to outputMaxChars
}

/**
 * Options that control which parts are included and how content is rendered.
 *
 * All flags default to `true` (include everything) and all size limits default
 * to `undefined` (no truncation).  Pass explicit values only when you want to
 * restrict or condense the output — for example, a quick text-scan that skips
 * step boundaries, or a health-data payload that caps reasoning length.
 */
export interface FormatOptions {
  /** Include `reasoning` parts. Default: true */
  includeReasoning?: boolean;
  /** Include `tool` parts. Default: true */
  includeTools?: boolean;
  /** Include `patch` parts. Default: true */
  includePatches?: boolean;
  /** Include `step_start` and `step_finish` boundary parts. Default: true */
  includeStepBoundaries?: boolean;
  /**
   * Truncate each reasoning block to this many characters.
   * A truncation notice is appended when content is cut.
   * Default: undefined (no truncation — full content is always returned).
   */
  reasoningMaxChars?: number;
  /**
   * Truncate tool `output` strings to this many characters.
   * A truncation notice is appended when content is cut.
   * Default: undefined (no truncation — full output is always returned).
   */
  toolOutputMaxChars?: number;
  /**
   * Truncate the JSON-serialised tool `input` preview to this many characters.
   * Only relevant when using `toToolCallSummary()` — the `FmtTool.input` field
   * in the parts array is always the full object regardless of this setting.
   * Default: undefined (no truncation).
   */
  toolInputMaxChars?: number;
}

/** One entry in the payload returned by session_health_data.ts */
export interface HealthDataEntry {
  id: string;
  title: string;
  tag: string | null;
  status: 'busy' | 'idle';
  last_active: number;
  recent_messages: FormattedMessage[];
}

/** Result of a single stop/delete action on one session */
export interface SessionActionResult {
  session_id: string;
  title: string;
  action: 'stopped' | 'deleted' | 'stop_and_deleted' | 'already_deleted' | 'skipped';
  success: boolean;
  message: string;
}

// ─────────────────────────────────────────────────────────────────────────────
// UTILITY HELPERS
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Extract the bracketed tag from a session title.
 * "[AUTO-IMP-001] implementor-worker" → "AUTO-IMP-001"
 * "untitled"                          → null
 */
export function extractTag(title: string): string | null {
  const m = title.match(/^\[([^\]]+)\]/);
  return m ? m[1] : null;
}

/** Sleep for a given number of milliseconds */
export function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// ─────────────────────────────────────────────────────────────────────────────
// HTTP CLIENT — single request with automatic retry on transient failures
// ─────────────────────────────────────────────────────────────────────────────

async function apiRequest<T>(
  method: string,
  url: string,
  body?: unknown,
): Promise<T> {
  let lastErr: Error | null = null;

  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
    if (attempt > 0) {
      const delayMs = RETRY_DELAYS_MS[attempt - 1];
      process.stderr.write(`Retry ${attempt}/${RETRY_DELAYS_MS.length} in ${delayMs / 1000}s…\n`);
      await sleep(delayMs);
    }
    try {
      const opts: RequestInit = {
        method,
        headers: { 'Content-Type': 'application/json' },
        signal: AbortSignal.timeout(30_000),
      };
      if (body !== undefined) opts.body = JSON.stringify(body);

      const res = await fetch(url, opts);

      // 204 No Content — success with no body (e.g. prompt_async)
      if (res.status === 204) return {} as T;

      if (!res.ok) {
        const text = await res.text().catch(() => '');
        throw new Error(`HTTP ${res.status}: ${text || '(empty body)'} — ${url}`);
      }
      return res.json() as Promise<T>;
    } catch (err) {
      lastErr = err instanceof Error ? err : new Error(String(err));
      process.stderr.write(`Request error (attempt ${attempt + 1}): ${lastErr.message}\n`);
    }
  }
  throw lastErr ?? new Error('Request failed after all retries');
}

// ─────────────────────────────────────────────────────────────────────────────
// PUBLIC API FUNCTIONS
// ─────────────────────────────────────────────────────────────────────────────

/** GET /session — list all sessions */
export async function listSessions(server: string): Promise<Session[]> {
  return apiRequest<Session[]>('GET', `${server}/session`);
}

/** POST /session — create a new session with the given title */
export async function createSession(server: string, title: string): Promise<Session> {
  return apiRequest<Session>('POST', `${server}/session`, { title });
}

/** GET /session/status — get all session statuses in one call */
export async function getAllStatuses(server: string): Promise<AllStatuses> {
  return apiRequest<AllStatuses>('GET', `${server}/session/status`);
}

/** GET /session/{id} — get a single session by ID (throws on 404) */
export async function getSession(server: string, sessionId: string): Promise<Session> {
  return apiRequest<Session>('GET', `${server}/session/${encodeURIComponent(sessionId)}`);
}

/**
 * POST /session/{id}/prompt_async — fire-and-forget agent launch.
 * Returns immediately after the server accepts the request (HTTP 204).
 */
export async function promptAsync(
  server: string,
  sessionId: string,
  agentName: string,
  promptText: string,
): Promise<void> {
  await apiRequest<unknown>(
    'POST',
    `${server}/session/${encodeURIComponent(sessionId)}/prompt_async`,
    { agent: agentName, parts: [{ type: 'text', text: promptText }] },
  );
}

/**
 * GET /session/{id}/message — retrieve messages from a session.
 *
 * Ordering: the API returns the `limit` most-recent messages, sorted
 * oldest-first within that window.  With no limit, all messages are returned.
 *
 * `offset` skips that many messages from the newest end before applying
 * `limit`.  Both `offset` and `page` use the same skip semantics.
 */
export async function getMessages(
  server: string,
  sessionId: string,
  limit?: number,
  offset?: number,
): Promise<Message[]> {
  const params: Record<string, string> = {};
  if (limit  !== undefined) params.limit  = String(limit);
  if (offset !== undefined) params.offset = String(offset);
  const qs = Object.keys(params).length > 0
    ? `?${new URLSearchParams(params)}`
    : '';
  return apiRequest<Message[]>(
    'GET',
    `${server}/session/${encodeURIComponent(sessionId)}/message${qs}`,
  );
}

/**
 * DELETE /session/{id} — terminate and remove a session.
 * Returns true with HTTP 200 on success.
 */
export async function deleteSession(server: string, sessionId: string): Promise<unknown> {
  return apiRequest<unknown>('DELETE', `${server}/session/${encodeURIComponent(sessionId)}`);
}

// ─────────────────────────────────────────────────────────────────────────────
// FORMATTING HELPERS
// ─────────────────────────────────────────────────────────────────────────────

/** Append a truncation notice to a string that was cut short */
function truncate(s: string, max: number): string {
  if (s.length <= max) return s;
  return s.slice(0, max) + ` … [${s.length - max} chars omitted]`;
}

/**
 * Convert a single raw API part into its formatted equivalent, applying any
 * filtering or truncation specified by `opts`.
 *
 * Returns `null` when the part type is excluded by opts, or when the type is
 * unknown (forward-compat: new part types are silently dropped).
 */
function formatPart(raw: MessagePart, opts: {
  includeReasoning: boolean;
  includeTools: boolean;
  includePatches: boolean;
  includeStepBoundaries: boolean;
  reasoningMaxChars: number | undefined;
  toolOutputMaxChars: number | undefined;
  toolInputMaxChars: number | undefined;
}): FmtPart | null {
  switch (raw.type) {
    case 'text': {
      // text is always included; there is no opt-out for the core visible output
      const p = raw as TextPart;
      return { type: 'text', content: p.text };
    }

    case 'reasoning': {
      if (!opts.includeReasoning) return null;
      const p = raw as ReasoningPart;
      const content = opts.reasoningMaxChars !== undefined
        ? truncate(p.text, opts.reasoningMaxChars)
        : p.text;
      return { type: 'reasoning', content };
    }

    case 'tool': {
      if (!opts.includeTools) return null;
      const p = raw as ToolPart;
      const s = p.state ?? {};
      const output = opts.toolOutputMaxChars !== undefined
        ? truncate(s.output ?? '', opts.toolOutputMaxChars)
        : (s.output ?? '');
      return {
        type:      'tool',
        tool:      p.tool ?? '(unknown)',
        call_id:   p.callID ?? '',
        status:    s.status ?? 'unknown',
        has_error: s.status === 'error' || s.error != null,
        input:     s.input,
        output,
        ...(s.error != null ? { error: s.error } : {}),
      };
    }

    case 'patch': {
      if (!opts.includePatches) return null;
      const p = raw as PatchPart;
      return {
        type:  'patch',
        hash:  p.hash  ?? '',
        files: p.files ?? [],
      };
    }

    case 'step-start': {
      if (!opts.includeStepBoundaries) return null;
      const p = raw as StepStartPart;
      return { type: 'step_start', snapshot: p.snapshot };
    }

    case 'step-finish': {
      if (!opts.includeStepBoundaries) return null;
      const p = raw as StepFinishPart;
      return {
        type:     'step_finish',
        reason:   p.reason,
        snapshot: p.snapshot,
        tokens:   p.tokens,
        cost:     p.cost,
      };
    }

    default:
      return null;  // unknown future part type — drop safely
  }
}

/**
 * Convert raw API messages into structured FormattedMessage objects.
 *
 * By default every known part type is included in full, with no truncation:
 *   step_start → reasoning → text → tool → patch → step_finish
 *
 * Pass `options` to restrict the output — for example to exclude step
 * boundaries for a compact view, or cap reasoning length for a large batch.
 * All FormatOptions fields default to "include everything / no truncation"
 * when not specified.
 */
export function formatMessages(
  messages: Message[],
  options: FormatOptions = {},
): FormattedMessage[] {
  // Resolved options — boolean flags have definite defaults; max-char limits
  // remain undefined when not specified (undefined = no truncation).
  type ResolvedOpts = {
    includeReasoning: boolean;
    includeTools: boolean;
    includePatches: boolean;
    includeStepBoundaries: boolean;
    reasoningMaxChars: number | undefined;
    toolOutputMaxChars: number | undefined;
    toolInputMaxChars: number | undefined;
  };
  const opts: ResolvedOpts = {
    includeReasoning:      options.includeReasoning      ?? true,
    includeTools:          options.includeTools          ?? true,
    includePatches:        options.includePatches         ?? true,
    includeStepBoundaries: options.includeStepBoundaries ?? true,
    reasoningMaxChars:     options.reasoningMaxChars,     // undefined = no truncation
    toolOutputMaxChars:    options.toolOutputMaxChars,    // undefined = no truncation
    toolInputMaxChars:     options.toolInputMaxChars,     // undefined = no truncation
  };

  return messages.map(msg => ({
    role:  msg.info.role,
    agent: msg.info.agent,
    time:  msg.info.time.created,
    parts: msg.parts
      .map(raw => formatPart(raw, opts))
      .filter((p): p is FmtPart => p !== null),
  }));
}

/**
 * Produce a compact ToolCallSummary from an FmtTool part.
 * Useful when you want a condensed, string-only view of tool calls —
 * e.g. for logging or diff-friendly output — without the full input object.
 *
 * `inputMaxChars` and `outputMaxChars` default to no truncation when omitted.
 */
export function toToolCallSummary(
  tool: FmtTool,
  inputMaxChars?: number,
  outputMaxChars?: number,
): ToolCallSummary {
  const inputJson = tool.input ? JSON.stringify(tool.input) : '';
  const inputPreview  = inputMaxChars  !== undefined ? truncate(inputJson,  inputMaxChars)  : inputJson;
  const outputPreview = outputMaxChars !== undefined ? truncate(tool.output, outputMaxChars) : tool.output;
  return {
    tool:           tool.tool,
    call_id:        tool.call_id,
    status:         tool.status,
    has_error:      tool.has_error,
    input_preview:  inputPreview,
    output_preview: outputPreview,
  };
}

/**
 * Combine a session list with the status map into SessionWithStatus objects.
 * Sessions absent from the status map are treated as idle.
 */
export function withStatuses(
  sessions: Session[],
  statuses: AllStatuses,
): SessionWithStatus[] {
  return sessions.map(s => ({
    id: s.id,
    title: s.title,
    tag: extractTag(s.title),
    status: statuses[s.id]?.type ?? 'idle',
    last_active: s.time.updated,
  }));
}

// ─────────────────────────────────────────────────────────────────────────────
// CLI HELPERS — shared argument parsing used by the session_* scripts
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Strip the optional --server URL flag from an argv array.
 * Returns { server, rest } where `rest` contains the remaining args.
 */
export function parseServerFlag(argv: string[]): { server: string; rest: string[] } {
  const args = argv.slice(2);
  let server = DEFAULT_SERVER;
  const rest: string[] = [];

  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--server' && args[i + 1]) {
      server = args[i + 1];
      i++;
    } else {
      rest.push(args[i]);
    }
  }

  return { server, rest };
}

/** Read a string flag value from a flat args array, advancing past the value */
export function getFlag(args: string[], flag: string): string | undefined {
  const idx = args.indexOf(flag);
  return idx !== -1 ? args[idx + 1] : undefined;
}

/** Return true if a boolean flag (no value) is present in args */
export function hasFlag(args: string[], flag: string): boolean {
  return args.includes(flag);
}
