"use client";

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

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog";

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 `idleSeconds` window
   * (impersonated portal sessions, whose token has a fixed, short `exp`). When set,
   * "Continue session" calls this instead of the plain heartbeat GET — the heartbeat only
   * touches last_seen_at and never actually extends the token's own expiry, so without a
   * real refresh the session would still die on schedule despite the user clicking Continue.
   */
  refreshPath?: string;
  /**
   * The token's real `exp` (epoch ms), read server-side off the cookie. Used as the fixed-expiry
   * anchor instead of guessing "a fresh idleSeconds window starting now" — that guess drifts from
   * the real exp whenever the guard mounts any time after token issuance (new tab, reload
   * mid-session), and the drift meant the edge middleware could kill the session on the real exp
   * before the client's own guessed countdown ever reached its warning threshold. Only meaningful
   * alongside refreshPath; ignored otherwise.
   */
  initialExpiresAt?: number | null;
  /** localStorage key so remounts/HMR and other tabs share the idle clock. */
  storageKey?: string;
  /** Seconds before idle expiry to show the continue-session warning. */
  warnLeadSeconds?: number;
  /**
   * Optional second session to keep alive (admin JWT while impersonating a customer).
   * The dashboard idle guard is not mounted on portal pages, so without this the admin
   * sid idle-revokes and Exit impersonation / dashboard tabs land on /login.
   * A 401 here must NOT end the portal session — only the primary heartbeat does that.
   */
  companionHeartbeatPath?: string;
  /** localStorage / BroadcastChannel key for the companion (admin) idle clock. */
  companionStorageKey?: string;
};

