/**
 * Idempotency hooks for @beignet/core/server
 */

import {
  type ErrorReporterPort,
  tryReportException,
} from "../../error-reporting/index.js";
import { AppError, httpErrors } from "../../errors/index.js";
import {
  createIdempotencyFingerprint,
  IdempotencyConflictError,
  IdempotencyInProgressError,
  type IdempotencyMeta,
  type IdempotencyPort,
  type IdempotencyScope,
} from "../../idempotency/index.js";
import {
  type ActivityActor,
  type ActivityTenant,
  AuthUnauthorizedError,
  TenantRequiredError,
} from "../../ports/index.js";
import {
  type ResponseFinalizerServerHook,
  responseFinalizerHook,
} from "../internal-hooks.js";
import { finalizeResponse } from "../response-finalization.js";
import type {
  HttpRequestLike,
  HttpResponseLike,
  ServerHook,
} from "../types.js";

/**
 * Ports required by idempotency hooks.
 */
export type IdempotencyPorts = {
  idempotency: IdempotencyPort;
};

type OptionalErrorReporterPorts = IdempotencyPorts & {
  errorReporter?: ErrorReporterPort;
};

/**
 * Minimal context shape required for actor- and tenant-scoped idempotency.
 */
export type CtxWithIdempotency = {
  ports: IdempotencyPorts;
  actor?: ActivityActor;
  tenant?: ActivityTenant;
};

/**
 * Options for `createIdempotencyHooks(...)`.
 */
export interface IdempotencyHooksOptions<Ctx> {
  /**
   * Build the idempotency namespace for a contract.
   *
   * Defaults to `http.<contract name>` so HTTP reservations never collide with
   * use-case `runIdempotently(...)` namespaces.
   */
  namespace?: (args: { contract: { name: string } }) => string;
  /**
   * Build the idempotency scope after context exists.
   *
   * Defaults to a scope derived from `meta.scope`: omitted metadata scopes by
   * `ctx.actor?.id` and also `ctx.tenant?.id` when present, `"actor"` scopes by
   * the actor only, `"global"` stays global, `"tenant"` scopes by the tenant,
   * and `"actor-tenant"` scopes by both.
   */
  scope?: (args: {
    ctx: Ctx;
    req: HttpRequestLike;
    meta: IdempotencyMeta;
  }) => IdempotencyScope;
  /**
   * Build the fingerprint input from the parsed request.
   *
   * Defaults to `{ path, query, body }`.
   */
  fingerprintInput?: (args: {
    path: unknown;
    query: unknown;
    body: unknown;
  }) => unknown;
}

/**
 * Header set on replayed responses.
 */
const IDEMPOTENCY_REPLAYED_HEADER = "idempotency-replayed";

type PendingReservation = {
  port: IdempotencyPort;
  namespace: string;
  key: string;
  scope: IdempotencyScope;
  fingerprint: string;
  reservationToken: string;
};

function defaultIdempotencyScope(
  ctx: CtxWithIdempotency,
  meta: IdempotencyMeta,
): IdempotencyScope {
  const mode = meta.scope ?? (ctx.tenant?.id ? "actor-tenant" : "actor");

  switch (mode) {
    case "global":
      return "global";
    case "actor":
      if (!ctx.actor?.id) throw new AuthUnauthorizedError();
      return { actorId: ctx.actor.id };
    case "tenant":
      if (!ctx.tenant?.id) throw new TenantRequiredError();
      return { tenantId: ctx.tenant.id };
    case "actor-tenant":
      if (!ctx.actor?.id) throw new AuthUnauthorizedError();
      if (!ctx.tenant?.id) throw new TenantRequiredError();
      return { actorId: ctx.actor.id, tenantId: ctx.tenant.id };
  }
}

function isReplayableHttpResponse(value: unknown): value is HttpResponseLike {
  if (typeof value !== "object" || value === null) return false;

  const candidate = value as { status?: unknown; headers?: unknown };
  if (typeof candidate.status !== "number") return false;

  if (
    candidate.headers !== undefined &&
    (typeof candidate.headers !== "object" ||
      candidate.headers === null ||
      Array.isArray(candidate.headers))
  ) {
    return false;
  }

  return true;
}

