import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { OcrPage, OcrProvider, OcrWord } from "./ocr.provider";

const run = promisify(execFile);

/**
 * Self-hosted OCR: `pdftoppm` (poppler) to rasterise, `tesseract` to read.
 *
 * Both are external binaries rather than a native npm addon, which keeps the
 * pnpm workspace free of a compiled dependency and makes the alpine runtime
 * image a two-package change (see docker/api.Dockerfile). Like StorageService,
 * a missing binary degrades rather than crashes the API: the module reports
 * itself unavailable and statement ingest returns 503, while every other
 * feature keeps working.
 *
 * The settings below are not arbitrary — they were measured against the real
 * scanned samples:
 *   - 300 DPI grayscale. The source scans are phone photos of paper at ~5MB a
 *     page; below 300 the small print (RMU, clave catastral) stops resolving,
 *     above it costs time for no additional fields.
 *   - `--psm 6` ("assume a single uniform block of text"). The default page
 *     segmentation splits these dense forms into columns and interleaves them,
 *     which destroys the label-then-value adjacency every parser depends on.
 *   - Spanish traineddata, with a graceful fall back to English if the language
 *     pack is absent — an accented label reads worse but the digits, which are
 *     what actually gets matched, are unaffected.
 */
@Injectable()
export class TesseractOcrProvider implements OcrProvider {
  private readonly logger = new Logger(TesseractOcrProvider.name);
  private readonly dpi: number;
  private readonly lang: string;
  private probe: Promise<boolean> | null = null;

  constructor(config: ConfigService) {
    this.dpi = Number(config.get("OCR_DPI") ?? 300);
    this.lang = config.get<string>("OCR_LANG") ?? "spa";
  }

  /** Cached — the binaries do not appear or vanish while the process runs. */
  available(): Promise<boolean> {
    if (!this.probe) {
      this.probe = (async () => {
        try {
          await Promise.all([
            run("tesseract", ["--version"]),
            run("pdftoppm", ["-v"]),
          ]);
          return true;
        } catch {
          this.logger.warn(
            "OCR unavailable: `tesseract` and/or `pdftoppm` not found on PATH. " +
              "Statement ingest is disabled; every other feature is unaffected.",
          );
          return false;
        }
      })();
    }
    return this.probe;
  }

  private async require(): Promise<void> {
    if (!(await this.available())) {
      throw new ServiceUnavailableException(
        "El servicio de OCR no está disponible en este servidor.",
      );
    }
  }

