/* Parley — the arithmetic. Every figure in the product is one of these
   functions called on one of the fixture arrays; no screen adds up its own
   numbers and no screen types a price.

   Two quantities, and keeping them apart is the whole of this file:

     · CONTEXT is what the window holds — every message body in the thread plus
       every attached file that actually indexed. It is what the meter shows.
     · BILLING is what the turns cost. A model re-reads the whole conversation
       on every turn, so an assistant turn is billed for the context that
       existed when it ran (input) plus what it wrote (output). Nothing stores
       that: it is derived from the messages before it, which is why the usage
       screen can never drift from the threads.

   A figure names the array it comes from. `billingRows(threads, files)` is
   every billed turn in the workspace; the usage table's model rows partition
   that array and therefore sum back to its headline. */

const DAY = 864e5;

const modelOf = (id) => MODELS.find((m) => m.value === id) || MODELS[0];

/* Four characters to a token — what the composer's live counter uses. The
   fixture messages carry their own counts. */
const estimateTokens = (text) => ((text || "").trim() ? Math.round(text.length / 4) : 0);
const blocksText = (blocks) => (blocks || []).map((b) => (b.t === "code" ? b.code : b.items ? b.items.join(" ") : b.text || "")).join(" ");
const blockTokens = (blocks) => estimateTokens(blocksText(blocks));

/* ── Context ───────────────────────────────────────────────────────────── */

const messageTokens = (m) => m.tokens || 0;
/* Only files the index could read are carried; the others cost nothing and
   contribute nothing, which is exactly why they are worth chasing. */
const threadFileTokens = (thread, files) => (thread.fileIds || [])
  .map((id) => files.find((f) => f.id === id))
  .filter((f) => f && FILE_FLAGS.inContext(f))
  .reduce((sum, f) => sum + f.tokens, 0);

const contextUsed = (thread, files) => threadFileTokens(thread, files) + thread.messages.reduce((sum, m) => sum + messageTokens(m), 0);
const contextMax = (thread) => modelOf(thread.model).contextTokens;
const contextRatio = (thread, files) => contextUsed(thread, files) / contextMax(thread);
const contextLeft = (thread, files) => Math.max(0, contextMax(thread) - contextUsed(thread, files));

/* ── Billing ───────────────────────────────────────────────────────────── */

/* What one assistant turn was billed: everything in the window when it ran,
   plus what it produced. A failed turn produced nothing and is billed nothing. */
function turnBilling(thread, index, files) {
  const message = thread.messages[index];
  if (message.role !== "assistant" || message.status === "failed") return { in: 0, out: 0, cost: 0 };
  const carried = threadFileTokens(thread, files) + thread.messages.slice(0, index).reduce((sum, m) => sum + messageTokens(m), 0);
  const out = messageTokens(message);
  const model = modelOf(message.model || thread.model);
  return { in: carried, out: out, cost: (carried * model.inRate + out * model.outRate) / 1e6 };
}

/* The array every usage figure reads: one row per billed turn. */
function billingRows(threads, files) {
  const rows = [];
  threads.forEach((thread) => {
    thread.messages.forEach((message, i) => {
      if (message.role !== "assistant" || message.status === "failed") return;
      const bill = turnBilling(thread, i, files);
      rows.push({ id: thread.id + "/" + message.id, thread: thread, message: message, at: message.at, model: message.model || thread.model, in: bill.in, out: bill.out, cost: bill.cost });
    });
  });
  return rows.sort((a, b) => a.at - b.at);
}

const tokensOf = (rows) => rows.reduce((sum, r) => sum + r.in + r.out, 0);
const costOf = (rows) => rows.reduce((sum, r) => sum + r.cost, 0);
const threadBilling = (thread, files) => {
  const rows = billingRows([thread], files);
  return { rows: rows, tokens: tokensOf(rows), cost: costOf(rows) };
};

/* ── Groupings — each partitions the array it is given, so the parts sum ── */

function byModel(rows) {
  return MODELS.map((model) => {
    const own = rows.filter((r) => r.model === model.value);
    return { key: model.value, model: model, rows: own, tokens: tokensOf(own), cost: costOf(own) };
  }).filter((row) => row.rows.length);
}

function byOwner(rows, people) {
  return people.map((person) => {
    const own = rows.filter((r) => r.thread.ownerId === person.id);
    return { key: person.id, person: person, rows: own, threads: own.reduce((ids, r) => (ids.indexOf(r.thread.id) > -1 ? ids : ids.concat([r.thread.id])), []).length, tokens: tokensOf(own), cost: costOf(own) };
  }).filter((row) => row.rows.length).sort((a, b) => b.tokens - a.tokens);
}

/* The day chart. Buckets the same rows the headline reads, so the bars add up
   to it — true only while every billed turn in the cycle falls inside the
   window, which the caller states in words beside the chart. */
