import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync } from "node:child_process";

const HOME = os.homedir();
const DAY_MS = 24 * 60 * 60 * 1000;
const DEFAULT_CLAUDE_ROOT = path.join(HOME, ".claude", "projects");
const DEFAULT_CODEX_ROOT = path.join(HOME, ".codex", "sessions");
const DEFAULT_CODEX_STATE = path.join(HOME, ".codex", "state_5.sqlite");
const PRICING_UPDATED = "2026-07-10";
const PRICING_SOURCES = [
  "https://developers.openai.com/api/docs/pricing",
  "https://developers.openai.com/api/docs/models/gpt-5.6-sol",
  "https://developers.openai.com/api/docs/models/gpt-5.6-terra",
  "https://developers.openai.com/api/docs/models/gpt-5.6-luna",
  "https://developers.openai.com/api/docs/models/gpt-5-codex",
  "https://developers.openai.com/api/docs/models/gpt-5.2-codex",
  "https://developers.openai.com/api/docs/models/gpt-5.3-codex",
  "https://platform.claude.com/docs/en/about-claude/pricing"
];

const OPENAI_PRICES = {
  "gpt-5.6": { input: 5, cachedInput: 0.5, output: 30 },
  "gpt-5.6-sol": { input: 5, cachedInput: 0.5, output: 30 },
  "gpt-5.6-terra": { input: 2.5, cachedInput: 0.25, output: 15 },
  "gpt-5.6-luna": { input: 1, cachedInput: 0.1, output: 6 },
  "gpt-5.5": { input: 5, cachedInput: 0.5, output: 30 },
  "gpt-5.4": { input: 2.5, cachedInput: 0.25, output: 15 },
  "gpt-5.3-codex": { input: 1.75, cachedInput: 0.175, output: 14 },
  "gpt-5.2-codex": { input: 1.75, cachedInput: 0.175, output: 14 },
  "gpt-5.2": { input: 1.75, cachedInput: 0.175, output: 14 },
  "gpt-5-codex": { input: 1.25, cachedInput: 0.125, output: 10 },
  "gpt-5": { input: 1.25, cachedInput: 0.125, output: 10 }
};

const CLAUDE_PRICES = {
  fable5: {
    input: 10,
    cacheWrite5m: 12.5,
    cacheWrite1h: 20,
    cacheRead: 1,
    output: 50
  },
  opusCurrent: {
    input: 5,
    cacheWrite5m: 6.25,
    cacheWrite1h: 10,
    cacheRead: 0.5,
    output: 25
  },
  opusLegacy: {
    input: 15,
    cacheWrite5m: 18.75,
    cacheWrite1h: 30,
    cacheRead: 1.5,
    output: 75
  },
  sonnet5Intro: {
    input: 2,
    cacheWrite5m: 2.5,
    cacheWrite1h: 4,
    cacheRead: 0.2,
    output: 10
  },
  sonnet: {
    input: 3,
    cacheWrite5m: 3.75,
    cacheWrite1h: 6,
    cacheRead: 0.3,
    output: 15
  },
  haiku45: {
    input: 1,
    cacheWrite5m: 1.25,
    cacheWrite1h: 2,
    cacheRead: 0.1,
    output: 5
  }
};

function expandHome(input) {
  if (!input) return input;
  if (input === "~") return HOME;
  if (input.startsWith("~/")) return path.join(HOME, input.slice(2));
  return input;
}

function n(value) {
  return Number.isFinite(Number(value)) ? Number(value) : 0;
}

function parseTime(value, fallback = Date.now()) {
  const ms = Date.parse(value);
  return Number.isFinite(ms) ? ms : fallback;
}

function dayKey(ms) {
  const date = new Date(ms);
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, "0");
  const d = String(date.getDate()).padStart(2, "0");
  return `${y}-${m}-${d}`;
}

function hourKey(ms) {
  const date = new Date(ms);
  const y = date.getUTCFullYear();
  const m = String(date.getUTCMonth() + 1).padStart(2, "0");
  const d = String(date.getUTCDate()).padStart(2, "0");
  const h = String(date.getUTCHours()).padStart(2, "0");
  return `${y}-${m}-${d} ${h}:00`;
}