/**
 * Create metadata-driven idempotency hooks.
 *
 * The hook reads `contract.metadata.idempotency` and enforces it with
 * `ctx.ports.idempotency`. In `beforeHandle` it reserves the client key after
 * request parsing and route hook identity resolution, replays completed matching
 * responses with an `idempotency-replayed: true` header, and rejects in-progress
 * or conflicting keys with the framework
 * `IdempotencyInProgress`/`IdempotencyConflict` catalog errors. After the
 * response-validation phase, it stores final route-owned 2xx framework-neutral
 * responses for replay and releases the reservation for framework-owned
 * responses, errors, non-2xx responses, and native `Response` results, which
 * are not replayable.
 *
 * Use `runIdempotently(...)` from `@beignet/core/idempotency` for non-HTTP
 * workflows such as jobs, listeners, webhooks, and schedules.
 *
 * @param options - Optional namespace, scope, and fingerprint-input builders.
 * @returns A server hook backed by `ctx.ports.idempotency`.
 */
export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
  options: IdempotencyHooksOptions<Ctx> = {},
): ServerHook<Ctx, IdempotencyPorts> {
  const pending = new WeakMap<HttpRequestLike, PendingReservation>();

  const hooks: ServerHook<Ctx, IdempotencyPorts> &
    ResponseFinalizerServerHook<Ctx> = {
    name: "idempotency",
    beforeHandle: async ({ ctx, contract, req, path, query, body }) => {
      const meta = contract.metadata?.idempotency;
      if (!meta) {
        return undefined;
      }

      const header = (meta.header ?? "idempotency-key").toLowerCase();
      const key = req.headers.get(header);

      if (!key) {
        if (meta.required) {
          throw new AppError(
            httpErrors.BadRequest,
            {
              contract: contract.name,
              header,
            },
            `Missing required idempotency key header "${header}"`,
          );
        }
        return undefined;
      }

      const namespace =
        options.namespace?.({ contract: { name: contract.name } }) ??
        `http.${contract.name}`;
      const scope =
        options.scope?.({ ctx, req, meta }) ??
        defaultIdempotencyScope(ctx, meta);
      const fingerprint = await createIdempotencyFingerprint(
        options.fingerprintInput?.({ path, query, body }) ?? {
          path,
          query,
          body,
        },
      );

      const reservation = await ctx.ports.idempotency.reserve({
        namespace,
        key,
        scope,
        fingerprint,
        ttlSec: meta.ttlSec,
        reservationTtlSec: meta.reservationTtlSec,
      });

      switch (reservation.status) {
        case "replay": {
          if (!isReplayableHttpResponse(reservation.result)) {
            throw new AppError(
              httpErrors.InternalServerError,
              { namespace, key },
              `Stored idempotency result for "${namespace}" key "${key}" is not a replayable HTTP response`,
            );
          }

          const validated = await finalizeResponse(
            contract,
            reservation.result,
          );
          return {
            status: validated.status,
            headers: {
              ...(validated.headers ?? {}),
              [IDEMPOTENCY_REPLAYED_HEADER]: "true",
            },
            body: validated.body,
          };
        }
        case "inProgress": {
          throw new IdempotencyInProgressError(reservation);
        }
        case "conflict": {
          throw new IdempotencyConflictError(reservation);
        }
        case "reserved": {
          pending.set(req, {
            port: ctx.ports.idempotency,
            namespace,
            key,
            scope,
            fingerprint,
            reservationToken: reservation.reservationToken,
          });
          return undefined;
        }
      }
    },
    [responseFinalizerHook]: async ({
      req,
      ctx,
      response,
      error,
      native,
      owner,
      responseValidation,
    }) => {
      const reservation = pending.get(req);
      if (!reservation) {
        return undefined;
      }
      pending.delete(req);

      const { port, namespace, key, scope, fingerprint, reservationToken } =
        reservation;

      if (
        owner === "route" &&
        responseValidation === "validated" &&
        !native &&
        !error &&
        response.status >= 200 &&
        response.status < 300
      ) {
        await port.complete({
          namespace,
          key,
          scope,
          fingerprint,
          reservationToken,
          result: {
            status: response.status,
            headers: response.headers,
            body: response.body,
          },
        });
        return undefined;
      }

      // Framework-owned responses, errors, non-2xx responses, and native
      // `Response` results release the reservation. Streams are not replayable.
      try {
        await port.fail({
          namespace,
          key,
          scope,
          fingerprint,
          reservationToken,
          error,
        });
      } catch (settlementError) {
        if (error === undefined) {
          throw settlementError;
        }

        await tryReportException({
          reporter: ctx
            ? (ctx.ports as OptionalErrorReporterPorts).errorReporter
            : undefined,
          error: settlementError,
          reportOptions: {
            level: "error",
            mechanism: "beignet.idempotency.settlement",
            handled: true,
            tags: {
              "idempotency.namespace": namespace,
            },
            contexts: {
              idempotency: {
                namespace,
                key,
              },
            },
          },
        });
      }
      return undefined;
    },
  };

  return hooks;
}
