"use client";

import { useEffect, useRef, useState } from "react";

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { dashboardCsrfHeader } from "@/lib/csrf.client";
import { SESSION_PASSIVE_HEADER } from "@/lib/auth/session-passive";
import {
  applyServerIdleRemaining,
  clearIdleStorage,
  evaluateSessionClock,
  expiresAtStorageKey,
  parseHardAnchor,
  resolveActivityBaseline,
  resolveHardDeadline,
  isRenewClaimed,
  shouldRenewSilently,
  serializeHardAnchor,
  type SessionClockReason,
} from "@/lib/auth/session-clock";

type SessionIdleGuardProps = {
  /** Seconds without user activity before ending the browser session. */
  idleSeconds: number;
  /** Clear-cookie bounce path (absolute path). */
  expiredPath: string;
  /**
   * Optional authenticated BFF that touches Yii last_seen_at.
   * Called at most once per minute while the user is active.
   */
  heartbeatPath?: string;
  /**
   * POST endpoint that reissues the session token for another full window and rotates the
   * cookie. When set, the token's own expiry (the HARD deadline) is tracked alongside the idle
   * deadline, and "Continue session" calls this: a heartbeat only touches last_seen_at and can
   * never move a token's `exp`, so without a real refresh the session dies on schedule no matter
   * how often Continue is clicked.
   */
  refreshPath?: string;
  /**
   * Send the dashboard CSRF token with the refresh POST. The staff dashboard's route factory
   * enforces it; the portal's guest routes deliberately do not (they run before a token exists).
   */
  refreshCsrf?: "dashboard";
  /**
   * GET endpoint that reports how much idle time the SERVER still credits this session, without
   * counting as activity. Consulted before an idle warning/expiry: activity the browser never saw
   * (an admin working in an impersonated portal on another origin keeps their own session busy
   * through the parent link) must not log them out of a session the server considers alive.
   */
  probePath?: string;
  /**
   * How long the token has left, in ms, measured on the SERVER at render time. A duration, not a
   * timestamp: an absolute server time compared with this browser's clock is only as right as the
   * browser's clock, and a wrong PC clock logged users out early or gave no warning at all.
   * Only meaningful alongside refreshPath.
   */
  initialExpiresInMs?: number | null;
  /**
   * The token's real `iat` (epoch ms), read server-side off the cookie. localStorage outlives a
   * session, so anything written before this token was issued belongs to a previous login and
   * must not make the new session look idle or mask its deadline (see session-clock).
   */
  initialIssuedAt?: number | null;
  /** localStorage key so remounts/HMR and other tabs share the idle clock. */
  storageKey?: string;
  /** Seconds before a deadline to show the continue-session warning. */
  warnLeadSeconds?: number;
};

type ContinueBroadcast = {
  type: "continued";
  lastActivityAt: number;
  hardExpiresAt: number | null;
};

/** Intentional interaction only — mousemove/scroll were resetting idle while the tab sat open. */
const ACTIVITY_EVENTS = ["pointerdown", "keydown", "touchstart", "click"] as const;

const HEARTBEAT_MIN_MS = 60_000;
const CHECK_EVERY_MS = 1_000;
const DEFAULT_WARN_LEAD_SECONDS = 60;
// Bounds how long a stalled (not just failed) refresh call can hold checkIdle's expiry check
// off — otherwise a hung request leaves refreshInFlight true indefinitely.
const REFRESH_TIMEOUT_MS = 15_000;
/** Brief grace after plain Continue so the 1s interval cannot expire mid-reset. */
const PLAIN_CONTINUE_GRACE_MS = 2_000;
/** Ask the server this long before an idle warning would open, so it can veto it in time. */
const PROBE_LEAD_MS = 30_000;
/** Minimum spacing between probes while an idle deadline is approaching. */
const PROBE_MIN_MS = 15_000;
const PROBE_TIMEOUT_MS = 10_000;
/** Wait this long before retrying a background renewal that failed. */
const SILENT_RENEW_RETRY_MS = 20_000;

function formatCountdown(totalSeconds: number): string {
  const safe = Math.max(0, Math.floor(totalSeconds));
  const minutes = Math.floor(safe / 60);
  const seconds = safe % 60;
  return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}

function readStoredNumber(key: string): number | null {
  try {
    const raw = localStorage.getItem(key);
    const parsed = raw ? Number(raw) : NaN;
    if (Number.isFinite(parsed) && parsed > 0) {
      return parsed;
    }
  } catch {
    /* private mode / blocked storage */
  }
  return null;
}

