/**
 * @file Express router for managing pricing rules and calculating costs based on token usage. It provides endpoints to list, create/update, and delete pricing rules, as well as calculate total costs across all sessions or for a specific session. The cost calculation matches token usage against the most specific applicable pricing rule based on model patterns.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const { Router } = require("express");
const { stmts, db } = require("../db");
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
const {
  WEB_SEARCH_PER_1K_SEARCHES,
  CODE_EXEC_PER_HOUR,
  CODE_EXEC_FREE_HOURS,
  estimateCodeExecHours,
  DATA_RESIDENCY_US_MULTIPLIER,
  BATCH_DISCOUNT_MULTIPLIER,
} = require("../lib/pricing-constants");

const router = Router();

const round4 = (n) => Math.round(n * 10000) / 10000;

/**
 * Resolve the effective per-MTok rates for a token bucket, applying the pricing
 * modifiers carried on the bucket (fast mode, US data residency, Batch API).
 *   - Fast mode: premium input/output rates; cache rates scale with the fast
 *     input base (the standard caching multipliers ride on top of fast pricing).
 *   - Data residency "us": 1.1x across every category.
 *   - Batch tier: 50% off across every category.
 * Older buckets default to speed=standard / geo=global / tier=standard, so they
 * resolve to exactly the standard rates — historical sessions price unchanged.
 */
function ratesForBucket(rule, row, asOf) {
  const r = rule || {};

  // Time-limited introductory rates. Prefer the usage row's own date (so
  // historical usage keeps the rate it was billed at and future usage picks up
  // the standard rate), else the caller-provided asOf, else today. Dates are
  // compared as YYYY-MM-DD strings (both intro_until and the daily date use that
  // shape), so slice any full ISO timestamp to its day.
  const day = String(row.date || asOf || new Date().toISOString()).slice(0, 10);
  const useIntro = !!r.intro_until && day <= r.intro_until;
  const pick = (introVal, stdVal) => (useIntro && (introVal || 0) > 0 ? introVal : stdVal || 0);

  let rIn = pick(r.intro_input_per_mtok, r.input_per_mtok);
  let rOut = pick(r.intro_output_per_mtok, r.output_per_mtok);
  let rRead = pick(r.intro_cache_read_per_mtok, r.cache_read_per_mtok);
  let r5m = pick(r.intro_cache_write_per_mtok, r.cache_write_per_mtok);
  let r1h = pick(r.intro_cache_write_1h_per_mtok, r.cache_write_1h_per_mtok);

  if (row.speed === "fast" && (r.fast_input_per_mtok || 0) > 0) {
    const baseIn = r.input_per_mtok || 0;
    const factor = baseIn > 0 ? r.fast_input_per_mtok / baseIn : 1;
    rIn = r.fast_input_per_mtok;
    rOut = (r.fast_output_per_mtok || 0) > 0 ? r.fast_output_per_mtok : rOut * factor;
    rRead *= factor;
    r5m *= factor;
    r1h *= factor;
  }
  if (row.inference_geo === "us") {
    const m = DATA_RESIDENCY_US_MULTIPLIER;
    rIn *= m;
    rOut *= m;
    rRead *= m;
    r5m *= m;
    r1h *= m;
  }
  if (row.service_tier === "batch") {
    const m = BATCH_DISCOUNT_MULTIPLIER;
    rIn *= m;
    rOut *= m;
    rRead *= m;
    r5m *= m;
    r1h *= m;
  }
  return { rIn, rOut, rRead, r5m, r1h };
}

