import { HTMLGenerator } from '@pdfdraft/core/lib/html';
import { isStickyExportElement } from '@pdfdraft/core/lib/stickyExportAttr';
import { deferExecution, getDimensions } from '@pdfdraft/core/lib/utils';
import type { Page } from '@pdfdraft/core/stores/editor.store.types';
import type { Font } from '@pdfdraft/core/stores/font.store.types';
import jsPDF from '@pdfdraft/document-tools';
import snapshot from '@pdfdraft/snapshot-kit';
import { PRODUCT_THUMB_PLACEHOLDER_DATA_URL } from '../../constants/product-thumb-placeholder';
import { registerHtmlSerializers } from './registerHtmlSerializers';

type MergeTags = Record<string, unknown>;

interface DesignContent {
	pages: Record<string, Page>;
	pagesOrder: string[];
	settings: {
		name?: string;
		layout: {
			height: number;
			width: number;
			orientation: 'portrait' | 'landscape';
			unit: 'px' | 'in' | 'cm';
		};
	};
	fonts?: [string, Font][];
}

interface ExportResult {
	blob: Blob;
	filename: string;
}

/** Snapshot scale factor — higher = sharper PDF, larger file size. */
const SNAPSHOT_SCALE = 2;

/** JPEG quality for PDF image encoding (0–1). 0.85 is visually lossless for text/table invoices. */
const JPEG_QUALITY = 0.85;

/**
 * Continuation slices: margin from the page top to the product table after header blocks
 * are hidden (extra space above the table on page 2+).
 */
const PRODUCT_TABLE_CONTINUATION_TOP_PX = 40;

/**
 * When measuring how many line items fit per PDF slice, require this much clear space
 * between the table bottom and the page bottom so rows are not flush to the edge.
 */
const PRODUCT_TABLE_SLICE_BOTTOM_PADDING_PX = 32;

const PRODUCT_TABLE_FIRST_SLICE_BOTTOM_PADDING_PX = 35;

/** Avoid a tail PDF page when overflow is only a few pixels (rounding / subpixel). */
const TAIL_PAGE_OVERFLOW_TOLERANCE_PX = 10;

/** Ignore html2canvas chunk slivers smaller than this (prevents extra near-blank PDF pages). */
const MIN_CANVAS_CHUNK_HEIGHT_PX = 2;

/** Skip tail render if the snapshot is effectively empty (prevents blank extra pages). */
const MIN_TAIL_PAGE_CANVAS_HEIGHT_PX = 48;

function getInvoiceSnapshotOptions(layoutWidthPx: number, layoutHeightPx: number) {
	return {
		scale: SNAPSHOT_SCALE,
		logging: process.env.NODE_ENV === 'development',
		// Match the hidden iframe size so html2canvas does not use the parent window width
		// (which would letterbox the design with empty space on the side).
		windowWidth: layoutWidthPx,
		windowHeight: layoutHeightPx,
	};
}

function exportDebugEnabled(): boolean {
	try {
		// Allow forcing debug in production by setting window flag.
		const w = window as any;
		const cfg = w?.__PDFDRAFT_PDF_INVOICES_FOR_WOOCOMMERCE_INVOICE_DOWNLOAD__;
		return (
			process.env.NODE_ENV === 'development' ||
			cfg?.debug === true ||
			w?.__PDFDRAFT_PDF_INVOICES_FOR_WOOCOMMERCE_EXPORT_DEBUG__ === true
		);
	} catch {
		return process.env.NODE_ENV === 'development';
	}
}

function exportLog(...args: any[]): void {
	if (!exportDebugEnabled()) return;
	// eslint-disable-next-line no-console
	console.log('[pdfdraft-pdf-invoices-for-woocommerce:export]', ...args);
}

