import { join } from 'node:path';
import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
import { DATA_PATH } from '../../data-path';

/**
 * A resumable session, and whose it is.
 *
 * `userId` exists because the map is global to this sidecar and `sessionKey` travels in a client message. The
 * pty sidecar learned this first (`4d4a253f`): *"re-attaching to a session belonging to another account is
 * refused, otherwise a member resumes someone else's shell by guessing an id that travels in a query
 * string."* Chat never got the same treatment, because both gates made it unreachable and therefore invisible.
 *
 * Stored rather than derived: there is nothing in a `sessionKey` or a Claude transcript uuid that says who
 * owns it, so ownership has to be written down at the moment it is created.
 */
export type SessionRecord = { userId: number; claudeSessionId: string };

export type PersistedState = {
  proxySecret: string;
  claudeSessions: Record<string, SessionRecord>; // sessionKey → whose, and which transcript
};

const DEFAULT_STATE: PersistedState = {
  proxySecret: '',
  claudeSessions: {},
};

let stateDir: string;
let stateFile: string;
let lockFile: string;
let currentState: PersistedState = { ...DEFAULT_STATE };
let saveTimer: Timer | null = null;

/** Call once at startup to configure paths. For proxy: no email. For per-user: pass email. */
export function initPaths(email?: string): void {
  if (email) {
    stateDir = join(DATA_PATH, email, 'sidecar');
    stateFile = join(stateDir, 'claude-state.json');
    lockFile = join(stateDir, 'claude.lock');
  } else {
    stateDir = join(DATA_PATH, 'sidecar');
    stateFile = join(stateDir, 'claude-state.json');
    lockFile = join(stateDir, 'claude.lock');
  }
}

// Default to proxy paths
initPaths();

function ensureDir() {
  if (!existsSync(stateDir)) {
    mkdirSync(stateDir, { recursive: true });
  }
}

/**
 * @param ownerUserId who existing sessions belong to — see the migration below. Omitted by the proxy
 *   process, which holds no sessions; legacy entries are then dropped rather than attributed to a guess.
 */
export function loadState(ownerUserId?: number): PersistedState {
  ensureDir();
  try {
    if (!existsSync(stateFile)) {
      currentState = { ...DEFAULT_STATE };
      return currentState;
    }
    const text = readFileSync(stateFile, 'utf-8');
    currentState = { ...DEFAULT_STATE, ...JSON.parse(text) };
    currentState.claudeSessions = migrateSessions(currentState.claudeSessions, ownerUserId);
    return currentState;
  } catch {
    currentState = { ...DEFAULT_STATE };
    return currentState;
  }
}

/**
 * Entries used to be a bare `sessionKey → transcript uuid` string. Adopt those to the owner.
 *
 * Safe because it is a statement about the past rather than a guess: until this commit, `api/chat/chat.ts`
 * and the chat socket refused every non-owner, so nothing but the owner could ever have created one. The
 * alternative — dropping unrecognised entries — would silently lose the owner's resumable history on upgrade,
 * and "my old chats stopped resuming" is a bad way to discover a migration.
 */
function migrateSessions(raw: Record<string, unknown>, ownerUserId?: number): Record<string, SessionRecord> {
  const out: Record<string, SessionRecord> = {};
  for (const [key, value] of Object.entries(raw ?? {})) {
    if (typeof value === 'string') {
      if (ownerUserId !== undefined) out[key] = { userId: ownerUserId, claudeSessionId: value };
    } else if (value && typeof value === 'object' && 'claudeSessionId' in value && 'userId' in value) {
      out[key] = value as SessionRecord;
    }
    // Anything else is unreadable and dropped: a malformed entry cannot be attributed to anyone, and
    // guessing an owner for it is exactly the mistake this whole change exists to stop.
  }
  return out;
}

export async function saveState(): Promise<void> {
  ensureDir();
  await Bun.write(stateFile, JSON.stringify(currentState, null, 2));
}

export function getState(): PersistedState {
  return currentState;
}

export function updateState(patch: Partial<PersistedState>): void {
  Object.assign(currentState, patch);
  scheduleSave();
}

/**
 * Both of these write through instead of debouncing.
 *
 * This map is the only thing that lets a conversation be reattached with `--resume` after the agent
 * sidecar dies, so it is precisely the state that must not be in a 30s window when the process is
 * killed rather than asked to stop — `flushAndSave` on SIGTERM covers a `pm2 restart`, but not a
 * crash or a SIGKILL, which is the case the resume path exists for. Losing it silently starts the
 * next turn as a fresh Claude session and orphans the transcript on disk.
 *
 * There is nothing to debounce: `onSessionId` fires on every message but with the same id, so the
 * equality guard collapses it to one write per session, and clearing happens once.
 */
