// Tenant (multi-user) auth middleware.
//
// Two modes:
//   - single (the original self-hosted experience): no-op. Sets
//     req.userId = "owner" so downstream code can scope everything
//     uniformly to a single synthetic user.
//   - multi (cloud / family-share): validates the recap_session cookie
//     against the sessions table, looks up the user, attaches both to
//     req. Non-public paths without a valid session get a 401.
//
// This is layered AFTER the admin-auth middleware (which gates /api/*
// behind the operator's password in single mode). The two are
// orthogonal:
//   - admin-auth: "is the OPERATOR allowed in?" (single mode only)
//   - tenant-auth: "which user is this request from?" (multi mode only)
// In multi mode the admin-auth gate is disabled — tenant accounts
// authenticate themselves directly.

import { getDb } from "./db.js";
import {
  TRIAL_COOKIE,
  lookupTrial,
  hasTrialBudget,
} from "./anon-trial.js";

const SESSION_COOKIE = "recap_session";

// Endpoints that must remain reachable WITHOUT a session — the auth
// flow itself, health check, static assets, the BTCPay webhook. The
// frontend's static files are served outside the /api/* tree so they
// bypass this middleware entirely; this list only matters for /api/*
// and /auth/* paths.
const PUBLIC_PATH_PREFIXES = [
  "/auth/", // /auth/request-link, /auth/verify, /auth/signout
  "/api/health",
  "/api/auth/", // future client-facing auth shims (CSRF token issue, etc.)
  "/api/btcpay/webhook", // BTCPay needs to reach this without a session
  "/api/network-mode", // returns lan-vs-local; safe to expose
  "/api/relay/status", // public relay capabilities — pre-trial visibility
  "/api/account/whoami", // returns state — anonymous visitors must call this
  // License-status family — anonymous visitors must call these on page
  // load to render the right header/badge. Multi-mode handlers branch
  // on req.user/req.trial to return per-user views.
  "/api/license-status",
  "/api/install-id",
  // Credit-purchase family — accepts both signed-in users AND anon
  // trial cookies (the buy handler routes credits to the right local
  // balance based on which identity made the call). Each handler
  // validates buyer presence inline, so leaving these "public" is
  // safe — it just defers the auth check from the middleware to the
  // handler where buyer-type-specific logic lives anyway.
  "/api/credits/",
  // License purchase + poll — the 3-tier signup modal lets anon
  // visitors buy a Pro/Max license at the same moment they create
  // their account. /policies is a passthrough to Keysat's public
  // /v1/products/.../policies (no auth needed there). /purchase
  // accepts an anon buyer_email and records a pending_signups row
  // so the poll-settle handler can create the user + attach the
  // license + send a magic-link email. /poll just reads invoice
  // status from Keysat — same trust model as the credit flow.
  //
  // Note: /api/license/activate and /api/license/deactivate are
  // NOT in this prefix (they're operator-only single-mode endpoints
  // that write /data/license.txt; tenants shouldn't reach them).
  "/api/license/policies",
  "/api/license/purchase",
  "/api/license/poll/",
];

// Paths where an unauthenticated visitor is allowed to obtain (or use)
// an anonymous-trial cookie. Restricting trials to the actual
// "use the product" endpoint keeps bots that scrape the homepage from
// minting trial rows just by visiting /.
const TRIAL_ELIGIBLE_PATHS = new Set(["/api/process"]);

function isTrialEligiblePath(reqPath, method) {
  if (method !== "POST") return false;
  return TRIAL_ELIGIBLE_PATHS.has(reqPath);
}

function isPublicPath(reqPath) {
  for (const p of PUBLIC_PATH_PREFIXES) {
    if (reqPath === p || reqPath.startsWith(p)) return true;
  }
  return false;
}

// We only gate /api/* paths. Static assets (/, /auth.html, /assets/*)
// and the auth-flow routes (/auth/*) are ALWAYS reachable so anonymous
// visitors can see the landing page and the sign-in form. The handlers
// behind /api/* are where actual access decisions need to happen.
function isGatedPath(reqPath) {
  return reqPath.startsWith("/api/");
}

// Lightweight cookie parser — same dep (`cookie` v1) the rest of the
// project uses. Keeps us from pulling in cookie-parser middleware just
// for one header.
import * as cookie from "cookie";

function parseCookies(req) {
  const header = req.headers?.cookie;
  if (!header) return {};
  try {
    return cookie.parse(header);
  } catch {
    return {};
  }
}