function byDay(rows, now, days) {
  const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
  const first = midnight.getTime() - (days - 1) * DAY;
  const buckets = [];
  for (let i = 0; i < days; i++) {
    const from = first + i * DAY;
    const own = rows.filter((r) => r.at >= from && r.at < from + DAY);
    buckets.push({ at: from, rows: own, tokens: tokensOf(own), cost: costOf(own) });
  }
  return buckets;
}

/* ── The cycle, the plan and the bill ─────────────────────────────────── */

/* One function per figure, and the billing screen and the usage screen call the
   same ones. The current cycle is never stored: it is the billed turns whose
   timestamps fall inside it, which is why sending a message moves the estimate. */
const planOf = (id) => PLANS.find((p) => p.value === id) || PLANS[0];
const cycleRows = (data) => billingRows(data.threads, data.files).filter((r) => r.at >= WORKSPACE.cycleStart && r.at <= WORKSPACE.cycleEnd);
const cycleUsage = (data) => { const rows = cycleRows(data); return { rows: rows, tokens: tokensOf(rows), cost: costOf(rows) }; };

/* The turns that ran after the allowance was exhausted. A turn is inside the
   allowance or outside it — nothing is prorated, and the screen says so. */
function overageRows(data) {
  const included = planOf(WORKSPACE.plan).includedTokens;
  let carried = 0;
  return cycleRows(data).filter((r) => { const before = carried; carried += r.in + r.out; return before >= included; });
}

function cycleCharges(data) {
  const plan = planOf(WORKSPACE.plan);
  const over = overageRows(data);
  const seatCost = WORKSPACE.seats * plan.seatPrice;
  const overage = costOf(over);
  return { plan: plan, seats: WORKSPACE.seats, seatCost: seatCost, overageRows: over, overage: overage, total: seatCost + overage };
}

const invoiceTotal = (invoice) => invoice.seatCost + invoice.usageCost;
const chargedInvoices = (invoices) => invoices.filter(INVOICE_FLAGS.isCharged);

/* ── Seats and people ─────────────────────────────────────────────────── */

/* A seat is held by a person or by an invite still standing. An expired invite
   holds nothing, which is why the two figures differ. */
const standingInvites = (data) => data.invites.filter((i) => INVITE_FLAGS.isStanding(i, data.now));
const expiredInvites = (data) => data.invites.filter((i) => INVITE_FLAGS.isExpired(i, data.now));
const seatsUsed = (data) => data.people.length + standingInvites(data).length;
const seatsLeft = (data) => Math.max(0, WORKSPACE.seats - seatsUsed(data));
const canInvite = (data) => seatsLeft(data) > 0;

/* One row per person in the workspace, whether or not they have run a turn —
   the members table lists people, and usage's `byOwner` lists spenders. */
function memberRows(data) {
  const rows = billingRows(data.threads, data.files);
  return data.people.map((person) => {
    const own = rows.filter((r) => r.thread.ownerId === person.id);
    return {
      person: person,
      threads: data.threads.filter((t) => t.ownerId === person.id),
      rows: own, tokens: tokensOf(own), cost: costOf(own),
      dormant: PERSON_FLAGS.isDormant(person, data.now),
    };
  });
}

/* ── Shelves and search ───────────────────────────────────────────────── */

const liveThreads = (threads) => threads.filter((t) => !THREAD_FLAGS.isArchived(t));
const archivedThreads = (threads) => threads.filter(THREAD_FLAGS.isArchived);
/* Shared WITH you: someone else's, readable, and not on the archive shelf. */
const sharedThreads = (threads, userId) => liveThreads(threads).filter((t) => t.ownerId !== userId && t.visibility !== "private");
const sharedProjects = (projects, userId) => projects.filter((p) => p.ownerId !== userId && p.visibility === "workspace" && !PROJECT_FLAGS.isArchived(p));

/* Search reads the four fixture arrays and says which one each hit came from,
   plus the line that matched — a result with no reason is not checkable. */
