// Cookie-gated "taste before sign-up" trial for unauthenticated
// visitors on a multi-tenant Recap. First time a visitor POSTs to
// /api/process without a session cookie, we issue a recap_anon_trial
// cookie (32-byte random), insert an anon_trials row with N credits,
// and let them summarize without signing up. After credits_used >=
// credits_total, the UI nudges them to create an account.
//
// Trial summaries forward the OPERATOR's install_id + license to the
// relay — they're paid for out of the operator's credit pool, gated
// solely by the anon_trials.credits_total field. tenant_credits is
// irrelevant for trials (no user row exists yet).
//
// Multi-mode only. Single-mode never imports this module.

import { randomBytes } from "crypto";
import { getDb } from "./db.js";
import { getConfigSnapshot } from "./config.js";

export const TRIAL_COOKIE = "recap_anon_trial";
const TRIAL_COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days

// Returns the trial config from the StartOS config snapshot. Cached
// per-call by getConfigSnapshot (already polled by config.js), so
// reading per-request is cheap.
async function getTrialConfig() {
  const snap = await getConfigSnapshot();
  // Prefer the new `trials_per_ip_lifetime` field; fall back to the
  // legacy `trials_per_ip_per_day` so a config that hasn't been
  // re-saved under the new name keeps working with its existing value.
  // (Semantics is now lifetime in BOTH cases — the rename is mostly
  // cosmetic for the operator-facing knob.)
  const ipCap =
    snap.trials_per_ip_lifetime ??
    snap.trials_per_ip_per_day ??
    5;
  return {
    creditsPerVisitor: Math.max(
      0,
      parseInt(snap.trial_credits_per_visitor ?? 1, 10) || 0,
    ),
    perIpLifetime: Math.max(1, parseInt(ipCap, 10) || 5),
  };
}

// Crude IPv4-or-IPv6 string extraction. Trusts the X-Forwarded-For
// header's first hop because Recap sits behind StartOS's tunnel — the
// header is set by the operator's infrastructure, not by clients
// directly. If you ever expose the server without a trusted proxy,
// revisit this.
export function getClientIp(req) {
  const xff = req.headers?.["x-forwarded-for"];
  if (xff) {
    const first = String(xff).split(",")[0].trim();
    if (first) return first;
  }
  return (req.socket?.remoteAddress || "").replace(/^::ffff:/, "");
}

// Expand an IPv6 string to its full 8-group :-separated form with
// each group lowercased + zero-padded to 4 hex chars. Returns null
// if the input doesn't look like a valid IPv6 string. Used by
// ipCapKey() below — we need a stable canonical form before we can
// extract the /64 prefix for cap counting.
function expandIpv6(addr) {
  if (!addr || typeof addr !== "string") return null;
  // Strip any IPv4-mapped suffix (e.g. ::ffff:1.2.3.4) — those are
  // really IPv4 addresses tunneled through an IPv6 envelope; the
  // caller has already stripped ::ffff: in getClientIp, but
  // defensively handle the bare form.
  if (/\d+\.\d+\.\d+\.\d+$/.test(addr)) return null;
  if (!addr.includes(":")) return null;
  const dblIdx = addr.indexOf("::");
  let groups;
  if (dblIdx === -1) {
    // No ::, must be 8 groups.
    groups = addr.split(":");
    if (groups.length !== 8) return null;
  } else {
    // :: shorthand — split into left and right halves, fill with 0s.
    if (addr.indexOf("::", dblIdx + 2) !== -1) return null; // two :: → invalid
    const left = addr.slice(0, dblIdx);
    const right = addr.slice(dblIdx + 2);
    const leftGroups = left ? left.split(":") : [];
    const rightGroups = right ? right.split(":") : [];
    const missing = 8 - leftGroups.length - rightGroups.length;
    if (missing < 0) return null;
    groups = [...leftGroups, ...Array(missing).fill("0"), ...rightGroups];
  }
  // Validate + normalize each group.
  const out = [];
  for (const g of groups) {
    if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
    out.push(g.toLowerCase().padStart(4, "0"));
  }
  return out.join(":");
}

