// Admin login gate.
//
// Reads username + scrypt password hash + session secret out of
// /data/config/startos-config.json (set by the "Set Admin Password"
// StartOS action). When a hash is set, gates /api/* behind a signed
// HttpOnly cookie. The static frontend stays open so the login screen
// can paint, but every API endpoint except the gate's own + /api/health
// returns 401 admin_login_required until the cookie validates.
//
// Cookie format: <base64url(payload)>.<base64url(hmac)>
//   payload: { u: username, iat: epoch_ms, fp: fingerprint(passwordHash) }
//   hmac:    HMAC-SHA256(payload, sessionSecret)
//
// Changing the password rotates fp, which invalidates all existing
// sessions on the next request. Changing/clearing the session secret
// has the same effect.
//
// ADMIN is exported as a `let` binding so importers see the live value
// after each config-poll refresh.

import fs from "fs/promises";
import path from "path";
import {
  randomBytes,
  scryptSync,
  createHmac,
  timingSafeEqual,
} from "crypto";

const COOKIE_NAME = "recap_admin_session";
const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const SCRYPT_KEYLEN = 64;

// Endpoints reachable WITHOUT an admin session, even when the gate is
// enabled. The login flow itself + the bare-minimum status endpoints.
const ADMIN_OPEN_PATHS = new Set([
  "/api/admin/status",
  "/api/admin/login",
  "/api/admin/logout",
  "/api/health",
]);

// ── Module state ────────────────────────────────────────────────────────────
// Live snapshot of the admin-auth config. Refreshed from
// /data/config/startos-config.json every CONFIG_POLL_MS. `enabled` is
// derived (true iff a hash is set).
export let ADMIN = {
  enabled: false,
  username: "",
  passwordHash: "",
  passwordSalt: "",
  sessionSecret: "",
};

let startosConfigPath = null;

// ── Init ────────────────────────────────────────────────────────────────────
export async function initAdminAuth({ dataDir }) {
  startosConfigPath = path.join(dataDir, "config", "startos-config.json");
  await refreshAdminConfig("startup");
  const pollMs = parseInt(
    process.env.RECAP_CONFIG_POLL_MS || "3000",
    10
  );
  setInterval(() => {
    refreshAdminConfig("config poll").catch(() => {});
  }, pollMs);
}

async function refreshAdminConfig(reason) {
  let next = {
    enabled: false,
    username: "",
    passwordHash: "",
    passwordSalt: "",
    sessionSecret: "",
  };
  try {
    const content = await fs.readFile(startosConfigPath, "utf-8");
    const cfg = JSON.parse(content);
    next = {
      enabled: !!(cfg.recap_admin_password_hash && cfg.recap_admin_password_salt),
      username: cfg.recap_admin_username || "",
      passwordHash: cfg.recap_admin_password_hash || "",
      passwordSalt: cfg.recap_admin_password_salt || "",
      sessionSecret: cfg.recap_admin_session_secret || "",
    };
  } catch {
    // File missing / unreadable — leave gate disabled.
  }
  if (
    next.enabled !== ADMIN.enabled ||
    next.username !== ADMIN.username ||
    next.passwordHash !== ADMIN.passwordHash ||
    next.sessionSecret !== ADMIN.sessionSecret
  ) {
    ADMIN = next;
    if (reason !== "config poll" || ADMIN.enabled !== false) {
      console.log(
        `[admin-auth] refresh (${reason}): enabled=${ADMIN.enabled} user=${ADMIN.username || "(unset)"}`
      );
    }
  } else {
    ADMIN = next;
  }
}

// ── Cookie helpers ──────────────────────────────────────────────────────────
function b64url(buf) {
  return Buffer.from(buf)
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

function b64urlDecode(s) {
  const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4);
  const padded = s + "=".repeat(pad);
  return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}

function hashFingerprint(hash) {
  // First 16 hex chars of the password hash. Stored in the cookie so
  // changing the password invalidates all existing sessions.
  return (hash || "").slice(0, 16);
}

function signSession({ username, hash, secret }) {
  const payload = JSON.stringify({
    u: username,
    iat: Date.now(),
    fp: hashFingerprint(hash),
  });
  const payloadB64 = b64url(payload);
  const sig = createHmac("sha256", secret).update(payloadB64).digest();
  return `${payloadB64}.${b64url(sig)}`;
}

