// Accounts: the header "Sign in / account" entry, the auth modal, and the shared
// helpers other modules (cache.js, mappicker.js, subscriptions.js, compare.js,
// digest.js) use to talk to backend — API calls and page/asset links to
// backend-owned paths (the interactive tool pages, /alerts, /digest, …) alike.
//
// Auth is an HttpOnly cookie; credentials: "include" (not "same-origin") so it
// still rides along when this script itself is loaded cross-origin (repo-split
// Stage 5 — a frontend SSR service on a different origin than backend, proven
// via a genuinely cross-origin LAN-dev run). Loaded on every page the way
// units.js is (imported by each page's entry module).

let currentUser = null;          // {id, email, display_name} or null
let discordEnabled = false;      // is Discord linking configured on the server?
let discordChecked = false;      // have we asked yet? (once per page load)
const authCbs = [];              // notified on login/logout so pages can re-gate

// backend's own origin+base (e.g. "https://thermograph.org/thermograph", or
// "http://192.168.1.5:8137/thermograph" when frontend and backend are on
// genuinely different origins) — derived from this module's own URL, since
// account.js (like every other frontend/*.js file) is always served BY
// backend, never by the frontend SSR service. Keeping the full origin (not
// just the path, as this used to) is what makes fetch()/href targets built
// from it resolve to backend even when this script was loaded from a page on
// a different origin (a frontend_ssr-rendered page in the cross-origin case).
export const APP_BASE = new URL(".", import.meta.url).href.replace(/\/$/, "");
export const u = (path) => `${APP_BASE}/${String(path).replace(/^\//, "")}`;

// Single pin point for the backend content/data API version every fetch below
// targets — was ~30 hardcoded "api/v2/..." literals spread across ~10 files
// with no one place to bump. Bump only in lockstep with a verified backend
// /api/version check: this frontend and its backend now deploy asynchronously
// (repo-split), so an unverified bump here would just turn every API call
// into a 404 the moment it ships ahead of a backend that doesn't speak it yet.
export const API_VERSION = "v2";  // bump only in lockstep with a verified backend /api/version check

// Turns a bare resource path ("grade", "users/me") — or one a caller already
// prefixed with a (possibly stale) version ("api/v2/place") — into a full
// request URL pinned to API_VERSION. Accepting the already-prefixed form too
// means a copy-pasted literal from an old call site normalizes instead of
// silently double-prefixing.
export const uv = (path) =>
  u(`api/${API_VERSION}/${String(path).replace(/^\/?(api\/v\d+\/)?/, "")}`);

// --- shared fetch helper -----------------------------------------------------
// `url` must already be a full request URL — build it with uv() so every call
// is pinned to API_VERSION. Cookie sent, and a constant X-TG-Auth header the
// server can require as cheap CSRF defense (still meaningful cross-origin:
// forging it needs a preflight-clearing request we never grant, same as
// same-origin).
export async function apiFetch(url, { method = "GET", json, form } = {}) {
  const opts = { method, credentials: "include", headers: { "X-TG-Auth": "1" } };
  if (json !== undefined) {
    opts.headers["Content-Type"] = "application/json";
    opts.body = JSON.stringify(json);
  } else if (form !== undefined) {
    opts.headers["Content-Type"] = "application/x-www-form-urlencoded";
    opts.body = new URLSearchParams(form).toString();
  }
  return fetch(url, opts);
}

// Parse a JSON response, throwing a friendly Error on failure. fastapi-users
// returns machine codes ("LOGIN_BAD_CREDENTIALS") or {detail:{reason}} for
// password rejects; map the common ones to human text.
async function readJson(res) {
  let data = null;
  try { data = await res.json(); } catch (e) { /* empty body (e.g. 204) */ }
  if (!res.ok) {
    const detail = data && data.detail;
    let msg;
    if (detail && typeof detail === "object" && detail.reason) msg = detail.reason;
    else if (detail === "LOGIN_BAD_CREDENTIALS") msg = "Wrong email or password.";
    else if (detail === "REGISTER_USER_ALREADY_EXISTS") msg = "That email is already registered.";
    else if (detail === "VERIFY_USER_BAD_TOKEN") msg = "That verification link is invalid or expired.";
    else if (detail === "VERIFY_USER_ALREADY_VERIFIED") msg = "This account is already verified.";
    else if (typeof detail === "string") msg = detail.replace(/_/g, " ").toLowerCase();
    else msg = `Request failed (${res.status}).`;
    throw new Error(msg);
  }
  return data;
}