// Cap-counting key for an IP. IPv4 returns the address unchanged
// (whole-address caps work fine — only one device can claim a
// public-routable IPv4). IPv6 returns the /64 prefix because RFC
// 4941 privacy extensions rotate the lower 64 bits of the address
// per-device, per-session, often hourly — counting by full IPv6
// address means a single laptop can mint a fresh trial cookie
// every time its OS picks a new temporary address. /64 is the
// smallest network unit an ISP delegates to a subscriber, so it's
// the correct boundary for "this household / this network" caps.
// Returns null when the input isn't usable; callers treat null as
// "no cap" (same fallback behavior as before).
//
// Trade-off: a /64 might be shared by multiple unrelated subscribers
// in some carrier configurations, so legitimate visitors could
// (rarely) get capped together. Operator can tune
// trials_per_ip_lifetime higher if they're seeing real complaints.
export function ipCapKey(ip) {
  if (!ip) return null;
  // IPv4: whole address.
  if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return ip;
  // IPv6: /64 prefix. Expand to full form, take first 4 groups,
  // append `:` so the SQL LIKE match doesn't accidentally match
  // unrelated prefixes that happen to share a textual leading
  // substring (e.g. "2600:17" prefix-matching "2600:171…").
  const expanded = expandIpv6(ip);
  if (!expanded) return null;
  const groups = expanded.split(":");
  if (groups.length < 4) return null;
  return groups.slice(0, 4).join(":") + ":";
}

// Truncate UA so a pathological 8KB header doesn't bloat the DB. 256
// chars covers every legit browser UA string with room to spare.
function clipUA(ua) {
  if (!ua) return "";
  return String(ua).slice(0, 256);
}

// Count ALL trial cookies ever issued from this IP. Switched from
// rolling-24h to lifetime in 0.2.84 — a user who clears cookies can no
// longer just wait a day and replay the trial. The trade-off: a
// shared-NAT household whose router IP got 5 different family
// members' trials over a year will eventually be capped. The
// operator can tune `trials_per_ip_lifetime` higher if that's a
// real concern, or grant credits manually from the admin panel.
export function ipTrialsLifetime(ip) {
  const key = ipCapKey(ip);
  if (!key) return 0;
  // IPv4 → exact match. IPv6 → prefix LIKE match (key ends with ":",
  // so the SQL LIKE 'key%' walks every full IPv6 in that /64 prefix).
  // Both are index-friendly when ip_address is indexed.
  if (key.includes(":")) {
    const row = getDb()
      .prepare("SELECT COUNT(*) AS n FROM anon_trials WHERE ip_address LIKE ?")
      .get(key + "%");
    return row?.n || 0;
  }
  const row = getDb()
    .prepare("SELECT COUNT(*) AS n FROM anon_trials WHERE ip_address = ?")
    .get(key);
  return row?.n || 0;
}

// Insert a new trial row and return it. Caller is responsible for
// setting the Set-Cookie header on the response.
function createTrial({ ip, ua, creditsTotal }) {
  const cookieId = randomBytes(32).toString("base64url");
  const now = Date.now();
  getDb()
    .prepare(
      `INSERT INTO anon_trials
        (cookie_id, ip_address, user_agent, created_at, credits_total, credits_used)
       VALUES (?, ?, ?, ?, ?, 0)`,
    )
    .run(cookieId, ip || null, clipUA(ua), now, creditsTotal);
  return {
    cookie_id: cookieId,
    ip_address: ip || null,
    user_agent: clipUA(ua),
    created_at: now,
    credits_total: creditsTotal,
    credits_used: 0,
    last_used_at: null,
    converted_to_user_id: null,
  };
}

