import { realpathSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import {
  appendTextAs,
  listTranscriptsAs,
  readHeadAs,
  readTailAs,
  readTextAs,
  removeAs,
  type AsUser,
} from '../../read-as-user';

// ── Claude session store (source of truth) ──
// The `claude` CLI persists every session as a JSONL transcript at
//   $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
// where <slug> is the working directory with every non-alphanumeric char replaced by '-'.
// Claude runs with no isolation for the OWNER — HOME is their real home — so its transcripts are the same
// store their terminal `claude` uses. We never keep our own copy; Claude's files are authoritative.
//
// ── Why this takes a home instead of an email ──
//
// It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discarded its argument
// whenever HOME_DIR was set — which was always, on a real install. Every read therefore resolved to the OWNER'S
// transcripts regardless of who was asking, and the comment above it said "single-user platform" as though
// that were a property rather than an assumption. A member reaching these functions would have been handed the
// owner's conversation list.
//
// So the home arrives resolved, from `resolveHomeDir(userId)`, and this file has no way to invent one.
//
// The email travels alongside it rather than being derived from it, because the two answer different
// questions: the email says WHO, the home says WHERE. They were briefly conflated in the other direction —
// `general_chat_sessions` was an email-derived path under DATA_PATH used as a chat's working directory, and
// because `confineUserTree` makes every sibling of a home the platform's at 0700, a member's turn started in
// a directory it could not enter. That default is now the caller's own home; the pairing survives because the
// distinction it encodes is real.
export type ChatIdentity = {
  email: string;
  /** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */
  home: string;
  /**
   * Whose identity to read a member's transcripts as — `null` for the owner. From `resolveHomeDir`.
   *
   * This said locating them "never needed this: their directories are 775". There are no 775 directories on
   * this path — `claude` creates `projects/` and every group at 700, which clamps the platform's ACL entry
   * to nothing exactly as mode 600 does for the files. So `readdirSync`, `statSync` and unlink all fail too,
   * and `existsSync` answers **false** rather than throwing, which is why it read as "no such session"
   * everywhere instead of as an error. See `read-as-user.ts`; the listing goes through `listTranscriptsAs`.
   */
  osUser: string | null;
  /**
   * From `resolveHomeDir`. Carried as its own fact rather than inferred from `osUser === null` — that
   * equivalence holds today only because `resolveHomeDir` refuses a member without one, so reading it as
   * "is the owner" would silently become wrong the moment that refusal is relaxed.
   *
   * Used to gate the OpenCode harness, which is owner-only until it carries an identity. See `chat.ts`.
   */
  isOwner: boolean;
};

const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects');

/**
 * Where a session's transcript actually is, or null.
 *
 * One listing as the transcripts' owner, then a lookup — never `existsSync` on a candidate path. For a
 * member `existsSync` answers **false** on a file that is plainly there, because the group directory is
 * mode 700 and the service user cannot traverse it, and every caller read that false as "no such session".
 *
 * `cwd` names the group to prefer, not the group to trust: a session's group and the caller's current one
 * disagree routinely (a deep link has not resolved its group yet, or the list is showing another). Reads
 * have always fallen back like this; writes did not, so delete and rename returned "not found" for a
 * session that was on screen.
 */
function locateTranscript(who: ChatIdentity, sessionId: string, cwd?: string): string | null {
  const projectsDir = claudeProjectsDir(who.home);
  const files = listTranscriptsAs(who.osUser, projectsDir);
  const preferred = cwd ? projectSlug(cwd) : null;
  const hit =
    (preferred && files.find((f) => f.id === sessionId && f.slug === preferred)) ||
    files.find((f) => f.id === sessionId);
  return hit ? join(projectsDir, hit.slug, `${sessionId}.jsonl`) : null;
}

/** Claude's folder name for a working directory. */
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');

/** What a session's continuation looks like to the app. See `applyLineage` for how it is derived. */
export type ClaudeSessionOrigin = { id: string; title: string };

export type ClaudeSessionSummary = {
  id: string; // session uuid (= transcript filename)
  title: string; // explicit title, else first human message, else a number in a /clear chain
  cwd: string;
  createdAt: string; // ISO, first entry
  updatedAt: string; // ISO, last entry
  messageCount: number; // human + assistant turns
  harness?: 'claude' | 'opencode'; // which harness produced it (defaults to claude when absent)
  /** This transcript opens with `/clear`, so it is the continuation of an earlier conversation. */
  bornFromClear?: boolean;
  /** The conversation this one continues, when it could be identified. Inferred — see `applyLineage`. */
  continuedFrom?: ClaudeSessionOrigin | null;
  /**
   * How many transcripts this row stands for. Present only on a `/clear` chain, which the list shows as
   * one conversation — see `mergeChains`. Absent means the ordinary case: one row, one transcript.
   */
  partCount?: number;
};

/** `ClaudeSessionSummary` plus the bits only lineage resolution needs. Never leaves this module. */
type TranscriptSummary = ClaudeSessionSummary & {
  /** The title came from a `summary` record — Claude's own, or an Officer rename. Never overwrite it. */
  hasExplicitTitle: boolean;
  /**
   * Every minute in which this transcript recorded anything (epoch minutes).
   *
   * Lineage needs "was this conversation alive at the instant that one started", and `updatedAt` cannot
   * answer it: a session that is cleared and then **resumed later** has an `updatedAt` long after its
   * child was born. Two of the three cleared sessions on this machine were in exactly that state, so
   * matching on `updatedAt` found no parent for either. Minute buckets survive resumption and cost one
   * integer per minute of activity rather than one per entry.
   */
  activeMinutes: Set<number>;
};

/** Pull the plain text out of a transcript entry's `message.content` (string or content-block array). */
function entryText(message: unknown): string {
  if (!message || typeof message !== 'object') return '';
  const content = (message as { content?: unknown }).content;
  if (typeof content === 'string') return content;
  if (Array.isArray(content)) {
    return content
      .map((block) =>
        block && typeof block === 'object' && (block as { type?: string }).type === 'text'
          ? ((block as { text?: string }).text ?? '')
          : '',
      )
      .join('')
      .trim();
  }
  return '';
}

type Entry = {
  type?: string;
  timestamp?: string;
  sessionId?: string;
  cwd?: string;
  uuid?: string;
  summary?: string;
  message?: { role?: string; content?: unknown };
  isMeta?: boolean;
  /** `system` entries carry their kind here — `compact_boundary` is the one this reader cares about. */
  subtype?: string;
  /** Present on a `compact_boundary`. camelCase on disk; the live SDK stream uses snake_case. */
  compactMetadata?: { trigger?: string; preTokens?: number; durationMs?: number };
};

/**
 * Summaries keyed by path, invalidated on mtime. A group's transcripts are re-read on every list AND
 * on every session open, and they run to tens of megabytes here — the same file was being parsed
 * several times a second. An mtime key is exact for this store: Claude only ever appends.
 */
const summaryCache = new Map<string, { mtimeMs: number; summary: TranscriptSummary }>();

// `mtimeMs` is passed in rather than stat'ed here: `statSync` is EACCES on a member's transcript, and the
// listing that found the file already carries its mtime. Stat'ing again would be a second fork per file AND
// would fail for exactly the accounts this exists to serve.
function summarizeTranscript(osUser: AsUser, filePath: string, id: string, mtimeMs: number): TranscriptSummary | null {
  const mtime = new Date(mtimeMs).toISOString();
  const cached = summaryCache.get(filePath);
  if (cached && cached.mtimeMs === mtimeMs) return cached.summary;

  let raw: string;
  try {
    raw = readTextAs(osUser, filePath);
  } catch {
    return null;
  }

  let firstUserText = '';
  let summaryTitle = ''; // a `summary` entry (our rename, appended to the transcript) wins over the first message
  let cwd = '';
  let firstTs = '';
  let lastTs = '';
  let messageCount = 0;
  let sawFirstUserEntry = false;
  let bornFromClear = false;
  const activeMinutes = new Set<number>();

  for (const line of raw.split('\n')) {
    if (!line.trim()) continue;
    let entry: Entry;
    try {
      entry = JSON.parse(line) as Entry;
    } catch {
      continue;
    }
    if (entry.cwd && !cwd) cwd = entry.cwd;
    if (entry.timestamp) {
      if (!firstTs) firstTs = entry.timestamp;
      lastTs = entry.timestamp;
      const at = Date.parse(entry.timestamp);
      if (Number.isFinite(at)) activeMinutes.add(Math.floor(at / 60_000));
    }
    if (entry.type === 'summary' && typeof entry.summary === 'string') {
      summaryTitle = entry.summary; // last one wins
    } else if (entry.type === 'user' || entry.type === 'assistant') {
      messageCount += 1;
      if (entry.type === 'user' && !entry.isMeta) {
        const text = entryText(entry.message).split('\n')[0]!.trim();
        // Claude records `/clear`, `/compact` and friends as ordinary user entries wrapping
        // `<command-name>…`, and does NOT set `isMeta` on them. Two consequences:
        //  - a slash command is not a title, so skip past them for `firstUserText` (every cleared
        //    session used to be listed as "<command-name>/clear</command-name>");
        //  - a transcript whose very first user entry is `/clear` was opened by clearing another
        //    conversation, which is the only trace of that handoff Claude leaves anywhere.
        if (text && !sawFirstUserEntry) {
          sawFirstUserEntry = true;
          bornFromClear = text.startsWith('<command-name>/clear</command-name>');
        }
        // A resumed session can open with a background task reporting in, which is no more a title than a
        // slash command is — see `isTaskNotification`.
        if (text && !firstUserText && !text.startsWith('<command-name>') && !isTaskNotification(text)) {
          firstUserText = text;
        }
      }
    }
  }

  const summary: TranscriptSummary = {
    id,
    title: summaryTitle || firstUserText || '(untitled)',
    hasExplicitTitle: Boolean(summaryTitle),
    bornFromClear,
    cwd,
    createdAt: firstTs || mtime,
    updatedAt: lastTs || mtime,
    messageCount,
    activeMinutes,
  };
  summaryCache.set(filePath, { mtimeMs, summary });
  return summary;
}

/**
 * Was this conversation writing to disk around the given instant?
 *
 * `/clear` happens inside one process, so the old transcript's last write and the new transcript's
 * first are the same moment — 4ms apart in the case measured here (…28.525Z → …28.529Z).
 *
 * The window is **symmetric**, which is not obvious and was found the hard way. Clearing makes Claude
 * summarise the conversation it is ending, and that costs a model call: the parent's final `summary`
 * record can therefore land *after* the child's first entry. One session on this machine sits exactly
 * there — its parent's nearest write is a minute the wrong side of the birth — and a
 * before-only window silently lost it.
 *
 * It asks about *activity*, never about `updatedAt`. A conversation that is cleared and then **resumed
 * later** has an `updatedAt` days after its child was born, so an end-time test discards that parent
 * outright.
 */
const wasActiveAt = (session: TranscriptSummary, at: number): boolean => {
  const minute = Math.floor(at / 60_000);
  return (
    session.activeMinutes.has(minute - 1) || session.activeMinutes.has(minute) || session.activeMinutes.has(minute + 1)
  );
};

/** "Platform Arch" → "Platform Arch 2" → "Platform Arch 3". */
function nextInChain(parentTitle: string): string {
  // A title already ending in a number continues its count. This does mean a conversation genuinely
  // called "Sidecar stage 3" would be continued as "Sidecar stage 4" — accepted, because it is a
  // display-only guess and renaming overrides it.
  const numbered = parentTitle.match(/^(.*\S)\s+(\d+)$/);
  if (numbered) return `${numbered[1]} ${Number(numbered[2]) + 1}`;
  return `${parentTitle} 2`;
}

/**
 * Link each `/clear`-born session to the conversation it continues, and give it a numbered title.
 *
 * **This is an inference, and the only one in this file.** Claude records no parent: not in
 * `compactMetadata`/`logicalParentUuid` (those are in-file compaction), not in `summary.leafUuid`
 * (every one resolves inside its own transcript), not in `slug` (random per session), not in
 * `~/.claude/sessions/*.json` (a live process registry). What it does record is that the new transcript
 * opens with `/clear` and that the old one was still writing that same second — so the parent is the
 * conversation in the same group that was alive at the instant this one began.
 *
 * **Ambiguity fails closed.** If two transcripts in the group were active in that minute — two claude
 * processes in one directory, which does happen here — neither is named. No parent is a much smaller
 * mistake than the wrong parent, because the wrong parent also renames the conversation.
 *
 * Read-only: nothing is written back to Claude's store, so a wrong guess is cosmetic and disappears on
 * the next read. An explicit title always wins — this never overwrites a rename.
 *
 * (`/compact` needs none of this. It appends to the same transcript and keeps the session id.)
 */
function applyLineage(summaries: TranscriptSummary[]): TranscriptSummary[] {
  // Oldest first, so a parent's own computed title is settled before its child asks for it and a chain
  // of clears numbers straight through.
  const ordered = [...summaries].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
  const resolved = new Map<string, TranscriptSummary>();

  for (const session of ordered) {
    const out: TranscriptSummary = { ...session };
    const startedAt = Date.parse(session.createdAt);

    if (session.bornFromClear && Number.isFinite(startedAt)) {
      const candidates = ordered.filter((other) => other.id !== session.id && wasActiveAt(other, startedAt));
      const only = candidates.length === 1 ? candidates[0]! : null;
      // A parent resolved earlier in this loop may itself have been renumbered, so prefer that copy —
      // that is what makes "Arch" → "Arch 2" → "Arch 3" work instead of two conversations called "Arch 2".
      const parent = only ? (resolved.get(only.id) ?? only) : null;
      if (parent) {
        out.continuedFrom = { id: parent.id, title: parent.title };
        if (!session.hasExplicitTitle) out.title = nextInChain(parent.title);
      }
    }
    resolved.set(session.id, out);
  }

  return summaries.map((session) => resolved.get(session.id) ?? session);
}

/** Drop the fields the app has no business seeing — `activeMinutes` would go over the wire as `{}`. */
function publish(session: TranscriptSummary): ClaudeSessionSummary {
  const { hasExplicitTitle: _hasExplicitTitle, activeMinutes: _activeMinutes, ...rest } = session;
  return rest;
}

/** A session and every conversation it continues, oldest first. Cycles cannot happen, but guard anyway. */
function chainOf(head: TranscriptSummary, byId: Map<string, TranscriptSummary>): TranscriptSummary[] {
  const parts: TranscriptSummary[] = [];
  const seen = new Set<string>();
  let cursor: TranscriptSummary | undefined = head;
  while (cursor && !seen.has(cursor.id)) {
    seen.add(cursor.id);
    parts.unshift(cursor);
    cursor = cursor.continuedFrom ? byId.get(cursor.continuedFrom.id) : undefined;
  }
  return parts;
}

/**
 * Show a `/clear` chain as the one conversation it is: a single row, standing for every part.
 *
 * The row is the **newest** link, not the oldest, because that is the only one you can carry on — a
 * cleared session is finished, and `--resume` on it would start a second branch. Everything else on the
 * row is the whole chain's: the root's start time, the summed message count, and the root's title, so a
 * conversation does not rename itself every time you clear it. A rename still wins, and it is the head
 * that carries it, which is the same id the row links to and acts on.
 *
 * `continuedFrom` is cleared here on purpose. It described a hop the reader now never has to make —
 * `loadChainTranscript` has already spliced the parts into one transcript — and leaving it would put a
 * "continues Platform Arch" line above a conversation that visibly contains Platform Arch.
 */
function mergeChains(sessions: TranscriptSummary[]): TranscriptSummary[] {
  const byId = new Map(sessions.map((session) => [session.id, session]));
  const continued = new Set(sessions.flatMap((session) => (session.continuedFrom ? [session.continuedFrom.id] : [])));

  return sessions
    .filter((session) => !continued.has(session.id))
    .map((head) => {
      const parts = chainOf(head, byId);
      if (parts.length < 2) return head;
      const root = parts[0]!;
      return {
        ...head,
        title: head.hasExplicitTitle ? head.title : root.title,
        createdAt: root.createdAt,
        messageCount: parts.reduce((total, part) => total + part.messageCount, 0),
        partCount: parts.length,
        continuedFrom: null,
      };
    });
}

// ── Loading a full transcript for display ──
// App-facing message shape (matches the frontend ChatMessage union), rebuilt from Claude's blocks.
export type ClaudeChatMessage =
  | { role: 'user'; text: string }
  | { role: 'assistant'; id: string; text: string }
  | {
      role: 'tool';
      toolName: string;
      toolInput: Record<string, unknown>;
      toolCallId: string;
      output?: string;
      isError?: boolean;
    }
  // Where one part of a `/clear` chain ends and the next begins. Only ever produced by
  // `loadChainTranscript`; `sessionId` is the part that ENDS here, which gives the row an identity the
  // virtualised list can key on. It has to be visible, not smoothed over: the parts read as one
  // conversation to you, but the agent's context was emptied at this line and nothing above it is in
  // its memory.
  | { role: 'divider'; sessionId: string }
  /** A turn you stopped. See `INTERRUPTION_MARKERS`. */
  | { role: 'interrupted' }
  // Where the agent rewrote its own context. Kept for the same reason as `divider`: the conversation
  // above it is still yours to read, and the agent's memory of it is a summary. It is also the answer to
  // "why did it go quiet for two minutes there", which is only useful if it survives a reload.
  | { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true };

/**
 * Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the
 * model is told, on the next turn, that it was cut off. Replayed literally it reads as something you
 * typed, so the transcript showed a message you never sent. Matched whole-string only: this text appears
 * inside real messages too (this file's own conversation being one), and those are genuinely yours.
 */
const INTERRUPTION_MARKERS = new Set(['[Request interrupted by user]', '[Request interrupted by user for tool use]']);

type ContentBlock =
  | { type: 'text'; text?: string }
  | { type: 'thinking' }
  | { type: 'tool_use'; id: string; name: string; input?: Record<string, unknown> }
  | { type: 'tool_result'; tool_use_id: string; is_error?: boolean | null; content?: unknown };

function blockText(content: unknown): string {
  if (typeof content === 'string') return content;
  if (Array.isArray(content)) {
    return content
      .map((b) =>
        b && typeof b === 'object' && (b as { type?: string }).type === 'text'
          ? ((b as { text?: string }).text ?? '')
          : '',
      )
      .join('');
  }
  return '';
}

/**
 * A background task reporting in. The harness delivers these to the agent by writing them as the *user's*
 * next message — the same trick as `INTERRUPTION_MARKERS`, and the same consequence: replayed literally,
 * the transcript shows a raw XML envelope in a bubble on your side of the conversation that you never
 * typed. Live it never appears, because a finished task travels as its own `task:notification` event; it
 * surfaces only when a refresh rebuilds the conversation from Claude's file.
 *
 * Anchored to the start of the message rather than matched anywhere in it, so quoting one inside a real
 * message — this file's own conversation does exactly that — stays yours.
 */
const isTaskNotification = (text: string) => text.trimStart().startsWith('<task-notification>');

function userMessageFrom(text: string): ClaudeChatMessage | null {
  if (isTaskNotification(text)) return null;
  if (INTERRUPTION_MARKERS.has(text.trim())) return { role: 'interrupted' };
  return { role: 'user', text };
}

export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] };