export function getUser() { return currentUser; }
export function onAuthChange(cb) { authCbs.push(cb); }
function emitAuth() { authCbs.forEach((cb) => { try { cb(currentUser); } catch (e) {} }); }

async function refreshUser() {
  try {
    const res = await apiFetch(uv("users/me"));
    currentUser = res.ok ? await res.json() : null;
  } catch (e) { currentUser = null; }
  // Learn once whether Discord linking is configured, so the account menu only
  // offers "Link Discord" when it will actually work (checked lazily, and only
  // for a signed-in user — the menu never shows it to anyone else).
  if (currentUser && !discordChecked) {
    discordChecked = true;
    try {
      const r = await apiFetch(uv("discord/config"));
      if (r.ok) discordEnabled = (await r.json()).enabled === true;
    } catch (e) { /* leave it hidden */ }
  }
  return currentUser;
}

// --- auth calls --------------------------------------------------------------
async function login(email, password) {
  // fastapi-users login is an OAuth2 form: username=email, password.
  const res = await apiFetch(uv("auth/login"), { method: "POST", form: { username: email, password } });
  await readJson(res);           // throws on bad creds; 204 body is empty
}
async function register(email, password) {
  const res = await apiFetch(uv("auth/register"), { method: "POST", json: { email, password } });
  await readJson(res);
}
async function verifyEmail(token) {
  const res = await apiFetch(uv("auth/verify"), { method: "POST", json: { token } });
  await readJson(res);
}
export async function logout() {
  try { await apiFetch(uv("auth/logout"), { method: "POST" }); } catch (e) {}
  currentUser = null;
  renderHeader();
  emitAuth();
}

// --- auth modal --------------------------------------------------------------
let modal = null, mode = "login";

function buildModal() {
  modal = document.createElement("div");
  modal.className = "mp-overlay acct-overlay";
  modal.hidden = true;
  modal.innerHTML = `
    <div class="mp-modal acct-modal" role="dialog" aria-modal="true" aria-label="Account">
      <div class="mp-head">
        <h2 class="acct-title">Sign in</h2>
        <button type="button" class="mp-close" aria-label="Close">&times;</button>
      </div>
      <form class="acct-form" autocomplete="on">
        <label>Email
          <input type="email" name="email" required autocomplete="email" placeholder="you@example.com" />
        </label>
        <label>Password
          <input type="password" name="password" required minlength="8" autocomplete="current-password" placeholder="At least 8 characters" />
        </label>
        <p class="acct-error" role="alert" hidden></p>
        <button type="submit" class="acct-submit">Sign in</button>
        <p class="acct-switch"></p>
      </form>
    </div>`;
  document.body.appendChild(modal);

  modal.querySelector(".mp-close").onclick = closeAuth;
  modal.addEventListener("pointerdown", (e) => { if (e.target === modal) closeAuth(); });
  document.addEventListener("keydown", (e) => { if (!modal.hidden && e.key === "Escape") closeAuth(); });

  const form = modal.querySelector(".acct-form");
  form.addEventListener("submit", onSubmit);
  modal.querySelector(".acct-switch").addEventListener("click", (e) => {
    const b = e.target.closest("button");
    if (b) setMode(mode === "login" ? "register" : "login");
  });
  setMode("login");
}