function verifySession(token, { username, hash, secret }) {
  if (!token || typeof token !== "string") return false;
  const dot = token.indexOf(".");
  if (dot < 0) return false;
  const payloadB64 = token.slice(0, dot);
  const sigB64 = token.slice(dot + 1);
  if (!payloadB64 || !sigB64) return false;

  let expected;
  try {
    expected = createHmac("sha256", secret).update(payloadB64).digest();
  } catch {
    return false;
  }
  let provided;
  try {
    provided = b64urlDecode(sigB64);
  } catch {
    return false;
  }
  if (provided.length !== expected.length) return false;
  if (!timingSafeEqual(provided, expected)) return false;

  let payload;
  try {
    payload = JSON.parse(b64urlDecode(payloadB64).toString("utf-8"));
  } catch {
    return false;
  }
  if (!payload || payload.u !== username) return false;
  if (payload.fp !== hashFingerprint(hash)) return false;
  if (typeof payload.iat !== "number") return false;
  if (Date.now() - payload.iat > COOKIE_MAX_AGE_MS) return false;
  return true;
}

function parseCookies(header) {
  const out = {};
  if (!header || typeof header !== "string") return out;
  for (const part of header.split(";")) {
    const eq = part.indexOf("=");
    if (eq < 0) continue;
    const k = part.slice(0, eq).trim();
    const v = part.slice(eq + 1).trim();
    if (k) out[k] = decodeURIComponent(v);
  }
  return out;
}

function buildSetCookie(value, { req, maxAgeMs }) {
  const parts = [
    `${COOKIE_NAME}=${value}`,
    "HttpOnly",
    "SameSite=Lax",
    "Path=/",
  ];
  if (maxAgeMs > 0) {
    parts.push(`Max-Age=${Math.floor(maxAgeMs / 1000)}`);
  } else {
    parts.push("Max-Age=0");
  }
  // Mark Secure only when the request itself is HTTPS, so the cookie
  // still works on plain-HTTP LAN access (the common StartOS dev setup).
  const proto = (req.headers["x-forwarded-proto"] || "").toString().toLowerCase();
  const isHttps = req.secure || proto.includes("https");
  if (isHttps) parts.push("Secure");
  return parts.join("; ");
}

function isSessionAuthed(req) {
  if (!ADMIN.enabled) return true;
  const cookies = parseCookies(req.headers.cookie);
  return verifySession(cookies[COOKIE_NAME], {
    username: ADMIN.username,
    hash: ADMIN.passwordHash,
    secret: ADMIN.sessionSecret,
  });
}

// ── Middleware ──────────────────────────────────────────────────────────────
// Register BEFORE setupLicenseMiddleware. When the admin gate is
// disabled, this is a no-op pass-through.
export function setupAdminAuthMiddleware(app) {
  app.use((req, res, next) => {
    if (!ADMIN.enabled) return next();
    if (!req.path.startsWith("/api/")) return next();
    if (ADMIN_OPEN_PATHS.has(req.path)) return next();
    if (isSessionAuthed(req)) return next();
    return res.status(401).json({
      error: "admin_login_required",
      message: "Admin login required.",
    });
  });
}

// ── Routes ──────────────────────────────────────────────────────────────────
export function setupAdminAuthRoutes(app) {
  app.get("/api/admin/status", (req, res) => {
    res.json({
      enabled: ADMIN.enabled,
      authed: isSessionAuthed(req),
      username: ADMIN.enabled ? ADMIN.username : null,
    });
  });

  app.post("/api/admin/login", (req, res) => {
    if (!ADMIN.enabled) {
      // No password set; treat as success so the frontend doesn't get
      // stuck on the login screen if the admin clears the password.
      return res.json({ ok: true, enabled: false });
    }
    const username = (req.body && req.body.username) || "";
    const password = (req.body && req.body.password) || "";
    if (!username || !password) {
      return res
        .status(400)
        .json({ error: "missing_credentials", message: "Username and password required." });
    }
    if (username !== ADMIN.username) {
      return res
        .status(401)
        .json({ error: "invalid_credentials", message: "Invalid username or password." });
    }
    let computed;
    try {
      computed = scryptSync(password, ADMIN.passwordSalt, SCRYPT_KEYLEN);
    } catch {
      return res
        .status(500)
        .json({ error: "hash_failed", message: "Could not verify password." });
    }
    let stored;
    try {
      stored = Buffer.from(ADMIN.passwordHash, "hex");
    } catch {
      return res
        .status(500)
        .json({ error: "stored_hash_invalid", message: "Stored password hash is unreadable." });
    }
    if (computed.length !== stored.length || !timingSafeEqual(computed, stored)) {
      return res
        .status(401)
        .json({ error: "invalid_credentials", message: "Invalid username or password." });
    }
    const token = signSession({
      username: ADMIN.username,
      hash: ADMIN.passwordHash,
      secret: ADMIN.sessionSecret,
    });
    res.setHeader(
      "Set-Cookie",
      buildSetCookie(token, { req, maxAgeMs: COOKIE_MAX_AGE_MS })
    );
    res.json({ ok: true, enabled: true, username: ADMIN.username });
  });

  app.post("/api/admin/logout", (req, res) => {
    res.setHeader("Set-Cookie", buildSetCookie("", { req, maxAgeMs: 0 }));
    res.json({ ok: true });
  });
}
