import { __ } from './i18n';
import type { Anchor, ScreenshotState } from '../types';

const CAPTURE_SIZE = 400;
const MAX_OUTPUT_BYTES = 900 * 1024;

export interface CapturedScreenshot {
  payload: string;
  mimeType: 'image/jpeg';
  state: Extract<ScreenshotState, 'captured' | 'partial'>;
}

type Html2Canvas = typeof import('html2canvas').default;
type StyleProperty = readonly [name: string, value: string, priority: string];

interface HoverSnapshot {
  applyToClone: (clonedDocument: Document) => void;
  dispose: () => void;
}

let renderer: Html2Canvas | undefined;
let rendererLoading: Promise<Html2Canvas> | undefined;
let hoverSnapshotSequence = 0;

/**
 * Starts loading the renderer before a reviewer clicks the page. This lets the
 * click-time capture start before the composer is displayed, which preserves
 * transient states such as CSS hover content in the stored context image.
 */
export function preloadScreenshotCapture(): void {
	void loadRenderer().catch(() => {
		// Capture remains best-effort; the normal post path reports an unavailable
		// screenshot if the renderer cannot be loaded.
	});
}

/**
 * Captures a fixed 400 × 400 CSS-pixel context around the original click.
 * The point itself is drawn back onto the rendered result, so a reviewer can
 * distinguish the selected target from neighbouring UI in a dense region.
 */
export async function captureAnchorScreenshot(anchor: Anchor, hoverTarget?: Element): Promise<CapturedScreenshot> {
  const hoverSnapshot = hoverTarget ? createHoverSnapshot(hoverTarget) : undefined;
  const point = capturePoint(anchor);
  const bounds = documentBounds();
  const crop = {
    left: clamp(point.x - CAPTURE_SIZE / 2, 0, Math.max(0, bounds.width - CAPTURE_SIZE)),
    top: clamp(point.y - CAPTURE_SIZE / 2, 0, Math.max(0, bounds.height - CAPTURE_SIZE)),
    width: Math.min(CAPTURE_SIZE, bounds.width),
    height: Math.min(CAPTURE_SIZE, bounds.height)
  };

  // The renderer is preloaded in comment mode, then started before the
  // composer is mounted. Rendering documentElement lets html2canvas crop the
  // page directly instead of producing an element-sized image that can hide
  // the surrounding context.
  try {
    const html2canvas = renderer ?? await loadRenderer();
    const rendered = await html2canvas(document.documentElement, {
      allowTaint: false,
      backgroundColor: '#ffffff',
      ignoreElements: (element) => element.id === 'commentmate-root' || Boolean(element.closest('#commentmate-root')),
      logging: false,
      onclone: (clonedDocument) => hoverSnapshot?.applyToClone(clonedDocument),
      scale: 1,
      scrollX: 0,
      scrollY: 0,
      useCORS: true,
      // Keep the clone's viewport faithful to the one the reviewer saw. Using
      // the whole document here makes viewport units and fixed/sticky elements
      // behave as though the page were thousands of pixels tall.
      windowWidth: Math.max(1, window.innerWidth),
      windowHeight: Math.max(1, window.innerHeight),
      x: crop.left,
      y: crop.top,
      width: crop.width,
      height: crop.height
    });

    const canvas = normaliseCanvas(rendered, crop, point);
    return {
      payload: compressCanvas(canvas),
      mimeType: 'image/jpeg',
      state: hasCaptureGaps(crop) ? 'partial' : 'captured'
    };
  } finally {
    hoverSnapshot?.dispose();
  }
}

function loadRenderer(): Promise<Html2Canvas> {
	if (renderer) return Promise.resolve(renderer);
	if (!rendererLoading) {
		rendererLoading = import('html2canvas').then(({ default: loaded }) => {
			renderer = loaded;
			return loaded;
		});
	}
	return rendererLoading;
}

/**
 * html2canvas renders a cloned document, where CSS :hover no longer applies.
 * Copying the live computed styles from the click target and a few ancestors
 * retains hover-revealed text without changing the page or delaying the
 * composer. Pseudo elements are included for hover labels implemented in CSS.
 */
function createHoverSnapshot(target: Element): HoverSnapshot {
  const attribute = 'data-commentmate-hover-snapshot';
  const snapshots: Array<{ id: string; element: HTMLElement; previous: string | null; styles: StyleProperty[]; pseudoStyles: string[] }> = [];
  let element: HTMLElement | null = target instanceof HTMLElement ? target : target.parentElement;

  for (let depth = 0; element && depth < 5; depth += 1, element = element.parentElement) {
    const id = `commentmate-hover-${++hoverSnapshotSequence}`;
    const previous = element.getAttribute(attribute);
    element.setAttribute(attribute, id);
    snapshots.push({
      id,
      element,
      previous,
      styles: computedStyles(element),
      pseudoStyles: [pseudoStyleRule(element, id, '::before'), pseudoStyleRule(element, id, '::after')].filter((style): style is string => Boolean(style))
    });
  }

  return {
    applyToClone(clonedDocument) {
      const rules: string[] = [];
      for (const snapshot of snapshots) {
        const clone = clonedDocument.querySelector<HTMLElement>(`[${attribute}="${snapshot.id}"]`);
        if (!clone) continue;
        for (const [name, value, priority] of snapshot.styles) clone.style.setProperty(name, value, priority);
        rules.push(...snapshot.pseudoStyles);
      }
      if (rules.length > 0) {
        const style = clonedDocument.createElement('style');
        style.textContent = rules.join('\n');
        clonedDocument.head.append(style);
      }
    },
    dispose() {
      for (const snapshot of snapshots) {
        if (snapshot.previous === null) snapshot.element.removeAttribute(attribute);
        else snapshot.element.setAttribute(attribute, snapshot.previous);
      }
    }
  };
}