function setMode(m) {
  mode = m;
  const isLogin = m === "login";
  modal.querySelector(".acct-title").textContent = isLogin ? "Sign in" : "Create account";
  modal.querySelector(".acct-submit").textContent = isLogin ? "Sign in" : "Create account";
  modal.querySelector('input[name="password"]').setAttribute(
    "autocomplete", isLogin ? "current-password" : "new-password");
  modal.querySelector(".acct-switch").innerHTML = isLogin
    ? 'Need an account? <button type="button">Create one</button>'
    : 'Already have an account? <button type="button">Sign in</button>';
  showError("");
}

function showError(msg) {
  const el = modal.querySelector(".acct-error");
  el.textContent = msg || "";
  el.hidden = !msg;
}

async function onSubmit(e) {
  e.preventDefault();
  const email = modal.querySelector('input[name="email"]').value.trim();
  const password = modal.querySelector('input[name="password"]').value;
  const btn = modal.querySelector(".acct-submit");
  btn.disabled = true;
  showError("");
  try {
    if (mode === "register") { await register(email, password); }
    await login(email, password);      // register flow signs in right after
    await refreshUser();
    closeAuth();
    renderHeader();
    emitAuth();
  } catch (err) {
    showError(err.message || "Something went wrong.");
  } finally {
    btn.disabled = false;
  }
}

export function openAuth(startMode = "login") {
  if (!modal) buildModal();
  setMode(startMode);
  modal.querySelector('input[name="email"]').value = "";
  modal.querySelector('input[name="password"]').value = "";
  modal.hidden = false;
  modal.querySelector('input[name="email"]').focus();
}
function closeAuth() { if (modal) modal.hidden = true; }