/** Parse a session's JSONL transcript into a flat, display-ready message list. Claude is the source. */
function parseClaudeTranscript(
  osUser: AsUser,
  filePath: string,
  sessionId: string,
  fallbackCwd = '',
): ClaudeSessionDetail | null {
  // No `existsSync` guard: it is redundant with the catch below (a missing file throws there just the same)
  // and it is actively WRONG for a member, answering false on a transcript that exists — which is how a
  // /chat/<id> deep link 404'd on a session the member was looking at.
  const messages: ClaudeChatMessage[] = [];
  const toolById = new Map<string, Extract<ClaudeChatMessage, { role: 'tool' }>>();
  let model = '';
  let sessionCwd = fallbackCwd;

  // `existsSync` above passes for a member's transcript and the read still fails — the file is theirs at
  // mode 600. That combination used to escape as a 500 from `GET /chat/sessions/:id`, because only the
  // list path caught its read. "Not found" is the honest answer for a transcript we cannot open.
  let raw: string;
  try {
    raw = readTextAs(osUser, filePath);
  } catch {
    return null;
  }

  for (const line of raw.split('\n')) {
    if (!line.trim()) continue;
    let entry: Entry & { message?: { role?: string; content?: unknown; model?: string } };
    try {
      entry = JSON.parse(line);
    } catch {
      continue;
    }
    // First cwd wins — a session's home is where it was launched, which is how Claude Code files the
    // transcript on disk and how parseSummary groups it in the list. Later entries record wherever a tool
    // happened to cd to, so taking the last one made the detail disagree with the list: opening a session
    // moved the cwd picker to a subdirectory. It also let a stale entry clobber `fallbackCwd` even when the
    // caller already knew the group it was loading from.
    if (entry.cwd && !sessionCwd) sessionCwd = entry.cwd;
    if (entry.message?.model && !model) model = entry.message.model;

    const content = entry.message?.content;

    if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
      const meta = entry.compactMetadata ?? {};
      messages.push({
        role: 'compact',
        trigger: meta.trigger === 'manual' ? 'manual' : 'auto',
        preTokens: meta.preTokens,
        durationMs: meta.durationMs,
        done: true,
      });
      continue;
    }

    if (entry.type === 'user' && !entry.isMeta) {
      if (typeof content === 'string') {
        const message = content.trim() ? userMessageFrom(content) : null;
        if (message) messages.push(message);
        continue;
      }
      if (Array.isArray(content)) {
        for (const block of content as ContentBlock[]) {
          if (block.type === 'text' && block.text?.trim()) {
            const message = userMessageFrom(block.text);
            if (message) messages.push(message);
          } else if (block.type === 'tool_result') {
            const tool = toolById.get(block.tool_use_id);
            if (tool) {
              tool.output = blockText(block.content);
              tool.isError = block.is_error === true;
            }
          }
        }
      }
      continue;
    }

    if (entry.type === 'assistant' && Array.isArray(content)) {
      for (const block of content as ContentBlock[]) {
        if (block.type === 'text' && block.text?.trim()) {
          messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: block.text });
        } else if (block.type === 'tool_use') {
          const tool = {
            role: 'tool' as const,
            toolName: block.name,
            toolInput: block.input ?? {},
            toolCallId: block.id,
          };
          messages.push(tool);
          toolById.set(block.id, tool);
        }
      }
    }
  }

  return { id: sessionId, model, cwd: sessionCwd, messages };
}

