/**
 * HTTP transport primitive for the web (client/server) adapter set — ticket #13,
 * lot F1. This is the HTTP analogue of Tauri's `invoke`: it forwards one backend
 * *command* + its camelCase argument envelope to the shared backend core over
 * HTTP, and preserves the exact `ErrorDto` shape ({@link GatewayError}) on
 * failure.
 *
 * **Contract choice (F1):** rather than a per-command REST resource tree (the
 * first-draft routes in `docs/ticket13-b0-backend-transport-inventory.md`), F1
 * uses a single generic RPC endpoint `POST {baseUrl}/api/invoke` with body
 * `{ command, args }`. This mirrors the Tauri `invoke(command, args)` seam
 * one-for-one, so **every** request/response gateway reuses the *identical*
 * command names and `{ request: { … } }` envelopes already frozen for the Tauri
 * adapter — no DTO divergence. Switching to REST later (if the backend picks that
 * in B3/B4) only touches this file + the gateway wiring, never a component.
 * This divergence from the B0 REST sketch is flagged for DevBackend to confirm.
 *
 * Only `src/adapters/**` may own transport code (CI guard
 * `no-direct-invoke.test.ts`); this file lives there and touches no
 * `@tauri-apps/api`.
 */

import type { GatewayError } from "@/domain";

/** The `fetch` surface this invoker needs; injectable so tests pass a stub. */
export type FetchLike = (
  input: string,
  init?: {
    method?: string;
    headers?: Record<string, string>;
    body?: string;
    signal?: AbortSignal;
    /** Cookie policy — F2 uses `same-origin` so the session cookie rides along. */
    credentials?: "same-origin" | "include" | "omit";
  },
) => Promise<{
  ok: boolean;
  status: number;
  /** Parsed JSON body (may reject/return undefined for empty bodies). */
  json(): Promise<unknown>;
  text(): Promise<string>;
}>;

/**
 * Resolves the default `fetch`, **bound to `globalThis`**.
 *
 * WHATWG `fetch` is a method of the global object and checks its receiver: once
 * stored in a field and called as `this.fetchImpl(…)`, its `this` is the owning
 * adapter instance, not `window`. A real browser rejects that — Firefox with
 * `TypeError: 'fetch' called on an object that does not implement interface
 * Window`, Chrome with "Illegal invocation". jsdom does not enforce the receiver,
 * so the unit tests never caught it; the bind is what keeps the fallback callable
 * from a field. An *injected* `fetchImpl` is left untouched (a test stub is a
 * plain function and needs no receiver).
 *
 * When there is no global `fetch` (SSR / bare Node), the value is returned as-is
 * so the failure still happens at call time, exactly as before, rather than
 * throwing during construction.
 */
export function defaultFetch(): FetchLike {
  const globalFetch = globalThis.fetch;
  return (
    typeof globalFetch === "function" ? globalFetch.bind(globalThis) : globalFetch
  ) as unknown as FetchLike;
}

/** Configuration for the HTTP invoker. */
export interface HttpInvokerConfig {
  /** Absolute base URL of the backend, e.g. `https://host:port`. No trailing slash. */
  baseUrl: string;
  /** Bearer token obtained from pairing (ticket #13 auth); sent as `Authorization`. */
  token?: string;
  /** Injected fetch (defaults to global `fetch`). */
  fetchImpl?: FetchLike;
  /**
   * Called when the backend answers `401` (the session cookie is missing or
   * expired). F2 wires this to the web session so the app routes back to the
   * pairing screen. The `GatewayError` is still thrown to the caller.
   */
  onUnauthorized?: () => void;
}

/** Builds a {@link GatewayError} from an arbitrary thrown/parsed value. */
function toGatewayError(value: unknown, fallbackMessage: string): GatewayError {
  if (value && typeof value === "object") {
    const rec = value as Record<string, unknown>;
    const code = typeof rec.code === "string" ? rec.code : undefined;
    const message = typeof rec.message === "string" ? rec.message : undefined;
    if (code || message) {
      return { code: code ?? "ERROR", message: message ?? fallbackMessage };
    }
  }
  return { code: "TRANSPORT_ERROR", message: fallbackMessage };
}

/**
 * Sends backend commands over HTTP. Mirrors the Tauri `invoke` signature so the
 * web gateways can reuse the exact command + argument envelopes of their Tauri
 * siblings.
 */
export class HttpInvoker {
  private readonly baseUrl: string;
  private readonly token?: string;
  private readonly fetchImpl: FetchLike;
  private readonly onUnauthorized?: () => void;

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

  /**
   * Invokes a backend command. `args` is the same camelCase argument object the
   * Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the
   * parsed result, or rejects with a {@link GatewayError}.
   */
  invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
    return this.request<T>("POST", "/api/invoke", { command, args }, `command '${command}'`);
  }

  /**
   * Sends one authenticated REST request and maps the response exactly like
   * {@link invoke} (401 routing + `ErrorDto` preservation).
   *
   * The device/auth surface of ticket #77 lives on dedicated routes rather than
   * the generic RPC, alongside the pre-existing `/api/pair` and `/api/logout` —
   * the RPC endpoint is itself auth-gated, so the surface that *manages* auth
   * cannot ride on it. `what` names the call in fallback error messages.
   */
  async request<T>(
    method: string,
    path: string,
    body?: unknown,
    what = `${method} ${path}`,
  ): Promise<T> {
    const headers: Record<string, string> = {
      "Content-Type": "application/json",
    };
    if (this.token) headers.Authorization = `Bearer ${this.token}`;

    let res: Awaited<ReturnType<FetchLike>>;
    try {
      res = await this.fetchImpl(`${this.baseUrl}${path}`, {
        method,
        headers,
        body: body === undefined ? undefined : JSON.stringify(body),
        // Same-origin so the HttpOnly session cookie is sent automatically
        // (ticket #13: no secret in the URL/headers from JS).
        credentials: "same-origin",
      });
    } catch (networkError) {
      const err: GatewayError = {
        code: "TRANSPORT_ERROR",
        message: `HTTP request for ${what} failed: ${String(networkError)}`,
      };
      throw err;
    }

    if (!res.ok) {
      // A 401 means the session cookie is missing/expired: signal the app to
      // route back to pairing (the error is still thrown to the caller).
      if (res.status === 401) this.onUnauthorized?.();
      // Preserve the backend `ErrorDto` when present; fall back to the status.
      let parsed: unknown;
      try {
        parsed = await res.json();
      } catch {
        parsed = undefined;
      }
      throw toGatewayError(parsed, `${what} failed (HTTP ${res.status})`);
    }

    // A 204 / empty body maps to `undefined` (the void commands).
    let parsed: unknown;
    try {
      parsed = await res.json();
    } catch {
      parsed = undefined;
    }
    return parsed as T;
  }
}
