import {
  BEIGNET_ERROR_OWNER_HEADER,
  type ContractErrorDefinition,
  type HttpContractConfig,
} from "../contracts/index.js";
import { contractLifecycleResponseHeaders } from "../contracts/lifecycle.js";
import {
  createErrorResponseBody,
  isErrorResponseBody,
} from "../errors/index.js";
import { getRequestIdFromContext } from "./hooks/utils.js";
import type {
  HttpResponse,
  HttpResponseHeaders,
  HttpResponseLike,
} from "./http.js";
import type { ResponseFinalizerResponseOwner } from "./internal-hooks.js";
import {
  parseStandardSchema,
  SchemaValidationError,
} from "./providers/index.js";

export type ResponseOwner = ResponseFinalizerResponseOwner;

export function errorResponse(
  status: number,
  code: string,
  message: string,
  details?: unknown,
): HttpResponseLike {
  return {
    status,
    body: createErrorResponseBody({ code, message, details }),
  };
}

function contractDiagnostics(contract: HttpContractConfig) {
  return {
    contract: contract.name,
    method: contract.method,
    path: contract.path,
  };
}

export function normalizeResponse(res: HttpResponseLike): HttpResponseLike {
  return {
    status: res.status,
    headers: res.headers,
    body: res.body,
  };
}

export function isWebResponse(value: unknown): value is Response {
  return typeof Response !== "undefined" && value instanceof Response;
}

export function normalizeHttpResponse(res: HttpResponse): HttpResponse {
  return isWebResponse(res) ? res : normalizeResponse(res);
}

export function withFrameworkErrorOwnerHeader(
  res: HttpResponseLike,
  owner: ResponseOwner,
): HttpResponseLike {
  if (
    owner !== "framework" ||
    res.status < 400 ||
    !isErrorResponseBody(res.body)
  ) {
    return res;
  }

  return {
    ...res,
    headers: {
      ...(res.headers ?? {}),
      [BEIGNET_ERROR_OWNER_HEADER]: "framework",
    },
  };
}

function setRecordHeader(
  headers: HttpResponseHeaders,
  name: string,
  value: string,
): void {
  const existingName = Object.keys(headers).find(
    (key) => key.toLowerCase() === name.toLowerCase(),
  );
  if (existingName && existingName !== name) {
    delete headers[existingName];
  }
  headers[name] = value;
}

function headerValues(value: string | readonly string[]): readonly string[] {
  return typeof value === "string" ? [value] : value;
}

function sameHeaderValue(
  left: string | readonly string[] | undefined,
  right: string | readonly string[],
): boolean {
  if (left === undefined) return false;
  const leftValues = headerValues(left);
  const rightValues = headerValues(right);
  return (
    leftValues.length === rightValues.length &&
    leftValues.every((value, index) => value === rightValues[index])
  );
}

function appendHeaderValues(
  headers: Headers,
  name: string,
  value: string | readonly string[],
): void {
  headers.delete(name);
  for (const item of headerValues(value)) {
    headers.append(name, item);
  }
}

/** Apply contract-owned deprecation headers to any response representation. */
export function withContractLifecycleHeaders(
  res: HttpResponse,
  contract: HttpContractConfig,
): HttpResponse {
  const lifecycleHeaders = contractLifecycleResponseHeaders(contract);
  if (Object.keys(lifecycleHeaders).length === 0) return res;

  if (isWebResponse(res)) {
    const headers = new Headers(res.headers);
    for (const [name, value] of Object.entries(lifecycleHeaders)) {
      if (name.toLowerCase() === "link" && headers.has(name)) {
        headers.append(name, value);
      } else {
        headers.set(name, value);
      }
    }
    return new Response(res.body, {
      status: res.status,
      statusText: res.statusText,
      headers,
    });
  }

  const headers: HttpResponseHeaders = { ...(res.headers ?? {}) };
  for (const [name, value] of Object.entries(lifecycleHeaders)) {
    if (name.toLowerCase() === "link") {
      const existingName = Object.keys(headers).find(
        (key) => key.toLowerCase() === "link",
      );
      const existing = existingName ? headers[existingName] : undefined;
      const existingValue = existing ? headerValues(existing).join(", ") : "";
      setRecordHeader(
        headers,
        name,
        existingValue ? `${existingValue}, ${value}` : value,
      );
    } else {
      setRecordHeader(headers, name, value);
    }
  }
  return { ...res, headers };
}

export function responseOwnerFor(
  res: HttpResponse,
  owner?: ResponseOwner,
): ResponseOwner {
  if (isWebResponse(res)) return "transport";
  return owner ?? "route";
}

