/**
 * design/extract-url.mjs — Playwright-based URL extractor (C43).
 *
 * INVARIANT I-1: This module MUST NOT import any LLM client or SDK at any
 * depth. The AST invariant test in taste-infer.test.ts asserts no offending
 * imports. Extraction reads computed styles + stylesheet text off a live
 * page; any naming/role inference is deferred to `synthesize.mjs`.
 *
 * What this module does:
 *
 *   1. Lazy-imports `playwright`. Missing dep → TasteInferMissingDep with a
 *      hint telling the user to `npx playwright install chromium`.
 *   2. Launches chromium (`--no-sandbox` flags when `CI=true` per R9).
 *   3. page.goto(url, { waitUntil: 'networkidle', timeout }).
 *   4. Auth-wall heuristic: if `<input type="password">` is visible on first
 *      paint, abort with `target_requires_auth` (D-3).
 *   5. Via `page.evaluate`: sample computed styles on representative elements
 *      (body, main, h1, button, a, nav, input) and collect raw stylesheet
 *      text. The stylesheet text is fed through the same css-tree walker as
 *      extract-repo.mjs so URL mode and repo mode share one tokenizer.
 *   6. Optional scroll captures (0/25/50/75/100 %) to the configured
 *      screenshots dir.
 *   7. `browser.close()` in `finally{}` — never leak a chromium.
 *
 * The exported `extractFromUrl` is async. See `synthesize.mjs` for the
 * downstream consumer and `commands/taste-infer.mjs` for the CLI wrapper.
 */
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { HEX_COLOR_RE, RAW_LENGTH_RE, RGB_HSL_RE } from './extract.mjs';
import { bumpCount, toWeighted } from './weighted-map.mjs';
// --- Error shape (mirrors extract-repo.mjs) --------------------------------
/**
 * Error thrown when a required optional dep is missing. The CLI wrapper
 * turns this into a `{status:'error', hint:'run pnpm add …'}` envelope.
 */
export class TasteInferMissingDep extends Error {
    constructor(dep, underlying) {
        super(`taste-infer requires the optional dependency '${dep}'`);
        this.name = 'TasteInferMissingDep';
        this.dep = dep;
        this.hint =
            dep === 'playwright'
                ? 'run: pnpm add -D playwright && npx playwright install chromium'
                : `run: pnpm add -D ${dep}`;
        this.underlying = underlying;
    }
}
/**
 * Error thrown when the target URL responds with an auth wall. Detected by
 * (a) HTTP 401/403 on the nav response, (b) a visible `<input type=password>`
 * on first paint.
 */
export class TasteInferAuthWall extends Error {
    constructor(reason) {
        super(`target_requires_auth: ${reason}`);
        this.name = 'TasteInferAuthWall';
        this.reason = reason;
        this.hint = 'Point taste-infer at a public entry point (marketing site / docs page).';
    }
}
/**
 * @template T
 * @param {string} dep
 * @returns {Promise<T>}
 */
