/**
 * F2 — the web session: pairing handshake (success marks the flag; wrong code
 * rejects with a clear message), and the 401 → unauthorized transition that
 * clears the flag and notifies subscribers.
 */
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 the code 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("4821-93");

    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: "4821-93" });
  });

  it("rejects a wrong code with a clear message and stays unpaired", async () => {
    const store = memStore();
    const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" });
    const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });

    await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" });
    expect(session.isPaired()).toBe(false);
  });

  it("falls back to a default 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")).rejects.toMatchObject({
      code: "INVALID_PAIRING_CODE",
      message: "Code d'appairage invalide.",
    });
  });
});

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("4821-93");
    } 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);
  });
});