function headersToRecord(headers: Headers): HttpResponseHeaders {
  const record: HttpResponseHeaders = {};
  headers.forEach((value, key) => {
    record[key] = value;
  });
  const setCookies =
    (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie?.call(
      headers,
    ) ?? [];
  if (setCookies.length > 0) {
    record["set-cookie"] = setCookies;
  }
  return record;
}

export function responseForHooks(res: HttpResponse): HttpResponseLike {
  if (!isWebResponse(res)) {
    return normalizeResponse(res);
  }

  return {
    status: res.status,
    headers: headersToRecord(res.headers),
  };
}

/**
 * Merge hook-applied header changes onto a native web Response.
 *
 * Starts from the native response's `Headers` so `set-cookie` multiplicity is
 * preserved, then applies headers the beforeSend chain added or changed
 * relative to the original headers-only view. The body stream passes through
 * untouched; status and statusText are preserved.
 */
export function mergeNativeResponseHeaders(
  nativeResponse: Response,
  originalHeaders: HttpResponseHeaders,
  finalHeaders: HttpResponseHeaders,
): Response {
  const originalByLowerKey = new Map<string, string | readonly string[]>();
  for (const [key, value] of Object.entries(originalHeaders)) {
    originalByLowerKey.set(key.toLowerCase(), value);
  }

  let changed = false;
  const merged = new Headers(nativeResponse.headers);
  for (const [key, value] of Object.entries(finalHeaders)) {
    const lowerKey = key.toLowerCase();
    if (sameHeaderValue(originalByLowerKey.get(lowerKey), value)) continue;
    changed = true;
    appendHeaderValues(merged, key, value);
  }

  if (!changed) {
    return nativeResponse;
  }

  return new Response(nativeResponse.body, {
    status: nativeResponse.status,
    statusText: nativeResponse.statusText,
    headers: merged,
  });
}

export function isHttpResponseLike(value: unknown): value is HttpResponseLike {
  return (
    !isWebResponse(value) &&
    typeof value === "object" &&
    value !== null &&
    "status" in value &&
    typeof (value as { status?: unknown }).status === "number"
  );
}

export class ResponseContractViolationError extends Error {
  readonly code: "RESPONSE_VALIDATION_ERROR" | "UNDECLARED_RESPONSE_STATUS";
  readonly details?: unknown;

  constructor(args: {
    code: "RESPONSE_VALIDATION_ERROR" | "UNDECLARED_RESPONSE_STATUS";
    message: string;
    details?: unknown;
  }) {
    super(args.message);
    this.name = "ResponseContractViolationError";
    this.code = args.code;
    this.details = args.details;
  }
}

function responseContractViolationMessage(
  contract: HttpContractConfig,
  status: number,
): string {
  return (
    `Response validation failed for ${contract.method} ${contract.path} ` +
    `(status ${status}, contract: ${contract.name})`
  );
}

function declaredResponseStatuses(contract: HttpContractConfig): number[] {
  return Object.keys(contract.responses)
    .map((status) => Number(status))
    .filter((status) => Number.isFinite(status))
    .sort((a, b) => a - b);
}

function responseContractViolationDetails(
  contract: HttpContractConfig,
  status: number,
  details?: Record<string, unknown>,
) {
  return {
    ...contractDiagnostics(contract),
    location: "response",
    status,
    declaredStatuses: declaredResponseStatuses(contract),
    ...details,
  };
}

function getDeclaredCatalogErrorsForStatus(
  contract: HttpContractConfig,
  status: number,
): ContractErrorDefinition[] {
  const errors = contract.metadata?.errors;
  if (typeof errors !== "object" || errors === null) return [];

  return Object.values(errors).filter(
    (error): error is ContractErrorDefinition =>
      typeof error === "object" &&
      error !== null &&
      typeof (error as { code?: unknown }).code === "string" &&
      typeof (error as { status?: unknown }).status === "number" &&
      typeof (error as { message?: unknown }).message === "string" &&
      (error as { status: number }).status === status,
  );
}

async function parseCatalogErrorResponse<C extends HttpContractConfig>(
  contract: C,
  res: HttpResponseLike,
): Promise<HttpResponseLike> {
  const body = res.body;
  if (res.status < 400 || !isErrorResponseBody(body)) return res;

  const declaredErrors = getDeclaredCatalogErrorsForStatus(
    contract,
    res.status,
  );
  if (declaredErrors.length === 0) return res;

  const matchingError = declaredErrors.find(
    (error) => error.code === body.code,
  );
  if (!matchingError) {
    throw new ResponseContractViolationError({
      code: "RESPONSE_VALIDATION_ERROR",
      message: responseContractViolationMessage(contract, res.status),
      details: responseContractViolationDetails(contract, res.status, {
        issues: [
          {
            message:
              `Error response code "${body.code}" is not declared for status ${res.status}. ` +
              `Expected one of: ${declaredErrors.map((error) => error.code).join(", ")}.`,
          },
        ],
      }),
    });
  }

  if (matchingError.details && body.details !== undefined) {
    try {
      const parsedDetails = await parseStandardSchema(
        matchingError.details,
        body.details,
      );
      const { details: _details, ...bodyWithoutDetails } = body;
      return {
        ...res,
        body:
          parsedDetails === undefined
            ? bodyWithoutDetails
            : { ...bodyWithoutDetails, details: parsedDetails },
      };
    } catch (error) {
      if (error instanceof SchemaValidationError) {
        throw new ResponseContractViolationError({
          code: "RESPONSE_VALIDATION_ERROR",
          message: responseContractViolationMessage(contract, res.status),
          details: responseContractViolationDetails(contract, res.status, {
            issues: error.issues,
          }),
        });
      }
      throw error;
    }
  }

  return res;
}

async function parseResponseAgainstContract<C extends HttpContractConfig>(
  contract: C,
  res: HttpResponseLike,
  responseValidationExemptStatus?: number,
): Promise<HttpResponseLike> {
  const statusKey = String(res.status);
  const hasDeclaredStatus = Object.hasOwn(contract.responses, statusKey);

  if (!hasDeclaredStatus) {
    if (Object.keys(contract.responses).length === 0) return res;

    throw new ResponseContractViolationError({
      code: "UNDECLARED_RESPONSE_STATUS",
      message:
        `Handler returned undeclared status ${res.status} for ` +
        `${contract.method} ${contract.path} (contract: ${contract.name})`,
      details: responseContractViolationDetails(contract, res.status, {
        returnedStatus: res.status,
      }),
    });
  }

  const responseSchema = contract.responses[res.status];
  if (responseSchema === null) {
    if (res.body !== undefined && res.body !== null) {
      throw new ResponseContractViolationError({
        code: "RESPONSE_VALIDATION_ERROR",
        message: responseContractViolationMessage(contract, res.status),
        details: responseContractViolationDetails(contract, res.status, {
          issues: [
            {
              message:
                "Response body must be empty for a null response schema.",
            },
          ],
        }),
      });
    }
    return res;
  }

  if (!responseSchema) return res;

  // Binder routes whose use case output schema is the same object as the
  // declared success response schema skip the redundant success-status parse.
  // Error statuses and undeclared statuses are validated unchanged.
  if (res.status === responseValidationExemptStatus) return res;

  try {
    const parsed = {
      ...res,
      body: await parseStandardSchema(responseSchema, res.body),
    };
    return await parseCatalogErrorResponse(contract, parsed);
  } catch (error) {
    if (error instanceof SchemaValidationError) {
      throw new ResponseContractViolationError({
        code: "RESPONSE_VALIDATION_ERROR",
        message: responseContractViolationMessage(contract, res.status),
        details: responseContractViolationDetails(contract, res.status, {
          issues: error.issues,
        }),
      });
    }
    throw error;
  }
}

const BODYLESS_RESPONSE_STATUSES = new Set([204, 205, 304]);

function validateHttpResponseSemantics(
  contract: HttpContractConfig,
  res: HttpResponseLike,
): void {
  if (
    !BODYLESS_RESPONSE_STATUSES.has(res.status) ||
    res.body === undefined ||
    res.body === null
  ) {
    return;
  }

  throw new ResponseContractViolationError({
    code: "RESPONSE_VALIDATION_ERROR",
    message: responseContractViolationMessage(contract, res.status),
    details: responseContractViolationDetails(contract, res.status, {
      issues: [
        {
          message: `HTTP status ${res.status} must not include a response body.`,
        },
      ],
    }),
  });
}

export async function finalizeResponse<C extends HttpContractConfig>(
  contract: C,
  res: HttpResponseLike,
  responseValidationExemptStatus?: number,
  options: { validateContract?: boolean } = {},
): Promise<HttpResponseLike> {
  const normalized = normalizeResponse(res);
  validateHttpResponseSemantics(contract, normalized);
  if (options.validateContract ?? true) {
    return parseResponseAgainstContract(
      contract,
      normalized,
      responseValidationExemptStatus,
    );
  }
  return normalized;
}

export function toContractViolationResponse(
  error: ResponseContractViolationError,
): HttpResponseLike {
  return {
    status: 500,
    body: createErrorResponseBody({
      code: error.code,
      message: error.message,
      details: error.details,
    }),
  };
}

export function defaultErrorResponse(
  err: unknown,
  ctx?: unknown,
): HttpResponseLike {
  const requestId = getRequestIdFromContext(ctx);
  const exposeErrorDetails =
    process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
  return {
    status: 500,
    body: createErrorResponseBody({
      code: "INTERNAL_SERVER_ERROR",
      message: "Internal server error",
      requestId,
      details:
        exposeErrorDetails && err instanceof Error
          ? {
              error: {
                message: err.message,
                stack: err.stack,
              },
            }
          : undefined,
    }),
  };
}