export async function exportInvoicePdfFromSlug(
	designSlug: string,
	mergeTags: MergeTags,
	orderId: number,
	nonce: string,
	designEndpoint?: string,
): Promise<ExportResult> {
	try {
		const w = window as any;
		const cfg = w?.__PDFDRAFT_PDF_INVOICES_FOR_WOOCOMMERCE_INVOICE_DOWNLOAD__;
		if (cfg?.debug === true || w?.__PDFDRAFT_PDF_INVOICES_FOR_WOOCOMMERCE_EXPORT_DEBUG__ === true) {
			// eslint-disable-next-line no-console
			console.log('[pdfdraft-pdf-invoices-for-woocommerce:export] start', {
				orderId,
				designSlug,
				designEndpoint: Boolean(designEndpoint),
				debugEnabled: exportDebugEnabled(),
				hasConfig: Boolean(cfg),
			});
		}
	} catch {
		// ignore
	}
	if (!designSlug) {
		throw new Error('No active invoice design configured');
	}

	const fetchUrl = designEndpoint
		?? `/wp-json/pdfdraft/v1/pdf-designs/${encodeURIComponent(designSlug)}?context=edit`;

	const res = await fetch(fetchUrl, {
		credentials: 'include',
		headers: { 'X-WP-Nonce': nonce },
	});

	if (!res.ok) {
		throw new Error(`Failed to fetch invoice design: ${res.status}`);
	}

	const design = await res.json();
	const content = design.content as DesignContent;
	const { pages, pagesOrder, settings } = content;

	if (!pages || !pagesOrder?.length) {
		throw new Error('Design has no pages');
	}

	registerHtmlSerializers();

	const fontsMap = new Map<string, Font>(content.fonts ?? []);
	if (!fontsMap.has('inter')) {
		fontsMap.set('inter', {
			id: 'inter',
			family: 'Inter',
			variants: [300, 400, 500, 600, 700],
			subsets: ['latin'],
			category: 'sans-serif',
			version: '',
			lastModified: '',
			popularity: 0,
			defSubset: 'latin',
			defVariant: '400',
			url: '',
		});
	}

	const pdf = new jsPDF({
		orientation: settings.layout.orientation,
		unit: settings.layout.unit,
		format: [settings.layout.width, settings.layout.height],
	});

	const htmlGenerator = new HTMLGenerator();
	const dimensions = getDimensions(settings.layout);
	const dimensionsPx = getDimensions(settings.layout, true);

	const baseStyles = getBaseStyles();
	const fontStyles = getFontLinkTags(Array.from(fontsMap.values()));
	const styles = fontStyles ? baseStyles + fontStyles : baseStyles;

	let isFirst = true;

	for (const pageId of pagesOrder) {
		const page = pages[pageId];
		if (!page) continue;

		await deferExecution();

		const rawPageHtml = htmlGenerator.generatePageHtml(page);
		exportLog('page', { pageId });

		const orderItems = Array.isArray((mergeTags as any).order_items)
			? ((mergeTags as any).order_items as Array<Record<string, string>>)
			: [];

		const productTableDesignedBounds = extractProductTableDesignedBounds(rawPageHtml);
		exportLog('product-table designedBounds', productTableDesignedBounds);

		// Determine slice size by fit (handles tall rows, thumbnails, long meta, etc.).
		const sliceSize = await measureProductTableSliceSizeByFit({
			rawPageHtml,
			mergeTags,
			orderItems,
			settings,
			styles,
			dimensions,
			pageHeightPx: dimensionsPx.height,
			designedBounds: productTableDesignedBounds,
			htmlGenerator,
		});
		exportLog('sliceSize', { sliceSize, orderItems: orderItems.length });

		const slices =
			sliceSize && orderItems.length > sliceSize
				? Math.ceil(orderItems.length / sliceSize)
				: 1;
		exportLog('slices', { slices });

		const wrapper = htmlGenerator.generateWrapper(settings);

		for (let sliceIndex = 0; sliceIndex < slices; sliceIndex++) {
			const isFirstSlice = sliceIndex === 0;
			const isLastSlice = sliceIndex === slices - 1;
			exportLog('slice begin', { sliceIndex, isFirstSlice, isLastSlice });

			const orderItemsOverride =
				slices > 1 && sliceSize
					? orderItems.slice(
							sliceIndex * sliceSize,
							Math.min(orderItems.length, (sliceIndex + 1) * sliceSize),
						)
					: undefined;
			exportLog('slice items', {
				count: orderItemsOverride?.length ?? orderItems.length,
				from: orderItemsOverride ? sliceIndex * (sliceSize ?? 0) : 0,
			});

			let pageHtml = applyMergeTags(rawPageHtml, mergeTags, {
				orderItemsOverride,
			});
			pageHtml = sanitizeProductTableForExport(pageHtml);

			const wrappedHtml = wrapper.replace('{{HTML}}', pageHtml);
			const fullHTML = buildFullHTML(
				styles,
				wrappedHtml,
				dimensionsPx.width,
				dimensionsPx.height,
			);

			const iframe = createHiddenIframe(dimensions);
			document.body.appendChild(iframe);

			try {
				const doc = await loadDocumentInIframe(iframe, fullHTML);

				const layout = adjustProductTableLayout(doc, {
					isFirstSlice,
					isLastSlice,
					designedBounds: productTableDesignedBounds,
					pageHeightPx: dimensionsPx.height,
				});
				exportLog('layout', layout);

				const pageRoot =
					(doc.body.firstElementChild as HTMLElement | null) ?? doc.body;
				const canvas = await snapshot(
					pageRoot,
					getInvoiceSnapshotOptions(dimensionsPx.width, dimensionsPx.height),
				);
				exportLog('snapshot', {
					canvasHeight: canvas.height,
					canvasWidth: canvas.width,
					pageHeightPx: dimensionsPx.height,
				});

				const pdfWidth = pdf.internal.pageSize.getWidth();
				const pageHeightCanvas = Math.round(dimensionsPx.height * SNAPSHOT_SCALE);

				if (canvas.height <= pageHeightCanvas) {
					const image = canvas.toDataURL('image/jpeg', JPEG_QUALITY);
					const imageProps = pdf.getImageProperties(image);
					const pdfHeight = (imageProps.height * pdfWidth) / imageProps.width;
					if (!isFirst) pdf.addPage();
					else isFirst = false;
					pdf.addImage(image, 'JPEG', 0, 0, pdfWidth, pdfHeight, undefined, 'FAST');
				} else {
					// Canvas is taller than one page — split into chunks.
					const chunks = Math.ceil(canvas.height / pageHeightCanvas);
					for (let chunk = 0; chunk < chunks; chunk++) {
						const chunkHeight = Math.min(
							pageHeightCanvas,
							canvas.height - chunk * pageHeightCanvas,
						);
						if (chunkHeight < MIN_CANVAS_CHUNK_HEIGHT_PX) {
							exportLog('skip tiny chunk', { chunk, chunkHeight });
							continue;
						}
						const chunkCanvas = document.createElement('canvas');
						chunkCanvas.width = canvas.width;
						chunkCanvas.height = chunkHeight;
						const ctx = chunkCanvas.getContext('2d')!;
						ctx.drawImage(canvas, 0, -chunk * pageHeightCanvas);
						const image = chunkCanvas.toDataURL('image/jpeg', JPEG_QUALITY);
						const pdfHeight = (chunkHeight * pdfWidth) / canvas.width;
						if (pdfHeight < 0.02) {
							exportLog('skip tiny pdf slice', { chunk, pdfHeight });
							continue;
						}
						if (!isFirst) pdf.addPage();
						else isFirst = false;
						pdf.addImage(image, 'JPEG', 0, 0, pdfWidth, pdfHeight, undefined, 'FAST');
					}
				}

				// If "below table" content didn't fit on last slice, render a tail page.
				if (layout?.needsTailPage) {
					exportLog('tail page', { sliceIndex });
					const tailIframe = createHiddenIframe(dimensions);
					document.body.appendChild(tailIframe);
					try {
						const tailDoc = await loadDocumentInIframe(tailIframe, fullHTML);
						const tailPageWrapper =
							tailDoc.body.firstElementChild as HTMLElement | null;
						if (!tailPageWrapper) {
							// no-op
						} else {
							const productTable = tailDoc.querySelector<HTMLElement>(
								'[data-pdfdraft-product-table="1"]',
							);
							if (productTable) productTable.style.display = 'none';

							// Hide everything not marked as below-table.
							Array.from(
								tailPageWrapper.querySelectorAll<HTMLElement>('[id]'),
							).forEach((el) => {
								const topLevel =
									el.parentElement === tailPageWrapper
										? el
										: (el.closest(':scope > [id]') as HTMLElement | null) ?? el;
								const isBelow =
									topLevel.getAttribute('data-pdfdraft-below-table') === '1';
								topLevel.style.display = isBelow ? '' : 'none';
							});

							// Shift below-table elements up to start near top.
							const belowEls = Array.from(
								tailPageWrapper.querySelectorAll<HTMLElement>(
									':scope > [id][data-pdfdraft-below-table="1"]',
								),
							);
							if (belowEls.length) {
								const rect0 = tailPageWrapper.getBoundingClientRect();
								const tops = belowEls
									.map((el) => el.getBoundingClientRect().top - rect0.top)
									.filter((n) => Number.isFinite(n));
								const minTop = tops.length ? Math.min(...tops) : 0;
								const shiftUp = Math.max(0, minTop - 20);
								for (const el of belowEls) {
									const current = el.style.transform || '';
									el.style.transform =
										`${current} translate(0px, -${shiftUp}px)`.trim();
								}
							}

							const tailRoot =
								(tailDoc.body.firstElementChild as HTMLElement | null) ??
								tailDoc.body;
							const tailCanvas = await snapshot(
								tailRoot,
								getInvoiceSnapshotOptions(
									dimensionsPx.width,
									dimensionsPx.height,
								),
							);
							if (tailCanvas.height < MIN_TAIL_PAGE_CANVAS_HEIGHT_PX) {
								exportLog('skip tail page (canvas too small)', {
									h: tailCanvas.height,
								});
							} else {
								const tailImage = tailCanvas.toDataURL('image/jpeg', JPEG_QUALITY);
								const tailProps = pdf.getImageProperties(tailImage);
								const tailPdfHeight =
									(tailProps.height * pdfWidth) / tailProps.width;
								if (tailPdfHeight < 0.02) {
									exportLog('skip tail page (pdf height tiny)', {
										tailPdfHeight,
									});
								} else {
									if (!isFirst) pdf.addPage();
									else isFirst = false;
									pdf.addImage(
										tailImage,
										'JPEG',
										0,
										0,
										pdfWidth,
										tailPdfHeight,
										undefined,
										'FAST',
									);
								}
							}
						}
					} finally {
						tailIframe.parentNode?.removeChild(tailIframe);
					}
				}
			} finally {
				iframe.parentNode?.removeChild(iframe);
			}
		}
	}

	const filename = `Invoice_${orderId}.pdf`;
	const blob = pdf.output('blob');
	return { blob, filename };
}

