import { mkdir } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { basename, dirname, join } from 'node:path';
import type { TurnMessage } from '../chat/types';
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
import { renameClaudeSession, type ChatIdentity } from '../chat/claude-sessions';
import { getAgentRunsDir, getOwnerHomeDir } from '../../data-path';
import { getAgentByDirName, DEFAULT_AGENT_MODEL, type AgentRecord } from './agent-files';
import { logger } from '../chat/logger';

// Starting an agent run is deliberately not "spawning a process". The claude binary is already there
// and the sidecar already owns a persistent-session abstraction, so a run is exactly a chat session
// with three things fixed up front: a working directory, an opening prompt loaded from AGENT.md, and
// inputs appended to it. Nothing here kills anything — claude-manager's idle GC reaps the session
// once it goes quiet, the same way it does for /chat.

export type AgentRun = {
  /** Our handle for the session. NOT the claude session uuid that names the transcript file. */
  sessionKey: string;
  dirName: string;
  cwd: string;
  model: string;
  startedAt: number;
  finishedAt: number | null;
  status: 'running' | 'finished' | 'failed';
  /** Claude's own session uuid, once the sidecar reports it. Absent until then. */
  claudeSessionId: string | null;
};

// In-memory only, and that is on purpose: the durable record of a run is its transcript on disk plus
// its events in chat_session_events. This map is a live view for the current server process, so
// losing it to a restart costs nothing that matters.
const runs = new Map<string, AgentRun>();

export const listAgentRuns = (dirName?: string): AgentRun[] =>
  Array.from(runs.values())
    .filter((run) => !dirName || run.dirName === dirName)
    .sort((a, b) => b.startedAt - a.startedAt);

export const getAgentRun = (sessionKey: string): AgentRun | null => runs.get(sessionKey) ?? null;

/**
 * Resolve a user-supplied path to an absolute one.
 *
 * The file browser autofills `entry_path` in tilde form (`~/music/foo`), and a prompt must never see
 * that: an agent works in absolute paths from a cwd that is NOT the target directory, so a leading
 * `~` would either be pasted into a shell that expands it against the wrong home or, worse, treated
 * as a literal directory name. Expanding here is what keeps every prompt free of the convention —
 * `download-media` had to reimplement this itself, and no agent should have to.
 */
export function absolutizePath(value: string, homeDir: string): string {
  const trimmed = value.trim();
  if (trimmed === '~') return homeDir;
  if (trimmed.startsWith('~/')) return join(homeDir, trimmed.slice(2));
  return trimmed;
}

const expandInputs = (inputs: Record<string, unknown>, homeDir: string): Record<string, unknown> =>
  Object.fromEntries(
    Object.entries(inputs).map(([key, value]) => [
      key,
      typeof value === 'string' && value.trimStart().startsWith('~') ? absolutizePath(value, homeDir) : value,
    ]),
  );

/**
 * The opening prompt: the AGENT.md body verbatim, then the inputs. The body is authored as a complete
 * runbook, so nothing is injected ahead of it — the inputs are appended as the one thing the document
 * cannot know.
 */
export function buildAgentPrompt(agent: AgentRecord, inputs: Record<string, unknown>): string {
  const entries = Object.entries(inputs).filter(([, value]) => value !== undefined && value !== null && value !== '');
  if (entries.length === 0) return agent.body.trim();

  const lines = entries.map(
    ([key, value]) => `- **${key}**: ${typeof value === 'string' ? value : JSON.stringify(value)}`,
  );
  return `${agent.body.trim()}\n\n## Inputs\n\n${lines.join('\n')}\n`;
}

/**
 * Give the run's transcript a title you can tell apart from the others.
 *
 * Without this every run of an agent is titled from the same opening prompt — an AGENT.md body can be
 * tens of KB, and the list shows the first line of it, so twenty runs look identical. The transcript
 * only becomes addressable at the end of the turn (that's when the harness reports its session uuid),
 * which is fine: while a run is live you find it as the newest entry in the agent's project group.
 */