export function setClaudeSession(sessionKey: string, sessionId: string, userId: number): void {
  const existing = currentState.claudeSessions[sessionKey];
  if (existing?.claudeSessionId === sessionId && existing.userId === userId) return;
  currentState.claudeSessions[sessionKey] = { userId, claudeSessionId: sessionId };
  writeThrough();
}

export function clearClaudeSession(sessionKey: string): void {
  if (!(sessionKey in currentState.claudeSessions)) return;
  delete currentState.claudeSessions[sessionKey];
  writeThrough();
}

function writeThrough(): void {
  void flushAndSave().catch((err) => {
    console.error('[claude-state] save failed:', err instanceof Error ? err.message : err);
  });
}

/**
 * The transcript for this session key, **only if it belongs to the caller**.
 *
 * A mismatch returns undefined rather than throwing: to the caller it is simply "no session to resume", which
 * is the truthful answer — there is no session of theirs under that key. Throwing would confirm that somebody
 * else's exists, which is the one thing a guesser learns from.
 */
export function getClaudeSession(sessionKey: string, userId: number): string | undefined {
  const record = currentState.claudeSessions[sessionKey];
  if (!record || record.userId !== userId) return undefined;
  return record.claudeSessionId;
}

/**
 * The same map read backwards: Claude's transcript uuid → the key officer made up for the session.
 *
 * A browser that has refreshed holds only the uuid, because that is what is in the URL; officer's own
 * key lived in page state and is gone. This is the only record anywhere that relates the two, which is
 * why re-binding a socket to a running turn has to come through the sidecar rather than being answerable
 * on the platform side.
 *
 * A linear scan over a handful of live sessions. If that ever stops being true, add the inverse map —
 * but a second copy of a mapping is a second thing to keep honest, and this one is written on every turn.
 * Newest wins: a transcript resumed under a fresh key leaves the old entry in place, and the caller wants
 * the session generating now, not the one that produced the same file yesterday.
 */
export function findSessionKeyByClaudeSession(claudeSessionId: string, userId: number): string | undefined {
  const keys = Object.keys(currentState.claudeSessions);
  for (let i = keys.length - 1; i >= 0; i--) {
    const key = keys[i]!;
    const record = currentState.claudeSessions[key];
    // Scoped to the caller: this is the reattach hinge, and a browser holding a transcript uuid it should
    // not have would otherwise be handed the session key that drives it.
    if (record?.claudeSessionId === claudeSessionId && record.userId === userId) return key;
  }
  return undefined;
}

function scheduleSave() {
  if (saveTimer) return;
  saveTimer = setTimeout(async () => {
    saveTimer = null;
    await saveState();
  }, 30_000);
}

export async function flushAndSave(): Promise<void> {
  if (saveTimer) {
    clearTimeout(saveTimer);
    saveTimer = null;
  }
  await saveState();
}

/**
 * Read the Anthropic proxy secret out of the *proxy* sidecar's state file.
 *
 * The proxy (`officer-anthropic-proxy`) and the agent (`officer-agent`) keep separate state — see
 * `initPaths`: `DATA_PATH/sidecar/` versus `DATA_PATH/<email>/sidecar/` — so the agent cannot reach
 * the secret through `getState()`. It used to be handed the secret in env by the main server, and
 * needing that handoff is precisely why the agent had to be spawned by `officer` (and therefore died
 * with it). Reading it off disk keeps the two processes independent, with the proxy still the only
 * writer.
 *
 * Returns '' when the secret is not on disk yet: `ensureProxySecret` persists through a 30s debounce,
 * so a brand-new install has a window where the file exists without it. Callers should treat '' as
 * "retry later" rather than fatal.
 */
export function readProxySecretFromDisk(): string {
  try {
    const proxyStateFile = join(DATA_PATH, 'sidecar', 'claude-state.json');
    if (!existsSync(proxyStateFile)) return '';
    const parsed = JSON.parse(readFileSync(proxyStateFile, 'utf-8')) as Partial<PersistedState>;
    return parsed.proxySecret ?? '';
  } catch {
    return '';
  }
}

// ── Lockfile ──

export function acquireLock(): boolean {
  ensureDir();
  try {
    if (existsSync(lockFile)) {
      const pidStr = readFileSync(lockFile, 'utf-8').trim();
      const pid = Number(pidStr);
      if (pid && isProcessAlive(pid)) {
        return false;
      }
    }
    writeFileSync(lockFile, String(process.pid));
    return true;
  } catch {
    return false;
  }
}

export function releaseLock(): void {
  try {
    if (existsSync(lockFile)) {
      unlinkSync(lockFile);
    }
  } catch {
    // best effort
  }
}

function isProcessAlive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}