// Calculate cost for a set of token buckets against pricing rules. Each bucket
// is (model, speed, inference_geo, service_tier) with token counts plus the 1h
// cache-write split and server-tool request counts. Cost = token cost (rate-
// modified) + web-search surcharge ($10/1k) + estimated code-execution time
// (free when used with web search/fetch; org free-hours allowance applied once).
function calculateCost(tokenRows, pricingRules, asOf) {
  const sortedRules = [...pricingRules].sort(
    (a, b) => b.model_pattern.length - a.model_pattern.length
  );

  let tokenCost = 0;
  let webSearchCost = 0;
  let codeExecHours = 0;
  // Breakdown is aggregated per (model, speed, geo, tier) tuple, not per input
  // row. This lets callers feed date-split rows (e.g. the daily-usage query,
  // one row per date × model) so each row is priced at its own date's rate,
  // while the breakdown still collapses to one entry per model. For callers that
  // already pass one row per tuple (aggregate total, per-session), it's a no-op.
  const breakdownMap = new Map();
  // Track buckets that matched NO pricing rule. Their cost is $0, which would
  // silently under-report the true total — surface them so the number is honest
  // and the user knows to add a rule (e.g. a brand-new model id).
  const unpriced = new Map();

  for (const row of tokenRows) {
    const rule = sortedRules.find((p) => {
      const pattern = p.model_pattern.replace(/%/g, ".*");
      return new RegExp("^" + pattern + "$").test(row.model);
    });

    if (!rule) {
      const u = unpriced.get(row.model) || {
        model: row.model,
        input_tokens: 0,
        output_tokens: 0,
        cache_read_tokens: 0,
        cache_write_tokens: 0,
      };
      u.input_tokens += row.input_tokens || 0;
      u.output_tokens += row.output_tokens || 0;
      u.cache_read_tokens += row.cache_read_tokens || 0;
      u.cache_write_tokens += row.cache_write_tokens || 0;
      unpriced.set(row.model, u);
    }

    const { rIn, rOut, rRead, r5m, r1h } = ratesForBucket(rule, row, asOf);
    const cw1h = row.cache_write_1h_tokens || 0;
    const cw5m = Math.max(0, (row.cache_write_tokens || 0) - cw1h);
    const tCost =
      (row.input_tokens / 1e6) * rIn +
      (row.output_tokens / 1e6) * rOut +
      (row.cache_read_tokens / 1e6) * rRead +
      (cw5m / 1e6) * r5m +
      (cw1h / 1e6) * r1h;

    const wsCost = ((row.web_search_requests || 0) / 1000) * WEB_SEARCH_PER_1K_SEARCHES;
    const ceHours = estimateCodeExecHours(
      row.code_execution_requests,
      row.web_search_requests,
      row.web_fetch_requests
    );

    tokenCost += tCost;
    webSearchCost += wsCost;
    codeExecHours += ceHours;

    const key = `${row.model}|${row.speed || "standard"}|${row.inference_geo || "global"}|${row.service_tier || "standard"}`;
    const agg = breakdownMap.get(key) || {
      model: row.model,
      speed: row.speed || "standard",
      inference_geo: row.inference_geo || "global",
      service_tier: row.service_tier || "standard",
      input_tokens: 0,
      output_tokens: 0,
      cache_read_tokens: 0,
      cache_write_tokens: 0,
      cache_write_1h_tokens: 0,
      web_search_requests: 0,
      web_fetch_requests: 0,
      code_execution_requests: 0,
      _cost: 0,
      matched_rule: rule?.model_pattern || null,
    };
    agg.input_tokens += row.input_tokens || 0;
    agg.output_tokens += row.output_tokens || 0;
    agg.cache_read_tokens += row.cache_read_tokens || 0;
    agg.cache_write_tokens += row.cache_write_tokens || 0;
    agg.cache_write_1h_tokens += cw1h;
    agg.web_search_requests += row.web_search_requests || 0;
    agg.web_fetch_requests += row.web_fetch_requests || 0;
    agg.code_execution_requests += row.code_execution_requests || 0;
    agg._cost += tCost + wsCost;
    breakdownMap.set(key, agg);
  }
  const breakdown = [...breakdownMap.values()].map(({ _cost, ...b }) => ({
    ...b,
    cost: round4(_cost),
  }));

  // Code execution is billed by container-time, estimated at the 5-minute
  // minimum per request. Apply the org free-hours allowance once, then charge
  // the remainder — so normal usage (well under the allowance) costs $0.
  const chargedHours = Math.max(0, codeExecHours - CODE_EXEC_FREE_HOURS);
  const codeExecCost = chargedHours * CODE_EXEC_PER_HOUR;
  const total = tokenCost + webSearchCost + codeExecCost;

  return {
    total_cost: round4(total),
    breakdown,
    feature_costs: {
      web_search_cost: round4(webSearchCost),
      web_fetch_cost: 0,
      code_execution_cost: round4(codeExecCost),
      code_execution_hours_estimated: round4(codeExecHours),
      code_execution_free_hours: CODE_EXEC_FREE_HOURS,
    },
    // Models with usage but no matching pricing rule (cost not counted).
    unpriced_models: [...unpriced.values()],
  };
}

