import { readFile } from 'node:fs/promises';
import path from 'node:path';
import {
  CONTEXT_TIMEOUT_MS,
  PAGE_TIMEOUT_MS,
  cleanupBrowserTmp,
  openBrowser,
  safeCloseBrowser,
  safeCloseContext,
  safeClosePage,
  setupBrowserTmp,
  shouldRetryNavigation,
  withTimeout,
} from './lib/browser.mjs';
import { discoverRoutes, requireDist, startDistServer } from './lib/dist.mjs';

const previewCss = path.resolve('src/styles/global.css');
const browserTmp = path.resolve('.astro', 'playwright-preview-cropping-tmp');
const PREVIEW_SELECTOR = '[data-uncropped-preview]';
// Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
// iPad landscape / common laptop / full HD desktop.
const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920];
const MAX_NAV_RETRIES = 4;
const MEASURE_TIMEOUT_MS = 30000;
const CLIP_TOLERANCE_PX = 0.75;
const RATIO_TOLERANCE = 0.01;

await requireDist();

function lineAndColumn(text, index) {
  const before = text.slice(0, index);
  const lines = before.split('\n');
  return {
    line: lines.length,
    column: lines.at(-1).length + 1,
  };
}

function declarationValue(body, property) {
  const pattern = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, 'i');
  return body.match(pattern)?.[1]?.trim();
}

function scaleValueExpands(value) {
  for (const match of value.matchAll(/\bscale(?:3d|x|y)?\(([^)]*)\)/gi)) {
    const name = match[0].slice(0, match[0].indexOf('(')).toLowerCase();
    const values = match[1]
      .split(/[\s,]+/)
      .map(Number)
      .filter(Number.isFinite);

    if (name === 'scalex' || name === 'scaley') {
      if ((values[0] ?? 1) > 1) return true;
      continue;
    }

    if ((values[0] ?? 1) > 1 || (values[1] ?? values[0] ?? 1) > 1) {
      return true;
    }
  }

  return value
    .split(/[\s,]+/)
    .map(Number)
    .filter(Number.isFinite)
    .some((number) => number > 1);
}