/**
 * Splice in everything this conversation continues, so a `/clear` chain reads as one transcript.
 *
 * `/clear` is a fact about Claude's process, not about what you were doing — you were having one
 * conversation, and it left you with three unrelated-looking rows. The parts are concatenated
 * oldest-first with a `divider` between them, and the divider is the honest part: your history is
 * continuous, the agent's context is not.
 *
 * Only ancestors, never descendants. Ancestry is what "continues" means, and it keeps the returned id
 * resumable — a mid-chain deep link from an old bookmark shows its own story up to that point rather
 * than a conversation that carries on past where it was addressed.
 *
 * Pagination needs nothing: the client asks for index windows into whatever the server calls the
 * transcript, so a longer one simply pages further back.
 */
function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): ClaudeSessionDetail {
  const parts = (() => {
    const group = scanGroup(who, detail.cwd);
    const head = group.find((session) => session.id === detail.id);
    return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : [];
  })();
  if (parts.length < 2) return detail;

  const dir = join(claudeProjectsDir(who.home), projectSlug(detail.cwd));
  const earlier: ClaudeChatMessage[] = [];
  for (const part of parts.slice(0, -1)) {
    const segment = parseClaudeTranscript(who.osUser, join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
    if (!segment) continue;
    earlier.push(...segment.messages, { role: 'divider', sessionId: part.id });
  }

  return { ...detail, messages: [...earlier, ...detail.messages] };
}

/** Load a session when its cwd (project group) is known. */
export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): ClaudeSessionDetail | null {
  const detail = parseClaudeTranscript(
    who.osUser,
    join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`),
    sessionId,
    cwd,
  );
  return detail && loadChainTranscript(who, detail);
}

/** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link /
 *  refresh to /chat/<id>, when the cwd isn't known yet; the transcript records the real cwd, which the
 *  caller uses to scope the list + cwd picker. */
export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): ClaudeSessionDetail | null {
  const filePath = locateTranscript(who, sessionId);
  if (!filePath) return null;
  const detail = parseClaudeTranscript(who.osUser, filePath, sessionId);
  return detail && loadChainTranscript(who, detail);
}

/**
 * The transcript file for a session: in the named group if it is there, otherwise wherever it actually
 * is. The caller's cwd is a hint, not an authority — a session's group is a property of the session,
 * and the two disagree routinely (the list is showing one group while you act on a row from another,
 * or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did
 * not, so delete and rename returned "not found" for a session that was plainly on screen.
 */
const findTranscript = (who: ChatIdentity, cwd: string, sessionId: string): string | null =>
  locateTranscript(who, sessionId, cwd);

/**
 * Delete a conversation by removing its transcript file — and, when it is a `/clear` chain, the files
 * of every part it continues. Returns false if it didn't exist.
 *
 * The chain is resolved from the transcript's OWN directory, not the caller's `cwd`: the two disagree
 * routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
 * part of several.
 */
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): boolean {
  const filePath = findTranscript(who, cwd, sessionId);
  if (!filePath) return false;

  const ownCwd = firstCwd(who.osUser, filePath);
  const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
  // `removeAs`, not `rmSync`: unlinking needs `w`+`x` on the DIRECTORY, which the service user does not have
  // on a member's group. `rmSync` guarded by `existsSync` therefore deleted nothing and still reported
  // success — the conversation reappeared on the next refresh.
  const dir = dirname(filePath);
  for (const id of ids) removeAs(who.osUser, join(dir, `${id}.jsonl`));
  return true;
}

/**
 * Rename a session by appending a `summary` entry to its transcript — Claude's own on-disk format, so
 * the title lives in .claude (source of truth). Our reader takes the last summary as the title; no
 * timestamp is written so the rename doesn't reorder the list.
 */
export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: string, title: string): boolean {
  const filePath = findTranscript(who, cwd, sessionId);
  if (!filePath) return false;

  // Attach the summary to the transcript's tip (the last entry carrying a uuid).
  let leafUuid = sessionId;
  const lines = readTextAs(who.osUser, filePath).split('\n');
  for (let i = lines.length - 1; i >= 0; i--) {
    if (!lines[i]!.trim()) continue;
    try {
      const entry = JSON.parse(lines[i]!) as Entry;
      if (entry.uuid) {
        leafUuid = entry.uuid;
        break;
      }
    } catch {
      /* skip */
    }
  }

  appendTextAs(who.osUser, filePath, `${JSON.stringify({ type: 'summary', summary: title, leafUuid })}\n`);
  return true;
}

// ── Background tasks ──
// A background task (run_in_background, Monitor, a backgrounded subagent) reports nothing over the wire
// between `task:started` and its notification — the only thing that knows what it is doing is the file
// Claude Code streams it into:
//
//   $TMPDIR/claude-<uid>/<project-slug>/<session-uuid>/tasks/<task-id>.output
//
// For a shell task that file IS the output. For an agent task it is a SYMLINK to the subagent's own
// transcript under ~/.claude/projects/<slug>/<session>/subagents/agent-<task-id>.jsonl — which is the
// same JSONL format as any other session, so the parser above reads it unchanged. Both kinds are
// reachable from the one directory, which is why we look there first; the projects tree is the fallback
// for when /tmp has been cleared out from under a still-referenced agent transcript.
//
// Resolution is by task id ALONE, deliberately. The client learns a task id from `task:started` and
// nothing else — officer's per-connection session key is not Claude's session uuid, and the uuid only
// arrives with the turn `result`, long after the tray needs to show the task.

/** Task ids come from Claude, but they reach us through a URL — never let one address a parent. */
const TASK_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;

/** How much of a shell task's log to return. It is a tail: the end is the part that is still moving. */
const LOG_TAIL_BYTES = 64 * 1024;

/** Per tool result. A background agent's greps run to megabytes and the tray shows a preview, not a file. */
const OUTPUT_CAP = 4000;

export type BackgroundTaskDetail =
  | { kind: 'agent'; messages: ClaudeChatMessage[] }
  | { kind: 'log'; text: string; truncated: boolean };

function findTaskOutput(who: ChatIdentity, taskId: string): string | null {
  const tmpRoot = process.env.TMPDIR ?? '/tmp';
  const candidates: [string, string][] = [
    [tmpRoot, `claude-*/*/*/tasks/${taskId}.output`],
    [claudeProjectsDir(who.home), `*/*/subagents/agent-${taskId}.jsonl`],
  ];
  for (const [root, pattern] of candidates) {
    try {
      for (const rel of new Bun.Glob(pattern).scanSync({ cwd: root, followSymlinks: false, onlyFiles: false })) {
        return join(root, rel);
      }
    } catch {
      /* root doesn't exist — try the next one */
    }
  }
  return null;
}