  private async scratch<T>(fn: (dir: string) => Promise<T>): Promise<T> {
    const dir = await mkdtemp(join(tmpdir(), "stmt-ocr-"));
    try {
      return await fn(dir);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  }

  async renderPages(pdf: Buffer): Promise<Buffer[]> {
    await this.require();
    return this.scratch(async (dir) => {
      const src = join(dir, "in.pdf");
      await writeFile(src, pdf);
      // -gray: these are grayscale scans already; colour triples the bytes
      // handed to tesseract for no gain in character recognition.
      await run("pdftoppm", [
        "-r",
        String(this.dpi),
        "-gray",
        "-png",
        src,
        join(dir, "page"),
      ]);
      const files = (await readdir(dir))
        .filter((f) => f.startsWith("page") && f.endsWith(".png"))
        // pdftoppm zero-pads its page numbers, so lexical order is page order.
        .sort();
      return Promise.all(files.map((f) => readFile(join(dir, f))));
    });
  }

  /**
   * `pdftotext -bbox-layout` — the same poppler package `pdftoppm` comes from,
   * so this costs no extra dependency in the runtime image.
   *
   * A page is only accepted when it carries a real text layer. Scanned PDFs
   * frequently contain a handful of stray glyphs (a scanner watermark, a page
   * number stamped by the MFP), and treating those as the page's text would
   * hand every parser an almost-empty string and silently take OCR out of the
   * loop — so a floor of MIN_TEXT_WORDS words has to be present before the
   * layer is believed.
   */
  async textPages(pdf: Buffer): Promise<(OcrPage | null)[]> {
    await this.require();
    return this.scratch(async (dir) => {
      const src = join(dir, "in.pdf");
      await writeFile(src, pdf);
      const out = join(dir, "out.html");
      try {
        await run("pdftotext", ["-bbox-layout", src, out]);
      } catch (err) {
        this.logger.warn(
          `pdftotext failed; falling back to OCR for this file: ${(err as Error).message}`,
        );
        return [];
      }
      // Points to pixels at the render DPI, so word boxes from either source
      // land in one coordinate space and `valueUnder`'s thresholds hold.
      return parseBboxLayout(await readFile(out, "utf8"), this.dpi / 72);
    });
  }

  async recognize(pageImage: Buffer): Promise<OcrPage> {
    await this.require();
    return this.scratch(async (dir) => {
      const img = join(dir, "page.png");
      await writeFile(img, pageImage);

      // One tesseract invocation produces both outputs; TSV carries the word
      // boxes and per-word confidence, and its text can be reassembled into
      // reading order, so there is no need to run the engine twice.
      const out = join(dir, "out");
      try {
        await run("tesseract", [img, out, "-l", this.lang, "--psm", "6", "tsv"]);
      } catch (err) {
        if (this.lang !== "eng") {
          this.logger.warn(
            `Tesseract failed with lang "${this.lang}", retrying with "eng": ${
              (err as Error).message
            }`,
          );
          await run("tesseract", [img, out, "-l", "eng", "--psm", "6", "tsv"]);
        } else {
          throw err;
        }
      }

      const tsv = await readFile(`${out}.tsv`, "utf8");
      return parseTsv(tsv);
    });
  }
}

/**
 * Below this many words a "text layer" is scanner debris, not a document.
 * The real born-digital samples carry 400+ words a page; the scanned ones
 * carry none at all, so the exact threshold is not delicate.
 */
const MIN_TEXT_WORDS = 40;

const ENTITIES: Record<string, string> = {
  amp: "&",
  lt: "<",
  gt: ">",
  quot: '"',
  apos: "'",
};

function decodeEntities(s: string): string {
  return s.replace(/&(#x?[0-9a-fA-F]+|[a-z]+);/g, (whole, body: string) => {
    if (body[0] === "#") {
      const code =
        body[1] === "x" || body[1] === "X"
          ? parseInt(body.slice(2), 16)
          : parseInt(body.slice(1), 10);
      return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
    }
    return ENTITIES[body] ?? whole;
  });
}

/**
 * Turn `pdftotext -bbox-layout`'s XHTML into one OcrPage per PDF page.
 *
 * Parsed with regexes rather than an XML library on purpose: the output is
 * machine-generated by poppler with a fixed element shape (`page` > `flow` >
 * `block` > `line` > `word`), and the alternative is a parser dependency in
 * the API for one file format read in one place. Only `page` and `word` are
 * consulted — see below for why poppler's own `line` grouping is discarded.
 *
 * `confidence` is 1 for every word: these are the document's own characters,
 * not a recognition guess.
 */
export function parseBboxLayout(xhtml: string, scale: number): (OcrPage | null)[] {
  const pages: (OcrPage | null)[] = [];

  for (const pageMatch of xhtml.matchAll(/<page\b[^>]*>([\s\S]*?)<\/page>/g)) {
    const words: OcrWord[] = [];

    for (const w of pageMatch[1].matchAll(
      /<word\s+xMin="([\d.eE+-]+)"\s+yMin="([\d.eE+-]+)"\s+xMax="([\d.eE+-]+)"\s+yMax="([\d.eE+-]+)"\s*>([\s\S]*?)<\/word>/g,
    )) {
      const text = decodeEntities(w[5]).trim();
      if (!text) continue;
      const left = Number(w[1]) * scale;
      const top = Number(w[2]) * scale;
      words.push({
        text,
        left,
        top,
        width: Number(w[3]) * scale - left,
        height: Number(w[4]) * scale - top,
        confidence: 1,
      });
    }

    pages.push(
      words.length >= MIN_TEXT_WORDS
        ? { text: toVisualRows(words), words, confidence: 1 }
        : null,
    );
  }

  return pages;
}

/**
 * Reassemble words into the rows a reader sees, left to right.
 *
 * Poppler's own `<line>` grouping cannot be used for this. It groups by text
 * flow, and these invoices lay their fields out as two columns of independent
 * flows — so `PERIODO FACTURADO:` and the `20260630-20260630` printed beside
 * it end up in different `<line>` elements, and every label-then-value pattern
 * in the parsers misses a value that is plainly there on the page. Regrouping
 * by vertical position restores the adjacency, and matches what tesseract
 * hands back for the scanned version of the same layout.
 *
 * Rows are cut when a word's vertical centre leaves the band established by
 * the row's first word, which tolerates the sub-pixel baseline differences
 * between fonts on one line without merging two genuinely separate lines.
 */
function toVisualRows(words: OcrWord[]): string {
  const centre = (w: OcrWord) => w.top + w.height / 2;
  const sorted = [...words].sort((a, b) => centre(a) - centre(b) || a.left - b.left);

  const rows: OcrWord[][] = [];
  let current: OcrWord[] = [];
  let band = 0;

  for (const w of sorted) {
    if (!current.length) {
      current = [w];
      band = centre(w);
      continue;
    }
    // Half the word's own height: tall headings and body text both sit within
    // their own line's band, and neither reaches into the next one.
    if (Math.abs(centre(w) - band) <= Math.max(w.height, current[0].height) / 2) {
      current.push(w);
    } else {
      rows.push(current);
      current = [w];
      band = centre(w);
    }
  }
  if (current.length) rows.push(current);

  return rows
    .map((r) =>
      [...r]
        .sort((a, b) => a.left - b.left)
        .map((w) => w.text)
        .join(" "),
    )
    .join("\n");
}

/**
 * Turn tesseract's TSV into words plus reassembled text.
 *
 * Columns are: level, page_num, block_num, par_num, line_num, word_num, left,
 * top, width, height, conf, text. Rows with level < 5 are structural (page,
 * block, paragraph, line) and carry no text; only level 5 is a word. A conf of
 * -1 marks a structural row, so those are dropped rather than averaged in —
 * including them would drag every page's confidence toward zero.
 */
export function parseTsv(tsv: string): OcrPage {
  const lines = tsv.split("\n");
  const header = lines[0]?.split("\t") ?? [];
  const col = (name: string) => header.indexOf(name);
  const iLeft = col("left");
  const iTop = col("top");
  const iWidth = col("width");
  const iHeight = col("height");
  const iConf = col("conf");
  const iText = col("text");
  const iLine = col("line_num");
  const iBlock = col("block_num");

  const words: OcrWord[] = [];
  // Keyed by block+line so the reassembled text preserves the engine's own
  // reading order instead of sorting words by raw y, which interleaves columns.
  const byLine = new Map<string, string[]>();

  for (let i = 1; i < lines.length; i++) {
    const f = lines[i].split("\t");
    if (f.length <= iText) continue;
    const text = f[iText]?.trim();
    if (!text) continue;
    const confidence = Number(f[iConf]);
    if (!Number.isFinite(confidence) || confidence < 0) continue;

    words.push({
      text,
      left: Number(f[iLeft]) || 0,
      top: Number(f[iTop]) || 0,
      width: Number(f[iWidth]) || 0,
      height: Number(f[iHeight]) || 0,
      confidence: confidence / 100,
    });

    const key = `${f[iBlock]}:${f[iLine]}`;
    const bucket = byLine.get(key);
    if (bucket) bucket.push(text);
    else byLine.set(key, [text]);
  }

  const text = [...byLine.values()].map((w) => w.join(" ")).join("\n");
  const confidence = words.length
    ? words.reduce((sum, w) => sum + w.confidence, 0) / words.length
    : 0;

  return { text, words, confidence };
}