async function checkPreviewCroppingStyles() {
  const css = await readFile(previewCss, 'utf8');
  const styleFailures = [];
  const blockPattern = /([^{}]+)\{([^{}]*)\}/g;

  for (const match of css.matchAll(blockPattern)) {
    const selector = match[1].replace(/\/\*[\s\S]*?\*\//g, '').trim();
    const body = match[2];
    // Only inspect rules that target elements explicitly opted in to the
    // no-crop contract via [data-uncropped-preview]. Listing thumbnails
    // that intentionally cover-crop don't carry this attribute.
    if (!selector || !/\[data-uncropped-preview\b/.test(selector)) continue;

    const targetsMedia = /\b(img|picture|video|canvas)\b/i.test(selector);
    const objectFit = declarationValue(body, 'object-fit');
    const backgroundSize = declarationValue(body, 'background-size');
    const transform = declarationValue(body, 'transform');
    const scale = declarationValue(body, 'scale');

    const addFailure = (property, reason) => {
      const propertyIndex = css.indexOf(property, match.index);
      const { line, column } = lineAndColumn(css, propertyIndex);
      styleFailures.push(
        `${path.relative(process.cwd(), previewCss)}:${line}:${column}: ${selector} ${reason}`
      );
    };

    if (targetsMedia && /^cover\b/i.test(objectFit ?? '')) {
      addFailure('object-fit', 'uses object-fit: cover');
    }

    if (/^cover\b/i.test(backgroundSize ?? '')) {
      addFailure('background-size', 'uses background-size: cover');
    }

    if (targetsMedia && transform && scaleValueExpands(transform)) {
      addFailure('transform', 'scales preview media larger than its container');
    }

    if (targetsMedia && scale && scaleValueExpands(scale)) {
      addFailure('scale', 'scales preview media larger than its container');
    }
  }

  return styleFailures;
}

await setupBrowserTmp(browserTmp);

const routes = await discoverRoutes();
const failures = await checkPreviewCroppingStyles();
const { server, port } = await startDistServer();

async function newMeasurementContext(browser, width) {
  const context = await browser.newContext({
    viewport: { width, height: 900 },
    javaScriptEnabled: true,
    reducedMotion: 'reduce',
  });

  await context.route('**/*', (route) => {
    const type = route.request().resourceType();
    if (type === 'image' || type === 'media') {
      route.abort('blockedbyclient');
    } else {
      route.continue();
    }
  });

  return context;
}

async function openMeasurementContext(browser, width) {
  return withTimeout(
    newMeasurementContext(browser, width),
    CONTEXT_TIMEOUT_MS,
    `Timed out while creating ${width}px Playwright context`
  );
}

async function inspectPreviews(page, route, width, phase, index = null) {
  const findings = await page.evaluate(
    ({ clipTolerancePx, index, phase, ratioTolerance, selector }) => {
      const failures = [];
      const previews = [...document.querySelectorAll(selector)];
      const selectedPreviews =
        index === null ? previews : [previews[index]].filter(Boolean);
      const clippingValues = new Set(['hidden', 'clip', 'scroll', 'auto']);

      function isClippingElement(element) {
        const style = getComputedStyle(element);
        return (
          clippingValues.has(style.overflow) ||
          clippingValues.has(style.overflowX) ||
          clippingValues.has(style.overflowY)
        );
      }

      function nearestClippingAncestor(image, preview) {
        let current = image.parentElement;

        while (current && preview.contains(current)) {
          if (isClippingElement(current)) return current;
          if (current === preview) break;
          current = current.parentElement;
        }

        return null;
      }

      function labelFor(preview, image) {
        const label =
          preview.getAttribute('data-preview-label') ||
          preview.getAttribute('aria-label') ||
          image.alt ||
          'preview image';
        const source = image.currentSrc || image.src;

        let pathname = source;
        try {
          pathname = new URL(source, document.baseURI).pathname;
        } catch {
          // Keep the raw source if URL parsing fails.
        }

        return `${label} (${pathname})`;
      }

      function ratioDelta(first, second) {
        return Math.abs(first - second) / Math.max(first, second);
      }

      function isRectClipped(rect, clipRect) {
        return (
          rect.left < clipRect.left - clipTolerancePx ||
          rect.top < clipRect.top - clipTolerancePx ||
          rect.right > clipRect.right + clipTolerancePx ||
          rect.bottom > clipRect.bottom + clipTolerancePx
        );
      }

      for (const preview of selectedPreviews) {
        if (!(preview instanceof HTMLElement)) continue;

        const image = preview.querySelector('img');
        if (!(image instanceof HTMLImageElement)) {
          failures.push({
            label: preview.getAttribute('data-preview-label') || 'preview image',
            reason: 'has no rendered <img>',
          });
          continue;
        }

        const label = labelFor(preview, image);
        const sourceWidth = Number(image.getAttribute('width')) || image.naturalWidth;
        const sourceHeight = Number(image.getAttribute('height')) || image.naturalHeight;

        if (sourceWidth <= 0 || sourceHeight <= 0) {
          failures.push({ label, reason: 'image has no intrinsic dimensions' });
          continue;
        }

        const imageRect = image.getBoundingClientRect();
        const imageWidth = image.clientWidth || imageRect.width;
        const imageHeight = image.clientHeight || imageRect.height;

        if (imageWidth <= 0 || imageHeight <= 0) {
          failures.push({ label, reason: 'image rendered with no visible size' });
          continue;
        }

        const imageStyle = getComputedStyle(image);
        const sourceRatio = sourceWidth / sourceHeight;
        const boxRatio = imageWidth / imageHeight;

        if (
          imageStyle.objectFit === 'cover' &&
          ratioDelta(sourceRatio, boxRatio) > ratioTolerance
        ) {
          failures.push({
            label,
            reason: `uses object-fit: cover for a ${sourceWidth}x${sourceHeight} source in a ${Math.round(imageWidth)}x${Math.round(imageHeight)} box`,
          });
        }

        const clippingAncestor = nearestClippingAncestor(image, preview);
        if (clippingAncestor) {
          const clipRect = clippingAncestor.getBoundingClientRect();
          if (isRectClipped(imageRect, clipRect)) {
            failures.push({
              label,
              reason: 'image bounds are clipped by an overflow container',
            });
          }
        }
      }

      return failures.map((failure) => ({
        ...failure,
        phase,
      }));
    },
    {
      clipTolerancePx: CLIP_TOLERANCE_PX,
      index,
      phase,
      ratioTolerance: RATIO_TOLERANCE,
      selector: PREVIEW_SELECTOR,
    }
  );

  return findings.map(
    (finding) =>
      `${route} at ${width}px (${finding.phase}): ${finding.label}: ${finding.reason}`
  );
}

async function measureRoute(context, route, width) {
  let page;

  try {
    page = await withTimeout(
      context.newPage(),
      PAGE_TIMEOUT_MS,
      `Timed out while creating page for ${route}`
    );

    return await withTimeout(
      (async () => {
        await page.goto(`http://127.0.0.1:${port}${route}`, {
          waitUntil: 'domcontentloaded',
          timeout: 15000,
        });
        await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});

        return inspectPreviews(page, route, width, 'normal');
      })(),
      MEASURE_TIMEOUT_MS,
      `Timed out while checking preview cropping for ${route}`
    );
  } finally {
    if (page) await safeClosePage(page);
  }
}

try {
  for (const width of VIEWPORT_WIDTHS) {
    let browser;
    let context;

    try {
      browser = await openBrowser(browserTmp);
      context = await openMeasurementContext(browser, width);

      for (const route of routes) {
        let result;

        for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) {
          try {
            result = await measureRoute(context, route, width);
            break;
          } catch (error) {
            if (!shouldRetryNavigation(error) || attempt === MAX_NAV_RETRIES - 1) {
              throw error;
            }

            await safeCloseContext(context);
            await safeCloseBrowser(browser);
            browser = await openBrowser(browserTmp);
            context = await openMeasurementContext(browser, width);
          }
        }

        failures.push(...result);
      }
    } finally {
      if (context) await safeCloseContext(context);
      if (browser) await safeCloseBrowser(browser);
    }
  }
} finally {
  server.close();
  await cleanupBrowserTmp(browserTmp);
}

if (failures.length > 0) {
  console.error(failures.join('\n'));
  process.exit(1);
}

console.log(
  `No cropped previews detected at ${VIEWPORT_WIDTHS.join(', ')}px across ${routes.length} routes.`
);