/** The tail of a file, as text, without reading the whole thing. */
function tailFile(osUser: AsUser, filePath: string, bytes: number): { text: string; truncated: boolean } {
  const { text, truncated } = readTailAs(osUser, filePath, bytes);
  // A byte offset lands mid-line (and possibly mid-codepoint); drop the partial head.
  return { text: truncated ? text.slice(text.indexOf('\n') + 1) : text, truncated };
}

/**
 * What a background task is doing right now. Returns null when nothing has been written yet — which is
 * the normal state for the first second or two of a task's life, not an error.
 */
export function loadBackgroundTask(who: ChatIdentity, taskId: string): BackgroundTaskDetail | null {
  if (!TASK_ID_RE.test(taskId)) return null;
  const found = findTaskOutput(who, taskId);
  if (!found) return null;

  let target = found;
  try {
    target = realpathSync(found);
  } catch {
    return null; // a dangling symlink — the task registered before its transcript existed
  }

  if (target.endsWith('.jsonl')) {
    const detail = parseClaudeTranscript(who.osUser, target, taskId);
    if (!detail) return null;
    const messages = detail.messages.map((m) =>
      m.role === 'tool' && m.output && m.output.length > OUTPUT_CAP
        ? { ...m, output: `${m.output.slice(0, OUTPUT_CAP)}\n… (truncated)` }
        : m,
    );
    return { kind: 'agent', messages };
  }

  try {
    const { text, truncated } = tailFile(who.osUser, target, LOG_TAIL_BYTES);
    return { kind: 'log', text, truncated };
  } catch {
    return null;
  }
}