function searchAll(data, query) {
  const q = query.trim().toLowerCase();
  if (!q) return [];
  const hit = (text) => (text || "").toLowerCase().indexOf(q) > -1;
  const clip = (text) => (text.length > 120 ? text.slice(0, 120).trim() + "…" : text);
  const groups = [];

  const threads = [];
  data.threads.forEach((thread) => {
    const inTitle = hit(thread.title);
    const message = thread.messages.find((m) => hit(blocksText(m.blocks)));
    if (!inTitle && !message) return;
    threads.push({
      id: thread.id, title: thread.title,
      meta: (thread.projectId ? projectById(data.projects, thread.projectId).name : "No project") + " · " + personById(data.people, thread.ownerId).name + (THREAD_FLAGS.isArchived(thread) ? " · archived" : ""),
      why: inTitle ? "The title matches." : clip(blocksText(message.blocks)),
      route: resolvePath("/chats/:id", { id: thread.id }),
      recessed: THREAD_FLAGS.isArchived(thread),
    });
  });
  groups.push({ kind: "threads", items: threads });

  groups.push({ kind: "projects", items: data.projects.filter((p) => hit(p.name) || hit(p.purpose) || hit(p.instructions)).map((p) => ({
    id: p.id, title: p.name,
    meta: threadsInProject(data.threads, p.id).length + " threads · " + filesInProject(data.files, p.id).length + " files",
    why: hit(p.name) ? "The name matches." : clip(hit(p.purpose) ? p.purpose : p.instructions),
    route: resolvePath("/projects/:id", { id: p.id }),
    recessed: PROJECT_FLAGS.isArchived(p),
  })) });

  groups.push({ kind: "files", items: data.files.filter((f) => hit(f.name) || hit(f.note)).map((f) => ({
    id: f.id, title: f.name,
    meta: formatBytes(f.bytes) + " · " + statusValue(FILE_STATUS, f.status).label.toLowerCase(),
    why: hit(f.name) ? "The name matches." : clip(f.note),
    route: resolvePath("/files/:id", { id: f.id }),
    recessed: !FILE_FLAGS.inContext(f),
  })) });

  groups.push({ kind: "prompts", items: data.prompts.filter((p) => hit(p.title) || hit(p.body)).map((p) => ({
    id: p.id, title: p.title,
    meta: labelOf(PROMPT_CATEGORIES, p.category) + " · used " + p.uses + " times",
    why: hit(p.title) ? "The title matches." : clip(p.body),
    route: resolvePath("/library/:id", { id: p.id }),
    recessed: PROMPT_FLAGS.isStale(p, data.now),
  })) });

  return groups;
}

const searchTotal = (groups) => groups.reduce((n, g) => n + g.items.length, 0);

/* ── Relations ─────────────────────────────────────────────────────────── */

const threadById = (threads, id) => threads.find((t) => t.id === id) || null;
const projectById = (projects, id) => projects.find((p) => p.id === id) || null;
const fileById = (files, id) => files.find((f) => f.id === id) || null;
const personById = (people, id) => people.find((p) => p.id === id) || people[0];
const promptById = (prompts, id) => prompts.find((p) => p.id === id) || null;
const threadsInProject = (threads, projectId) => threads.filter((t) => t.projectId === projectId);
const filesInProject = (files, projectId) => files.filter((f) => f.projectId === projectId);
/* Threads this account can open: its own, plus anything shared into the
   workspace. The nested nav list, the palette and the project record read it. */
const visibleThreads = (threads, userId) => threads.filter((t) => t.ownerId === userId || t.visibility !== "private");

/* ── Spellings — one per quantity, product-wide ────────────────────────── */

function formatTokens(n) {
  if (n < 1000) return String(Math.round(n));
  if (n < 1e6) return (n / 1000).toFixed(n < 10000 ? 1 : 0).replace(/\.0$/, "") + "k";
  return (n / 1e6).toFixed(2).replace(/0$/, "") + "M";
}
const formatCost = (cents) => "$" + (cents / 100).toFixed(2);
const formatRate = (centsPerMillion) => "$" + (centsPerMillion / 100).toFixed(2) + "/M";
const formatBytes = (bytes) => (bytes >= 1e6 ? (bytes / 1e6).toFixed(1).replace(/\.0$/, "") + " MB" : Math.round(bytes / 1000) + " KB");
const formatDay = (ts) => new Date(ts).toLocaleDateString("en-GB", { day: "numeric", month: "short" });
const formatDayFull = (ts) => new Date(ts).toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" });
const formatClock = (ts) => new Date(ts).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });

/* One spelling for "how long ago", used by the thread list and the file table
   and nowhere spelled a second way. */
function formatAge(ts, now) {
  const d = now - ts;
  if (d < 3600e3) return Math.max(1, Math.round(d / 60000)) + "m ago";
  if (d < DAY) return Math.round(d / 3600e3) + "h ago";
  if (d < 7 * DAY) return Math.round(d / DAY) + "d ago";
  return formatDay(ts);
}
/* The nested thread list groups by when a thread was last touched. */
function ageBucket(ts, now) {
  const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
  if (ts >= midnight.getTime()) return "Today";
  if (ts >= midnight.getTime() - DAY) return "Yesterday";
  if (ts >= midnight.getTime() - 7 * DAY) return "Earlier this week";
  return "Older";
}

Object.assign(window, {
  DAY, modelOf, estimateTokens, blocksText, blockTokens,
  messageTokens, threadFileTokens, contextUsed, contextMax, contextRatio, contextLeft,
  turnBilling, billingRows, tokensOf, costOf, threadBilling,
  byModel, byOwner, byDay,
  threadById, projectById, fileById, personById, promptById, threadsInProject, filesInProject, visibleThreads,
  planOf, cycleRows, cycleUsage, overageRows, cycleCharges, invoiceTotal, chargedInvoices,
  standingInvites, expiredInvites, seatsUsed, seatsLeft, canInvite, memberRows,
  liveThreads, archivedThreads, sharedThreads, sharedProjects, searchAll, searchTotal,
  formatTokens, formatCost, formatRate, formatBytes, formatDay, formatDayFull, formatClock, formatAge, ageBucket,
});
