/**
 * Web client session (pairing + auth routing) — ticket #13, lot F2.
 *
 * The real authentication material is a session cookie set by the server at
 * pairing (`POST /api/pair {code}` → `Set-Cookie: … HttpOnly; Secure;
 * SameSite=Strict`). Because it is **HttpOnly** the browser sends it
 * automatically on same-origin requests but JS can never read it. So this module
 * keeps only a lightweight, JS-visible **"paired" flag** (in `localStorage`) to
 * decide which screen to render — never the secret itself.
 *
 * On a `401` from any `/api/invoke`, the {@link HttpInvoker} calls
 * {@link WebSession.notifyUnauthorized}, which clears the flag and notifies
 * subscribers so the app routes back to the pairing screen. "Forget" is a
 * best-effort local clear (server-side cookie revocation is B8).
 *
 * Lives in `src/adapters/**`; touches no `@tauri-apps/api`.
 */

import type { GatewayError, Unsubscribe } from "@/domain";
import { defaultFetch, type FetchLike } from "./httpInvoker";

const PAIRED_FLAG_KEY = "idea.web.paired";

/** Minimal `localStorage`-like surface, injectable for tests. */
export interface FlagStore {
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  removeItem(key: string): void;
}

/** Configuration for a {@link WebSession}. */
export interface WebSessionConfig {
  /** Backend base URL (no trailing slash). Defaults to the page origin. */
  baseUrl?: string;
  /** Injected fetch (defaults to global `fetch`, with same-origin credentials). */
  fetchImpl?: FetchLike;
  /** Injected flag store (defaults to `window.localStorage`, else in-memory). */
  store?: FlagStore;
}

/** In-memory fallback when `localStorage` is unavailable (SSR/tests). */
function memoryStore(): FlagStore {
  const map = new Map<string, string>();
  return {
    getItem: (k) => map.get(k) ?? null,
    setItem: (k, v) => void map.set(k, v),
    removeItem: (k) => void map.delete(k),
  };
}

function defaultStore(): FlagStore {
  try {
    if (typeof window !== "undefined" && window.localStorage) {
      return window.localStorage;
    }
  } catch {
    /* access can throw in a sandboxed iframe; fall through */
  }
  return memoryStore();
}

function defaultBaseUrl(): string {
  return typeof window !== "undefined" && window.location
    ? window.location.origin
    : "http://localhost";
}

/**
 * The failure codes `POST /api/pair` exposes (#77).
 *
 * `invalidOrExpired` deliberately covers wrong, expired, superseded **and
 * already-used** codes: the API gives an attacker no oracle to tell them apart,
 * so neither may the UI. Do not add a case here without changing that decision.
 *
 * `invalidName` is *not* an exception to that rule: the name is the caller's own
 * input, so rejecting it reveals nothing about the code. The server validates it
 * **before** consuming the code (B3), which is what makes the distinction safe
 * to surface — and what lets the user retry with the same code.
 */
export type PairingErrorCode =
  | "invalidOrExpired"
  | "invalidName"
  | "rateLimited"
  | "pairingFailed";

/** User-facing labels, frozen by UX in the #77 carnet. */
const PAIRING_ERROR_MESSAGE: Record<PairingErrorCode, string> = {
  invalidOrExpired: "Code invalide ou expiré.",
  // Says which field is wrong and, just as importantly, that the code survived:
  // without that clause the reflex is to close the panel and burn a new code.
  invalidName: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
  rateLimited:
    "Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.",
  pairingFailed: "Échec de l'appairage.",
};

/**
 * Maps a `/api/pair` failure onto one of the exposed codes.
 *
 * The server's own `message` is **not** forwarded: it is mapped through the
 * frozen labels above so a future backend wording can never reintroduce the
 * wrong/expired/used distinction in the UI. The wire code wins; the HTTP status
 * is the fallback for an unparseable body.
 */
function toPairingError(parsed: unknown, status: number): GatewayError {
  const wire =
    parsed && typeof parsed === "object" && "code" in parsed
      ? String((parsed as { code: unknown }).code)
      : undefined;

  let code: PairingErrorCode;
  if (wire === "invalid_or_expired") code = "invalidOrExpired";
  else if (wire === "invalid_name") code = "invalidName";
  else if (wire === "rate_limited") code = "rateLimited";
  else if (status === 429) code = "rateLimited";
  // A bare 400 is not mapped to `invalidName`: only the wire code may claim the
  // name is at fault, or an unrelated bad request would send the user editing a
  // field that was fine.
  else if (status === 401 || status === 403) code = "invalidOrExpired";
  else code = "pairingFailed";

  return { code, message: PAIRING_ERROR_MESSAGE[code] };
}