function writeStoredNumber(key: string, value: number) {
  try {
    localStorage.setItem(key, String(value));
  } catch {
    /* ignore */
  }
}

function readStoredHardAnchor(key: string) {
  try {
    return parseHardAnchor(localStorage.getItem(key));
  } catch {
    return null;
  }
}

function writeStoredHardAnchor(key: string, at: number) {
  try {
    localStorage.setItem(key, serializeHardAnchor({ at, savedAt: Date.now() }));
  } catch {
    /* ignore */
  }
}

function openContinueChannel(storageKey: string): BroadcastChannel | null {
  if (typeof BroadcastChannel === "undefined") {
    return null;
  }
  try {
    return new BroadcastChannel(`auth:idle:continue:${storageKey}`);
  } catch {
    return null;
  }
}

/**
 * Ends the browser session at whichever of two deadlines comes first, with a one-minute warning
 * and a Continue session action before either:
 *
 * - IDLE: last activity (clicks / keys / touches) + the idle window. Activity keeps moving it, so
 *   users who are working are not warned. The edge proxy only validates JWT signature/exp — Yii's
 *   idle revoke runs only on API hits — so a tab left open would otherwise stay "logged in".
 * - HARD (only when refreshPath is set): the token's own `exp`. Activity cannot move it; only a
 *   successful refresh can. Staff and ordinary portal tokens live 24h and impersonated ones 30
 *   minutes, and an active user used to be cut off at that point with no warning at all.
 *
 * Before an IDLE warning or expiry the guard asks the server (probePath) how much idle time it
 * still credits, and only ever extends its own clock from the answer.
 *
 * Continue is session-wide: localStorage + BroadcastChannel so every open tab/page for this
 * user closes the warning and adopts the new window after a single click.
 *
 * (The former "companion heartbeat" that kept an impersonating admin's own session alive from
 * the portal tab is gone: Yii now links an impersonated session to the admin session that started
 * it and touches both, which also works across origins where a browser-side ping could not.)
 */