// Factory — returns the middleware closure. `mode` is the RECAP_MODE
// env value ("single" | "multi"). Captured at boot so we don't branch
// on a hot path on every request.
export function buildTenantAuthMiddleware({ mode }) {
  if (mode !== "multi") {
    // Single-mode shim: stamp every request with the synthetic owner
    // userId so user-scoped handlers (history reads, library inserts,
    // etc.) keep working without per-call branching.
    return function singleModeAuth(req, _res, next) {
      req.userId = "owner";
      req.user = null;
      req.recapMode = "single";
      next();
    };
  }

  // Multi-mode: validate session on every request. Cache one prepared
  // statement per query in module scope (better-sqlite3 idiom — `.prepare`
  // returns a reusable handle).
  let sessionLookupStmt = null;
  let userLookupStmt = null;
  let sessionTouchStmt = null;

  function stmts() {
    const db = getDb();
    if (!sessionLookupStmt) {
      sessionLookupStmt = db.prepare(
        "SELECT * FROM sessions WHERE id = ? AND expires_at > ?",
      );
      userLookupStmt = db.prepare("SELECT * FROM users WHERE id = ?");
      sessionTouchStmt = db.prepare(
        "UPDATE sessions SET last_used_at = ? WHERE id = ?",
      );
    }
    return { sessionLookupStmt, userLookupStmt, sessionTouchStmt };
  }

  // Touch debounce — every authenticated request would otherwise issue
  // an UPDATE for last_used_at. We coalesce per-session: at most one
  // touch per LAST_USED_DEBOUNCE_MS window.
  const LAST_USED_DEBOUNCE_MS = 60_000;
  const lastTouchedAt = new Map();

  function maybeTouch(sessionId) {
    const now = Date.now();
    const prev = lastTouchedAt.get(sessionId) || 0;
    if (now - prev < LAST_USED_DEBOUNCE_MS) return;
    lastTouchedAt.set(sessionId, now);
    try {
      stmts().sessionTouchStmt.run(now, sessionId);
    } catch {
      // Touch is best-effort; a write contention here shouldn't fail
      // the request.
    }
  }

  return function multiModeAuth(req, res, next) {
    req.recapMode = "multi";

    // Static assets and the auth-flow pages aren't gated at all — they
    // need to be reachable for any visitor to see the landing page or
    // sign-in form. Just pass through without attaching anything.
    if (!isGatedPath(req.path)) {
      return next();
    }

    const cookies = parseCookies(req);
    const sessionId = cookies[SESSION_COOKIE];

    if (!sessionId) {
      // Look up the trial cookie FIRST (regardless of path) so handlers
      // like /api/account/whoami can see req.trial even on public paths.
      // We separate "trial is present" from "trial gates access" —
      // attachment is unconditional, gating is path-dependent below.
      let trial = null;
      const trialCookieId = cookies[TRIAL_COOKIE];
      if (trialCookieId) {
        try {
          trial = lookupTrial(trialCookieId);
        } catch (err) {
          console.warn("[tenant-auth] trial lookup failed:", err);
        }
        if (trial) {
          req.trial = trial;
        }
      }

      // Public paths (auth flow, health, whoami, etc.) pass through.
      // Handlers should treat req.userId === undefined as "anonymous"
      // and inspect req.trial separately to render appropriate UI.
      if (isPublicPath(req.path)) {
        req.user = null;
        return next();
      }

      // Second lane: anonymous trial cookie with remaining budget.
      // Attach a synthetic userId so /api/process and other gated
      // endpoints can run. If the cookie exists but is exhausted,
      // fall through to 401 — the UI's "sign up for more" nudge
      // takes it from here.
      if (trial && hasTrialBudget(trial)) {
        req.userId = `anon:${trial.cookie_id}`;
        req.user = null;
        return next();
      }

      // No session, no usable trial. The /api/process handler is the
      // one place where a fresh trial cookie CAN still be minted on
      // first POST — let it through with no user attached so it can
      // call issueIfEligible() and either issue + proceed or 401.
      if (isTrialEligiblePath(req.path, req.method)) {
        req.userId = undefined; // signal: "pre-trial, mint if eligible"
        req.user = null;
        return next();
      }

      return res.status(401).json({ error: "auth_required" });
    }

    let session;
    try {
      session = stmts().sessionLookupStmt.get(sessionId, Date.now());
    } catch (err) {
      console.warn("[tenant-auth] session lookup failed:", err);
      if (isPublicPath(req.path)) return next();
      return res.status(500).json({ error: "auth_lookup_failed" });
    }

    if (!session) {
      // Stale or expired cookie. Clear it so the browser stops sending
      // a dead token on every request.
      res.clearCookie?.(SESSION_COOKIE);
      if (isPublicPath(req.path)) return next();
      return res.status(401).json({ error: "session_expired" });
    }

    let user;
    try {
      user = stmts().userLookupStmt.get(session.user_id);
    } catch (err) {
      console.warn("[tenant-auth] user lookup failed:", err);
      if (isPublicPath(req.path)) return next();
      return res.status(500).json({ error: "user_lookup_failed" });
    }

    if (!user) {
      // User row was deleted but the session row survived — shouldn't
      // happen under ON DELETE CASCADE, but defend in depth.
      res.clearCookie?.(SESSION_COOKIE);
      if (isPublicPath(req.path)) return next();
      return res.status(401).json({ error: "user_gone" });
    }

    req.userId = user.id;
    req.user = user;
    req.session = session;
    maybeTouch(sessionId);
    next();
  };
}

// Convenience: a guard middleware to chain after auth, for endpoints
// that MUST have a user (where the single-mode synthetic "owner"
// won't do, e.g. /api/account/*). Single-mode falls through because
// req.userId === "owner" is truthy.
export function requireUser(req, res, next) {
  if (!req.userId) {
    return res.status(401).json({ error: "auth_required" });
  }
  next();
}

// Operator-only guard: passes through in single mode (the operator
// IS the only user), in multi mode requires req.user.is_admin === 1.
// Used to gate the full settings panel (provider keys, prompts, etc.).
export function requireOperator(req, res, next) {
  if (req.recapMode !== "multi") return next();
  if (!req.user || !req.user.is_admin) {
    return res.status(403).json({ error: "operator_only" });
  }
  next();
}