function titleRun(
  who: ChatIdentity,
  cwd: string,
  claudeSessionId: string,
  agentName: string,
  inputs: Record<string, unknown>,
): void {
  const firstPath = Object.values(inputs).find((v): v is string => typeof v === 'string' && v.startsWith('/'));
  const subject = firstPath ? basename(firstPath) : null;
  const when = new Date().toLocaleString('sv', { dateStyle: 'short', timeStyle: 'short' });
  const title = [agentName, subject, when].filter(Boolean).join(' · ');

  try {
    if (!renameClaudeSession(who, cwd, claudeSessionId, title)) {
      logger.warn('Could not title agent run — transcript not found', { claudeSessionId, cwd });
    }
  } catch (err) {
    // Cosmetic. A run that completed successfully must not be reported as failed because of a title.
    logger.warn('Failed to title agent run', { claudeSessionId, error: String(err) });
  }
}

type StartAgentRunParams = {
  dirName: string;
  inputs?: Record<string, unknown>;
  user: { id: number; email: string; username: string };
};

export type StartAgentRunResult = {
  sessionKey: string;
  dirName: string;
  /**
   * The directory the run executes in — which is also its project group in /chat, so the caller can
   * build the link to it. This used to also return a ready-made `chatUrl`, which meant the server held
   * an opinion about frontend URL shape and drifted the moment that shape changed. `cwd` is the fact;
   * the URL is the frontend's business (`chatListPath`).
   */
  cwd: string;
  model: string;
};

export async function startAgentRun(params: StartAgentRunParams): Promise<StartAgentRunResult> {
  const agent = await getAgentByDirName(params.dirName);
  if (!agent) throw new Error('Agent not found');

  const homeDir = getOwnerHomeDir(params.user.email);
  const inputs = expandInputs(params.inputs ?? {}, homeDir);

  for (const [key, value] of Object.entries(inputs)) {
    if (typeof value === 'string' && value.startsWith('~')) {
      throw new Error(`Input "${key}" could not be resolved to an absolute path`);
    }
  }

  // Shared per agent, not per run — this is the project-group pin (see getAgentRunsDir).
  const cwd = getAgentRunsDir(agent.dirName);
  await mkdir(cwd, { recursive: true });

  const sessionKey = randomUUID();
  const model = agent.model || DEFAULT_AGENT_MODEL;

  // The agent's own directory, so a prompt can refer to its sibling scripts. It has to be injected:
  // cwd is the shared runs dir (the project-group pin), NOT the item directory, so nothing the agent
  // can see would otherwise tell it where its own files are. Appended last so a real input still wins
  // when we pick a subject for the run's title.
  const prompt = buildAgentPrompt(agent, { ...inputs, agent_dir: dirname(agent.filePath) });

  const run: AgentRun = {
    sessionKey,
    dirName: agent.dirName,
    cwd,
    model,
    startedAt: Date.now(),
    finishedAt: null,
    status: 'running',
    claudeSessionId: null,
  };
  runs.set(sessionKey, run);

  const onMessage = (msg: TurnMessage) => {
    if (msg.type === 'result') {
      run.status = 'finished';
      run.finishedAt = Date.now();
      if (msg.claudeSessionId) {
        run.claudeSessionId = msg.claudeSessionId;
        titleRun(
          // `osUser: null` and `isOwner: true` both track `homeDir` above: it is `getOwnerHomeDir`, which
          // discards the email it is given, so an agent run is always the owner's — its transcript is theirs
          // and readable directly. `agents` is an `execution` capability, so no other account reaches this.
          // If agent runs ever reach members, this and line 134 have to move together.
          { email: params.user.email, home: homeDir, osUser: null, isOwner: true },
          cwd,
          msg.claudeSessionId,
          agent.name || agent.dirName,
          inputs,
        );
      }
    } else if (msg.type === 'error') {
      run.status = 'failed';
      run.finishedAt = Date.now();
      logger.error('Agent run failed', { sessionKey, dirName: agent.dirName });
    }
  };

  try {
    // durable: true — the events land in chat_session_events, so an officer restart mid-run costs a
    // replay rather than the output. That matters more here than in a pipeline step, because an agent
    // run is long and, before long, unattended.
    await sendClaudeCodeStreaming({
      userId: params.user.id,
      email: params.user.email,
      username: params.user.username,
      prompt,
      sessionKey,
      cwd,
      model,
      durable: true,
      onMessage,
    });
  } catch (err) {
    run.status = 'failed';
    run.finishedAt = Date.now();
    throw err;
  }

  logger.info('Agent run started', { sessionKey, dirName: agent.dirName, cwd, model });

  return {
    sessionKey,
    dirName: agent.dirName,
    cwd,
    model,
  };
}