function calculateDailyCosts(dailyTokenRows, pricingRules) {
  const rowsByDate = new Map();
  for (const row of dailyTokenRows) {
    const rows = rowsByDate.get(row.date) || [];
    rows.push({
      model: row.model,
      speed: row.speed,
      inference_geo: row.inference_geo,
      service_tier: row.service_tier,
      input_tokens: row.input_tokens,
      output_tokens: row.output_tokens,
      cache_read_tokens: row.cache_read_tokens,
      cache_write_tokens: row.cache_write_tokens,
      cache_write_1h_tokens: row.cache_write_1h_tokens,
      web_search_requests: row.web_search_requests,
      web_fetch_requests: row.web_fetch_requests,
      code_execution_requests: row.code_execution_requests,
    });
    rowsByDate.set(row.date, rows);
  }

  return [...rowsByDate.entries()]
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([date, rows]) => ({ date, cost: calculateCost(rows, pricingRules, date).total_cost }));
}

// GET /api/pricing - List all pricing rules
router.get("/", (_req, res) => {
  const rules = stmts.listPricing.all();
  res.json({ pricing: rules });
});

// PUT /api/pricing - Create or update a pricing rule
router.put("/", (req, res) => {
  const {
    model_pattern,
    display_name,
    input_per_mtok,
    output_per_mtok,
    cache_read_per_mtok,
    cache_write_per_mtok,
    cache_write_1h_per_mtok,
    fast_input_per_mtok,
    fast_output_per_mtok,
    // Time-limited introductory rates (all optional). A caller that omits every
    // intro field leaves any existing promo untouched (see introProvided below).
    intro_input_per_mtok,
    intro_output_per_mtok,
    intro_cache_read_per_mtok,
    intro_cache_write_per_mtok,
    intro_cache_write_1h_per_mtok,
    intro_until,
  } = req.body;
  if (!model_pattern || !display_name) {
    return res.status(400).json({
      error: { code: "INVALID_INPUT", message: "model_pattern and display_name are required" },
    });
  }

  // Every rate field must be a non-negative finite number when present. A
  // typo'd value (Number("abc") → NaN) or a negative rate would otherwise be
  // written straight into model_pricing and silently corrupt every downstream
  // cost calculation until someone noticed and fixed the row by hand.
  const RATE_FIELDS = [
    "input_per_mtok",
    "output_per_mtok",
    "cache_read_per_mtok",
    "cache_write_per_mtok",
    "cache_write_1h_per_mtok",
    "fast_input_per_mtok",
    "fast_output_per_mtok",
    "intro_input_per_mtok",
    "intro_output_per_mtok",
    "intro_cache_read_per_mtok",
    "intro_cache_write_per_mtok",
    "intro_cache_write_1h_per_mtok",
  ];
  for (const field of RATE_FIELDS) {
    const raw = req.body[field];
    if (raw === undefined || raw === null || raw === "") continue;
    const n = Number(raw);
    if (!Number.isFinite(n) || n < 0) {
      return res.status(400).json({
        error: { code: "INVALID_INPUT", message: `${field} must be a non-negative number` },
      });
    }
  }
  // Absent/empty rates default to 0; numeric strings are coerced so a rate can
  // never be bound into the DB as text.
  const num = (v) => (v === undefined || v === null || v === "" ? 0 : Number(v));

  // Only touch the intro columns when the caller actually sent at least one
  // intro field. This keeps the endpoint backward-compatible: existing clients
  // that PUT just the standard rates never clobber a promo, while the Settings
  // UI (which always sends the full intro block) is authoritative for it.
  const introKeys = [
    "intro_input_per_mtok",
    "intro_output_per_mtok",
    "intro_cache_read_per_mtok",
    "intro_cache_write_per_mtok",
    "intro_cache_write_1h_per_mtok",
    "intro_until",
  ];
  const introProvided = introKeys.some((k) => req.body[k] !== undefined);

  // Normalize / validate intro_until: an empty value clears the promo (NULL);
  // a present value must be a YYYY-MM-DD date so the date-string comparisons in
  // ratesForBucket stay correct.
  let normalizedIntroUntil = null;
  if (introProvided) {
    const raw = typeof intro_until === "string" ? intro_until.trim() : intro_until;
    if (raw) {
      if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
        return res.status(400).json({
          error: { code: "INVALID_INPUT", message: "intro_until must be a YYYY-MM-DD date" },
        });
      }
      normalizedIntroUntil = raw;
    }
  }

  const writeRule = db.transaction(() => {
    stmts.upsertPricing.run(
      model_pattern,
      display_name,
      num(input_per_mtok),
      num(output_per_mtok),
      num(cache_read_per_mtok),
      num(cache_write_per_mtok),
      num(cache_write_1h_per_mtok),
      num(fast_input_per_mtok),
      num(fast_output_per_mtok)
    );
    if (introProvided) {
      // A cleared promo (no date) zeroes the intro rates too so a stale value
      // can't resurface if a date is re-added later without re-entering rates.
      const keepRates = !!normalizedIntroUntil;
      stmts.setIntroPricing.run(
        keepRates ? num(intro_input_per_mtok) : 0,
        keepRates ? num(intro_output_per_mtok) : 0,
        keepRates ? num(intro_cache_read_per_mtok) : 0,
        keepRates ? num(intro_cache_write_per_mtok) : 0,
        keepRates ? num(intro_cache_write_1h_per_mtok) : 0,
        normalizedIntroUntil,
        model_pattern
      );
    }
  });
  writeRule();

  const rule = stmts.getPricing.get(model_pattern);
  res.json({ pricing: rule });
});