async function loadOptionalDep(dep) {
    try {
        return /** @type {T} */ (await import(dep));
    }
    catch (err) {
        const code = err && typeof err === 'object' && 'code' in err ? /** @type {any} */ (err).code : null;
        const msg = err instanceof Error ? err.message : String(err);
        if (code === 'ERR_MODULE_NOT_FOUND' ||
            code === 'MODULE_NOT_FOUND' ||
            /Cannot find /.test(msg)) {
            throw new TasteInferMissingDep(dep, err);
        }
        throw err;
    }
}
// --- Helpers ----------------------------------------------------------------
//
// `bumpCount` + `toWeighted` live in ./weighted-map.mjs so extract-repo.mjs
// and this file share one implementation (MI-3 in the C43 review).
// Sample elements we grab computed styles from. Each weight is a hint to the
// synthesis heuristics — body/main carry more signal than a single nav link.
const SAMPLE_ELEMENTS = [
    { selector: 'body', weight: 5 },
    { selector: 'main', weight: 4 },
    { selector: 'h1', weight: 3 },
    { selector: 'h2', weight: 2 },
    { selector: 'button', weight: 3 },
    { selector: 'a', weight: 2 },
    { selector: 'nav', weight: 2 },
    { selector: 'input', weight: 2 },
];
// --- Browser-side script (stringified for page.evaluate) -------------------
//
// We keep this logic inline + small so it works on ancient Node-level type
// narrowing. It returns a plain object with `stylesheetTexts[]` and
// `computed[]` lists for the node extractor to walk.
const BROWSER_SCRIPT = `async () => {
  const out = { stylesheetTexts: [], computed: [], hasAuthWall: false };
  if (document.querySelector('input[type="password"]')) out.hasAuthWall = true;

  // Inline <style> blocks.
  for (const s of document.querySelectorAll('style')) {
    if (s.textContent) out.stylesheetTexts.push(s.textContent);
  }
  // Same-origin <link rel="stylesheet"> — we fetch each href and pull text.
  const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
  await Promise.all(links.map(async (l) => {
    const href = l.href;
    if (!href) return;
    try {
      const r = await fetch(href);
      if (r.ok) out.stylesheetTexts.push(await r.text());
    } catch { /* cross-origin, offline, etc. — ignore */ }
  }));

  // Sample computed styles.
  const samples = ${JSON.stringify(SAMPLE_ELEMENTS)};
  for (const s of samples) {
    const el = document.querySelector(s.selector);
    if (!el) continue;
    const cs = getComputedStyle(el);
    out.computed.push({
      selector: s.selector,
      weight: s.weight,
      color: cs.color,
      backgroundColor: cs.backgroundColor,
      fontFamily: cs.fontFamily,
      fontSize: cs.fontSize,
      lineHeight: cs.lineHeight,
      padding: cs.padding,
      margin: cs.margin,
      borderRadius: cs.borderRadius,
      boxShadow: cs.boxShadow,
      transitionDuration: cs.transitionDuration,
      transitionTimingFunction: cs.transitionTimingFunction,
    });
  }
  return out;
}`;
// --- Walk CSS text via css-tree (same walker shape as extract-repo.mjs) ---
async function harvestStylesheetText(text, acc) {
    // css-tree is already required for URL mode — same dep as repo mode.
    const cssTree = await loadOptionalDep('css-tree');
    let ast;
    try {
        ast = cssTree.parse(text, { positions: false });
    }
    catch {
        return;
    }
    const { walk, generate } = cssTree;
    walk(ast, (node) => {
        if (!node || node.type !== 'Declaration')
            return;
        const prop = (node.property || '').toLowerCase();
        if (!prop)
            return;
        let value = '';
        try {
            value = generate(node.value);
        }
        catch {
            return;
        }
        // Any declaration value — scan for colours.
        for (const m of value.matchAll(HEX_COLOR_RE)) {
            bumpCount(acc.colorsCss, m[0].toLowerCase(), 1);
        }
        for (const m of value.matchAll(RGB_HSL_RE)) {
            const normalized = m[0].replace(/\s+/g, ' ').trim().toLowerCase();
            bumpCount(acc.colorsCss, normalized, 1);
        }
        if (prop === 'font-family') {
            for (const part of value.split(',')) {
                const cleaned = part.trim().replace(/^['"]|['"]$/g, '');
                if (cleaned.length > 0 && cleaned.length < 64)
                    bumpCount(acc.fonts, cleaned);
            }
        }
        if (prop === 'font-size') {
            for (const m of value.matchAll(RAW_LENGTH_RE))
                bumpCount(acc.fontSizes, m[0]);
        }
        if (prop === 'line-height') {
            const v = value.trim();
            if (v.length > 0 && v.length < 64)
                bumpCount(acc.lineHeights, v);
        }
        if (prop === 'padding' ||
            prop.startsWith('padding-') ||
            prop === 'margin' ||
            prop.startsWith('margin-') ||
            prop === 'gap' ||
            prop === 'row-gap' ||
            prop === 'column-gap') {
            for (const m of value.matchAll(RAW_LENGTH_RE))
                bumpCount(acc.spacing, m[0]);
        }
        if (prop === 'border-radius' || prop.endsWith('-radius')) {
            for (const m of value.matchAll(RAW_LENGTH_RE))
                bumpCount(acc.radii, m[0]);
        }
        if (prop === 'box-shadow' || prop === 'text-shadow') {
            const v = value.trim();
            if (v.length > 0 && v.length < 256)
                bumpCount(acc.shadows, v);
        }
        if (prop === 'transition-duration' ||
            prop === 'animation-duration' ||
            prop === 'transition' ||
            prop === 'animation') {
            for (const m of value.matchAll(/\b\d+(?:\.\d+)?(?:ms|s)\b/g)) {
                bumpCount(acc.motionDurations, m[0]);
            }
        }
        if (prop === 'transition-timing-function' || prop === 'animation-timing-function') {
            for (const m of value.matchAll(/cubic-bezier\([^)]+\)|\b(?:linear|ease|ease-in|ease-out|ease-in-out)\b/g)) {
                bumpCount(acc.motionEasings, m[0]);
            }
        }
    });
}
// --- Walk the computed-style sample returned from the browser -------------
function harvestComputed(sample, acc) {
    for (const s of sample) {
        const w = Math.max(1, s.weight | 0);
        // Colour-like fields — scan for hex/rgb in each.
        for (const field of ['color', 'backgroundColor']) {
            const v = s[field];
            if (typeof v !== 'string' || v.length === 0 || v === 'rgba(0, 0, 0, 0)')
                continue;
            for (const m of v.matchAll(HEX_COLOR_RE)) {
                bumpCount(acc.colorsComputed, m[0].toLowerCase(), w);
            }
            for (const m of v.matchAll(RGB_HSL_RE)) {
                const normalized = m[0].replace(/\s+/g, ' ').trim().toLowerCase();
                // Skip fully transparent colours — they carry no signal.
                if (normalized === 'rgba(0, 0, 0, 0)' || normalized === 'rgb(0, 0, 0)') {
                    bumpCount(acc.colorsComputed, normalized, Math.max(1, w - 1));
                }
                else {
                    bumpCount(acc.colorsComputed, normalized, w);
                }
            }
        }
        if (typeof s.fontFamily === 'string' && s.fontFamily.length > 0) {
            for (const part of s.fontFamily.split(',')) {
                const cleaned = part.trim().replace(/^['"]|['"]$/g, '');
                if (cleaned.length > 0 && cleaned.length < 64)
                    bumpCount(acc.fonts, cleaned, w);
            }
        }
        if (typeof s.fontSize === 'string' && s.fontSize.length > 0) {
            for (const m of s.fontSize.matchAll(RAW_LENGTH_RE))
                bumpCount(acc.fontSizes, m[0], w);
        }
        if (typeof s.lineHeight === 'string' && s.lineHeight.length > 0) {
            const v = s.lineHeight.trim();
            if (v && v !== 'normal' && v.length < 64)
                bumpCount(acc.lineHeights, v, w);
        }
        for (const field of ['padding', 'margin']) {
            const v = s[field];
            if (typeof v === 'string') {
                for (const m of v.matchAll(RAW_LENGTH_RE))
                    bumpCount(acc.spacing, m[0], w);
            }
        }
        if (typeof s.borderRadius === 'string' && s.borderRadius.length > 0) {
            for (const m of s.borderRadius.matchAll(RAW_LENGTH_RE))
                bumpCount(acc.radii, m[0], w);
        }
        if (typeof s.boxShadow === 'string' && s.boxShadow.length > 0 && s.boxShadow !== 'none') {
            bumpCount(acc.shadows, s.boxShadow.trim(), w);
        }
        if (typeof s.transitionDuration === 'string' && s.transitionDuration.length > 0) {
            for (const m of s.transitionDuration.matchAll(/\b\d+(?:\.\d+)?(?:ms|s)\b/g)) {
                bumpCount(acc.motionDurations, m[0], w);
            }
        }
        if (typeof s.transitionTimingFunction === 'string' && s.transitionTimingFunction.length > 0) {
            const m = s.transitionTimingFunction.match(/cubic-bezier\([^)]+\)|\b(?:linear|ease|ease-in|ease-out|ease-in-out)\b/);
            if (m)
                bumpCount(acc.motionEasings, m[0], w);
        }
    }
    // URL mode does field-level extraction on the computed-style sample, so
    // it never needs FONT_FAMILY_RE / FONT_SIZE_RAW_RE / LINE_HEIGHT_RE from
    // extract.mjs — those regex primitives only matter when walking arbitrary
    // text (repo mode).
}
// --- Public API ------------------------------------------------------------
/**
 * Extract design taste from a live URL.
 *
 * @param {string} url — full URL including protocol
 * @param {{
 *   timeout?: number,
 *   screenshots?: boolean,
 *   screenshotsDir?: string,
 *   launcher?: { chromium: { launch: (opts: any) => Promise<any> } },
 *   log?: (msg: string) => void,
 * }} [opts]
 *   - `launcher` — test-time injection point. When provided, used in place
 *     of the dynamically-imported `playwright.chromium`. Lets unit tests
 *     mock page.evaluate without installing a real Chromium.
 * @returns {Promise<import('./extract-repo.mjs').ExtractedTaste | any>}
 */
export async function extractFromUrl(url, opts = {}) {
    const timeout = Number(opts.timeout ?? 30_000);
    const wantScreenshots = opts.screenshots !== false;
    // Resolve the Playwright entry: either caller-injected (tests) or the
    // real dep. Split out so the lazy-import only runs when we actually
    // need it.
    let chromium = opts.launcher?.chromium;
    if (!chromium) {
        const playwright = await loadOptionalDep('playwright');
        // `playwright` has a `chromium` named export. Treat missing as a
        // malformed install.
        chromium =
            /** @type {any} */ (playwright).chromium ?? /** @type {any} */ (playwright).default?.chromium;
        if (!chromium || typeof chromium.launch !== 'function') {
            throw new TasteInferMissingDep('playwright');
        }
    }
    const launchArgs = process.env.CI === 'true' ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
    const acc = {
        colorsCss: /** @type {Map<string, number>} */ (new Map()),
        colorsComputed: /** @type {Map<string, number>} */ (new Map()),
        fonts: /** @type {Map<string, number>} */ (new Map()),
        fontSizes: /** @type {Map<string, number>} */ (new Map()),
        lineHeights: /** @type {Map<string, number>} */ (new Map()),
        spacing: /** @type {Map<string, number>} */ (new Map()),
        radii: /** @type {Map<string, number>} */ (new Map()),
        shadows: /** @type {Map<string, number>} */ (new Map()),
        motionDurations: /** @type {Map<string, number>} */ (new Map()),
        motionEasings: /** @type {Map<string, number>} */ (new Map()),
    };
    let browser = null;
    const screenshotsOut = [];
    try {
        browser = await chromium.launch({ args: launchArgs, timeout });
        const context = await browser.newContext();
        const page = await context.newPage();
        const response = await page.goto(url, { waitUntil: 'networkidle', timeout });
        if (response && typeof response.status === 'function') {
            const status = response.status();
            if (status === 401 || status === 403) {
                throw new TasteInferAuthWall(`HTTP ${status}`);
            }
        }
        const sampled = await page.evaluate(BROWSER_SCRIPT);
        if (sampled && sampled.hasAuthWall) {
            throw new TasteInferAuthWall('password input visible on first paint');
        }
        // CSS text pass.
        for (const text of sampled?.stylesheetTexts ?? []) {
            await harvestStylesheetText(String(text ?? ''), acc);
        }
        // Computed-style sample.
        harvestComputed(sampled?.computed ?? [], acc);
        // Scroll captures.
        if (wantScreenshots && opts.screenshotsDir) {
            mkdirSync(opts.screenshotsDir, { recursive: true });
            const positions = [0, 0.25, 0.5, 0.75, 1];
            for (let i = 0; i < positions.length; i++) {
                const pct = positions[i];
                const scrollY = await page.evaluate(`(pct) => { window.scrollTo({ top: Math.floor(document.body.scrollHeight * pct), behavior: 'instant' }); return window.scrollY; }`, pct);
                const outPath = `${opts.screenshotsDir}/scroll-${i}.png`;
                mkdirSync(dirname(outPath), { recursive: true });
                try {
                    await page.screenshot({ path: outPath, fullPage: false });
                    screenshotsOut.push({ path: outPath, scrollY: Number(scrollY) || 0 });
                }
                catch {
                    // Best-effort — one bad scroll shouldn't fail the whole extraction.
                }
            }
        }
    }
    finally {
        if (browser) {
            try {
                await browser.close();
            }
            catch {
                /* best-effort */
            }
        }
    }
    // Merge CSS + computed colour signal into a single list, tagging source.
    const colorMerged = new Map();
    for (const [value, weight] of acc.colorsCss) {
        colorMerged.set(value, { value, weight, source: 'css' });
    }
    for (const [value, weight] of acc.colorsComputed) {
        const prev = colorMerged.get(value);
        if (!prev) {
            colorMerged.set(value, { value, weight, source: 'computed' });
        }
        else {
            colorMerged.set(value, {
                value,
                weight: prev.weight + weight,
                source: prev.source === 'css' ? 'css' : 'computed',
            });
        }
    }
    const colorsOut = [...colorMerged.values()].sort((a, b) => b.weight - a.weight || a.value.localeCompare(b.value));
    return {
        colors: colorsOut,
        fonts: toWeighted(acc.fonts).map((f) => ({ family: f.value, weight: f.weight })),
        spacing: toWeighted(acc.spacing),
        radii: toWeighted(acc.radii),
        shadows: toWeighted(acc.shadows),
        motion: {
            duration: acc.motionDurations.size > 0
                ? toWeighted(acc.motionDurations).map((d) => d.value)
                : undefined,
            easing: acc.motionEasings.size > 0 ? toWeighted(acc.motionEasings).map((e) => e.value) : undefined,
        },
        screenshots: screenshotsOut.length > 0 ? screenshotsOut : undefined,
        target: url,
        mode: /** @type {const} */ ('url'),
        _fontSizes: toWeighted(acc.fontSizes),
        _lineHeights: toWeighted(acc.lineHeights),
    };
}
//# sourceMappingURL=extract-url.mjs.map