/**
 * F2 — the web session: pairing handshake (success marks the flag; a failure
 * rejects with a clear message), and the 401 → unauthorized transition that
 * clears the flag and notifies subscribers.
 *
 * #77 extends the handshake to `{code, name}` and freezes the two exposed
 * failure codes. The "no oracle" cases below are a **security** contract, not a
 * wording preference: if they start distinguishing wrong from expired from
 * already-used, the API is leaking to an attacker.
 */
import { describe, it, expect, vi } from "vitest";

import type { FetchLike } from "./httpInvoker";
import { WebSession, type FlagStore } from "./webSession";

function memStore(): 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 fetchReturning(status: number, body: unknown): {
  fetchImpl: FetchLike;
  calls: { url: string; init: unknown }[];
} {
  const calls: { url: string; init: unknown }[] = [];
  const fetchImpl: FetchLike = async (url, init) => {
    calls.push({ url, init });
    return {
      ok: status >= 200 && status < 300,
      status,
      json: async () => body,
      text: async () => JSON.stringify(body),
    };
  };
  return { fetchImpl, calls };
}

describe("WebSession pairing", () => {
  it("POSTs {code, name} to /api/pair and marks the flag on success", async () => {
    const store = memStore();
    const { fetchImpl, calls } = fetchReturning(200, { ok: true });
    const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });

    expect(session.isPaired()).toBe(false);
    await session.pair("AB12CD34", "iPhone");

    expect(session.isPaired()).toBe(true);
    expect(calls[0].url).toBe("https://host/api/pair");
    expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({
      code: "AB12CD34",
      name: "iPhone",
    });
  });

  it("maps invalid_or_expired to the single frozen message", async () => {
    const store = memStore();
    const { fetchImpl } = fetchReturning(401, {
      code: "invalid_or_expired",
      message: "whatever the server says",
    });
    const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });

    await expect(session.pair("nope", "iPhone")).rejects.toMatchObject({
      code: "invalidOrExpired",
      message: "Code invalide ou expiré.",
    });
    expect(session.isPaired()).toBe(false);
  });

  it("maps invalid_name to its own actionable message", async () => {
    // B3 validates the name *before* consuming the code, so this failure leaves
    // the code alive — the message must not send the user regenerating one.
    const { fetchImpl } = fetchReturning(400, {
      code: "invalid_name",
      message: "device name must be 40 characters or fewer",
    });
    const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });

    await expect(session.pair("AB12CD34", "x".repeat(60))).rejects.toMatchObject({
      code: "invalidName",
      message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
    });
  });

  it("does not blame the name for an unrelated 400", async () => {
    // Only the wire code may point at the name; a bare bad request must not send
    // the user editing a field that was fine.
    const { fetchImpl } = fetchReturning(400, undefined);
    const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });

    await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({
      code: "pairingFailed",
    });
  });

  it("maps rate_limited to its own message", async () => {
    const { fetchImpl } = fetchReturning(429, { code: "rate_limited", message: "slow down" });
    const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });

    await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({
      code: "rateLimited",
      message: "Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.",
    });
  });

  it("never forwards the server's own wording, invalid_name included", async () => {
    const { fetchImpl } = fetchReturning(400, {
      code: "invalid_name",
      message: "device name is required",
    });
    const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });

    await expect(session.pair("AB12CD34", " ")).rejects.toMatchObject({
      message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
    });
  });

  it("never forwards a server message that could distinguish failure causes", async () => {
    // Even if a future backend leaks the distinction in its message, the UI must
    // not repeat it: an attacker learns nothing about *why* a code was refused.
    const { fetchImpl } = fetchReturning(401, {
      code: "invalid_or_expired",
      message: "code already used at 14:02 by device iPhone",
    });
    const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });

    await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({
      message: "Code invalide ou expiré.",
    });
  });

  it("falls back to the invalid/expired message when the server sends no body", async () => {
    const { fetchImpl } = fetchReturning(403, undefined);
    const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });

    await expect(session.pair("x", "iPhone")).rejects.toMatchObject({
      code: "invalidOrExpired",
      message: "Code invalide ou expiré.",
    });
  });
});

describe("WebSession unauthorized handling", () => {
  it("clears the flag and notifies subscribers on notifyUnauthorized", () => {
    const store = memStore();
    const session = new WebSession({ baseUrl: "https://host", store });
    session.markPaired();
    const handler = vi.fn();
    session.onUnauthorized(handler);

    session.notifyUnauthorized();

    expect(session.isPaired()).toBe(false);
    expect(handler).toHaveBeenCalledOnce();
  });

  it("forget() clears the flag (best-effort local sign-out)", () => {
    const store = memStore();
    const session = new WebSession({ baseUrl: "https://host", store });
    session.markPaired();
    session.forget();
    expect(session.isPaired()).toBe(false);
  });
});

describe("WebSession default fetch receiver", () => {
  /**
   * Regression (live Firefox, ticket #13): the default global `fetch` was stored
   * unbound, so `this.fetchImpl(…)` called it with the WebSession as receiver ⇒
   * `TypeError: 'fetch' called on an object that does not implement interface
   * Window` at pairing. jsdom does not enforce the receiver, so assert it here.
   */
  async function pairWithRecordingGlobalFetch(): Promise<unknown[]> {
    const original = globalThis.fetch;
    const receivers: unknown[] = [];
    globalThis.fetch = function (this: unknown) {
      receivers.push(this);
      return Promise.resolve({
        ok: true,
        status: 200,
        json: async () => ({ ok: true }),
        text: async () => "{}",
      });
    } as unknown as typeof globalThis.fetch;
    try {
      // No `fetchImpl` injected ⇒ the global fallback is used.
      const session = new WebSession({ baseUrl: "https://host", store: memStore() });
      await session.pair("AB12CD34", "iPhone");
    } finally {
      globalThis.fetch = original;
    }
    return receivers;
  }

  it("pairs using the global fetch bound to globalThis, not the session", async () => {
    const receivers = await pairWithRecordingGlobalFetch();

    expect(receivers).toHaveLength(1);
    expect(receivers[0]).toBe(globalThis);
    expect(receivers[0]).not.toBeInstanceOf(WebSession);
  });
});

describe("WebSession logout", () => {
  it("POSTs /api/logout same-origin and clears the flag on success", async () => {
    const store = memStore();
    const { fetchImpl, calls } = fetchReturning(200, { revoked: true });
    const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });
    session.markPaired();

    await session.logout();

    expect(session.isPaired()).toBe(false);
    expect(calls[0].url).toBe("https://host/api/logout");
    const init = calls[0].init as { method: string; credentials: string };
    expect(init.method).toBe("POST");
    expect(init.credentials).toBe("same-origin");
  });

  it("still clears the flag when the server is unreachable (best-effort)", async () => {
    const store = memStore();
    const fetchImpl: FetchLike = async () => {
      throw new Error("network down");
    };
    const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });
    session.markPaired();

    await expect(session.logout()).resolves.toBeUndefined();
    expect(session.isPaired()).toBe(false);
  });
});