function shortPath(filePath) {
  return filePath.replace(HOME, "~");
}

function projectNameFromContext(cwd, sourcePath) {
  if (cwd) return path.basename(cwd) || "unknown-project";
  const normalized = sourcePath || "";
  const parts = normalized.split(path.sep).filter(Boolean);
  return parts.at(-2) || "unknown-project";
}

function normalizeModelName(value, fallback = "unknown") {
  if (!value || typeof value !== "string") return fallback;
  const trimmed = value.trim();
  if (!trimmed) return fallback;
  if (trimmed === "openai") return "openai/unknown";
  if (trimmed === "codex") return "codex/unknown";
  return trimmed;
}

function pricingForModel(model, provider) {
  const normalized = normalizeModelName(model, "").toLowerCase();

  if (provider === "openai") {
    const exact = OPENAI_PRICES[normalized];
    if (exact) return { provider, unit: "MTok", ...exact };
    return null;
  }

  if (provider === "anthropic") {
    if (
      normalized.startsWith("claude-fable-5") ||
      normalized.startsWith("claude-mythos-5")
    ) {
      return { provider, unit: "MTok", ...CLAUDE_PRICES.fable5 };
    }
    if (
      normalized.startsWith("claude-opus-4-8") ||
      normalized.startsWith("claude-opus-4-7") ||
      normalized.startsWith("claude-opus-4-6") ||
      normalized.startsWith("claude-opus-4-5")
    ) {
      return { provider, unit: "MTok", ...CLAUDE_PRICES.opusCurrent };
    }
    if (
      normalized.startsWith("claude-opus-4-1") ||
      normalized === "claude-opus-4"
    ) {
      return { provider, unit: "MTok", ...CLAUDE_PRICES.opusLegacy };
    }
    if (normalized.startsWith("claude-sonnet-5")) {
      return { provider, unit: "MTok", ...CLAUDE_PRICES.sonnet5Intro };
    }
    if (normalized.startsWith("claude-sonnet-4")) {
      return { provider, unit: "MTok", ...CLAUDE_PRICES.sonnet };
    }
    if (normalized.startsWith("claude-haiku-4-5")) {
      return { provider, unit: "MTok", ...CLAUDE_PRICES.haiku45 };
    }
  }

  return null;
}

function estimateEventCost(event) {
  const pricing = pricingForModel(event.model, event.provider);
  if (!pricing) {
    return {
      costUsd: 0,
      hasPricing: false,
      pricing: null,
      pricedTokens: 0,
      unpricedTokens: event.totalTokens
    };
  }

  let costUsd = 0;
  if (event.provider === "openai") {
    const cachedInput = event.cacheReadTokens;
    const freshInput = Math.max(0, event.inputTokens - cachedInput);
    costUsd =
      (freshInput * pricing.input) / 1_000_000 +
      (cachedInput * pricing.cachedInput) / 1_000_000 +
      (event.outputTokens * pricing.output) / 1_000_000;
  } else if (event.provider === "anthropic") {
    const knownCacheWrites =
      event.cacheCreation5mTokens + event.cacheCreation1hTokens;
    const unclassifiedCacheWrites = Math.max(
      0,
      event.cacheCreationTokens - knownCacheWrites
    );
    costUsd =
      (event.inputTokens * pricing.input) / 1_000_000 +
      (event.cacheReadTokens * pricing.cacheRead) / 1_000_000 +
      (event.cacheCreation5mTokens * pricing.cacheWrite5m) / 1_000_000 +
      (event.cacheCreation1hTokens * pricing.cacheWrite1h) / 1_000_000 +
      (unclassifiedCacheWrites * pricing.cacheWrite5m) / 1_000_000 +
      (event.outputTokens * pricing.output) / 1_000_000;
  }

  return {
    costUsd,
    hasPricing: true,
    pricing,
    pricedTokens: event.totalTokens,
    unpricedTokens: 0
  };
}

async function exists(filePath) {
  try {
    await fs.access(filePath);
    return true;
  } catch {
    return false;
  }
}