// --- notifications (header bell) ---------------------------------------------
const BELL_IC = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>`;

let notifications = [];
let unreadCount = 0;
let notifTimer = null;
let notifPolling = false;

// Backoff: a sustained outage shouldn't keep hitting the backend every 2 min
// forever. Doubles on each consecutive failure, capped, resets on success.
const NOTIF_POLL_BASE_MS = 120000;   // refresh unread every 2 min when healthy
const NOTIF_POLL_MAX_MS = 960000;    // cap at 16 min
let notifPollDelay = NOTIF_POLL_BASE_MS;
let notifFailStreak = 0;

function timeAgo(sec) {
  const d = Date.now() / 1000 - sec;
  if (d < 60) return "just now";
  if (d < 3600) return `${Math.floor(d / 60)}m ago`;
  if (d < 86400) return `${Math.floor(d / 3600)}h ago`;
  return `${Math.floor(d / 86400)}d ago`;
}

async function loadNotifications() {
  try {
    const res = await apiFetch(uv("notifications?limit=50"));
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();
    notifications = data.notifications || [];
    unreadCount = data.unread_count || 0;
    paintBadge();
    paintNotifList();
    notifFailStreak = 0;
    notifPollDelay = NOTIF_POLL_BASE_MS;
  } catch (e) {
    // offline / backend blip — leave prior state, back off the poll interval
    notifFailStreak++;
    notifPollDelay = Math.min(NOTIF_POLL_BASE_MS * 2 ** notifFailStreak, NOTIF_POLL_MAX_MS);
  }
}

function paintBadge() {
  if (!acctEl) return;
  const badge = acctEl.querySelector(".notif-badge");
  if (!badge) return;
  badge.textContent = unreadCount > 99 ? "99+" : String(unreadCount);
  badge.hidden = unreadCount === 0;
}

function paintNotifList() {
  if (!acctEl) return;
  const list = acctEl.querySelector(".notif-list");
  if (!list) return;
  if (!notifications.length) {
    list.innerHTML = `<li class="notif-empty muted">No alerts yet. Weather notifications will show up here.</li>`;
    return;
  }
  list.innerHTML = notifications.map((n) => `
    <li class="notif-item ${n.read_at ? "" : "is-unread"}" data-id="${n.id}">
      <div class="notif-item-title">${escapeHtml(n.title)}</div>
      <div class="notif-item-body">${escapeHtml(n.body || "")}</div>
      <div class="notif-item-time muted">${timeAgo(n.created_at)}</div>
    </li>`).join("");
  list.querySelectorAll(".notif-item.is-unread").forEach((li) => {
    li.addEventListener("click", () => markRead(Number(li.dataset.id), li));
  });
}

async function markRead(id, li) {
  try {
    const res = await apiFetch(uv(`notifications/${id}/read`), { method: "POST" });
    if (!res.ok) return;
    const n = notifications.find((x) => x.id === id);
    if (n && !n.read_at) { n.read_at = Date.now() / 1000; unreadCount = Math.max(0, unreadCount - 1); }
    li.classList.remove("is-unread");
    paintBadge();
  } catch (e) {}
}

async function markAllRead() {
  try {
    const res = await apiFetch(uv("notifications/read-all"), { method: "POST" });
    if (!res.ok) return;
    notifications.forEach((n) => { n.read_at = n.read_at || Date.now() / 1000; });
    unreadCount = 0;
    paintBadge();
    paintNotifList();
  } catch (e) {}
}

function startNotifPolling() {
  if (notifPolling) return;
  notifPolling = true;
  // Recursive setTimeout, not setInterval -- the delay is variable (backoff),
  // so the next tick has to be scheduled after each fetch settles rather than
  // fixed up front.
  const tick = async () => {
    await loadNotifications();
    if (!notifPolling) return;   // stopped while the fetch was in flight
    notifTimer = setTimeout(tick, notifPollDelay);
  };
  tick();
}
function stopNotifPolling() {
  notifPolling = false;
  if (notifTimer) { clearTimeout(notifTimer); notifTimer = null; }
  notifications = []; unreadCount = 0;
  notifFailStreak = 0; notifPollDelay = NOTIF_POLL_BASE_MS;
}

// --- header entry ------------------------------------------------------------
const USER_IC = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;

let acctEl = null;

function ensureAcctEl() {
  const brand = document.querySelector(".brand");
  if (!brand) return null;
  if (!acctEl) {
    acctEl = document.createElement("div");
    acctEl.className = "acct";
    // The account/bell live inside the nav menu panel: on desktop the panel is
    // display:contents so they render inline top-right; on phones they stack
    // inside the hamburger dropdown. Fall back to a brand sibling if unwrapped.
    const panel = brand.querySelector(".nav-panel");
    if (panel) panel.appendChild(acctEl);
    else brand.insertBefore(acctEl, brand.querySelector(".view-nav"));
  }
  return acctEl;
}

function renderHeader() {
  const el = ensureAcctEl();
  if (!el) return;
  if (currentUser) {
    const name = currentUser.display_name || currentUser.email;
    el.innerHTML = `
      <div class="notif">
        <button type="button" class="acct-btn notif-btn" aria-haspopup="true" aria-expanded="false" aria-label="Notifications">
          ${BELL_IC}<span class="notif-badge" hidden>0</span>
        </button>
        <div class="notif-pop" hidden>
          <div class="notif-head">
            <span>Notifications</span>
            <button type="button" class="notif-readall">Mark all read</button>
          </div>
          <ul class="notif-list"></ul>
          <a href="${APP_BASE}/alerts" class="notif-manage">Manage alerts →</a>
        </div>
      </div>
      <div class="acct-menu">
        <button type="button" class="acct-btn" aria-haspopup="true" aria-expanded="false" aria-label="Account: ${escapeHtml(name)}">
          ${USER_IC}<span class="acct-name">${escapeHtml(name)}</span>
        </button>
        <div class="acct-pop" hidden>
          <a href="${APP_BASE}/alerts" class="acct-pop-link">My alerts</a>
          ${currentUser.discord_id
            ? `<button type="button" class="acct-pop-link acct-discord-dm">${currentUser.discord_dm ? "Discord alerts: on" : "Discord alerts: off"}</button>`
              + '<button type="button" class="acct-pop-link acct-discord-unlink">Unlink Discord</button>'
            : (discordEnabled ? `<a href="${uv("discord/link/start")}" class="acct-pop-link">Link Discord</a>` : "")}
          <button type="button" class="acct-pop-link acct-signout">Sign out</button>
        </div>
      </div>`;
    const btn = el.querySelector(".acct-btn:not(.notif-btn)");
    const pop = el.querySelector(".acct-pop");
    btn.addEventListener("click", () => {
      const open = pop.hidden;
      pop.hidden = !open;
      btn.setAttribute("aria-expanded", open ? "true" : "false");
    });
    // Notification bell dropdown.
    const nBtn = el.querySelector(".notif-btn");
    const nPop = el.querySelector(".notif-pop");
    nBtn.addEventListener("click", () => {
      const open = nPop.hidden;
      nPop.hidden = !open;
      nBtn.setAttribute("aria-expanded", open ? "true" : "false");
      if (open) loadNotifications();     // refresh on open
    });
    el.querySelector(".notif-readall").addEventListener("click", markAllRead);
    document.addEventListener("click", (e) => {
      if (!el.contains(e.target)) {
        pop.hidden = true; btn.setAttribute("aria-expanded", "false");
        nPop.hidden = true; nBtn.setAttribute("aria-expanded", "false");
      }
    });
    el.querySelector(".acct-signout").addEventListener("click", logout);
    const unlinkBtn = el.querySelector(".acct-discord-unlink");
    if (unlinkBtn) unlinkBtn.addEventListener("click", async () => {
      unlinkBtn.disabled = true;
      try { await apiFetch(uv("discord/unlink"), { method: "POST" }); } catch (e) {}
      await refreshUser();
      emitAuth();              // repaint the popover in its unlinked state
    });
    const dmBtn = el.querySelector(".acct-discord-dm");
    if (dmBtn) dmBtn.addEventListener("click", async () => {
      dmBtn.disabled = true;
      try {
        await apiFetch(uv("discord/dm"), { method: "POST", json: { enabled: !currentUser.discord_dm } });
      } catch (e) {}
      await refreshUser();
      emitAuth();              // repaint with the new on/off label
    });
    paintBadge();
    paintNotifList();
    startNotifPolling();
  } else {
    stopNotifPolling();
    el.innerHTML = `<button type="button" class="acct-btn acct-signin" aria-label="Sign in">${USER_IC}<span>Sign in</span></button>`;
    el.querySelector(".acct-signin").addEventListener("click", () => openAuth("login"));
  }
}

function escapeHtml(s) {
  return String(s).replace(/[&<>"']/g, (c) =>
    ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}

// --- toast ---------------------------------------------------------------
// One-off status messages (e.g. "email verified") that aren't tied to the auth
// modal, which may not even be open. Success/failure share the same neutral
// style — the text says which, not the color; isError only changes the
// live-region role (alert = assertive, status = polite).
let toastTimer = null;

function showToast(msg, isError = false) {
  let el = document.querySelector(".acct-toast");
  if (!el) {
    el = document.createElement("div");
    el.className = "acct-toast";
    document.body.appendChild(el);
  }
  el.textContent = msg;
  el.setAttribute("role", isError ? "alert" : "status");
  el.hidden = false;
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
}

// --- boot --------------------------------------------------------------------
(async function initAccount() {
  ensureAcctEl();
  renderHeader();               // paint the signed-out state immediately
  await refreshUser();          // then confirm session from the cookie
  renderHeader();
  emitAuth();

  const params = new URLSearchParams(location.search);
  const token = params.get("verify_token");
  if (token) {
    params.delete("verify_token");
    const clean = location.pathname + (params.toString() ? `?${params}` : "") + location.hash;
    history.replaceState(null, "", clean);
    try {
      await verifyEmail(token);
      await refreshUser();
      renderHeader();
      emitAuth();
      showToast("Email verified — you're all set.");
    } catch (err) {
      showToast(err.message || "Verification link is invalid or expired.", true);
    }
  }
})();