function applyMergeTags(
	html: string,
	mergeTags: MergeTags,
	options?: {
		orderItemsOverride?: Array<Record<string, string>>;
	},
): string {
	// 1) Order-level tag replacements.
	// Resolve directly from mergeTags at replacement-time so adjacent tags
	// like {{grand_total}}{{currency}} are always handled independently.
	html = html.replace(/\{\{([^}{}]+)\}\}/g, (match, rawKey: string) => {
		const key = rawKey.replace(/\u200B/g, '').trim();
		if (!(key in mergeTags)) return match;
		const value = mergeTags[key];
		if (
			typeof value === 'string' ||
			typeof value === 'number' ||
			typeof value === 'boolean'
		) {
			return String(value);
		}
		return match;
	});

	const orderItemsCandidate =
		options?.orderItemsOverride ?? (mergeTags as any).order_items;
	if (Array.isArray(orderItemsCandidate)) {
		const items = orderItemsCandidate as Array<Record<string, string>>;

		// 2. Expand template rows inside <tr> elements (one <tr> per item)
		html = expandOrderItemRows(html, items);

		// 3. Replace any remaining {{item.*}} tags outside of tables
		//    (e.g. in standalone woocommerce div elements)
		//    Single item  → plain value
		//    Multi-item   → values joined by <br>
		html = html.replace(/\{\{item\.([^}\s]+)\s*\}\}/g, (_, field: string) => {
			return items
				.map((item) => getItemValueByField(item, field))
				.filter(Boolean)
				.join('<br>');
		});
	}

	return applyTotalBlockVisibility(html);
}