type ContinueBroadcast = {
  type: "continued";
  lastActivityAt: number;
  expiresAt: 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;

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 removeStored(key: string) {
  try {
    localStorage.removeItem(key);
  } catch {
    /* ignore */
  }
}

/**
 * Prefer the farthest future deadline. Continue writes a longer client deadline into
 * localStorage; a soft-nav remount often re-supplies a stale SSR `initialExpiresAt` that must
 * not clobber that extension (otherwise every page shows the dialog again).
 */
function resolveExpiresAt(
  serverExpiresAt: number | null | undefined,
  storedExpiresAt: number | null,
  fallback: number,
): number {
  const candidates: number[] = [];
  if (Number.isFinite(serverExpiresAt) && (serverExpiresAt as number) > 0) {
    candidates.push(serverExpiresAt as number);
  }
  if (storedExpiresAt != null) {
    candidates.push(storedExpiresAt);
  }
  if (candidates.length === 0) {
    return fallback;
  }
  return Math.max(...candidates);
}

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 after idleSeconds.
 *
 * Shows a one-minute warning with a Continue session action before logout.
 *
 * Two modes, chosen by whether `refreshPath` is set:
 * - Plain (no refreshPath): idle-based. Active users (clicks / keys / touches) keep resetting
 *   the idle clock so they are not logged out while working. Needed because the edge proxy only
 *   validates JWT signature/exp — Yii idle revoke runs only on API hits, so a tab left open would
 *   otherwise stay "logged in".
 * - Fixed-expiry (refreshPath set — impersonated sessions): the token has a fixed, short exp that
 *   activity cannot move, so the countdown runs on a wall-clock schedule regardless of clicks.
 *   Only clicking "Continue session" (which calls refreshPath) grants another full window; an
 *   active user who never clicks it still gets warned and then signed out on schedule.
 *
 * 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.
 *
 * Admin impersonation: pass companionHeartbeatPath so activity / Continue also touch the
 * backend admin session (otherwise it idle-revokes while you work in the portal).
 */
export function SessionIdleGuard({
  idleSeconds,
  expiredPath,
  heartbeatPath,
  refreshPath,
  initialExpiresAt,
  storageKey = "auth:idle:lastActivityAt",
  warnLeadSeconds = DEFAULT_WARN_LEAD_SECONDS,
  companionHeartbeatPath,
  companionStorageKey,
}: SessionIdleGuardProps) {
  const [warningOpen, setWarningOpen] = useState(false);
  const [secondsLeft, setSecondsLeft] = useState(warnLeadSeconds);
  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 lastCompanionHeartbeatAt = 0;
    // 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 expiresAt) and expire() the session out from under
    // a refresh that was about to succeed.
    let refreshInFlight = false;
    // 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;

    // Impersonated sessions carry a fixed-lifetime token (JwtService::impersonationTtl) that
    // only "Continue session" (refreshSession, below) can move — ordinary clicks/keys must NOT
    // push this deadline back, or an active user would sail past the real token exp and get
    // hard-bounced by the edge middleware with no warning at all.
    const fixedExpiry = Boolean(refreshPath);
    const expiresAtKey = `${storageKey}:expiresAt`;
    const continueChannel = openContinueChannel(storageKey);
    const companionChannel =
      companionStorageKey && companionStorageKey !== storageKey
        ? openContinueChannel(companionStorageKey)
        : null;

    let lastActivityAt = readStoredNumber(storageKey) ?? Date.now();
    writeStoredNumber(storageKey, lastActivityAt);

    // Anchor for fixed-expiry (impersonated) sessions. Take the farthest of SSR exp vs stored
    // Continue extension — never let a remount with a stale server prop erase a longer window.
    let expiresAt: number | null = fixedExpiry
      ? resolveExpiresAt(
          Number.isFinite(initialExpiresAt) ? (initialExpiresAt as number) : null,
          readStoredNumber(expiresAtKey),
          Date.now() + idleMs,
        )
      : null;
    if (fixedExpiry && expiresAt) {
      writeStoredNumber(expiresAtKey, expiresAt);
    }

    const clearStoredActivity = () => {
      removeStored(storageKey);
      removeStored(expiresAtKey);
    };

    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, nextExpiresAt: number | null) => {
      lastActivityAt = nextLastActivityAt;
      writeStoredNumber(storageKey, lastActivityAt);
      if (fixedExpiry && nextExpiresAt != null && Number.isFinite(nextExpiresAt)) {
        expiresAt = nextExpiresAt;
        writeStoredNumber(expiresAtKey, nextExpiresAt);
      }
      continueInFlightUntil = Date.now() + PLAIN_CONTINUE_GRACE_MS;
      warningVisible = false;
      setWarningOpen(false);
      setSecondsLeft(Math.ceil(warnLeadMs / 1000));
    };

    /**
     * Keep the admin dashboard idle clock / dialog in sync when Continue is clicked on an
     * impersonated portal tab (dashboard guard is not mounted here).
     */
    const publishCompanionContinued = (nextLastActivityAt: number) => {
      if (!companionStorageKey) return;
      writeStoredNumber(companionStorageKey, nextLastActivityAt);
      const payload: ContinueBroadcast = {
        type: "continued",
        lastActivityAt: nextLastActivityAt,
        // Admin sessions are idle-based (no fixed JWT window on Continue).
        expiresAt: null,
      };
      try {
        companionChannel?.postMessage(payload);
      } catch {
        /* ignore */
      }
    };

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

    const pingCompanionHeartbeat = (force = false) => {
      if (!companionHeartbeatPath || ended) return;
      const now = Date.now();
      if (!force && now - lastCompanionHeartbeatAt < HEARTBEAT_MIN_MS) return;
      lastCompanionHeartbeatAt = now;
      void fetch(companionHeartbeatPath, {
        method: "GET",
        credentials: "same-origin",
        cache: "no-store",
        headers: { Accept: "application/json" },
      })
        .then((response) => {
          // Never expire the portal session on companion failure — admin may already be gone.
          // Only sync the admin idle clock when the touch actually succeeded.
          if (response.ok && companionStorageKey) {
            writeStoredNumber(companionStorageKey, Date.now());
          }
        })
        .catch(() => {
          /* network blip — portal idle timer still applies */
        });
    };

    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 */
        });
      // While impersonating, also touch the admin sid so Exit / dashboard tabs stay live.
      pingCompanionHeartbeat(force);
    };

    // Reissues the token for another full window. Used instead of pingHeartbeat when refreshPath
    // is set, since a heartbeat alone cannot move a short-lived token's exp. Only a successful
    // reissue may push expiresAt back — merely calling this (or being active) must not.
    const refreshSession = () => {
      if (!refreshPath || ended) return;
      refreshInFlight = true;
      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" },
        signal: controller.signal,
      })
        .then(async (response) => {
          window.clearTimeout(timeoutId);
          if (!response.ok) {
            refreshInFlight = false;
            // 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.
            if (response.status === 401) {
              expire();
            }
            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 nextExpiresAt = Date.now() + grantedMs;
          const nextLastActivityAt = Date.now();
          refreshInFlight = false;
          // One Continue extends every open tab/page for this user (+ admin companion).
          publishContinued(nextLastActivityAt, nextExpiresAt);
          pingCompanionHeartbeat(true);
        })
        .catch(() => {
          // network blip or timeout — expiresAt untouched; next check retries via the warning,
          // unless the real deadline already passed while we waited (checkIdle catches that).
          window.clearTimeout(timeoutId);
          refreshInFlight = false;
        });
    };

    const noteActivity = () => {
      // While the warning is open, only "Continue session" extends the idle window.
      if (ended || warningVisible) return;
      // Fixed-expiry (impersonated) sessions: activity keeps last_seen_at fresh via the
      // heartbeat below, but must NOT move expiresAt — only a real refresh can do that.
      if (!fixedExpiry) {
        lastActivityAt = Date.now();
        writeStoredNumber(storageKey, lastActivityAt);
      }
      pingHeartbeat();
    };

    const continueSession = () => {
      if (ended) return;
      if (fixedExpiry) {
        // 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;
      }
      const nextLastActivityAt = Date.now();
      publishContinued(nextLastActivityAt, null);
      // Force a server touch so Yii last_seen_at moves with the continued client session.
      pingHeartbeat(true);
    };
    continueSessionRef.current = continueSession;

    const checkIdle = () => {
      if (ended) return;

      if (fixedExpiry) {
        // 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;

        // Re-read in case another tab Continued / refreshed the anchor.
        expiresAt = resolveExpiresAt(expiresAt, readStoredNumber(expiresAtKey), Date.now());
        const remainingMs = (expiresAt ?? Date.now()) - Date.now();

        if (remainingMs <= 0) {
          expire();
          return;
        }

        if (remainingMs <= warnLeadMs) {
          warningVisible = true;
          setWarningOpen(true);
          setSecondsLeft(Math.max(1, Math.ceil(remainingMs / 1000)));
        } else {
          warningVisible = false;
          setWarningOpen(false);
        }
        return;
      }

      if (Date.now() < continueInFlightUntil) return;

      // Re-read in case another tab updated storage.
      lastActivityAt = readStoredNumber(storageKey) ?? lastActivityAt;
      const idleFor = Date.now() - lastActivityAt;

      if (idleFor >= idleMs) {
        expire();
        return;
      }

      const remainingMs = idleMs - idleFor;
      if (remainingMs <= warnLeadMs) {
        warningVisible = true;
        setWarningOpen(true);
        setSecondsLeft(Math.max(1, Math.ceil(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.expiresAt);
      checkIdle();
    };
    continueChannel?.addEventListener("message", onContinueMessage);

    const timer = window.setInterval(checkIdle, CHECK_EVERY_MS);
    // Immediate check (covers remount after already-idle).
    checkIdle();
    // Touch admin promptly when landing in an impersonated portal (not only on first click).
    pingCompanionHeartbeat(true);

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

  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>
            {refreshPath ? "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>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogAction
            onClick={(event) => {
              event.preventDefault();
              continueSessionRef.current();
            }}
          >
            Continue session
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}
