const SP_TO_TEX_PT = 1 / 65536;
const TEX_PT_PER_INCH = 72.27;
const PDF_BP_PER_INCH = 72;
const CONTAINER_HEIGHT_LINE_MULTIPLE = 16;
const FALLBACK_VERTICAL_LINE_TOLERANCE = 1.5;
const MAX_FORWARD_LINE_DELTA = 12;

export type SyncTexRect = {
  x: number;
  y: number;
  width: number;
  height: number;
};

export type SyncTexForwardResult = {
  page: number;
  rects: SyncTexRect[];
  file: string;
  line: number;
};

export type SyncTexReverseResult = {
  file: string;
  line: number;
  column?: number;
};

export type SyncTexFileMapping = {
  file: string;
  line: number;
  column?: number;
  page: number;
  rects: SyncTexRect[];
};

export interface SyncTexIndex {
  forward(file: string, line: number, column?: number): SyncTexForwardResult | null;
  reverse(page: number, x: number, y: number): SyncTexReverseResult | null;
  mappingsForFile(file: string): SyncTexFileMapping[];
  pagesForFile(file: string): number[];
}

type Box = {
  page: number;
  file: string;
  line: number;
  column: number;
  x: number;
  y: number;
  width: number;
  height: number;
  depth: number;
  isContainer: boolean;
};

type ParsedRecord = {
  tag: number;
  line: number;
  column: number;
  x: number;
  y: number;
  width: number;
  height: number;
  depth: number;
};

function normalizeFile(file: string): string {
  const normalizedSlashes = file.replaceAll('\\\\', '/').replaceAll('\\', '/');
  return normalizedSlashes.replace(/^\.\/+/, '').replace(/^\/+/, '');
}

function fileMatchScore(indexedFile: string, requestedFile: string): number {
  const indexed = normalizeFile(indexedFile);
  const requested = normalizeFile(requestedFile);
  if (!indexed || !requested) return 0;
  if (indexed === requested) return 1000;
  if (indexed.endsWith(`/${requested}`)) return 900;
  if (requested.endsWith(`/${indexed}`)) return 850;

  const indexedBase = indexed.split('/').at(-1);
  const requestedBase = requested.split('/').at(-1);
  return indexedBase && indexedBase === requestedBase ? 100 : 0;
}

function parseRecord(raw: string): ParsedRecord | null {
  const colon = raw.indexOf(':');
  if (colon < 2) return null;

  const prefix = raw.slice(1, colon).split(',');
  const tag = Number.parseInt(prefix[0] ?? '', 10);
  const line = Number.parseInt(prefix[1] ?? '', 10);
  if (!Number.isFinite(tag) || !Number.isFinite(line)) return null;

  const fields = raw.slice(colon + 1).split(/[,:]/);
  const x = Number.parseInt(fields[0] ?? '', 10);
  const y = Number.parseInt(fields[1] ?? '', 10);
  if (!Number.isFinite(x) || !Number.isFinite(y)) return null;

  const parseOptional = (value: string | undefined): number => {
    const parsed = Number.parseInt(value ?? '', 10);
    return Number.isFinite(parsed) ? parsed : 0;
  };

  const column = Number.parseInt(prefix[2] ?? '', 10);
  return {
    tag,
    line,
    column: Number.isFinite(column) ? column : -1,
    x,
    y,
    width: parseOptional(fields[2]),
    height: parseOptional(fields[3]),
    depth: parseOptional(fields[4]),
  };
}

function rectForBox(box: Box): SyncTexRect {
  const width = Math.abs(box.width);
  const boxHeight = Math.abs(box.height + box.depth);
  return {
    x: box.width < 0 ? box.x - width : box.x,
    y: box.y - box.height,
    width,
    height: boxHeight,
  };
}

type SyncTexGeometry = {
  preMagnification: number;
  preUnit: number;
  preXOffset: number;
  preYOffset: number;
  postMagnification?: number;
  postXOffsetTexPt?: number;
  postYOffsetTexPt?: number;
};