/**
 * For the Total Block element:
 * - Subtotal / Discount / Tax: visibility follows toggle override
 * - Total: always show
 */
function applyTotalBlockVisibility(html: string): string {
	try {
		const doc = new DOMParser().parseFromString(
			`<div id="pdfdraft-export-root">${html}</div>`,
			'text/html',
		);
		const root = doc.getElementById('pdfdraft-export-root');
		if (!root) {
			return html;
		}

		root.querySelectorAll('tr[data-pdfdraft-total-row]').forEach((node) => {
			const row = node as HTMLElement;
			const key = row.getAttribute('data-pdfdraft-total-row') ?? '';
			if (key === 'grand_total') {
				return;
			}

			const enabled = row.getAttribute('data-enabled') !== '0';
			if (!enabled) {
				row.remove();
			}
		});

		return root.innerHTML;
	} catch {
		return html;
	}
}

/**
 * Finds every <tr> that contains at least one {{item.*}} tag and replaces it
 * with one <tr> per order item, substituting each item field.
 * Rows without any {{item.*}} tags (header/footer rows) are left untouched.
 */
function expandOrderItemRows(
	html: string,
	items: Array<Record<string, string>>,
): string {
	return html.replace(
		/(<tr(?:\s[^>]*)?>)([\s\S]*?)<\/tr>/gi,
		(fullRow, openTag: string, rowContent: string) => {
			if (!/\{\{\s*item\.[^}]+\}\}/.test(rowContent)) return fullRow;
			return items
				.map((item) => {
					const filled = rowContent.replace(
						/\{\{\s*item\.([^}\s]+)\s*\}\}/g,
						(_, field: string) => getItemValueByField(item, field),
					);
					return `${openTag}${filled}</tr>`;
				})
				.join('');
		},
	);
}

function getItemValueByField(item: Record<string, string>, field: string): string {
	if (field === 'product_image') {
		const raw = String(item.product_image ?? '').trim();
		if (!raw) {
			// Used by Product Table when Thumbnail column is enabled but Woo image is missing.
			return `<img src="${PRODUCT_THUMB_PLACEHOLDER_DATA_URL}" alt="" style="width:100%;height:100%;object-fit:cover;display:block;" />`;
		}
		return raw;
	}

	if (field === 'product_display_name') {
		const base = String(item.product_name ?? '').trim();
		const metaRaw = String(item.product_meta ?? item.variation_data ?? '').trim();
		if (!metaRaw) return base;

		// Convert common <br> separators to a readable inline comma format.
		const metaNormalized = metaRaw
			.replace(/<br\s*\/?>/gi, ', ')
			.replace(/&nbsp;/gi, ' ')
			.trim();

		return metaNormalized ? `${base} - ${metaNormalized}` : base;
	}

	return String(item[field] ?? '');
}