// ── Working directories (pwds) ──
// Every dir you've run Claude in shows up as a project group under ~/.claude/projects. We read the
// real `cwd` back from each group's transcripts so the UI can offer "jump to any project's sessions".

/** Read the `cwd` recorded in a transcript, from a bounded head read (cwd appears in early entries). */
function firstCwd(osUser: AsUser, filePath: string): string {
  try {
    return readHeadAs(osUser, filePath, 32768).match(/"cwd":"([^"]*)"/)?.[1] ?? '';
  } catch {
    return '';
  }
}

export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };

/** All working directories that have Claude sessions, plus the caller's home. Newest first. */
export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
  const projectsDir = claudeProjectsDir(who.home);
  const defaultCwd = who.home;
  const byCwd = new Map<string, { count: number; updatedAt: string }>();

  // One listing for the whole tree, grouped here. This used to be `readdirSync` + a `statSync` per file,
  // and BOTH are EACCES for a member — the readdir threw uncaught, so this endpoint answered 500 rather
  // than answering wrongly. That 500 was the visible half of the empty conversation list.
  const byGroup = new Map<string, { count: number; newest: number; first: string }>();
  for (const file of listTranscriptsAs(who.osUser, projectsDir)) {
    const prev = byGroup.get(file.slug);
    byGroup.set(file.slug, {
      count: (prev?.count ?? 0) + 1,
      newest: Math.max(prev?.newest ?? 0, file.mtimeMs),
      first: prev?.first ?? file.id,
    });
  }

  for (const [slug, group] of byGroup) {
    // The cwd is a property of the transcript's entries, not of the slug, which is lossy and cannot be
    // reversed. Any file in the group answers it.
    const cwd = firstCwd(who.osUser, join(projectsDir, slug, `${group.first}.jsonl`));
    if (!cwd) continue;
    const updatedAt = new Date(group.newest).toISOString();
    const prev = byCwd.get(cwd);
    byCwd.set(cwd, {
      count: (prev?.count ?? 0) + group.count,
      updatedAt: prev && prev.updatedAt > updatedAt ? prev.updatedAt : updatedAt,
    });
  }

  if (!byCwd.has(defaultCwd)) byCwd.set(defaultCwd, { count: 0, updatedAt: '' });

  return [...byCwd.entries()]
    .map(([cwd, v]) => ({ cwd, sessionCount: v.count, updatedAt: v.updatedAt, isDefault: cwd === defaultCwd }))
    .sort((a, b) => (a.isDefault ? -1 : b.isDefault ? 1 : b.updatedAt.localeCompare(a.updatedAt)));
}

