import { isError } from "es-toolkit";

import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { HttpTargetConfig, RawHttpExpectConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types";

import { checkContentExpectations, resolveContentExpectations } from "../../expect/content";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { checkHeaderExpectations } from "../../expect/headers";
import { resolveKeyedExpectations } from "../../expect/keyed";
import { checkStatusCode } from "../../expect/status";
import { checkValueExpectation, displayValueExpectation, resolveValueExpectation } from "../../expect/value";
import { parseSize } from "../../utils";
import { httpCheckerSchemas } from "./schema";
import { validateHttpConfig } from "./validate";

const CHARSET_RE = /charset="?([^";\s]+)"?/i;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);

export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
  readonly configKey = "http";

  readonly schemas = httpCheckerSchemas;

  readonly type = "http";

  buildDetail(observation: Record<string, unknown>): null | string {
    const statusCode = observation["statusCode"];
    return typeof statusCode === "number" ? `HTTP ${statusCode}` : null;
  }

  async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise<CheckResult> {
    const timestamp = new Date().toISOString();
    const expect = t.expect;
    const start = performance.now();

    try {
      const response = await fetchWithRedirects(t.http.url, t.http.maxRedirects, {
        body: t.http.method !== "GET" && t.http.method !== "HEAD" ? t.http.body : undefined,
        headers: t.http.headers,
        method: t.http.method,
        redirect: "manual",
        signal: ctx.signal,
        ...(t.http.ignoreSSL ? { tls: { rejectUnauthorized: false } } : {}),
      });

      const statusCode = response.status;
      const responseHeaders = truncateHeaders(Object.fromEntries(response.headers));
      let bodyPreview: null | string = null;
      let bodyText: null | string = null;
      const hasBodyExpectations = !!(expect?.body && expect.body.length > 0);

      const statusResult = checkStatusCode(statusCode, expect?.status ?? [200]);
      if (!statusResult.matched) {
        return makeResult(
          t,
          timestamp,
          performance.now() - start,
          statusResult.failure,
          response,
          responseHeaders,
          bodyPreview,
        );
      }

      const headersResult = checkHeaderExpectations(Object.fromEntries(response.headers), expect?.headers);
      if (!headersResult.matched) {
        return makeResult(
          t,
          timestamp,
          performance.now() - start,
          headersResult.failure,
          response,
          responseHeaders,
          bodyPreview,
        );
      }

      const earlyTimeout = hasBodyExpectations ? checkEarlyTimeout(start, expect?.durationMs) : null;
      if (earlyTimeout) {
        return makeResult(
          t,
          timestamp,
          earlyTimeout.elapsed,
          earlyTimeout.failure,
          response,
          responseHeaders,
          bodyPreview,
        );
      }

      if (hasBodyExpectations) {
        const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
        let bodyDecodeFailure: CheckResult["failure"] = null;

        if (bodyReadResult.data.byteLength > 0) {
          const decodeResult = decodeBody(bodyReadResult.data, response.headers);
          if (decodeResult.ok) {
            bodyText = decodeResult.text;
            bodyPreview = truncateBodyPreview(decodeResult.text);
          } else {
            bodyDecodeFailure = decodeResult.failure;
          }
        }

        if (!bodyReadResult.ok) {
          return makeResult(
            t,
            timestamp,
            performance.now() - start,
            bodyReadResult.failure,
            response,
            responseHeaders,
            bodyPreview,
          );
        }

        if (bodyDecodeFailure) {
          return makeResult(
            t,
            timestamp,
            performance.now() - start,
            bodyDecodeFailure,
            response,
            responseHeaders,
            bodyPreview,
          );
        }

        const bodyResult = checkContentExpectations(bodyText ?? "", expect.body, { path: "body", phase: "body" });
        if (!bodyResult.matched) {
          return makeResult(
            t,
            timestamp,
            performance.now() - start,
            bodyResult.failure,
            response,
            responseHeaders,
            bodyPreview,
          );
        }
      }

      const durationMs = Math.round(performance.now() - start);
      const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
        message: "durationMs mismatch",
        path: "durationMs",
        phase: "duration",
      });
      if (!durationResult.matched) {
        return makeResult(t, timestamp, durationMs, durationResult.failure, response, responseHeaders, bodyPreview);
      }

      return makeResult(t, timestamp, durationMs, null, response, responseHeaders, bodyPreview);
    } catch (error) {
      const durationMs = Math.round(performance.now() - start);
      const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");

      return {
        detail: null,
        durationMs,
        failure: errorFailure(
          "request",
          "request",
          isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
        ),
        matched: false,
        observation: null,
        targetId: t.id,
        timestamp,
      };
    }
  }

  resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget {
    const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
    const httpDefaults = context.defaults["http"] as
      | undefined
      | { headers?: Record<string, string>; maxBodyBytes?: string };

    const method = t.http.method ?? "GET";
    const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");

    const rawExpect = target.expect as RawHttpExpectConfig | undefined;
    const resolvedExpect: ResolvedHttpExpectConfig = rawExpect
      ? {
          body: resolveContentExpectations(rawExpect.body),
          durationMs: resolveValueExpectation(rawExpect.durationMs),
          headers: resolveKeyedExpectations(rawExpect.headers),
          status: rawExpect.status ?? [200],
        }
      : { status: [200] };

    return {
      description: null,
      expect: resolvedExpect,
      group: target.group ?? "default",
      http: {
        body: t.http.body,
        headers: { ...(httpDefaults?.headers ?? {}), ...(t.http.headers ?? {}) },
        ignoreSSL: t.http.ignoreSSL ?? false,
        maxBodyBytes,
        maxRedirects: t.http.maxRedirects ?? 0,
        method,
        url: t.http.url,
      },
      id: t.id,
      intervalMs: context.defaultIntervalMs,
      name: t.name ?? null,
      rawExpect,
      timeoutMs: context.defaultTimeoutMs,
      type: "http",
    } satisfies ResolvedHttpTarget;
  }

  serialize(t: ResolvedHttpTarget): { config: string; target: string } {
    return {
      config: JSON.stringify({
        body: t.http.body,
        headers: t.http.headers,
        ignoreSSL: t.http.ignoreSSL,
        maxBodyBytes: t.http.maxBodyBytes,
        maxRedirects: t.http.maxRedirects,
        method: t.http.method,
        url: t.http.url,
      }),
      target: t.http.url,
    };
  }

  validate(input: CheckerValidationInput) {
    return validateHttpConfig(input);
  }
}