async function walkJsonl(root) {
  const files = [];
  if (!(await exists(root))) return files;

  async function walk(dir) {
    let entries = [];
    try {
      entries = await fs.readdir(dir, { withFileTypes: true });
    } catch {
      return;
    }

    for (const entry of entries) {
      const full = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        await walk(full);
      } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
        files.push(full);
      }
    }
  }

  await walk(root);
  return files;
}

async function readJsonl(filePath, onObject) {
  let raw = "";
  try {
    raw = await fs.readFile(filePath, "utf8");
  } catch {
    return;
  }

  const lines = raw.split("\n");
  for (let index = 0; index < lines.length; index += 1) {
    const line = lines[index].trim();
    if (!line) continue;
    try {
      await onObject(JSON.parse(line), index);
    } catch {
      // Session files can contain partial trailing writes while an agent is active.
    }
  }
}

function claudeEventFromMessage(row, filePath, lineIndex, seenRequests) {
  const message = row?.message;
  const usage = message?.usage;
  if (!usage || row?.type !== "assistant") return null;

  const requestKey = row.requestId || row.uuid || `${filePath}:${lineIndex}`;
  if (seenRequests.has(requestKey)) return null;
  seenRequests.add(requestKey);

  const nestedCreation =
    n(usage.cache_creation?.ephemeral_5m_input_tokens) +
    n(usage.cache_creation?.ephemeral_1h_input_tokens);
  const cacheCreationTokens =
    n(usage.cache_creation_input_tokens) || nestedCreation;
  const cacheCreation5mTokens =
    n(usage.cache_creation?.ephemeral_5m_input_tokens) ||
    (cacheCreationTokens && !nestedCreation ? cacheCreationTokens : 0);
  const cacheCreation1hTokens = n(usage.cache_creation?.ephemeral_1h_input_tokens);
  const inputTokens = n(usage.input_tokens);
  const outputTokens = n(usage.output_tokens);
  const cacheReadTokens = n(usage.cache_read_input_tokens);
  const totalTokens =
    inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;

  if (totalTokens <= 0) return null;

  return {
    tool: "claude",
    timestamp: parseTime(row.timestamp),
    sessionId: row.sessionId || path.basename(filePath, ".jsonl"),
    model: message.model || "claude",
    provider: "anthropic",
    cwd: row.cwd || "",
    sourcePath: shortPath(filePath),
    totalTokens,
    inputTokens,
    outputTokens,
    cacheReadTokens,
    cacheCreationTokens,
    cacheCreation5mTokens,
    cacheCreation1hTokens,
    reasoningTokens: 0
  };
}

function codexUsageDelta(current, previous) {
  const currentTotal = n(current.total_tokens);
  const previousTotal = n(previous.total_tokens);
  const reset = previousTotal > currentTotal;
  const base = reset ? {} : previous;

  return {
    total_tokens: Math.max(0, currentTotal - n(base.total_tokens)),
    input_tokens: Math.max(0, n(current.input_tokens) - n(base.input_tokens)),
    output_tokens: Math.max(0, n(current.output_tokens) - n(base.output_tokens)),
    cached_input_tokens: Math.max(
      0,
      n(current.cached_input_tokens) - n(base.cached_input_tokens)
    ),
    reasoning_output_tokens: Math.max(
      0,
      n(current.reasoning_output_tokens) - n(base.reasoning_output_tokens)
    )
  };
}

function sameCodexUsage(left, right) {
  return [
    "total_tokens",
    "input_tokens",
    "output_tokens",
    "cached_input_tokens",
    "reasoning_output_tokens"
  ].every((key) => n(left?.[key]) === n(right?.[key]));
}

function codexForkParentId(payload) {
  return (
    payload?.forked_from_id ||
    payload?.parent_thread_id ||
    payload?.source?.subagent?.thread_spawn?.parent_thread_id ||
    ""
  );
}

function codexEventFromTokenCount(tokenCount, previousUsage) {
  const delta = codexUsageDelta(tokenCount.usage, previousUsage);

  if (delta.total_tokens <= 0) return null;

  return {
    tool: "codex",
    timestamp: tokenCount.timestamp,
    sessionId: tokenCount.sessionId,
    model: tokenCount.model,
    provider: tokenCount.provider,
    modelSource: tokenCount.modelSource,
    cwd: tokenCount.cwd,
    sourcePath: tokenCount.sourcePath,
    totalTokens: delta.total_tokens,
    inputTokens: delta.input_tokens,
    outputTokens: delta.output_tokens,
    cacheReadTokens: delta.cached_input_tokens,
    cacheCreationTokens: 0,
    cacheCreation5mTokens: 0,
    cacheCreation1hTokens: 0,
    reasoningTokens: delta.reasoning_output_tokens
  };
}