export function SessionIdleGuard({
  idleSeconds,
  expiredPath,
  heartbeatPath,
  refreshPath,
  refreshCsrf,
  probePath,
  initialExpiresInMs,
  initialIssuedAt,
  storageKey = "auth:idle:lastActivityAt",
  warnLeadSeconds = DEFAULT_WARN_LEAD_SECONDS,
}: SessionIdleGuardProps) {
  const [warningOpen, setWarningOpen] = useState(false);
  const [warnReason, setWarnReason] = useState<SessionClockReason>("idle");
  const [secondsLeft, setSecondsLeft] = useState(warnLeadSeconds);
  // Surfaced in the dialog so a failed extend is visible and retryable instead of the dialog
  // silently staying open until the countdown logs the user out.
  const [refreshing, setRefreshing] = useState(false);
  const [refreshError, setRefreshError] = useState<string | null>(null);
  const continueSessionRef = useRef<() => void>(() => {});

  useEffect(() => {
    if (!Number.isFinite(idleSeconds) || idleSeconds < 60) {
      return;
    }

    const idleMs = idleSeconds * 1000;
    const warnLeadMs = Math.min(
      Math.max(15, Math.floor(warnLeadSeconds)) * 1000,
      Math.max(15_000, idleMs - 15_000),
    );
    let ended = false;
    let warningVisible = false;
    let lastHeartbeatAt = 0;
    let lastProbeAt = 0;
    let probeInFlight = false;
    // Guards the window between clicking Continue and refreshSession's response: without it,
    // a click placed near the end of the countdown could still lose the race to the 1s interval
    // (checkIdle ticking against the still-old deadline) and expire() the session out from under
    // a refresh that was about to succeed.
    let refreshInFlight = false;
    // Spacing between background renewals, so a refusing server is not retried every second.
    let nextSilentRenewAt = 0;
    // Plain-mode Continue: briefly pause expiry checks while lastActivityAt is reset + heartbeat
    // is forced, so the 1s interval cannot call expire() on the still-old timestamp.
    let continueInFlightUntil = 0;

    const expiresAtKey = expiresAtStorageKey(storageKey);
    const renewClaimKey = `${storageKey}:renewing`;
    const continueChannel = openContinueChannel(storageKey);

    // Discard a stored timestamp that predates this token: it is left over from a previous
    // session and would make a fresh login idle on tick one.
    let lastActivityAt = resolveActivityBaseline({
      storedMs: readStoredNumber(storageKey),
      issuedAtMs: initialIssuedAt,
      nowMs: Date.now(),
    });
    writeStoredNumber(storageKey, lastActivityAt);

    // The token's own deadline, on THIS browser's clock. Only tracked when a refresh endpoint
    // exists to move it; without one the guard is idle-only, as before.
    let hardExpiresAt: number | null = refreshPath
      ? resolveHardDeadline({
          nowMs: Date.now(),
          serverRemainingMs: initialExpiresInMs,
          stored: readStoredHardAnchor(expiresAtKey),
          issuedAtMs: initialIssuedAt,
        })
      : null;
    if (hardExpiresAt !== null) {
      writeStoredHardAnchor(expiresAtKey, hardExpiresAt);
    }

    const clearStoredActivity = () => {
      try {
        clearIdleStorage(localStorage, storageKey);
      } catch {
        /* storage access can throw */
      }
    };

    const expire = () => {
      if (ended) return;
      ended = true;
      setWarningOpen(false);
      clearStoredActivity();
      const redirect = `${window.location.pathname}${window.location.search}`;
      const url = new URL(expiredPath, window.location.origin);
      if (redirect && !redirect.startsWith("/login") && !redirect.includes("session-expired")) {
        url.searchParams.set("redirect", redirect);
      }
      window.location.assign(url.toString());
    };

    const applyContinued = (nextLastActivityAt: number, nextHardExpiresAt: number | null) => {
      lastActivityAt = Math.max(lastActivityAt, nextLastActivityAt);
      writeStoredNumber(storageKey, lastActivityAt);
      if (nextHardExpiresAt != null && Number.isFinite(nextHardExpiresAt)) {
        hardExpiresAt = nextHardExpiresAt;
        writeStoredHardAnchor(expiresAtKey, nextHardExpiresAt);
      }
      continueInFlightUntil = Date.now() + PLAIN_CONTINUE_GRACE_MS;
      warningVisible = false;
      setRefreshError(null);
      setWarningOpen(false);
      setSecondsLeft(Math.ceil(warnLeadMs / 1000));
    };

    const publishContinued = (nextLastActivityAt: number, nextHardExpiresAt: number | null) => {
      applyContinued(nextLastActivityAt, nextHardExpiresAt);
      const payload: ContinueBroadcast = {
        type: "continued",
        lastActivityAt: nextLastActivityAt,
        hardExpiresAt: nextHardExpiresAt,
      };
      try {
        continueChannel?.postMessage(payload);
      } catch {
        /* ignore */
      }
    };

    const pingHeartbeat = (force = false) => {
      if (!heartbeatPath || ended) return;
      const now = Date.now();
      if (!force && now - lastHeartbeatAt < HEARTBEAT_MIN_MS) return;
      lastHeartbeatAt = now;
      void fetch(heartbeatPath, {
        method: "GET",
        credentials: "same-origin",
        cache: "no-store",
        headers: { Accept: "application/json" },
      })
        .then((response) => {
          if (response.status === 401) {
            expire();
          }
        })
        .catch(() => {
          /* network blip — idle timer still applies */
        });
    };

    // Asks the server how much idle time it still credits. Read-only (the BFF sends
    // X-Session-Probe, so Yii does not count it as activity) and extend-only on the way back in.
    const probeServer = () => {
      if (!probePath || ended || probeInFlight) return;
      const now = Date.now();
      if (now - lastProbeAt < PROBE_MIN_MS) return;
      lastProbeAt = now;
      probeInFlight = true;

      const controller = new AbortController();
      const timeoutId = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
      void fetch(probePath, {
        method: "GET",
        credentials: "same-origin",
        cache: "no-store",
        headers: { Accept: "application/json" },
        signal: controller.signal,
      })
        .then(async (response) => {
          window.clearTimeout(timeoutId);
          if (response.status === 401) {
            probeInFlight = false;
            expire();
            return;
          }
          if (!response.ok) {
            probeInFlight = false;
            return;
          }

          const data = (await response.json().catch(() => null)) as {
            session?: { idleRemainingSeconds?: unknown; expiresInSeconds?: unknown } | null;
          } | null;
          const idleRemaining = data?.session?.idleRemainingSeconds;
          const expiresIn = data?.session?.expiresInSeconds;
          const answeredAt = Date.now();

          const nextLastActivityAt = applyServerIdleRemaining({
            nowMs: answeredAt,
            idleMs,
            lastActivityAt,
            serverIdleRemainingMs: typeof idleRemaining === "number" ? idleRemaining * 1000 : null,
          });
          if (nextLastActivityAt !== lastActivityAt) {
            lastActivityAt = nextLastActivityAt;
            writeStoredNumber(storageKey, lastActivityAt);
          }

          // Same rule for the hard deadline: only a LATER one is believed.
          if (hardExpiresAt !== null && typeof expiresIn === "number" && Number.isFinite(expiresIn)) {
            const serverHardExpiresAt = answeredAt + expiresIn * 1000;
            if (serverHardExpiresAt > hardExpiresAt) {
              hardExpiresAt = serverHardExpiresAt;
              writeStoredHardAnchor(expiresAtKey, hardExpiresAt);
            }
          }

          probeInFlight = false;
          checkIdle();
        })
        .catch(() => {
          window.clearTimeout(timeoutId);
          probeInFlight = false;
        });
    };

    // Reissues the token for another full window. Used instead of pingHeartbeat when refreshPath
    // is set, since a heartbeat alone cannot move a token's exp. Only a successful reissue may
    // push the hard deadline back — merely calling this (or being active) must not.
    //
    // `silent` is the background renewal of a session the user is still working in: no spinner,
    // no error text, and it never counts as the user's activity — only the hard deadline moves.
    const refreshSession = (silent = false) => {
      if (!refreshPath || ended) return;
      refreshInFlight = true;
      if (!silent) {
        setRefreshing(true);
        setRefreshError(null);
      }
      const controller = new AbortController();
      const timeoutId = window.setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS);
      void fetch(refreshPath, {
        method: "POST",
        credentials: "same-origin",
        cache: "no-store",
        headers: {
          Accept: "application/json",
          ...(refreshCsrf === "dashboard" ? dashboardCsrfHeader() : {}),
          // A background renewal is not user activity: tell the server not to credit it.
          ...(silent ? { [SESSION_PASSIVE_HEADER]: "1" } : {}),
        },
        signal: controller.signal,
      })
        .then(async (response) => {
          window.clearTimeout(timeoutId);
          if (!response.ok) {
            refreshInFlight = false;
            setRefreshing(false);
            if (silent && response.status !== 401) return;
            // Only a true auth failure means the session is gone. Transient/ACL/proxy errors
            // (e.g. historical 403 on guest/refresh) must leave the warning open so Continue
            // can be retried instead of force-logging the user out — and must SAY so, or the
            // user just watches the countdown run out.
            if (response.status === 401) {
              expire();
              return;
            }
            setRefreshError(`Could not extend your session (error ${response.status}). Please try again.`);
            return;
          }
          const data = (await response.json().catch(() => null)) as {
            expires_in?: number;
          } | null;
          const grantedMs =
            data && Number.isFinite(data.expires_in) ? Number(data.expires_in) * 1000 : idleMs;
          const now = Date.now();
          refreshInFlight = false;
          setRefreshing(false);
          // One Continue extends every open tab/page for this user. A silent renewal passes the
          // activity clock through unchanged: being renewed is not being active.
          publishContinued(silent ? lastActivityAt : now, now + grantedMs);
        })
        .catch(() => {
          // network blip or timeout — the deadlines are untouched; the user can retry from the
          // dialog, unless the real deadline already passed while we waited (checkIdle catches
          // that).
          window.clearTimeout(timeoutId);
          refreshInFlight = false;
          setRefreshing(false);
          // A failed background renewal says nothing: the warning (and its Continue button)
          // still opens if the deadline gets close.
          if (!silent) setRefreshError("Could not reach the server. Please try again.");
        });
    };

    const noteActivity = () => {
      // While the warning is open, only "Continue session" extends the window.
      if (ended || warningVisible) return;
      // Activity moves the IDLE deadline only; the hard deadline needs a real refresh.
      lastActivityAt = Date.now();
      writeStoredNumber(storageKey, lastActivityAt);
      pingHeartbeat();
    };

    const continueSession = () => {
      if (ended) return;
      if (refreshPath) {
        // Keep the dialog open until refreshSession confirms a new window — an active user
        // must see the ask, not a silent skip. publishContinued runs after success.
        refreshSession();
        return;
      }
      publishContinued(Date.now(), null);
      // Force a server touch so Yii last_seen_at moves with the continued client session.
      pingHeartbeat(true);
    };
    continueSessionRef.current = continueSession;

    function checkIdle() {
      if (ended) return;

      // A refresh is in flight (Continue was just clicked): don't race it — let its own
      // response decide the outcome instead of expiring out from under it.
      if (refreshInFlight) return;
      if (Date.now() < continueInFlightUntil) return;

      // Re-read in case another tab was active / Continued / refreshed.
      lastActivityAt = Math.max(lastActivityAt, readStoredNumber(storageKey) ?? 0);
      if (hardExpiresAt !== null) {
        hardExpiresAt = Math.max(hardExpiresAt, readStoredHardAnchor(expiresAtKey)?.at ?? 0);
      }

      // The token is close to its `exp` but the user is still working: renew it quietly. The
      // dialog is for inactivity, so this must run before the warning can open for "expiry".
      const nowMs = Date.now();
      if (
        refreshPath &&
        !warningVisible &&
        nowMs >= nextSilentRenewAt &&
        shouldRenewSilently({ nowMs, idleMs, lastActivityAt, hardExpiresAt, warnLeadMs })
      ) {
        nextSilentRenewAt = nowMs + SILENT_RENEW_RETRY_MS;
        // One tab renews for all of them; the others pick the new deadline up from its broadcast.
        if (!isRenewClaimed(readStoredNumber(renewClaimKey), nowMs)) {
          writeStoredNumber(renewClaimKey, nowMs);
          refreshSession(true);
          return;
        }
      }

      const verdict = evaluateSessionClock({
        nowMs: Date.now(),
        idleMs,
        lastActivityAt,
        hardExpiresAt,
        warnLeadMs,
      });

      // An idle deadline is the one the server may know better about: ask before warning or
      // expiring, and hold the verdict while the answer is on its way.
      if (verdict.reason === "idle" && probePath) {
        if (verdict.remainingMs <= warnLeadMs + PROBE_LEAD_MS) {
          probeServer();
        }
        if (probeInFlight && verdict.action !== "none") {
          return;
        }
      }

      if (verdict.action === "expire") {
        expire();
        return;
      }

      if (verdict.action === "warn") {
        warningVisible = true;
        setWarnReason(verdict.reason);
        setWarningOpen(true);
        setSecondsLeft(Math.max(1, Math.ceil(verdict.remainingMs / 1000)));
      } else {
        warningVisible = false;
        setWarningOpen(false);
      }
    }

    for (const name of ACTIVITY_EVENTS) {
      window.addEventListener(name, noteActivity, true);
    }

    const onVisibility = () => {
      if (document.visibilityState === "visible") {
        checkIdle();
      }
    };
    document.addEventListener("visibilitychange", onVisibility);

    // Cross-tab: another tab's activity / Continue updates localStorage.
    const onStorage = (event: StorageEvent) => {
      if (event.key !== storageKey && event.key !== expiresAtKey) return;
      checkIdle();
    };
    window.addEventListener("storage", onStorage);

    // Same-origin tabs: Continue once → every dialog closes immediately.
    const onContinueMessage = (event: MessageEvent<ContinueBroadcast>) => {
      const data = event.data;
      if (!data || data.type !== "continued") return;
      if (ended) return;
      applyContinued(data.lastActivityAt, data.hardExpiresAt);
      checkIdle();
    };
    continueChannel?.addEventListener("message", onContinueMessage);

    const timer = window.setInterval(checkIdle, CHECK_EVERY_MS);
    // Immediate check (covers remount after already-idle).
    checkIdle();

    return () => {
      ended = true;
      window.clearInterval(timer);
      document.removeEventListener("visibilitychange", onVisibility);
      window.removeEventListener("storage", onStorage);
      continueChannel?.removeEventListener("message", onContinueMessage);
      try {
        continueChannel?.close();
      } catch {
        /* ignore */
      }
      for (const name of ACTIVITY_EVENTS) {
        window.removeEventListener(name, noteActivity, true);
      }
    };
  }, [
    idleSeconds,
    expiredPath,
    heartbeatPath,
    refreshPath,
    refreshCsrf,
    probePath,
    initialExpiresInMs,
    initialIssuedAt,
    storageKey,
    warnLeadSeconds,
  ]);

  return (
    <AlertDialog
      open={warningOpen}
      onOpenChange={(open) => {
        // Keep the warning until Continue session or auto-logout — ignore dismiss.
        if (open) setWarningOpen(true);
      }}
    >
      <AlertDialogContent size="default">
        <AlertDialogHeader>
          <AlertDialogTitle>Session expiring soon</AlertDialogTitle>
          <AlertDialogDescription>
            {warnReason === "expiry" ? "Your session is ending soon." : "You have been inactive."} You
            will be signed out in{" "}
            <span className="font-semibold tabular-nums text-foreground">
              {formatCountdown(secondsLeft)}
            </span>{" "}
            unless you continue this session.
          </AlertDialogDescription>
          {refreshError ? (
            <p role="alert" className="text-sm font-medium text-destructive">
              {refreshError}
            </p>
          ) : null}
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogAction
            disabled={refreshing}
            onClick={(event) => {
              event.preventDefault();
              continueSessionRef.current();
            }}
          >
            {refreshing ? "Extending…" : refreshError ? "Try again" : "Continue session"}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}