function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
  const result = new Uint8Array(totalBytes);
  let offset = 0;
  for (const chunk of chunks) {
    result.set(chunk, offset);
    offset += chunk.byteLength;
  }

  return result;
}

function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {
  let newInit = { ...init };
  const method = init.method?.toUpperCase();

  if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) {
    const headers =
      typeof init.headers === "object" && init.headers !== null
        ? { ...(init.headers as Record<string, string>) }
        : undefined;
    if (headers) {
      for (const key of Object.keys(headers)) {
        const lower = key.toLowerCase();
        if (lower === "content-type" || lower === "content-length") {
          delete headers[key];
        }
      }
    }
    newInit = { ...newInit, body: undefined, headers, method: "GET" };
  }

  try {
    const fromOrigin = new URL(fromUrl).origin;
    const toOrigin = new URL(toUrl).origin;
    if (fromOrigin !== toOrigin && typeof newInit.headers === "object" && newInit.headers !== null) {
      const headers = { ...(newInit.headers as Record<string, string>) };
      for (const key of Object.keys(headers)) {
        if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
          delete headers[key];
        }
      }
      newInit.headers = headers;
    }
  } catch {
    /* URL parsing failed, keep headers */
  }

  return newInit;
}

function checkEarlyTimeout(
  start: number,
  durationMatcher: ResolvedHttpExpectConfig["durationMs"] | undefined,
): null | { elapsed: number; failure: CheckResult["failure"] } {
  if (!durationMatcher) return null;
  const elapsed = performance.now() - start;
  const lteFailed = durationMatcher.lte !== undefined && elapsed > durationMatcher.lte;
  const ltFailed = durationMatcher.lt !== undefined && elapsed >= durationMatcher.lt;
  if (!lteFailed && !ltFailed) return null;

  const durationMs = Math.round(elapsed);
  const durationResult = checkValueExpectation(durationMs, durationMatcher, {
    message: "durationMs mismatch",
    path: "durationMs",
    phase: "duration",
  });
  return {
    elapsed,
    failure:
      durationResult.failure ??
      mismatchFailure(
        "duration",
        "durationMs",
        displayValueExpectation(durationMatcher),
        durationMs,
        "durationMs mismatch",
      ),
  };
}