function codexModelFromPayload(payload) {
  return (
    payload?.model ||
    payload?.model_slug ||
    payload?.default_model ||
    payload?.collaboration_mode?.settings?.model ||
    ""
  );
}

async function parseClaude(root) {
  const files = await walkJsonl(root);
  const events = [];
  const seenRequests = new Set();

  for (const filePath of files) {
    await readJsonl(filePath, (row, lineIndex) => {
      const event = claudeEventFromMessage(row, filePath, lineIndex, seenRequests);
      if (event) events.push(event);
    });
  }

  return { files: files.length, events };
}

function loadCodexThreadModelIndex() {
  const byId = new Map();
  const byPath = new Map();

  try {
    const output = execFileSync(
      "sqlite3",
      [
        "-separator",
        "\t",
        DEFAULT_CODEX_STATE,
        "select id, rollout_path, coalesce(model,''), coalesce(model_provider,'') from threads;"
      ],
      { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
    );

    for (const line of output.split("\n")) {
      if (!line.trim()) continue;
      const [id, rolloutPath, model, provider] = line.split("\t");
      const record = {
        model: normalizeModelName(model, ""),
        provider: provider || "openai",
        source: "thread-index"
      };
      if (id && record.model) byId.set(id, record);
      if (rolloutPath && record.model) byPath.set(rolloutPath, record);
    }
  } catch {
    // The JSONL parser still works without the optional SQLite index.
  }

  return { byId, byPath };
}

async function parseCodex(root) {
  const files = await walkJsonl(root);
  const threadModels = loadCodexThreadModelIndex();
  const sessions = [];

  for (const filePath of files) {
    const indexed = threadModels.byPath.get(filePath);
    const state = {
      sessionId: path.basename(filePath, ".jsonl"),
      model: indexed?.model || "",
      provider: indexed?.provider || "openai",
      modelSource: indexed ? indexed.source : "fallback",
      cwd: ""
    };
    const session = {
      sessionId: state.sessionId,
      forkedFromId: "",
      startedAt: 0,
      tokenCounts: []
    };
    let sawPrimarySessionMeta = false;

    await readJsonl(filePath, (row) => {
      if (row?.type === "session_meta") {
        if (!sawPrimarySessionMeta) {
          sawPrimarySessionMeta = true;
          state.sessionId = row.payload?.id || state.sessionId;
          session.sessionId = state.sessionId;
          session.forkedFromId = codexForkParentId(row.payload);
          session.startedAt = parseTime(row.timestamp);
        }

        const indexedById = threadModels.byId.get(session.sessionId);
        const metaModel = codexModelFromPayload(row.payload);
        state.model =
          normalizeModelName(metaModel, "") ||
          indexedById?.model ||
          state.model;
        state.provider =
          row.payload?.model_provider ||
          indexedById?.provider ||
          state.provider ||
          "openai";
        state.modelSource = metaModel
          ? "session-meta"
          : indexedById?.source || state.modelSource;
        state.cwd = row.payload?.cwd || state.cwd;
        return;
      }

      if (row?.type === "turn_context") {
        const turnModel = codexModelFromPayload(row.payload);
        if (turnModel) {
          state.model = normalizeModelName(turnModel);
          state.modelSource = "turn-context";
        }
        state.cwd = row.payload?.cwd || state.cwd;
        return;
      }

      const payload = row?.payload;
      const usage = payload?.info?.total_token_usage;
      if (row?.type !== "event_msg" || payload?.type !== "token_count" || !usage) return;

      session.tokenCounts.push({
        usage,
        timestamp: parseTime(row.timestamp),
        sessionId: session.sessionId,
        model: normalizeModelName(state.model, "openai/unknown"),
        provider: state.provider || "openai",
        modelSource: state.modelSource || "fallback",
        cwd: state.cwd || "",
        sourcePath: shortPath(filePath)
      });
    });

    sessions.push(session);
  }

  const byId = new Map(sessions.map((session) => [session.sessionId, session]));
  const events = [];
  let forks = 0;
  let dedupedForks = 0;
  let freshForks = 0;
  let unresolvedForks = 0;

  for (const session of sessions) {
    let previousUsage = {};
    let startIndex = 0;

    if (session.forkedFromId) {
      forks += 1;
      const parent = byId.get(session.forkedFromId);
      const inheritedUsage = parent?.tokenCounts
        .filter((tokenCount) => tokenCount.timestamp <= session.startedAt)
        .at(-1)?.usage;

      if (inheritedUsage) {
        const inheritedIndex = session.tokenCounts.findIndex((tokenCount) =>
          sameCodexUsage(tokenCount.usage, inheritedUsage)
        );

        if (inheritedIndex >= 0) {
          previousUsage = inheritedUsage;
          startIndex = inheritedIndex + 1;
          dedupedForks += 1;
        } else if (
          n(session.tokenCounts[0]?.usage?.total_tokens) <
          n(inheritedUsage.total_tokens)
        ) {
          freshForks += 1;
        } else {
          unresolvedForks += 1;
        }
      } else {
        freshForks += 1;
      }
    }

    for (const tokenCount of session.tokenCounts.slice(startIndex)) {
      const event = codexEventFromTokenCount(tokenCount, previousUsage);
      previousUsage = tokenCount.usage;
      if (event) events.push(event);
    }
  }

  return {
    files: files.length,
    events,
    forks,
    dedupedForks,
    freshForks,
    unresolvedForks
  };
}

function addTokens(target, event) {
  target.totalTokens += event.totalTokens;
  target.inputTokens += event.inputTokens;
  target.outputTokens += event.outputTokens;
  target.cacheReadTokens += event.cacheReadTokens;
  target.cacheCreationTokens += event.cacheCreationTokens;
  target.cacheCreation5mTokens += event.cacheCreation5mTokens;
  target.cacheCreation1hTokens += event.cacheCreation1hTokens;
  target.reasoningTokens += event.reasoningTokens;
  target.costUsd += event.costUsd;
  target.pricedTokens += event.pricedTokens;
  target.unpricedTokens += event.unpricedTokens;
  if (event.hasPricing) target.pricedEvents += 1;
  else target.unpricedEvents += 1;
  target.events += 1;
}

function emptyTotals() {
  return {
    totalTokens: 0,
    inputTokens: 0,
    outputTokens: 0,
    cacheReadTokens: 0,
    cacheCreationTokens: 0,
    cacheCreation5mTokens: 0,
    cacheCreation1hTokens: 0,
    reasoningTokens: 0,
    costUsd: 0,
    pricedTokens: 0,
    unpricedTokens: 0,
    pricedEvents: 0,
    unpricedEvents: 0,
    events: 0
  };
}

function titleFor(totalTokens) {
  if (totalTokens >= 100_000_000) return "Compute Event Horizon";
  if (totalTokens >= 25_000_000) return "Inference Furnace";
  if (totalTokens >= 5_000_000) return "Context Maxxer";
  if (totalTokens >= 1_000_000) return "Million Token Club";
  if (totalTokens >= 250_000) return "Prompt Overclocker";
  if (totalTokens >= 50_000) return "Warm Cache";
  return "Idle Runtime";
}

function aggregate(events, sourceStats, startedAt) {
  const now = Date.now();
  const startOfToday = new Date();
  startOfToday.setHours(0, 0, 0, 0);

  const totals = emptyTotals();
  const today = emptyTotals();
  const week = emptyTotals();
  const month = emptyTotals();
  const lastHour = emptyTotals();
  const tools = new Map();
  const models = new Map();
  const openaiModels = new Map();
  const sessions = new Map();
  const byDay = new Map();
  const contributionDays = new Map();
  const modelContributionDays = new Map();
  const byHour = new Map();
  const activeDays = new Set();

  for (let i = 13; i >= 0; i -= 1) {
    const date = new Date(now - i * DAY_MS);
    byDay.set(dayKey(date.getTime()), { date: dayKey(date.getTime()), totalTokens: 0, claude: 0, codex: 0 });
  }

  for (let i = 23; i >= 0; i -= 1) {
    const date = new Date(now - i * 60 * 60 * 1000);
    byHour.set(hourKey(date.getTime()), { hour: hourKey(date.getTime()), totalTokens: 0, costUsd: 0, events: 0 });
  }

  for (const event of events) {
    Object.assign(event, estimateEventCost(event));
    addTokens(totals, event);

    if (event.timestamp >= startOfToday.getTime()) addTokens(today, event);
    if (event.timestamp >= now - 7 * DAY_MS) addTokens(week, event);
    if (event.timestamp >= now - 30 * DAY_MS) addTokens(month, event);
    if (event.timestamp >= now - 60 * 60 * 1000) addTokens(lastHour, event);

    const toolTotals = tools.get(event.tool) || emptyTotals();
    addTokens(toolTotals, event);
    tools.set(event.tool, toolTotals);

    const modelKey = event.model || event.tool;
    const modelTotals = models.get(modelKey) || {
      model: modelKey,
      provider: event.provider || "unknown",
      tools: new Set(),
      sources: new Set(),
      ...emptyTotals()
    };
    addTokens(modelTotals, event);
    modelTotals.tools.add(event.tool);
    if (event.modelSource) modelTotals.sources.add(event.modelSource);
    models.set(modelKey, modelTotals);

    if (event.provider === "openai") {
      const openaiModelTotals = openaiModels.get(modelKey) || {
        model: modelKey,
        provider: "openai",
        tools: new Set(),
        sources: new Set(),
        ...emptyTotals()
      };
      addTokens(openaiModelTotals, event);
      openaiModelTotals.tools.add(event.tool);
      if (event.modelSource) openaiModelTotals.sources.add(event.modelSource);
      openaiModels.set(modelKey, openaiModelTotals);
    }

    const sessionKey = `${event.tool}:${event.sessionId}`;
    const session = sessions.get(sessionKey) || {
      id: event.sessionId,
      tool: event.tool,
      model: event.model,
      provider: event.provider,
      cwd: event.cwd,
      projectName: projectNameFromContext(event.cwd, event.sourcePath),
      sourcePath: event.sourcePath,
      totalTokens: 0,
      inputTokens: 0,
      outputTokens: 0,
      cacheReadTokens: 0,
      cacheCreationTokens: 0,
      reasoningTokens: 0,
      costUsd: 0,
      events: 0,
      firstSeen: event.timestamp,
      lastSeen: event.timestamp
    };
    session.totalTokens += event.totalTokens;
    session.inputTokens += event.inputTokens;
    session.outputTokens += event.outputTokens;
    session.cacheReadTokens += event.cacheReadTokens;
    session.cacheCreationTokens += event.cacheCreationTokens;
    session.reasoningTokens += event.reasoningTokens;
    session.costUsd += event.costUsd;
    session.events += 1;
    session.firstSeen = Math.min(session.firstSeen, event.timestamp);
    session.lastSeen = Math.max(session.lastSeen, event.timestamp);
    if (!session.cwd && event.cwd) session.cwd = event.cwd;
    sessions.set(sessionKey, session);

    const dKey = dayKey(event.timestamp);
    activeDays.add(dKey);
    const contributionBucket = contributionDays.get(dKey) || {
      date: dKey,
      totalTokens: 0,
      costUsd: 0,
      events: 0
    };
    contributionBucket.totalTokens += event.totalTokens;
    contributionBucket.costUsd += event.costUsd;
    contributionBucket.events += 1;
    contributionDays.set(dKey, contributionBucket);

    const modelDayKey = [
      dKey,
      event.tool || "unknown",
      event.provider || "unknown",
      event.model || event.tool || "unknown"
    ].join("\t");
    const modelDayBucket = modelContributionDays.get(modelDayKey) || {
      date: dKey,
      tool: event.tool || "unknown",
      provider: event.provider || "unknown",
      model: event.model || event.tool || "unknown",
      ...emptyTotals()
    };
    addTokens(modelDayBucket, event);
    modelContributionDays.set(modelDayKey, modelDayBucket);

    if (byDay.has(dKey)) {
      const bucket = byDay.get(dKey);
      bucket.totalTokens += event.totalTokens;
      bucket.costUsd = (bucket.costUsd || 0) + event.costUsd;
      bucket[event.tool] += event.totalTokens;
    }

    const hKey = hourKey(event.timestamp);
    if (byHour.has(hKey)) {
      byHour.get(hKey).totalTokens += event.totalTokens;
      byHour.get(hKey).costUsd += event.costUsd;
      byHour.get(hKey).events += 1;
    }
  }

  let streak = 0;
  for (let i = 0; i < 365; i += 1) {
    const key = dayKey(now - i * DAY_MS);
    if (!activeDays.has(key)) break;
    streak += 1;
  }

  const recentSessions = [...sessions.values()]
    .sort((a, b) => b.lastSeen - a.lastSeen)
    .slice(0, 10);
  const allSessions = [...sessions.values()].sort((a, b) => b.lastSeen - a.lastSeen);
  const biggestSession = [...sessions.values()].sort(
    (a, b) => b.totalTokens - a.totalTokens
  )[0] || null;

  return {
    generatedAt: new Date(now).toISOString(),
    scanMs: Date.now() - startedAt,
    roots: {
      claude: shortPath(sourceStats.claudeRoot),
      codex: shortPath(sourceStats.codexRoot)
    },
    sourceStats,
    pricing: {
      updated: PRICING_UPDATED,
      mode: "standard API list price estimate",
      currency: "USD",
      sources: PRICING_SOURCES
    },
    totals,
    windows: {
      today,
      week,
      month,
      lastHour
    },
    burnRatePerMinute: Math.round(lastHour.totalTokens / 60),
    title: titleFor(week.totalTokens),
    streakDays: streak,
    tools: [...tools.entries()]
      .map(([tool, value]) => ({ tool, ...value }))
      .sort((a, b) => b.totalTokens - a.totalTokens),
    models: [...models.values()]
      .map((model) => ({
        ...model,
        tools: [...model.tools].sort(),
        sources: [...model.sources].sort()
      }))
      .sort((a, b) => b.totalTokens - a.totalTokens)
      .slice(0, 12),
    openaiModels: [...openaiModels.values()]
      .map((model) => ({
        ...model,
        tools: [...model.tools].sort(),
        sources: [...model.sources].sort()
      }))
      .sort((a, b) => b.totalTokens - a.totalTokens),
    byDay: [...byDay.values()],
    contributionDays: [...contributionDays.values()].sort((a, b) =>
      a.date.localeCompare(b.date)
    ),
    modelContributionDays: [...modelContributionDays.values()].sort((a, b) => {
      const dateOrder = a.date.localeCompare(b.date);
      if (dateOrder) return dateOrder;
      return b.totalTokens - a.totalTokens;
    }),
    byHour: [...byHour.values()],
    sessions: allSessions,
    recentSessions,
    biggestSession
  };
}

export async function scanUsage(options = {}) {
  const startedAt = Date.now();
  const claudeRoot = expandHome(
    options.claudeRoot || process.env.CLAUDE_USAGE_ROOT || DEFAULT_CLAUDE_ROOT
  );
  const codexRoot = expandHome(
    options.codexRoot || process.env.CODEX_USAGE_ROOT || DEFAULT_CODEX_ROOT
  );

  const [claude, codex] = await Promise.all([
    parseClaude(claudeRoot),
    parseCodex(codexRoot)
  ]);
  const events = [...claude.events, ...codex.events].sort(
    (a, b) => a.timestamp - b.timestamp
  );

  return aggregate(
    events,
    {
      claudeRoot,
      codexRoot,
      claudeFiles: claude.files,
      codexFiles: codex.files,
      claudeEvents: claude.events.length,
      codexEvents: codex.events.length,
      codexForks: codex.forks,
      codexDedupedForks: codex.dedupedForks,
      codexFreshForks: codex.freshForks,
      codexUnresolvedForks: codex.unresolvedForks
    },
    startedAt
  );
}

export function formatCompact(value) {
  const abs = Math.abs(value);
  if (abs >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`;
  if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
  if (abs >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
  return String(Math.round(value));
}