/**
 * Every transcript in a working directory, one entry each, with lineage resolved.
 *
 * Lineage is worked out across the whole group at once — a session's parent is only knowable relative
 * to its neighbours, so there is no per-file answer to cache. The per-file summaries underneath it are
 * mtime-cached, which is what makes calling this on every request cheap.
 */
function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
  const slug = projectSlug(cwd);
  const dir = join(claudeProjectsDir(who.home), slug);

  const sessions: TranscriptSummary[] = [];
  for (const file of listTranscriptsAs(who.osUser, claudeProjectsDir(who.home), slug)) {
    const summary = summarizeTranscript(who.osUser, join(dir, `${file.id}.jsonl`), file.id, file.mtimeMs);
    if (summary) sessions.push(summary);
  }
  return applyLineage(sessions);
}

/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
  return mergeChains(scanGroup(who, cwd))
    .map(publish)
    .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}

/**
 * One session's list-level facts: the title it is shown under, and how many parts it spans.
 *
 * A transcript cannot answer either on its own — both are properties of the `/clear` chain it belongs
 * to — so this goes through the group listing. It exists so the open conversation is labelled the same
 * way its row is, instead of the two drifting.
 *
 * The fallback matters: the merged list only holds chain heads, so a link to a part in the middle of a
 * chain (an old bookmark, or the parent link from before merging) finds nothing there. Such a link
 * still opens a real transcript, so answer for the part itself rather than 404 the title.
 */