function computedStyles(element: Element, pseudo?: '::before' | '::after'): StyleProperty[] {
  const styles = window.getComputedStyle(element, pseudo);
  return Array.from(styles).map((name) => [name, styles.getPropertyValue(name), styles.getPropertyPriority(name)] as const);
}

function pseudoStyleRule(element: Element, id: string, pseudo: '::before' | '::after'): string | undefined {
  const styles = computedStyles(element, pseudo);
  const content = styles.find(([name]) => name === 'content')?.[1];
  if (!content || content === 'none' || content === 'normal') return undefined;

  return `[data-commentmate-hover-snapshot="${id}"]${pseudo}{${styles.map(([name, value, priority]) => `${name}:${value}${priority ? ' !important' : ''};`).join('')}}`;
}

function capturePoint(anchor: Anchor): { x: number; y: number } {
  const fallbackX = window.scrollX + window.innerWidth / 2;
  const fallbackY = window.scrollY + window.innerHeight / 2;
  return {
    x: Number.isFinite(anchor.documentX) ? anchor.documentX : fallbackX,
    y: Number.isFinite(anchor.documentY) ? anchor.documentY : fallbackY
  };
}

function documentBounds(): { width: number; height: number } {
  const root = document.documentElement;
  const body = document.body;
  return {
    width: Math.max(1, root.scrollWidth, root.offsetWidth, root.clientWidth, body?.scrollWidth ?? 0, body?.offsetWidth ?? 0),
    height: Math.max(1, root.scrollHeight, root.offsetHeight, root.clientHeight, body?.scrollHeight ?? 0, body?.offsetHeight ?? 0)
  };
}

function normaliseCanvas(rendered: HTMLCanvasElement, crop: { left: number; top: number; width: number; height: number }, point: { x: number; y: number }): HTMLCanvasElement {
  const canvas = document.createElement('canvas');
  canvas.width = CAPTURE_SIZE;
  canvas.height = CAPTURE_SIZE;
  const context = canvas.getContext('2d');
  if (!context) throw new Error(__('The browser could not prepare a context screenshot.', 'commentmate'));

  context.fillStyle = '#ffffff';
  context.fillRect(0, 0, CAPTURE_SIZE, CAPTURE_SIZE);
  context.drawImage(rendered, 0, 0, crop.width, crop.height);
  drawTargetMarker(context, clamp(point.x - crop.left, 0, CAPTURE_SIZE - 1), clamp(point.y - crop.top, 0, CAPTURE_SIZE - 1));
  return canvas;
}

function drawTargetMarker(context: CanvasRenderingContext2D, x: number, y: number): void {
  context.save();
  context.lineCap = 'round';
  context.lineWidth = 3;
  context.strokeStyle = '#ffffff';
  context.beginPath();
  context.arc(x, y, 9, 0, Math.PI * 2);
  context.stroke();
  context.lineWidth = 2.5;
  context.strokeStyle = '#2563eb';
  context.beginPath();
  context.arc(x, y, 8, 0, Math.PI * 2);
  context.stroke();
  context.fillStyle = '#2563eb';
  context.beginPath();
  context.arc(x, y, 3, 0, Math.PI * 2);
  context.fill();
  context.restore();
}

function hasCaptureGaps(crop: { left: number; top: number; width: number; height: number }): boolean {
  return Array.from(document.querySelectorAll('iframe, video, canvas')).some((element) => isInCrop(element, crop))
    || Array.from(document.querySelectorAll<HTMLImageElement>('img')).some((image) => {
      if (!isInCrop(image, crop)) return false;
      try {
        const source = new URL(image.currentSrc || image.src, window.location.href);
        return source.origin !== window.location.origin && !image.crossOrigin;
      } catch {
        return true;
      }
    });
}

function isInCrop(element: Element, crop: { left: number; top: number; width: number; height: number }): boolean {
  if (element.closest('#commentmate-root')) return false;
  const rect = element.getBoundingClientRect();
  const left = rect.left + window.scrollX;
  const top = rect.top + window.scrollY;
  return left < crop.left + crop.width
    && left + rect.width > crop.left
    && top < crop.top + crop.height
    && top + rect.height > crop.top;
}

function compressCanvas(canvas: HTMLCanvasElement): string {
  for (const quality of [0.88, 0.76, 0.62]) {
    const dataUrl = canvas.toDataURL('image/jpeg', quality);
    if (dataUrlBytes(dataUrl) <= MAX_OUTPUT_BYTES) return dataUrl;
  }

  throw new Error(__('The context screenshot is too large to save.', 'commentmate'));
}

function clamp(value: number, minimum: number, maximum: number): number {
  return Math.min(Math.max(value, minimum), maximum);
}

function dataUrlBytes(dataUrl: string): number {
  const comma = dataUrl.indexOf(',');
  const base64Length = comma >= 0 ? dataUrl.length - comma - 1 : dataUrl.length;
  return Math.floor((base64Length * 3) / 4);
}