function parseDimensionToTexPt(raw: string): number | undefined {
  const match = raw.trim().match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+))(?:\s*)(in|cm|mm|pt|bp|pc|sp|dd|cc|nd|nc)?/i);
  if (!match) return undefined;
  const value = Number.parseFloat(match[1] ?? '');
  if (!Number.isFinite(value)) return undefined;
  switch ((match[2] ?? 'sp').toLowerCase()) {
    case 'in': return value * TEX_PT_PER_INCH;
    case 'cm': return value * TEX_PT_PER_INCH / 2.54;
    case 'mm': return value * TEX_PT_PER_INCH / 25.4;
    case 'pt': return value;
    case 'bp': return value * TEX_PT_PER_INCH / PDF_BP_PER_INCH;
    case 'pc': return value * 12;
    case 'sp': return value * SP_TO_TEX_PT;
    case 'dd': return value * 1238 / 1157;
    case 'cc': return value * 14856 / 1157;
    case 'nd': return value * 685 / 642;
    case 'nc': return value * 1370 / 107;
    default: return undefined;
  }
}

/**
 * Mirror the reference SyncTeX scanner's geometry setup while keeping the
 * public Learnitive mapping contract in TeX points. Raw node coordinates are
 * multiplied by pre-unit, pre-magnification and optional post magnification;
 * the preamble origin offset is *not* magnified. Post-scriptum offsets replace
 * the preamble origin when present.
 */
function parseGeometry(lines: readonly string[]): SyncTexGeometry {
  const geometry: SyncTexGeometry = {
    preMagnification: 1000,
    // Real SyncTeX output includes Unit. Keep 1 as the compatibility fallback
    // for older/minimal fixtures that omitted the header entirely.
    preUnit: 1,
    preXOffset: 0,
    preYOffset: 0,
  };
  let inPostScriptum = false;
  for (const sourceLine of lines) {
    const raw = sourceLine.trim();
    if (!raw) continue;
    if (raw === 'Post scriptum:') {
      inPostScriptum = true;
      continue;
    }
    const split = raw.indexOf(':');
    if (split < 0) continue;
    const key = raw.slice(0, split);
    const value = raw.slice(split + 1).trim();
    if (!inPostScriptum) {
      const parsed = Number.parseFloat(value);
      if (!Number.isFinite(parsed)) continue;
      if (key === 'Magnification' && parsed > 0) geometry.preMagnification = parsed;
      else if (key === 'Unit' && parsed > 0) geometry.preUnit = parsed;
      else if (key === 'X Offset') geometry.preXOffset = parsed;
      else if (key === 'Y Offset') geometry.preYOffset = parsed;
      continue;
    }
    if (key === 'Magnification') {
      const parsed = Number.parseFloat(value);
      if (Number.isFinite(parsed) && parsed > 0) geometry.postMagnification = parsed;
    } else if (key === 'X Offset') {
      const parsed = parseDimensionToTexPt(value);
      if (parsed !== undefined) geometry.postXOffsetTexPt = parsed;
    } else if (key === 'Y Offset') {
      const parsed = parseDimensionToTexPt(value);
      if (parsed !== undefined) geometry.postYOffsetTexPt = parsed;
    }
  }
  return geometry;
}