export function claudeSessionContext(
  who: ChatIdentity,
  cwd: string,
  sessionId: string,
): { title: string; partCount: number } | null {
  const merged = listClaudeSessions(who, cwd).find((entry) => entry.id === sessionId);
  if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 };
  const part = scanGroup(who, cwd).find((entry) => entry.id === sessionId);
  return part ? { title: part.title, partCount: 1 } : null;
}

/**
 * The display title for a session known only by id — what `/chat/live` needs.
 *
 * `claudeSessionContext` is the real answer but has to be told the group, and a live session can be in
 * any of them; the agent reports session keys and nothing else. So find the transcript by scanning the
 * slugs, take the cwd off its own first entry, and hand that to the normal path — which means the Live
 * panel shows exactly the title the list shows, `/clear` chains merged and all, rather than a second
 * opinion about naming.
 *
 * Reads the file to answer. That is a few milliseconds against a poll every ten seconds over a handful
 * of live sessions, so it is not worth a cache yet — but it is worth knowing before this is called from
 * anywhere hotter.
 */
export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: string; cwd: string } | null {
  const filePath = locateTranscript(who, sessionId);
  if (!filePath) return null;

  // The cwd is a property of the transcript's entries, so the first one carrying it settles which group
  // this session belongs to — no need to reverse the slug, which is lossy.
  let cwd: string | null = null;
  try {
    for (const line of readTextAs(who.osUser, filePath).split('\n')) {
      if (!line.trim()) continue;
      const entry = JSON.parse(line) as { cwd?: string };
      if (entry.cwd) {
        cwd = entry.cwd;
        break;
      }
    }
  } catch {
    return null;
  }
  if (!cwd) return null;

  const context = claudeSessionContext(who, cwd, sessionId);
  return context ? { title: context.title, cwd } : null;
}

/**
 * Every transcript that has to go when this conversation is deleted: itself and everything it
 * continues. The row stands for the whole chain, so deleting it has to mean the whole chain — leaving
 * the ancestors behind would resurrect them as separate rows the moment their child was gone, which
 * reads as the delete having half worked.
 */
function chainFileIds(who: ChatIdentity, cwd: string, sessionId: string): string[] {
  const group = scanGroup(who, cwd);
  const head = group.find((session) => session.id === sessionId);
  if (!head) return [sessionId];
  return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id);
}