/**
 * The web session state machine. One instance is shared between the pairing UI
 * and the HTTP invoker (via the module singleton below).
 */
export class WebSession {
  private readonly baseUrl: string;
  private readonly fetchImpl: FetchLike;
  private readonly store: FlagStore;
  private readonly unauthorizedHandlers = new Set<() => void>();

  constructor(config: WebSessionConfig = {}) {
    this.baseUrl = (config.baseUrl ?? defaultBaseUrl()).replace(/\/+$/, "");
    // The global fallback must stay bound to `globalThis` — see `defaultFetch`.
    this.fetchImpl = config.fetchImpl ?? defaultFetch();
    this.store = config.store ?? defaultStore();
  }

  /** Whether the client believes it is paired (a session cookie should exist). */
  isPaired(): boolean {
    return this.store.getItem(PAIRED_FLAG_KEY) === "1";
  }

  /** Records the paired flag (called after a successful pair). */
  markPaired(): void {
    this.store.setItem(PAIRED_FLAG_KEY, "1");
  }

  /** Best-effort local sign-out: clears the flag (server revocation is B8). */
  forget(): void {
    this.store.removeItem(PAIRED_FLAG_KEY);
  }

  /**
   * Full sign-out (F6): asks the server to revoke the session cookie
   * (`POST /api/logout`, same-origin so the cookie rides along) and then clears
   * the local flag. Best-effort — a network failure or an already-expired session
   * still clears the flag locally, so the UI always returns to pairing without a
   * dead end. Disconnecting the live WS is the caller's job (composition seam).
   */
  async logout(): Promise<void> {
    try {
      await this.fetchImpl(`${this.baseUrl}/api/logout`, {
        method: "POST",
        credentials: "same-origin",
      });
    } catch {
      // Server unreachable / already gone: fall through to the local clear.
    } finally {
      this.forget();
    }
  }

  /**
   * Performs the pairing handshake: `POST /api/pair {code, name}` (#77). `name`
   * is the human label for *this* device, typed on it at pairing time. On success
   * the server sets the session cookie and this records the paired flag.
   *
   * Failures reject with a {@link GatewayError} carrying a user-facing message
   * from {@link pairingErrorMessage}.
   */
  async pair(code: string, name: string): Promise<void> {
    let res: Awaited<ReturnType<FetchLike>>;
    try {
      res = await this.fetchImpl(`${this.baseUrl}/api/pair`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ code, name }),
      });
    } catch (networkError) {
      const err: GatewayError = {
        code: "TRANSPORT_ERROR",
        message: `Impossible de joindre le serveur : ${String(networkError)}`,
      };
      throw err;
    }

    if (!res.ok) {
      let parsed: unknown;
      try {
        parsed = await res.json();
      } catch {
        parsed = undefined;
      }
      throw toPairingError(parsed, res.status);
    }

    // Cookie is now set by the server (HttpOnly — not read here); record the flag.
    this.markPaired();
  }

  /**
   * Called by the HTTP invoker on a `401`: the session cookie is missing/expired.
   * Clears the flag and notifies subscribers so the app returns to pairing.
   */
  notifyUnauthorized(): void {
    this.forget();
    for (const handler of this.unauthorizedHandlers) handler();
  }

  /** Subscribes to unauthorized transitions (returns an unsubscribe). */
  onUnauthorized(handler: () => void): Unsubscribe {
    this.unauthorizedHandlers.add(handler);
    return () => this.unauthorizedHandlers.delete(handler);
  }
}

// ---------------------------------------------------------------------------
// Module singleton (shared between the invoker and the pairing UI)
// ---------------------------------------------------------------------------

let singleton: WebSession | null = null;

/** Returns the shared web session, creating it on first use. */
export function getWebSession(): WebSession {
  if (!singleton) singleton = new WebSession();
  return singleton;
}

/** Overrides the singleton (tests). Pass `null` to reset to a fresh default. */
export function setWebSessionForTests(session: WebSession | null): void {
  singleton = session;
}