function buildIndex(text: string): SyncTexIndex {
  const inputs = new Map<number, string>();
  const boxes: Box[] = [];
  const byPage = new Map<number, Box[]>();
  const lastZeroWidthByKey = new Map<string, Box>();
  let page = 0;
  let inContent = false;
  const lines = text.split(/\r?\n/);
  const geometry = parseGeometry(lines);
  const postMagnification = geometry.postMagnification ?? 1;
  const geometryScale = SP_TO_TEX_PT * geometry.preUnit * (geometry.preMagnification / 1000) * postMagnification;
  const preOffsetScale = SP_TO_TEX_PT * geometry.preUnit;
  const xOffset = geometry.postXOffsetTexPt ?? geometry.preXOffset * preOffsetScale;
  const yOffset = geometry.postYOffsetTexPt ?? geometry.preYOffset * preOffsetScale;
  const coordinate = (value: number, offset: number) => value * geometryScale + offset;
  const dimension = (value: number) => value * geometryScale;

  for (const sourceLine of lines) {
    const raw = sourceLine.trimEnd();
    if (!raw) continue;

    if (raw.startsWith('Input:')) {
      const body = raw.slice('Input:'.length);
      const separator = body.indexOf(':');
      if (separator > 0) {
        const tag = Number.parseInt(body.slice(0, separator), 10);
        const file = body.slice(separator + 1).trim();
        if (Number.isFinite(tag) && file) inputs.set(tag, file);
      }
      continue;
    }

    if (!inContent) {
      if (raw === 'Content:') inContent = true;
      continue;
    }

    if (raw.startsWith('Postamble:')) break;

    const kind = raw[0];
    if (kind === '{') {
      const nextPage = Number.parseInt(raw.slice(1), 10);
      if (Number.isFinite(nextPage)) {
        page = nextPage;
        if (!byPage.has(page)) byPage.set(page, []);
      }
      continue;
    }
    if (kind === '}') {
      page = 0;
      continue;
    }
    if (page === 0 || !kind || !['h', '[', '(', 'r'].includes(kind)) continue;

    const record = parseRecord(raw);
    if (!record) continue;
    const file = inputs.get(record.tag);
    if (!file) continue;

    const recordKey = `${page}:${file}:${record.line}:${record.column}`;
    if (kind === 'r') {
      const previous = lastZeroWidthByKey.get(recordKey);
      if (previous) {
        previous.width = dimension(record.width);
        if (previous.height === 0) previous.height = dimension(record.height);
        if (previous.depth === 0) previous.depth = dimension(record.depth);
        lastZeroWidthByKey.delete(recordKey);
      }
      continue;
    }

    const box: Box = {
      page,
      file,
      line: record.line,
      column: record.column,
      x: coordinate(record.x, xOffset),
      y: coordinate(record.y, yOffset),
      width: dimension(record.width),
      height: dimension(record.height),
      depth: dimension(record.depth),
      isContainer: kind === '[',
    };
    boxes.push(box);
    byPage.get(page)?.push(box);
    if (box.width === 0) lastZeroWidthByKey.set(recordKey, box);
  }

  const bestMatchingBoxes = (requestedFile: string): Box[] => {
    let bestScore = 0;
    const scored = new Map<Box, number>();
    for (const box of boxes) {
      const score = fileMatchScore(box.file, requestedFile);
      if (score <= 0) continue;
      scored.set(box, score);
      if (score > bestScore) bestScore = score;
    }
    const matched = [...scored].filter(([, score]) => score === bestScore).map(([box]) => box);
    // Any equally strong path match must fail closed when it resolves to
    // more than one SyncTeX input. A bare basename such as `main.tex` scores
    // as a suffix match for every nested `.../main.tex`, so checking only the
    // weakest basename score would still allow ambiguous project jumps.
    const files = new Set(matched.map((box) => normalizeFile(box.file)));
    if (files.size > 1) return [];
    return matched;
  };

  return {
    forward(file, line, column) {
      const fileBoxes = bestMatchingBoxes(file);
      if (fileBoxes.length === 0) return null;

      let nearestDelta = Number.POSITIVE_INFINITY;
      let nearestLine = -1;
      for (const box of fileBoxes) {
        const delta = Math.abs(box.line - line);
        if (delta < nearestDelta) {
          nearestDelta = delta;
          nearestLine = box.line;
        }
      }
      if (nearestLine < 0 || nearestDelta > MAX_FORWARD_LINE_DELTA) return null;

      let candidates = fileBoxes.filter((box) => box.line === nearestLine);
      if (column !== undefined && column > 0) {
        const withColumns = candidates.filter((box) => box.column > 0);
        if (withColumns.length > 0) {
          const nearestColumnDelta = Math.min(...withColumns.map((box) => Math.abs(box.column - column)));
          candidates = withColumns.filter((box) => Math.abs(box.column - column) === nearestColumnDelta);
        }
      }
      // Prefer the page with the strongest local evidence rather than whichever
      // record happened to be parsed first when one source line spans pages.
      const pageCounts = new Map<number, number>();
      for (const box of candidates) {
        if (Math.abs(box.width) <= 0) continue;
        pageCounts.set(box.page, (pageCounts.get(box.page) ?? 0) + (box.isContainer ? 1 : 3));
      }
      const page = [...pageCounts].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]?.[0];
      if (!page) return null;
      const visible = candidates.filter((box) => box.page === page && Math.abs(box.width) > 0);
      if (visible.length === 0) return null;
      // Container records can span most of a page/paragraph. They are useful
      // for reverse mapping but make forward-sync highlights look like a giant
      // coloured box. Prefer leaf boxes for source-to-PDF location cues.
      const leafBoxes = visible.filter((box) => !box.isContainer);
      const onPage = leafBoxes.length ? leafBoxes : visible;

      return {
        page,
        rects: onPage.map(rectForBox),
        file: normalizeFile(onPage[0]?.file ?? file),
        line: nearestLine,
      };
    },

    reverse(targetPage, x, y) {
      const pageBoxes = byPage.get(targetPage);
      if (!pageBoxes?.length) return null;

      const lineHeights = pageBoxes
        .filter((box) => !box.isContainer && box.height + box.depth > 0)
        .map((box) => box.height + box.depth)
        .sort((a, b) => a - b);
      const medianLineHeight = lineHeights.length
        ? (lineHeights[Math.floor(lineHeights.length / 2)] ?? 0)
        : 0;
      const maxBoxHeight =
        medianLineHeight > 0
          ? medianLineHeight * CONTAINER_HEIGHT_LINE_MULTIPLE
          : Number.POSITIVE_INFINITY;

      const pick = (capContainers: boolean): Box | null => {
        let contained: Box | null = null;
        let containedScore = Number.POSITIVE_INFINITY;
        let nearest: Box | null = null;
        let nearestDistance = Number.POSITIVE_INFINITY;

        for (const box of pageBoxes) {
          const rect = rectForBox(box);
          const boxHeight = rect.height;
          if (capContainers && boxHeight > maxBoxHeight) continue;
          const left = rect.x;
          const top = rect.y;
          const right = rect.x + rect.width;
          const bottom = rect.y + rect.height;

          if (x >= left && x <= right && y >= top && y <= bottom) {
            const centerX = left + rect.width / 2;
            const centerY = top + rect.height / 2;
            const dx = x - centerX;
            const dy = y - centerY;
            const area = Math.max(1, rect.width * Math.max(1, boxHeight));
            const score = (dx * dx + dy * dy) * Math.sqrt(area);
            if (
              score < containedScore ||
              (score === containedScore && contained?.isContainer && !box.isContainer)
            ) {
              contained = box;
              containedScore = score;
            }
            continue;
          }

          if (!capContainers) continue;
          const closestX = Math.max(left, Math.min(x, right));
          const closestY = Math.max(top, Math.min(y, bottom));
          const dx = x - closestX;
          const dy = y - closestY;
          const lineHeight = Math.max(1, boxHeight);
          if (Math.abs(dy) > lineHeight * FALLBACK_VERTICAL_LINE_TOLERANCE) continue;
          const distance = dx * dx + dy * dy;
          if (
            distance < nearestDistance ||
            (distance === nearestDistance && nearest?.isContainer && !box.isContainer)
          ) {
            nearest = box;
            nearestDistance = distance;
          }
        }

        return contained ?? nearest;
      };

      const hit = pick(true) ?? pick(false);
      if (!hit) return null;
      return {
        file: normalizeFile(hit.file),
        line: hit.line,
        ...(hit.column >= 0 ? { column: hit.column } : {}),
      };
    },

    mappingsForFile(file) {
      const fileBoxes = bestMatchingBoxes(file);
      const grouped = new Map<string, Box[]>();
      for (const box of fileBoxes) {
        if (Math.abs(box.width) <= 0) continue;
        const key = `${box.page}:${box.line}:${box.column}`;
        const group = grouped.get(key);
        if (group) group.push(box);
        else grouped.set(key, [box]);
      }
      return [...grouped.values()]
        .map((group): SyncTexFileMapping => {
          const first = group[0];
          if (!first) throw new Error('SyncTeX mapping group is unexpectedly empty');
          return {
            file: normalizeFile(first.file),
            line: first.line,
            ...(first.column >= 0 ? { column: first.column } : {}),
            page: first.page,
            rects: group.map(rectForBox),
          };
        })
        .sort((a, b) => a.page - b.page || a.line - b.line);
    },

    pagesForFile(file) {
      const pages = new Set<number>();
      for (const box of bestMatchingBoxes(file)) pages.add(box.page);
      return [...pages].sort((a, b) => a - b);
    },
  };
}

async function decodeSyncTex(bytes: Uint8Array): Promise<string> {
  const gzip = bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
  if (!gzip) return new TextDecoder().decode(bytes);
  if (typeof DecompressionStream === 'undefined') {
    throw new Error('This browser cannot decompress SyncTeX data');
  }
  const compressed = new Blob([bytes.slice()]);
  const decompressed = compressed.stream().pipeThrough(new DecompressionStream('gzip'));
  return new Response(decompressed).text();
}

export function parseSyncTexText(text: string): SyncTexIndex {
  return buildIndex(text);
}

export async function parseSyncTex(bytes: Uint8Array): Promise<SyncTexIndex> {
  return buildIndex(await decodeSyncTex(bytes));
}
