import { DOMParser } from "@xmldom/xmldom";
import * as cheerio from "cheerio";
import { isPlainObject } from "es-toolkit";
import * as xpath from "xpath";

import type { CheckFailure } from "../types";
import type {
  ContentCssExpectation,
  ContentExpectation,
  ContentExpectations,
  ContentJsonExpectation,
  ContentValueExpectation,
  ContentXpathExpectation,
  ExpectationResult,
  RawContentCssExpectation,
  RawContentExpectation,
  RawContentExpectations,
  RawContentJsonExpectation,
  RawContentXpathExpectation,
  ValueExpectation,
  ValueMatcher,
} from "./types";

import { errorFailure, mismatchFailure } from "./failure";
import { CONTENT_EXTRACTOR_KEY_SET, MATCHER_KEY_SET } from "./keys";
import { applyValueMatcher, displayValueExpectation, evaluateJsonPath } from "./value";

type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };

export function checkContentExpectations(
  source: unknown,
  expectations: ContentExpectations | undefined,
  options: { path?: string; phase: CheckFailure["phase"] },
): ExpectationResult {
  if (!expectations || expectations.length === 0) return { failure: null, matched: true };

  const basePath = options.path ?? options.phase;
  let parsedJson: ParsedJsonResult | undefined;

  for (let i = 0; i < expectations.length; i++) {
    const expectation = expectations[i]!;
    if (expectation.kind === "json" && parsedJson === undefined) {
      parsedJson = parseJsonSource(source);
    }

    const result = checkSingleContentExpectation(source, expectation, `${basePath}[${i}]`, options.phase, parsedJson);
    if (!result.matched) return result;
  }

  return { failure: null, matched: true };
}

export function resolveContentExpectations(raw: RawContentExpectations | undefined): ContentExpectations | undefined {
  if (raw === undefined) return undefined;
  return raw.map((entry) => resolveContentExpectation(entry));
}

function checkCssExpectation(
  source: unknown,
  expectation: ContentCssExpectation,
  expectationPath: string,
  phase: CheckFailure["phase"],
): ExpectationResult {
  const fullPath = `${expectationPath}.css(${expectation.selector}${expectation.attr ? `@${expectation.attr}` : ""})`;

  let $: cheerio.CheerioAPI;
  try {
    $ = cheerio.load(contentText(source));
  } catch {
    return { failure: errorFailure(phase, fullPath, "failed to parse HTML"), matched: false };
  }

  const el = $(expectation.selector).first();
  const actual = el.length === 0 ? undefined : expectation.attr ? el.attr(expectation.attr) : el.text();

  if (!applyValueMatcher(actual, expectation.matcher)) {
    return {
      failure: mismatchFailure(
        phase,
        fullPath,
        displayValueExpectation(expectation.matcher),
        actual,
        `css selector ${expectation.selector} mismatch`,
      ),
      matched: false,
    };
  }

  return { failure: null, matched: true };
}

function checkJsonExpectation(
  expectation: ContentJsonExpectation,
  expectationPath: string,
  phase: CheckFailure["phase"],
  parsedJson?: ParsedJsonResult,
): ExpectationResult {
  const fullPath = `${expectationPath}.json(${expectation.path})`;

  if (!parsedJson?.ok) {
    return { failure: errorFailure(phase, fullPath, parsedJson?.error ?? "content is not valid JSON"), matched: false };
  }

  const actual = evaluateJsonPath(parsedJson.value, expectation.path);
  if (!applyValueMatcher(actual, expectation.matcher)) {
    return {
      failure: mismatchFailure(
        phase,
        fullPath,
        displayValueExpectation(expectation.matcher),
        actual,
        `json path ${expectation.path} mismatch`,
      ),
      matched: false,
    };
  }

  return { failure: null, matched: true };
}

function checkSingleContentExpectation(
  source: unknown,
  expectation: ContentExpectation,
  expectationPath: string,
  phase: CheckFailure["phase"],
  parsedJson?: ParsedJsonResult,
): ExpectationResult {
  switch (expectation.kind) {
    case "css":
      return checkCssExpectation(source, expectation, expectationPath, phase);
    case "json":
      return checkJsonExpectation(expectation, expectationPath, phase, parsedJson);
    case "value":
      return checkValueContentExpectation(source, expectation, expectationPath, phase);
    case "xpath":
      return checkXpathExpectation(source, expectation, expectationPath, phase);
  }
}