// DELETE /api/pricing/:pattern - Delete a pricing rule
router.delete("/:pattern", (req, res) => {
  // Express has already percent-decoded route params, so a client that
  // properly encodeURIComponent()s a pattern like "claude-opus-4-6%" hands us
  // the raw "%" here — a second decodeURIComponent() then throws URIError
  // (malformed escape) and the route 500s. Try the legacy double-decode for
  // backward compatibility, but fall back to the already-decoded value.
  let pattern;
  try {
    pattern = decodeURIComponent(req.params.pattern);
  } catch {
    pattern = req.params.pattern;
  }
  const existing = stmts.getPricing.get(pattern);
  if (!existing) {
    return res
      .status(404)
      .json({ error: { code: "NOT_FOUND", message: "Pricing rule not found" } });
  }
  stmts.deletePricing.run(pattern);
  res.json({ ok: true });
});

// GET /api/pricing/cost - Get total cost across all sessions.
// Honors the global data-scope: `?sources=<csv>` narrows the aggregate to those
// origin machines (see server/lib/source-filter.js), matching the sessions /
// stats / analytics endpoints so the Dashboard "total cost" tracks the selector.
router.get("/cost", (req, res) => {
  const rawOffset = parseInt(req.query.tz_offset, 10);
  const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
  const { clause, params } = sourceColumnClause(parseSources(req), "s.source");
  const whereClause = clause ? `WHERE ${clause}` : "";

  const dailyTokens = db
    .prepare(
      `SELECT
        DATE(s.started_at, ?) as date,
        tu.model as model,
        tu.speed as speed,
        tu.inference_geo as inference_geo,
        tu.service_tier as service_tier,
        SUM(tu.input_tokens + tu.baseline_input) as input_tokens,
        SUM(tu.output_tokens + tu.baseline_output) as output_tokens,
        SUM(tu.cache_read_tokens + tu.baseline_cache_read) as cache_read_tokens,
        SUM(tu.cache_write_tokens + tu.baseline_cache_write) as cache_write_tokens,
        SUM(tu.cache_write_1h_tokens + tu.baseline_cache_write_1h) as cache_write_1h_tokens,
        SUM(tu.web_search_requests + tu.baseline_web_search) as web_search_requests,
        SUM(tu.web_fetch_requests + tu.baseline_web_fetch) as web_fetch_requests,
        SUM(tu.code_execution_requests + tu.baseline_code_execution) as code_execution_requests
      FROM token_usage tu
      JOIN sessions s ON s.id = tu.session_id
      ${whereClause}
      GROUP BY 1, tu.model, tu.speed, tu.inference_geo, tu.service_tier`
    )
    .all(tzModifier, ...params);
  const rules = stmts.listPricing.all();
  // Price the date-split rows so each day's usage bills at the rate effective on
  // that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
  // Coverage equals the undated aggregate — token_usage cascades with sessions,
  // so the INNER JOIN drops nothing — and the breakdown re-collapses per model.
  const result = calculateCost(dailyTokens, rules);
  const daily_costs = calculateDailyCosts(dailyTokens, rules);
  res.json({ ...result, daily_costs });
});