function extractProductTableMaxRows(html: string): number | undefined {
	const regex =
		/<div\b(?=[^>]*data-pdfdraft-product-table=["']1["'])(?=[^>]*data-max-rows=["'](\d+)["'])[^>]*>/gi;
	const values: number[] = [];
	let match: RegExpExecArray | null = null;
	while ((match = regex.exec(html)) !== null) {
		const n = Number(match[1]);
		if (Number.isFinite(n) && n > 0) values.push(n);
	}
	if (!values.length) return undefined;
	return Math.max(...values);
}

function extractProductTableMetrics(
	html: string,
): { rowHeightPx: number; headerHeightPx: number } | undefined {
	const tagMatch = /<div\b[^>]*data-pdfdraft-product-table=["']1["'][^>]*>/i.exec(html);
	if (!tagMatch?.[0]) return undefined;
	const tag = tagMatch[0];
	const rowMatch = /\bdata-row-height-px=["']([0-9.]+)["']/i.exec(tag);
	const headerMatch = /\bdata-header-height-px=["']([0-9.]+)["']/i.exec(tag);
	const rowHeightPx = rowMatch?.[1] ? parseFloat(rowMatch[1]) : NaN;
	const headerHeightPx = headerMatch?.[1] ? parseFloat(headerMatch[1]) : NaN;
	if (!Number.isFinite(rowHeightPx) || rowHeightPx <= 0) return undefined;
	if (!Number.isFinite(headerHeightPx) || headerHeightPx <= 0) return undefined;
	return { rowHeightPx, headerHeightPx };
}

function computeProductTableRowsPerPage(opts: {
	topPx: number;
	pageHeightPx: number;
	headerHeightPx: number;
	rowHeightPx: number;
}): number {
	const { topPx, pageHeightPx, headerHeightPx, rowHeightPx } = opts;
	const available = Math.max(
		0,
		pageHeightPx - topPx - headerHeightPx - PRODUCT_TABLE_FIRST_SLICE_BOTTOM_PADDING_PX,
	);
	const rows = Math.floor(available / rowHeightPx);
	return Math.max(1, rows);
}

async function measureProductTableRowsPerPage(opts: {
	rawPageHtml: string;
	mergeTags: MergeTags;
	orderItems: Array<Record<string, string>>;
	settings: DesignContent['settings'];
	styles: string;
	dimensions: { width: string; height: string };
	designedBounds: { top: number; height: number } | undefined;
	htmlGenerator: HTMLGenerator;
}): Promise<number | undefined> {
	const {
		rawPageHtml,
		mergeTags,
		orderItems,
		settings,
		styles,
		dimensions,
		designedBounds,
		htmlGenerator,
	} = opts;
	if (!designedBounds) return undefined;
	if (!orderItems.length) return undefined;

	// Render a probe document with ALL rows, then count how many fully fit on the page.
	const wrapper = htmlGenerator.generateWrapper(settings as any);
	let pageHtml = applyMergeTags(rawPageHtml, mergeTags);
	pageHtml = sanitizeProductTableForExport(pageHtml);
	const wrappedHtml = wrapper.replace('{{HTML}}', pageHtml);
	const layoutPx = getDimensions(settings.layout, true);
	const fullHTML = buildFullHTML(
		styles,
		wrappedHtml,
		layoutPx.width,
		layoutPx.height,
	);

	const iframe = createHiddenIframe(dimensions);
	document.body.appendChild(iframe);
	try {
		const doc = await loadDocumentInIframe(iframe, fullHTML);
		const pageWrapper = doc.body.firstElementChild as HTMLElement | null;
		const productTable = doc.querySelector<HTMLElement>('[data-pdfdraft-product-table="1"]');
		const table = productTable?.querySelector<HTMLTableElement>('table');
		const tbody = table?.querySelector('tbody');
		if (!pageWrapper || !productTable || !table || !tbody) return undefined;

		// Ensure measurement isn't affected by clipping.
		pageWrapper.style.overflow = 'visible';
		productTable.style.overflow = 'visible';

		const pageRect = pageWrapper.getBoundingClientRect();
		const maxBottom =
			pageRect.top +
			getDimensions(settings.layout, true).height -
			PRODUCT_TABLE_FIRST_SLICE_BOTTOM_PADDING_PX;

		const rows = Array.from(tbody.querySelectorAll('tr'));
		if (!rows.length) return undefined;

		let fit = 0;
		for (const row of rows) {
			const r = row.getBoundingClientRect();
			if (r.bottom <= maxBottom) {
				fit++;
				continue;
			}
			break;
		}

		return Math.max(1, fit);
	} catch {
		return undefined;
	} finally {
		iframe.parentNode?.removeChild(iframe);
	}
}


function extractProductTableDesignedBounds(
	html: string,
): { top: number; height: number } | undefined {
	const tagMatch = /<div\b[^>]*data-pdfdraft-product-table=["']1["'][^>]*/i.exec(html);
	if (!tagMatch) return undefined;

	const styleMatch = /\bstyle=["']([^"']*)["']/i.exec(tagMatch[0]);
	const style = styleMatch?.[1];
	if (!style) return undefined;

	const topMatch = /(?:^|;)\s*top\s*:\s*([0-9.]+)px/i.exec(style);
	const heightMatch = /(?:^|;)\s*height\s*:\s*([0-9.]+)px/i.exec(style);
	let topPx: number | undefined = topMatch?.[1] ? parseFloat(topMatch[1]) : undefined;

	// Many PDFDraft elements are positioned via transform translate(...) rather than top/left.
	// Example: transform: translate(120px, 340px) rotate(0deg) ...
	if (topPx == null || !Number.isFinite(topPx)) {
		const translateMatch =
			/\btransform\s*:\s*[^;]*translate\(\s*[-0-9.]+px\s*,\s*([-0-9.]+)px\s*\)/i.exec(style) ||
			/\btransform\s*:\s*[^;]*translateY\(\s*([-0-9.]+)px\s*\)/i.exec(style);
		if (translateMatch?.[1]) {
			const n = parseFloat(translateMatch[1]);
			if (Number.isFinite(n)) topPx = n;
		}
	}
	if (topPx == null || !Number.isFinite(topPx)) return undefined;

	return {
		top: topPx,
		height: heightMatch?.[1] ? parseFloat(heightMatch[1]) : 0,
	};
}

interface ProductTableLayoutOptions {
	isFirstSlice: boolean;
	isLastSlice: boolean;
	designedBounds: { top: number; height: number } | undefined;
	pageHeightPx: number;
}

function adjustProductTableLayout(
	doc: Document,
	{ isFirstSlice, isLastSlice, designedBounds, pageHeightPx }: ProductTableLayoutOptions,
): { needsTailPage: boolean } | undefined {
	if (!designedBounds) return;

	const productTable = doc.querySelector<HTMLElement>('[data-pdfdraft-product-table="1"]');
	if (!productTable) return;

	// Always use the body's first element child (the page wrapper) rather than
	// productTable.parentElement — the table could be inside a group, which would
	// give us the wrong parent and miss all other page-level elements.
	const pageWrapper = doc.body.firstElementChild as HTMLElement | null;
	if (!pageWrapper) return;

	const designedTableBottom = designedBounds.top + designedBounds.height;
	const pageRect = pageWrapper.getBoundingClientRect();

	const getTopLevel = (node: HTMLElement): HTMLElement | null => {
		let cur: HTMLElement | null = node;
		while (cur && cur.parentElement && cur.parentElement !== pageWrapper) {
			cur = cur.parentElement as HTMLElement;
		}
		return cur && cur.parentElement === pageWrapper ? cur : null;
	};

	const shiftByPx = (el: HTMLElement, dy: number): void => {
		if (!Number.isFinite(dy) || dy === 0) return;
		const topStr = el.style.top;
		if (topStr) {
			const n = parseFloat(topStr);
			if (Number.isFinite(n)) {
				el.style.top = `${n + dy}px`;
				return;
			}
		}
		const current = el.style.transform || '';
		const m = current.match(/translate\(\s*([-\d.]+)px\s*,\s*([-\d.]+)px\s*\)/i);
		if (m?.[1] && m?.[2]) {
			const x = parseFloat(m[1]);
			const y = parseFloat(m[2]);
			const next = `translate(${x}px, ${y + dy}px)`;
			el.style.transform = current.replace(m[0], next);
			return;
		}
		const my = current.match(/translateY\(\s*([-\d.]+)px\s*\)/i);
		if (my?.[1]) {
			const y = parseFloat(my[1]);
			if (Number.isFinite(y)) {
				const next = `translateY(${y + dy}px)`;
				el.style.transform = current.replace(my[0], next);
				return;
			}
		}
		el.style.transform = `${current} translate(0px, ${dy}px)`.trim();
	};

	// Select all direct page-level elements by their `id` attribute.
	// Every design element has id="${data.id}" regardless of type.
	// Background overlay divs (no id) are naturally excluded.
	// This catches dividers, dates, shapes, text, total block, etc. — even
	// those that don't carry a data-type attribute.
	const pageElements = Array.from(pageWrapper.querySelectorAll<HTMLElement>('[id]'));
	const aboveElements: HTMLElement[] = [];
	/** Below or overlapping the table area: keep each element’s authored offset from the design table bottom. */
	const belowTableItems: {
		topLevel: HTMLElement;
		gapFromDesignedTableBottomPx: number;
	}[] = [];
	const seen = new Set<HTMLElement>();

	for (const el of pageElements) {
		if (el === productTable) continue;
		// Ignore nodes inside the product table itself.
		if (productTable.contains(el)) continue;

		const topLevel = getTopLevel(el);
		if (!topLevel) continue;
		if (topLevel === productTable) continue;
		if (seen.has(topLevel)) continue;
		seen.add(topLevel);

		if (isStickyExportElement(topLevel)) {
			continue;
		}

		// Elements can be positioned via top/left OR via transform translate.
		// Use rendered rects relative to the page wrapper for robustness.
		const rect = topLevel.getBoundingClientRect();
		const elTop = rect.top - pageRect.top;
		const elBottom = rect.bottom - pageRect.top;
		if (!Number.isFinite(elTop) || !Number.isFinite(elBottom)) continue;

		if (elBottom <= designedBounds.top) {
			aboveElements.push(topLevel);
		} else {
			// Distance from the design-time table bottom line to this element’s top (can be negative if overlapping).
			const gapFromDesignedTableBottomPx = elTop - designedTableBottom;
			belowTableItems.push({ topLevel, gapFromDesignedTableBottomPx });
		}
	}

	if (!isFirstSlice) {
		for (const el of aboveElements) {
			el.style.display = 'none';
		}
		// Without this, the table stays at the design Y while header blocks are hidden,
		// leaving a tall blank band and often creating spurious multi-chunk / blank PDF pages.
		const tableHost = getTopLevel(productTable) ?? productTable;
		const pageRectAfterHide = pageWrapper.getBoundingClientRect();
		const hostRect = tableHost.getBoundingClientRect();
		const tableTop = hostRect.top - pageRectAfterHide.top;
		const dy = PRODUCT_TABLE_CONTINUATION_TOP_PX - tableTop;
		if (Math.abs(dy) > 0.25) {
			shiftByPx(tableHost, dy);
			exportLog('continuation-shift-table', { dy, tableTop });
		}
	}

	if (!isLastSlice) {
		for (const { topLevel } of belowTableItems) {
			topLevel.style.display = 'none';
		}
		return { needsTailPage: false };
	}

	if (belowTableItems.length === 0) return { needsTailPage: false };

	// Re-measure after continuation shift so below-table math matches the rendered layout.
	const pageRectLaidOut = pageWrapper.getBoundingClientRect();

	const tableRect = productTable.getBoundingClientRect();
	const renderedTableBottom = tableRect.bottom - pageRectLaidOut.top;
	exportLog('reflow', {
		isFirstSlice,
		isLastSlice,
		designedTableBottom,
		renderedTableBottom,
	});

	// Place every below-table element so its offset from the *actual* table bottom matches
	// the design: gap from design table bottom → same gap from rendered table bottom.
	for (const { topLevel, gapFromDesignedTableBottomPx } of belowTableItems) {
		const rect = topLevel.getBoundingClientRect();
		const currentTop = rect.top - pageRectLaidOut.top;
		const desiredTop = renderedTableBottom + gapFromDesignedTableBottomPx;
		const dy = desiredTop - currentTop;
		if (Math.abs(dy) > 0.25) {
			topLevel.setAttribute('data-pdfdraft-below-table', '1');
			shiftByPx(topLevel, dy);
		}
	}

	const pageRectAfterBelow = pageWrapper.getBoundingClientRect();

	// Expand wrapper to fit the full product table plus all repositioned elements.
	const affectedMaxBottom = belowTableItems.reduce((max, { topLevel }) => {
		const r = topLevel.getBoundingClientRect();
		const bottom = r.bottom - pageRectAfterBelow.top;
		return Math.max(max, bottom);
	}, 0);

	const tableRectFinal = productTable.getBoundingClientRect();
	const renderedTableBottomFinal = tableRectFinal.bottom - pageRectAfterBelow.top;

	const totalContentBottom = Math.max(renderedTableBottomFinal, affectedMaxBottom);

	if (totalContentBottom > pageHeightPx + TAIL_PAGE_OVERFLOW_TOLERANCE_PX) {
		pageWrapper.style.height = `${totalContentBottom}px`;
		pageWrapper.style.overflow = 'visible';
		// Tell caller to render below-table content on another page.
		return { needsTailPage: true };
	}

	return { needsTailPage: false };
}

function sanitizeProductTableForExport(html: string): string {
	try {
		const doc = new DOMParser().parseFromString(
			`<div id="pdfdraft-export-root">${html}</div>`,
			'text/html',
		);
		const root = doc.getElementById('pdfdraft-export-root');
		if (!root) {
			return html;
		}
		root.querySelectorAll('[data-pdfdraft-product-table="1"]').forEach((el) => {
			const node = el as HTMLElement;
			node.style.overflow = 'visible';
			node.style.height = 'auto';
			node.style.minHeight = 'auto';
			node.style.maxHeight = 'none';
		});
		root.querySelectorAll('[data-pdfdraft-product-table="1"] table').forEach((el) => {
			const node = el as HTMLElement;
			node.style.height = 'auto';
		});
		// Allow real content-driven row height so pagination/reflow can measure correctly.
		root
			.querySelectorAll(
				'[data-pdfdraft-product-table="1"] tr, [data-pdfdraft-product-table="1"] td, [data-pdfdraft-product-table="1"] th',
			)
			.forEach((el) => {
				const node = el as HTMLElement;
				node.style.height = 'auto';
				node.style.maxHeight = 'none';
				node.style.overflow = 'visible';
			});
		return root.innerHTML;
	} catch {
		return html;
	}
}

async function measureProductTableSliceSizeByFit(opts: {
	rawPageHtml: string;
	mergeTags: MergeTags;
	orderItems: Array<Record<string, string>>;
	settings: DesignContent['settings'];
	styles: string;
	dimensions: { width: string; height: string };
	pageHeightPx: number;
	designedBounds: { top: number; height: number } | undefined;
	htmlGenerator: HTMLGenerator;
}): Promise<number | undefined> {
	const {
		rawPageHtml,
		mergeTags,
		orderItems,
		settings,
		styles,
		dimensions,
		pageHeightPx,
		designedBounds,
		htmlGenerator,
	} = opts;
	if (!designedBounds) return undefined;
	if (!orderItems.length) return undefined;

	const wrapper = htmlGenerator.generateWrapper(settings as any);
	const layoutPx = getDimensions(settings.layout, true);

	const fits = async (n: number): Promise<boolean> => {
		const override = orderItems.slice(0, n);
		let pageHtml = applyMergeTags(rawPageHtml, mergeTags, {
			orderItemsOverride: override,
		});
		pageHtml = sanitizeProductTableForExport(pageHtml);
		const wrappedHtml = wrapper.replace('{{HTML}}', pageHtml);
		const fullHTML = buildFullHTML(
			styles,
			wrappedHtml,
			layoutPx.width,
			layoutPx.height,
		);

		const iframe = createHiddenIframe(dimensions);
		document.body.appendChild(iframe);
		try {
			const doc = await loadDocumentInIframe(iframe, fullHTML);
			const pageWrapper = doc.body.firstElementChild as HTMLElement | null;
			const productTable = doc.querySelector<HTMLElement>('[data-pdfdraft-product-table="1"]');
			if (!pageWrapper || !productTable) return true;
			pageWrapper.style.overflow = 'hidden';
			productTable.style.overflow = 'visible';

			const rect0 = pageWrapper.getBoundingClientRect();
			const bottom = productTable.getBoundingClientRect().bottom - rect0.top;
			const ok =
				bottom <= pageHeightPx - PRODUCT_TABLE_FIRST_SLICE_BOTTOM_PADDING_PX;
			exportLog('fit?', {
				n,
				bottom,
				pageHeightPx,
				bottomPadding: PRODUCT_TABLE_FIRST_SLICE_BOTTOM_PADDING_PX,
				ok,
			});
			return ok;
		} catch {
			return true;
		} finally {
			iframe.parentNode?.removeChild(iframe);
		}
	};

	let lo = 1;
	let hi = orderItems.length;
	let best = 1;

	while (lo <= hi) {
		const mid = Math.floor((lo + hi) / 2);
		// eslint-disable-next-line no-await-in-loop
		if (await fits(mid)) {
			best = mid;
			lo = mid + 1;
		} else {
			hi = mid - 1;
		}
	}

	return Math.max(1, best);
}

function createHiddenIframe(dimensions: {
	width: string;
	height: string;
}): HTMLIFrameElement {
	const iframe = document.createElement('iframe');
	Object.assign(iframe.style, {
		position: 'fixed',
		top: '0px',
		left: '0px',
		width: dimensions.width,
		height: dimensions.height,
		zIndex: '-9999',
		visibility: 'hidden',
		border: 'none',
		pointerEvents: 'none',
	});
	return iframe;
}

function loadDocumentInIframe(
	iframe: HTMLIFrameElement,
	fullHTML: string,
): Promise<Document> {
	return new Promise((resolve, reject) => {
		const minifiedHTML = fullHTML.replace(/\s+/g, ' ').trim();

		const handleLoad = async () => {
			iframe.onload = null;
			const doc = iframe.contentDocument;
			if (!doc) {
				reject(new Error('Failed to load document in iframe'));
				return;
			}
			await waitForFonts(doc);

			resolve(doc);
		};

		iframe.onload = handleLoad;
		iframe.srcdoc = minifiedHTML;
	});
}

async function waitForFonts(doc: Document): Promise<void> {
	const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'));
	if (links.length > 0) {
		await Promise.race([
			Promise.all(
				links.map(
					(link) =>
						new Promise<void>((resolve) => {
							if ((link as HTMLLinkElement).sheet) {
								resolve();
								return;
							}
							link.addEventListener('load', () => resolve(), { once: true });
							link.addEventListener('error', () => resolve(), { once: true });
						}),
				),
			),
			new Promise<void>((r) => setTimeout(r, 8000)),
		]);
	}

	try {
		await Promise.race([
			doc.fonts.ready,
			new Promise<void>((r) => setTimeout(r, 5000)),
		]);
	} catch {
		// continue with fallback fonts
	}

	await new Promise<void>((r) => setTimeout(r, 200));
}

function buildFullHTML(
	styles: string,
	pageHtml: string,
	layoutWidthPx: number,
	layoutHeightPx: number,
): string {
	return `
		<html>
			<head>
				<meta charset="utf-8">
				<meta name="viewport" content="width=${layoutWidthPx}, initial-scale=1, maximum-scale=1, user-scalable=no">
				${styles}
				<style>
					html{box-sizing:border-box;width:${layoutWidthPx}px;margin:0;padding:0;overflow-x:hidden;}
					body{box-sizing:border-box;margin:0;width:100%;min-height:${layoutHeightPx}px;overflow-x:hidden;}
				</style>
			</head>
			<body>
				${pageHtml}
			</body>
		</html>
	`;
}

function getFontLinkTags(fonts: Font[]): string {
	if (!fonts.length) return '';

	const families = fonts.map((font) => {
		const family = font.family.replace(/ /g, '+');
		const weights = font.variants.sort((a, b) => a - b).join(';');
		return `family=${family}:wght@${weights}`;
	});

	const url = `https://fonts.googleapis.com/css2?${families.join('&')}&display=swap`;
	return `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="${url}" rel="stylesheet">`;
}

function getBaseStyles(): string {
	return `
		<style>*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}::after,::before{--tw-content:''}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset,menu,ol,ul{margin:0;padding:0}dialog,legend{padding:0}menu,ol,ul{list-style:none}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*{border-color:hsl(var(--border))}</style>
		<style>[data-type="text"] span{display:inline-block;}</style>
	`;
}