// lookupTrial(cookieId) — returns the row or null. Doesn't check
// credit balance; that's the caller's job.
export function lookupTrial(cookieId) {
  if (!cookieId) return null;
  return (
    getDb()
      .prepare("SELECT * FROM anon_trials WHERE cookie_id = ?")
      .get(cookieId) || null
  );
}

// hasTrialBudget(trial) — true iff the trial row exists and has
// unused credits. Centralized so the policy is one place.
export function hasTrialBudget(trial) {
  if (!trial) return false;
  return trial.credits_used < trial.credits_total;
}

// issueIfEligible({ req, res, forceMint }) — atomic "do we issue a
// trial cookie to this request?" decision. Called by the auth
// middleware when no session cookie is present AND the request is
// hitting a path that counts as "actually using the product" (e.g.
// POST /api/process).
//
// Returns the trial row on success, or null if:
//   - trial_credits_per_visitor is 0 (trials disabled) AND
//     forceMint is false
//   - the IP has already exhausted its lifetime mint quota AND
//     forceMint is false
//   - a DB error occurred (logged, fail-closed to "no trial")
//
// forceMint: caller asserts this isn't a free-trial-abuse scenario
// (e.g., the visitor is paying for credits — they need a tracking
// cookie regardless of trial policy). When set:
//   - IP lifetime cap is bypassed
//   - Trials-disabled config is bypassed, but the minted cookie
//     gets credits_total = 0 (no free bonus on a trials-off install)
// Normal /api/process traffic should NEVER set this; only paid-flow
// callers (/api/credits/buy) where "no cookie → can't credit the
// settle" is a worse failure than enforcing the free-trial cap.
//
// On success, also writes the Set-Cookie header so the browser
// carries the trial id on subsequent requests.
export async function issueIfEligible({ req, res, forceMint = false }) {
  let cfg;
  try {
    cfg = await getTrialConfig();
  } catch (err) {
    console.warn("[anon-trial] config read failed:", err);
    return null;
  }
  // Decide the credits_total this trial row starts with. Paying
  // buyers on a trials-off install still get a cookie — credits_total
  // is just 0 — so /api/credits/buy has somewhere to land the purchase.
  const creditsTotal = forceMint
    ? Math.max(0, cfg.creditsPerVisitor)
    : cfg.creditsPerVisitor;
  if (!forceMint && creditsTotal <= 0) return null; // trials disabled

  const ip = getClientIp(req);
  if (!forceMint && ip && ipTrialsLifetime(ip) >= cfg.perIpLifetime) {
    // Over the lifetime IP cap. Don't issue; visitor sees the same
    // "sign up" nudge a returning trial-exhausted user sees. The
    // operator can grep magic_link_tokens + anon_trials by ip_address
    // if a pattern emerges, or manually grant credits to specific
    // tenants from the admin panel.
    return null;
  }

  let trial;
  try {
    trial = createTrial({
      ip,
      ua: req.headers?.["user-agent"] || "",
      creditsTotal,
    });
  } catch (err) {
    console.warn("[anon-trial] createTrial failed:", err);
    return null;
  }
  // Diagnostic: log the resolved IP + the cap-counting key so an
  // operator can grep mint events to verify the IP detector is
  // working (a flood of mints with ip=null or ip=127.0.0.1 means
  // the reverse-proxy isn't passing X-Forwarded-For). Cap key
  // shows the bucket this mint counted under — for IPv6 this is
  // the /64 prefix the mint will share with future addresses on
  // the same network.
  console.log(
    `[anon-trial] minted cookie for ip=${ip || "(unknown)"} cap_key=${ipCapKey(ip) || "(none)"} credits=${creditsTotal}`,
  );

  // Set-Cookie. HttpOnly so browser-side JS can't read or replay it;
  // SameSite=Lax is enough since we never need cross-site trial
  // posts. Secure is on in production (StartOS terminates HTTPS at
  // the tunnel) but harmless on localhost dev.
  const maxAgeSeconds = Math.floor(TRIAL_COOKIE_MAX_AGE_MS / 1000);
  res.setHeader?.(
    "Set-Cookie",
    [
      `${TRIAL_COOKIE}=${trial.cookie_id}`,
      `Max-Age=${maxAgeSeconds}`,
      "Path=/",
      "HttpOnly",
      "SameSite=Lax",
      "Secure",
    ].join("; "),
  );
  return trial;
}

