import DOMPurify from 'dompurify';
import { compileGuided, PROCESS_LIMITS, validateProcess, type ProcessState } from './core';
import { fontBytes, pngToPdf } from '../shared/pdf';
import { assertPdfTextSupported } from '../shared/pdf-font';

export interface ProcessRender { svg: string; source: string; width: number; height: number }
const SVG_NS = 'http://www.w3.org/2000/svg';
const allowedTags = ['#text', 'svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'tspan', 'defs', 'marker', 'clippath', 'title', 'desc'];
const allowedAttributes = ['xmlns', 'id', 'class', 'style', 'viewBox', 'width', 'height', 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy', 'rx', 'ry', 'r', 'd', 'points', 'transform', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'opacity', 'text-anchor', 'dominant-baseline', 'alignment-baseline', 'dy', 'dx', 'font-size', 'font-weight', 'marker-start', 'marker-mid', 'marker-end', 'markerWidth', 'markerHeight', 'refX', 'refY', 'orient', 'markerUnits', 'clip-path', 'role', 'aria-labelledby', 'aria-describedby'];
const fixedStyles = `text,tspan{font-family:"Essential Tools Noto",sans-serif;font-size:14px;fill:#171717}.node .label text{text-anchor:middle}.node rect,.node circle,.node ellipse,.node polygon,.node path,rect.actor,rect.actor-top,rect.actor-bottom{fill:#f5f1ff;stroke:#6b46ff;stroke-width:1.5px}.edgePath path,.flowchart-link,.messageLine0,.messageLine1,.actor-line{stroke:#454545;stroke-width:1.5px;fill:none}.edgeLabel rect,.labelBkg{fill:#fff;opacity:1}.messageLine1,.actor-line{stroke-dasharray:4,4}.note{fill:#fff8df;stroke:#b69d45}.loopLine{stroke:#737373;fill:none}.loopText,.loopText tspan,.labelText,.labelText tspan,.messageText,.noteText,.noteText tspan{fill:#171717}.labelBox{fill:#f1f1f1;stroke:#737373}marker path{fill:#454545;stroke:#454545}`;
let fontPromise: Promise<Uint8Array> | undefined;
let serial: Promise<unknown> = Promise.resolve();
let renderIndex = 0;

function base64(bytes: Uint8Array): string {
  let binary = '';
  for (let offset = 0; offset < bytes.length; offset += 16_384) binary += String.fromCharCode(...bytes.subarray(offset, offset + 16_384));
  return btoa(binary);
}

async function diagramFont(): Promise<Uint8Array> {
  if (!fontPromise) fontPromise = (async () => {
    const bytes = await fontBytes();
    if (bytes.byteLength > 2_000_000) throw new Error('The diagram font is larger than expected. Please reload.');
    const font = new FontFace('Essential Tools Noto', bytes.slice().buffer);
    await font.load(); document.fonts.add(font); await document.fonts.ready;
    return bytes;
  })().catch(error => { fontPromise = undefined; throw error; });
  return fontPromise;
}

/** Incoming renderer CSS is discarded; only application-owned styling is added after sanitization. */
export function sanitizeSvg(svg: string): string {
  if (new TextEncoder().encode(svg).length > PROCESS_LIMITS.svgBytes) throw new Error('This diagram is too large to export. Split it into smaller processes.');
  const sanitized = DOMPurify.sanitize(svg, { ALLOWED_TAGS: allowedTags, ALLOWED_ATTR: allowedAttributes, ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: true, KEEP_CONTENT: false });
  const document = new DOMParser().parseFromString(sanitized, 'image/svg+xml');
  const root = document.documentElement;
  if (root.localName !== 'svg' || document.querySelector('parsererror')) throw new Error('The diagram did not produce a valid SVG.');
  if (root.querySelectorAll('*').length > 20_000) throw new Error('The diagram output is too complex. Split it into smaller processes.');
  for (const element of [root, ...root.querySelectorAll('*')]) {
    // Preserve the renderer's label alignment as a constrained SVG attribute. All
    // style text is still removed before this detached document is serialized.
    const anchor = element.getAttribute('style')?.match(/(?:^|;)\s*text-anchor\s*:\s*(start|middle|end)\s*(?:;|$)/i)?.[1];
    if (anchor) element.setAttribute('text-anchor', anchor.toLowerCase());
    for (const attribute of [...element.attributes]) {
      const name = attribute.name.toLowerCase(), value = attribute.value;
      if (name.startsWith('on') || name.includes('href') || name === 'style') { element.removeAttribute(attribute.name); continue; }
      if (['marker-start', 'marker-mid', 'marker-end', 'clip-path'].includes(name)) {
        if (!/^url\(#[A-Za-z][\w:.-]*\)$/.test(value)) element.removeAttribute(attribute.name);
      } else if (/url\s*\(|(?:javascript|data|https?|file):|[<>]/i.test(value) && name !== 'xmlns') element.removeAttribute(attribute.name);
      if (['fill', 'stroke'].includes(name) && !/^(?:none|transparent|currentColor|#[\da-f]{3,8}|[A-Za-z]{1,20}|rgba?\([\d.,%\s]+\))$/i.test(value)) element.removeAttribute(attribute.name);
      if (name === 'id' && !/^[A-Za-z][\w:.-]*$/.test(value)) element.removeAttribute(attribute.name);
    }
  }
  // Mermaid strict SVG labels can retain numeric entities as literal text. Decode exactly
  // one layer into text nodes after sanitization; never interpret the result as markup.
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
  while (walker.nextNode()) {
    const node = walker.currentNode;
    node.textContent = (node.textContent ?? '').replace(/&#(\d{1,7});/g, (entity, digits: string) => {
      const code = Number(digits);
      return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : entity;
    });
  }
  root.setAttribute('xmlns', SVG_NS);
  const style = document.createElementNS(SVG_NS, 'style'); style.textContent = fixedStyles; root.prepend(style);
  return new XMLSerializer().serializeToString(root);
}

export async function renderProcess(state: ProcessState, options: { id?: string; container?: HTMLElement } = {}): Promise<ProcessRender> {
  const checked = validateProcess(state);
  if (!checked.ok) throw new Error(checked.errors.map(issue => issue.message).join(' '));
  const source = checked.value.mode === 'guided' ? compileGuided(checked.value.graph) : checked.value.source;
  // Mermaid maintains shared renderer state. Serialize calls, then let callers reject outdated revisions.
  const operation = serial.then(async () => {
    const font = await diagramFont();
    const mermaid = (await import('mermaid')).default;
    mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', htmlLabels: false, suppressErrorRendering: true, maxTextSize: PROCESS_LIMITS.sourceBytes, maxEdges: PROCESS_LIMITS.edges, theme: 'base', fontFamily: 'Essential Tools Noto', fontSize: 14, deterministicIds: true, deterministicIDSeed: 'essential-tools-v1', flowchart: { useMaxWidth: false, htmlLabels: false, curve: 'linear', padding: 16 }, sequence: { useMaxWidth: false, wrap: true }, themeVariables: { primaryColor: '#f5f1ff', primaryBorderColor: '#6b46ff', primaryTextColor: '#171717', lineColor: '#454545', fontFamily: 'Essential Tools Noto', fontSize: '14px' }, secure: ['secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges', 'suppressErrorRendering', 'htmlLabels', 'theme', 'themeVariables', 'themeCSS', 'fontFamily', 'flowchart', 'sequence'] });
    const parsed = await mermaid.parse(source, { suppressErrors: true });
    if (!parsed) throw new Error('The diagram has a Mermaid syntax error. Check the highlighted source guide and try again.');
    const labelTexts = checked.value.mode === 'guided'
      ? [...checked.value.graph.nodes.map(node => node.label), ...checked.value.graph.edges.map(edge => edge.label)]
      : [source.replace(/#(\d{1,7});/g, (entity, digits: string) => { const code = Number(digits); return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : entity; })];
    await assertPdfTextSupported(labelTexts, font);
    const container = document.createElement('div');
    container.style.cssText = 'position:fixed;left:-20000px;top:0;width:1600px;visibility:hidden;pointer-events:none';
    container.setAttribute('aria-hidden', 'true'); (options.container ?? document.body).append(container);
    try {
      const id = /^etDiagram[A-Za-z0-9_]*$/.test(options.id ?? '') ? options.id! : `etDiagram${++renderIndex}`;
      const rendered = await mermaid.render(id, source, container);
      const safe = sanitizeSvg(rendered.svg);
      const svgDocument = new DOMParser().parseFromString(safe, 'image/svg+xml');
      const svg = svgDocument.documentElement;
      const bounds = (svg.getAttribute('viewBox') ?? '').trim().split(/[ ,]+/).map(Number);
      if (bounds.length !== 4 || bounds.some(value => !Number.isFinite(value)) || bounds[2] <= 0 || bounds[3] <= 0 || bounds[2] > 20_000 || bounds[3] > 20_000) throw new Error('The diagram is too wide or tall. Try another direction or split it into smaller diagrams.');
      svg.setAttribute('width', String(Math.ceil(bounds[2]))); svg.setAttribute('height', String(Math.ceil(bounds[3])));
      const style = svg.querySelector('style')!;
      // This asset is bundled and licensed by the application, never imported from user source.
      style.textContent = `@font-face{font-family:"Essential Tools Noto";src:url(data:font/ttf;base64,${base64(font)}) format("truetype")}\n${fixedStyles}`;
      const complete = new XMLSerializer().serializeToString(svg);
      if (new TextEncoder().encode(complete).length > PROCESS_LIMITS.svgBytes) throw new Error('The portable diagram exceeds the 5 MB output limit.');
      return { svg: complete, source, width: Math.ceil(bounds[2]), height: Math.ceil(bounds[3]) };
    } finally { container.remove(); }
  });
  serial = operation.catch(() => undefined);
  return operation;
}

export async function svgToPng(svg: string, scale = 2): Promise<Uint8Array> {
  const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement;
  const bounds = (root.getAttribute('viewBox') ?? '').split(/[ ,]+/).map(Number);
  if (root.localName !== 'svg' || bounds.length !== 4 || bounds.some(value => !Number.isFinite(value)) || bounds[2] <= 0 || bounds[3] <= 0) throw new Error('The diagram cannot be converted to an image.');
  const ratio = Math.min(Math.max(1, scale), 4096 / Math.max(bounds[2], bounds[3]), Math.sqrt(12_000_000 / (bounds[2] * bounds[3])));
  if (ratio < 0.5) throw new Error('This diagram is too large for a readable PNG/PDF. Export SVG or split the diagram.');
  const canvas = document.createElement('canvas'); canvas.width = Math.ceil(bounds[2] * ratio); canvas.height = Math.ceil(bounds[3] * ratio);
  const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
  try {
    const image = new Image();
    await new Promise<void>((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error('The diagram could not be converted to an image. Try SVG export.')); image.src = url; });
    const context = canvas.getContext('2d'); if (!context) throw new Error('Your browser could not create an image canvas.');
    context.fillStyle = '#ffffff'; context.fillRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height);
    const blob = await new Promise<Blob>((resolve, reject) => canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('The PNG export failed. Try SVG export.')), 'image/png'));
    return new Uint8Array(await blob.arrayBuffer());
  } finally { URL.revokeObjectURL(url); canvas.width = 0; canvas.height = 0; }
}

export async function processPdf(rendered: ProcessRender): Promise<Uint8Array> {
  const png = await svgToPng(rendered.svg);
  return pngToPdf(png, rendered.width, rendered.height);
}