function decodeBody(
  data: Uint8Array,
  headers: Headers,
): { failure: CheckResult["failure"]; ok: false } | { ok: true; text: string } {
  const contentType = headers.get("content-type") ?? "";
  const charsetMatch = CHARSET_RE.exec(contentType);
  const encoding = charsetMatch?.[1]?.toLowerCase() ?? "utf-8";

  try {
    const text = new TextDecoder(encoding).decode(data);
    return { ok: true, text };
  } catch {
    return {
      failure: errorFailure("body", "body", `不支持的字符编码: ${encoding}`),
      ok: false,
    };
  }
}

async function fetchWithRedirects(url: string, maxRedirects: number, init: RequestInit): Promise<Response> {
  let currentUrl = url;
  let currentInit = init;

  for (let followed = 0; ; followed++) {
    const response = await fetch(currentUrl, currentInit);
    if (!REDIRECT_STATUSES.has(response.status)) return response;

    const location = response.headers.get("location");
    if (!location || followed >= maxRedirects) return response;

    try {
      await response.arrayBuffer();
    } catch {
      /* ignore body drain error */
    }

    const nextUrl = new URL(location, currentUrl).toString();
    currentInit = buildRedirectInit(currentInit, response.status, currentUrl, nextUrl);
    currentUrl = nextUrl;
  }
}

function makeResult(
  t: ResolvedHttpTarget,
  timestamp: string,
  elapsed: number,
  failure: CheckResult["failure"],
  response: Response,
  headers: Record<string, string>,
  bodyPreview: null | string = null,
): CheckResult {
  const contentType = response.headers.get("content-type");
  const contentLengthHeader = response.headers.get("content-length");
  const contentLength = contentLengthHeader ? Number(contentLengthHeader) : null;

  const observation: Record<string, unknown> = {
    bodyPreview,
    contentLength: Number.isFinite(contentLength) ? contentLength : null,
    contentType,
    headers,
    statusCode: response.status,
  };

  return {
    detail: null,
    durationMs: Math.round(elapsed),
    failure,
    matched: failure === null,
    observation,
    targetId: t.id,
    timestamp,
  };
}

async function readBodyStream(
  response: Response,
  maxBodyBytes: number,
  truncateOnLimit = false,
): Promise<{ data: Uint8Array; failure: CheckResult["failure"]; ok: false } | { data: Uint8Array; ok: true }> {
  const reader = response.body?.getReader();
  if (!reader) {
    return { data: new Uint8Array(0), ok: true };
  }

  const chunks: Uint8Array[] = [];
  let totalBytes = 0;

  try {
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;

      totalBytes += value.byteLength;
      if (totalBytes > maxBodyBytes) {
        const allowedBytes = value.byteLength - (totalBytes - maxBodyBytes);
        if (truncateOnLimit && allowedBytes > 0) {
          chunks.push(value.slice(0, allowedBytes));
        }
        try {
          await reader.cancel();
        } catch {
          /* ignore cancel error */
        }
        const data = assembleChunks(chunks, Math.min(totalBytes, maxBodyBytes));
        if (truncateOnLimit) return { data, ok: true };
        return {
          data,
          failure: errorFailure("body", "body", `响应体大小超过限制 ${maxBodyBytes}`),
          ok: false,
        };
      }

      chunks.push(value);
    }
  } finally {
    reader.releaseLock();
  }

  return { data: assembleChunks(chunks, totalBytes), ok: true };
}

function truncateBodyPreview(text: string, maxLen = 1024): string {
  if (text.length <= maxLen) return text;
  return text.slice(0, maxLen);
}

function truncateHeaders(headers: Record<string, string>, maxCount = 20): Record<string, string> {
  const entries = Object.entries(headers);
  if (entries.length <= maxCount) return headers;
  return Object.fromEntries(entries.slice(0, maxCount));
}