// debitOne(cookieId) — atomic +1 to credits_used. Returns the new
// row. Caller (the /api/process handler in multi-mode) calls this
// AFTER the relay accepts the request, so a failed relay call
// doesn't burn a trial credit.
export function debitOne(cookieId) {
  const now = Date.now();
  getDb()
    .prepare(
      "UPDATE anon_trials SET credits_used = credits_used + 1, last_used_at = ? WHERE cookie_id = ?",
    )
    .run(now, cookieId);
  return lookupTrial(cookieId);
}

// linkToUser(cookieId, userId) — called by /auth/verify when a trial
// holder completes signup. Records the conversion for analytics AND
// transfers any unused credits on the trial to the user's
// tenant_credits balance. This is the user-facing promise: "your free
// trial credits + any credits you bought during the trial transfer
// to your account when you sign up."
//
// "Unused" = credits_total - credits_used. The default trial allowance
// (e.g. 1 free) plus any credits the visitor purchased anonymously
// minus whatever they've already spent.
//
// Returns the number of credits transferred (0 if none).
//
// Async because we opportunistically sweep any settled-but-unapplied
// pending purchases for this anon cookie FIRST, so a la carte credits
// the visitor bought right before signup (and where the BTCPay
// redirect killed the frontend poller) get rolled into credits_total
// before we compute the transfer. Without this sweep, a buy-then-
// immediately-sign-up flow drops the just-purchased credits on the
// floor.
export async function linkToUser(cookieId, userId) {
  if (!cookieId || !userId) return 0;
  try {
    const { sweepUnappliedPurchases } = await import("./credits-purchase.js");
    await sweepUnappliedPurchases({ buyerType: "anon", buyerId: cookieId });
  } catch (err) {
    console.warn(
      `[anon-trial] pre-link sweep failed for ${cookieId}: ${err?.message || err}`,
    );
  }
  const db = getDb();
  const trial = db
    .prepare(
      "SELECT credits_total, credits_used FROM anon_trials WHERE cookie_id = ?",
    )
    .get(cookieId);
  const remaining = trial
    ? Math.max(
        0,
        (trial.credits_total || 0) - (trial.credits_used || 0),
      )
    : 0;

  // Carry-over credits go into the PURCHASED bucket — they're a mix
  // of "leftover free trial allowance" + "credits the anon bought a
  // la carte". Treating all of them as purchased (permanent) is the
  // safe interpretation; the user paid for some of these and the
  // others were earned by clicking through a signup. We don't want
  // the next replenishment cycle wiping them.
  const tx = db.transaction(() => {
    db.prepare(
      "UPDATE anon_trials SET converted_to_user_id = ? WHERE cookie_id = ?",
    ).run(userId, cookieId);
    if (remaining > 0) {
      const existing = db
        .prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?")
        .get(userId);
      if (existing) {
        db.prepare(
          `UPDATE tenant_credits
           SET purchased_balance = purchased_balance + ?,
               lifetime_granted = lifetime_granted + ?
           WHERE user_id = ?`,
        ).run(remaining, remaining, userId);
      } else {
        db.prepare(
          `INSERT INTO tenant_credits
             (user_id, purchased_balance, replenish_balance, last_replenish_at,
              lifetime_granted, lifetime_consumed)
           VALUES (?, ?, 0, ?, ?, 0)`,
        ).run(userId, remaining, Date.now(), remaining);
      }
    }
  });
  tx();
  if (remaining > 0) {
    console.log(
      `[anon-trial] transferred ${remaining} unused credits from ${cookieId} → user ${userId}`,
    );
  }
  return remaining;
}