function checkValueContentExpectation(
  source: unknown,
  expectation: ContentValueExpectation,
  expectationPath: string,
  phase: CheckFailure["phase"],
): ExpectationResult {
  if (!applyValueMatcher(source, expectation.matcher, { stringifyNonString: true })) {
    return {
      failure: mismatchFailure(
        phase,
        expectationPath,
        displayValueExpectation(expectation.matcher),
        source,
        `${phase} expectation mismatch`,
      ),
      matched: false,
    };
  }
  return { failure: null, matched: true };
}

function checkXpathExpectation(
  source: unknown,
  expectation: ContentXpathExpectation,
  expectationPath: string,
  phase: CheckFailure["phase"],
): ExpectationResult {
  const fullPath = `${expectationPath}.xpath(${expectation.path})`;

  let doc: ReturnType<DOMParser["parseFromString"]>;
  try {
    doc = new DOMParser().parseFromString(contentText(source), "text/xml");
  } catch {
    return { failure: errorFailure(phase, fullPath, "failed to parse XML/HTML"), matched: false };
  }

  const result = xpath.select(expectation.path, doc as unknown as Node);
  const actual = xpathValue(result);

  if (!applyValueMatcher(actual, expectation.matcher)) {
    return {
      failure: mismatchFailure(
        phase,
        fullPath,
        displayValueExpectation(expectation.matcher),
        actual,
        `xpath ${expectation.path} mismatch`,
      ),
      matched: false,
    };
  }

  return { failure: null, matched: true };
}

function contentText(source: unknown): string {
  if (source === null || source === undefined) return "";
  if (typeof source === "string") return source;
  if (typeof source === "number" || typeof source === "boolean" || typeof source === "bigint") return String(source);
  if (typeof source === "symbol") return source.description ?? "";
  if (typeof source === "function") return source.name;
  return JSON.stringify(source) ?? "";
}

function extractDirectMatcher(raw: Record<string, unknown>): ValueMatcher {
  const matcher: ValueMatcher = {};
  for (const [key, value] of Object.entries(raw)) {
    if (MATCHER_KEY_SET.has(key) && value !== undefined) {
      (matcher as Record<string, unknown>)[key] = value;
    }
  }
  return matcher;
}

function extractExtractorMatcher(raw: Record<string, unknown>, ownFields: ReadonlySet<string>): ValueExpectation {
  const matcher: ValueMatcher = {};
  for (const [key, value] of Object.entries(raw)) {
    if (ownFields.has(key)) continue;
    if (MATCHER_KEY_SET.has(key) && value !== undefined) {
      (matcher as Record<string, unknown>)[key] = value;
    }
  }
  if (Object.keys(matcher).length === 0) return { exists: true };
  return matcher;
}

function parseJsonSource(source: unknown): ParsedJsonResult {
  if (typeof source !== "string") return { ok: true, value: source };
  try {
    return { ok: true, value: JSON.parse(source) as unknown };
  } catch {
    return { error: "content is not valid JSON", ok: false };
  }
}

function resolveContentExpectation(raw: RawContentExpectation): ContentExpectation {
  if (!isPlainObject(raw)) {
    return { kind: "value", matcher: { equals: raw } };
  }
  const record = raw as Record<string, unknown>;

  if (CONTENT_EXTRACTOR_KEY_SET.has("json") && isPlainObject(record["json"])) {
    const json = record["json"] as RawContentJsonExpectation;
    return {
      kind: "json",
      matcher: extractExtractorMatcher(json as unknown as Record<string, unknown>, new Set(["path"])),
      path: json.path,
    };
  }

  if (isPlainObject(record["css"])) {
    const css = record["css"] as RawContentCssExpectation;
    const expectation: ContentCssExpectation = {
      kind: "css",
      matcher: extractExtractorMatcher(css as unknown as Record<string, unknown>, new Set(["attr", "selector"])),
      selector: css.selector,
    };
    if (css.attr !== undefined) expectation.attr = css.attr;
    return expectation;
  }

  if (isPlainObject(record["xpath"])) {
    const xpathExpectation = record["xpath"] as RawContentXpathExpectation;
    return {
      kind: "xpath",
      matcher: extractExtractorMatcher(xpathExpectation as unknown as Record<string, unknown>, new Set(["path"])),
      path: xpathExpectation.path,
    };
  }

  return { kind: "value", matcher: extractDirectMatcher(record) };
}

function xpathValue(result: unknown): unknown {
  if (!Array.isArray(result)) return result;
  if (result.length === 0) return undefined;

  const node = (result as unknown[])[0]!;
  if (typeof node !== "object" || node === null) return node;
  const asNode = node as Node;
  return asNode.nodeValue ?? (asNode as unknown as Element).textContent ?? "";
}