// GET /api/pricing/cost/:sessionId - Get cost for a specific session
router.get("/cost/:sessionId", (req, res) => {
  const rawOffset = parseInt(req.query.tz_offset, 10);
  const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";

  const tokenRows = stmts.getTokensBySession.all(req.params.sessionId);
  const rules = stmts.listPricing.all();
  const started = db
    .prepare("SELECT DATE(started_at, ?) as date FROM sessions WHERE id = ?")
    .get(tzModifier, req.params.sessionId);
  // Price the session as of its start date so a session that ran during a promo
  // window keeps that rate (e.g. Sonnet 5 intro through 2026-08-31).
  const result = calculateCost(tokenRows, rules, started?.date);
  const daily_costs = started ? [{ date: started.date, cost: result.total_cost }] : [];
  res.json({ ...result, daily_costs });
});

/**
 * Compute a single agent's own cost from the token buckets stashed in its
 * metadata by the importer (agent.metadata.tokens). Returns 0 when the agent has
 * no per-agent usage recorded (e.g. main agents — whose cost is the session
 * total — compaction pseudo-agents, or live subagents not yet backfilled from
 * their transcript). Priced with the agent's start date so a promo/standard
 * cutover is respected, exactly like session cost.
 */
function agentOwnCost(agent, pricingRules) {
  if (!agent || !agent.metadata) return 0;
  let meta;
  try {
    meta = JSON.parse(agent.metadata);
  } catch {
    return 0;
  }
  const rows = Array.isArray(meta.tokens) ? meta.tokens : null;
  if (!rows || rows.length === 0) return 0;
  const asOf = agent.started_at ? String(agent.started_at).slice(0, 10) : undefined;
  return calculateCost(rows, pricingRules, asOf).total_cost;
}

/**
 * Return a shallow copy of each agent row with a computed `cost` field — the
 * agent's OWN cost (see agentOwnCost). Pricing rules are read once for the whole
 * batch. Used by the agent-list endpoints so subagent cards can show their real
 * cost instead of the session total.
 */
function attachAgentCosts(agents) {
  if (!Array.isArray(agents) || agents.length === 0) return agents;
  const rules = stmts.listPricing.all();
  return agents.map((a) => ({ ...a, cost: agentOwnCost(a, rules) }));
}

module.exports = router;
module.exports.calculateCost = calculateCost;
module.exports.agentOwnCost = agentOwnCost;
module.exports.attachAgentCosts = attachAgentCosts;
