/**
 * design/extract-repo.mjs — Filesystem-based design-taste 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 is deterministic — css-tree + @babel/parser + the
 * regex primitives from extract.mjs are the only parsers allowed here.
 *
 * What this module does:
 *
 *   - Walks a project root using the same traversal rules as `scan.mjs`
 *     (SCAN_EXTENSIONS + EXCLUDED_DIRS + MAX_FILES + MAX_FILE_BYTES), so
 *     brownfield trees never blow past predictable bounds.
 *   - CSS / SCSS files: lazy-imports `css-tree`, walks declarations for
 *     color-valued / length / radius / shadow / font-family properties and
 *     weights each observation by its occurrence count.
 *   - TS / TSX / JS / JSX / MJS / CJS files: lazy-imports `@babel/parser` +
 *     `@babel/traverse`, visits string and template literals, then runs the
 *     existing HEX/RGB/TW_COLOR/RAW_LENGTH/FONT_FAMILY regex constants from
 *     `./extract.mjs` over each literal. Tailwind utility classes like
 *     `bg-blue-600` are recognised via `TW_COLOR_RE`.
 *   - Returns an ExtractedTaste intermediate (see spec F-3) that
 *     `synthesize.mjs` consumes identically regardless of which extractor
 *     produced it.
 *
 * On optional-dep failure (css-tree / @babel/* not installed) this module
 * throws `TasteInferMissingDep` with a hint string — the CLI wrapper turns
 * that into a structured error envelope (spec AC-7).
 */
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, resolve as resolvePath } from 'node:path';
import { FONT_FAMILY_RE, FONT_SIZE_RAW_RE, HEX_COLOR_RE, LINE_HEIGHT_RE, RAW_LENGTH_RE, RGB_HSL_RE, TW_COLOR_RE, } from './extract.mjs';
import { EXCLUDED_DIRS, MAX_FILE_BYTES, MAX_FILES, SCAN_EXTENSIONS, SCAN_FILENAMES, } from './scan.mjs';
import { bumpCount, toWeighted } from './weighted-map.mjs';
// --- Constants --------------------------------------------------------------
const CSS_EXT = new Set(['.css', '.scss', '.sass', '.less']);
const JS_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
// CSS property names we sort declarations into. Matching is prefix-based so
// `-webkit-border-radius` still hits the radius bucket.
const COLOR_PROPS = new Set([
    'color',
    'background',
    'background-color',
    'border',
    'border-color',
    'border-top-color',
    'border-right-color',
    'border-bottom-color',
    'border-left-color',
    'outline',
    'outline-color',
    'fill',
    'stroke',
    'caret-color',
    'accent-color',
    'box-shadow', // value also contributes to shadows but hex colors inside are still colors
    'text-shadow',
]);
const SPACING_PROPS = new Set([
    'padding',
    'padding-top',
    'padding-right',
    'padding-bottom',
    'padding-left',
    'margin',
    'margin-top',
    'margin-right',
    'margin-bottom',
    'margin-left',
    'gap',
    'row-gap',
    'column-gap',
]);
const RADIUS_PROPS = new Set([
    'border-radius',
    'border-top-left-radius',
    'border-top-right-radius',
    'border-bottom-left-radius',
    'border-bottom-right-radius',
]);
const SHADOW_PROPS = new Set(['box-shadow', 'text-shadow']);
const FONT_FAMILY_PROP = 'font-family';
const FONT_SIZE_PROP = 'font-size';
const MOTION_DURATION_PROPS = new Set([
    'transition-duration',
    'animation-duration',
    'transition', // value shorthand — picked up below via regex
    'animation',
]);
const MOTION_EASING_PROPS = new Set(['transition-timing-function', 'animation-timing-function']);
// --- Public types (JSDoc only; .mjs file) ----------------------------------
/**
 * @typedef {{
 *   colors: { value: string, weight: number, source: 'css' | 'computed' | 'babel' }[],
 *   fonts:  { family: string, weight: number }[],
 *   spacing:{ value: string, weight: number }[],
 *   radii:  { value: string, weight: number }[],
 *   shadows:{ value: string, weight: number }[],
 *   motion: { duration?: string[], easing?: string[] },
 *   screenshots?: { path: string, scrollY: number }[],
 *   target: string,
 *   mode: 'url' | 'repo' | 'github'
 * }} ExtractedTaste
 */
/**
 * 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 = `run: pnpm add -D ${dep} (and for playwright also: npx playwright install chromium)`;
        this.underlying = underlying;
    }
}
/**
 * Dynamically import an optional dep, throwing TasteInferMissingDep on
 * MODULE_NOT_FOUND. Any other error bubbles through untouched so real bugs
 * aren't masked.
 *
 * @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;
    }
}
// --- Weighted accumulator helpers ------------------------------------------
//
// `bumpCount` + `toWeighted` are shared with extract-url.mjs via
// ./weighted-map.mjs (MI-3 in the C43 review).
// --- CSS walker -------------------------------------------------------------
/**
 * Given css-tree's AST of a stylesheet, harvest tokens into accumulators.
 * We use a simple visitor that does not depend on css-tree's optional
 * `walk` utility flags beyond `enter` so behaviour stays stable across
 * versions.
 */
function harvestCssAst(cssTree, ast, acc) {
    const { walk } = cssTree;
    walk(ast, (node) => {
        if (!node)
            return;
        if (node.type === 'Declaration') {
            const prop = (node.property || '').toLowerCase();
            if (!prop)
                return;
            // Reconstruct the raw value string for regex-based hex/rgb pickup.
            let valueText = '';
            try {
                valueText = cssTree.generate(node.value);
            }
            catch {
                valueText = '';
            }
            // Color values (from any color-accepting property).
            if (COLOR_PROPS.has(prop) || prop.endsWith('-color') || prop.startsWith('--color-')) {
                for (const m of valueText.matchAll(HEX_COLOR_RE)) {
                    acc.colorsCss.set(m[0].toLowerCase(), (acc.colorsCss.get(m[0].toLowerCase()) ?? 0) + 1);
                }
                for (const m of valueText.matchAll(RGB_HSL_RE)) {
                    const normalized = m[0].replace(/\s+/g, ' ').trim().toLowerCase();
                    acc.colorsCss.set(normalized, (acc.colorsCss.get(normalized) ?? 0) + 1);
                }
            }
            // Some properties (background, border) hold shorthand values that
            // also contain colours — the same hex/rgb regex above works on them
            // because COLOR_PROPS already includes `background` and `border`.
            if (prop === FONT_FAMILY_PROP) {
                for (const part of valueText.split(',')) {
                    const cleaned = part.trim().replace(/^['"]|['"]$/g, '');
                    if (cleaned.length > 0 && cleaned.length < 64) {
                        bumpCount(acc.fonts, cleaned);
                    }
                }
            }
            if (prop === FONT_SIZE_PROP) {
                for (const m of valueText.matchAll(RAW_LENGTH_RE))
                    bumpCount(acc.fontSizes, m[0]);
            }
            if (prop === 'line-height') {
                const v = valueText.trim();
                if (v.length > 0 && v.length < 64)
                    bumpCount(acc.lineHeights, v);
            }
            if (SPACING_PROPS.has(prop)) {
                for (const m of valueText.matchAll(RAW_LENGTH_RE))
                    bumpCount(acc.spacing, m[0]);
            }
            if (RADIUS_PROPS.has(prop)) {
                for (const m of valueText.matchAll(RAW_LENGTH_RE))
                    bumpCount(acc.radii, m[0]);
            }
            if (SHADOW_PROPS.has(prop)) {
                const v = valueText.trim();
                if (v.length > 0 && v.length < 256)
                    bumpCount(acc.shadows, v);
            }
            if (MOTION_DURATION_PROPS.has(prop)) {
                // Duration values have `ms` or `s` suffix.
                for (const m of valueText.matchAll(/\b\d+(?:\.\d+)?(?:ms|s)\b/g)) {
                    bumpCount(acc.motionDurations, m[0]);
                }
            }
            if (MOTION_EASING_PROPS.has(prop)) {
                for (const m of valueText.matchAll(/cubic-bezier\([^)]+\)|\b(?:linear|ease|ease-in|ease-out|ease-in-out)\b/g)) {
                    bumpCount(acc.motionEasings, m[0]);
                }
            }
        }
    });
}
// --- JS/TSX walker ----------------------------------------------------------
/**
 * Given a @babel/parser AST, harvest colour/spacing/font tokens from string
 * and template literals. We intentionally stay loose: every literal is fed
 * through the hex / rgb / tailwind / font-family regexes. False positives
 * are tolerated in v1 — scan.mjs/extract.mjs already ships against the same
 * strategy for the simpler `design-scan` command.
 */
function harvestJsAst(babelTraverse, ast, acc) {
    // @babel/traverse is CommonJS; the default export is the real function.
    const traverse = typeof babelTraverse === 'function'
        ? babelTraverse
        : (babelTraverse.default ?? babelTraverse.traverse);
    if (typeof traverse !== 'function') {
        throw new TasteInferMissingDep('@babel/traverse');
    }
    const consumeString = (text) => {
        if (typeof text !== 'string' || text.length === 0)
            return;
        for (const m of text.matchAll(HEX_COLOR_RE)) {
            bumpCount(acc.colorsBabel, m[0].toLowerCase());
        }
        for (const m of text.matchAll(RGB_HSL_RE)) {
            const normalized = m[0].replace(/\s+/g, ' ').trim().toLowerCase();
            bumpCount(acc.colorsBabel, normalized);
        }
        for (const m of text.matchAll(TW_COLOR_RE)) {
            // Strip utility prefix → `bg-blue-600` becomes `blue-600`.
            const value = m[0].replace(/^(?:bg|text|border|ring|from|to|via|fill|stroke|outline|divide|placeholder|caret|accent|decoration)-/, '');
            bumpCount(acc.twColors, value);
        }
        for (const m of text.matchAll(RAW_LENGTH_RE)) {
            bumpCount(acc.spacing, m[0]);
        }
        for (const m of text.matchAll(FONT_FAMILY_RE)) {
            for (const part of (m[1] ?? '').split(',')) {
                const cleaned = part.trim().replace(/^['"]|['"]$/g, '');
                if (cleaned.length > 0 && cleaned.length < 64) {
                    bumpCount(acc.fonts, cleaned);
                }
            }
        }
        for (const m of text.matchAll(FONT_SIZE_RAW_RE)) {
            const value = m[1].trim();
            if (value.length > 0 && value.length < 64)
                bumpCount(acc.fontSizes, value);
        }
        for (const m of text.matchAll(LINE_HEIGHT_RE)) {
            const value = m[1].trim();
            if (value.length > 0 && value.length < 64)
                bumpCount(acc.lineHeights, value);
        }
    };
    traverse(ast, {
        StringLiteral(path) {
            consumeString(path.node.value);
        },
        TemplateLiteral(path) {
            for (const q of path.node.quasis) {
                consumeString(q.value.cooked ?? q.value.raw ?? '');
            }
        },
        JSXAttribute(path) {
            const v = path.node.value;
            if (v && v.type === 'StringLiteral')
                consumeString(v.value);
        },
    });
}
// --- Orchestrator -----------------------------------------------------------
/**
 * Extract design taste from a repo directory.
 *
 * @param {string} root — absolute or relative path to the project root
 * @param {{root?: string, maxFiles?: number, maxFileBytes?: number}} [opts]
 * @returns {Promise<ExtractedTaste>}
 */
export async function extractFromRepo(root, opts = {}) {
    const absRoot = resolvePath(root || '.');
    const startDir = opts.root ? resolvePath(absRoot, opts.root) : absRoot;
    const maxFiles = opts.maxFiles ?? MAX_FILES;
    const maxFileBytes = opts.maxFileBytes ?? MAX_FILE_BYTES;
    const acc = {
        colorsCss: /** @type {Map<string, number>} */ (new Map()),
        colorsBabel: /** @type {Map<string, number>} */ (new Map()),
        twColors: /** @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 filesScanned = 0;
    // Lazy-load css-tree / @babel/parser / @babel/traverse once per extract call.
    // We only import what we need based on the files encountered, so a CSS-only
    // repo does not need babel installed, and a JS-only repo does not need css-tree.
    let cssTree = null;
    let babelParser = null;
    let babelTraverse = null;
    // Collect files up-front so we know which deps to lazy-load. This also
    // lets us apply MAX_FILES uniformly regardless of traversal order.
    const files = [];
    const queue = [startDir];
    while (queue.length > 0 && files.length < maxFiles) {
        const dir = queue.shift();
        let entries;
        try {
            entries = readdirSync(dir, { withFileTypes: true });
        }
        catch {
            continue;
        }
        for (const entry of entries) {
            if (files.length >= maxFiles)
                break;
            const full = join(dir, entry.name);
            if (entry.isDirectory()) {
                if (EXCLUDED_DIRS.has(entry.name))
                    continue;
                if (entry.name.startsWith('.') && entry.name !== '.aperant' && entry.name !== '.git') {
                    continue;
                }
                queue.push(full);
                continue;
            }
            if (!entry.isFile())
                continue;
            const lowerName = entry.name.toLowerCase();
            const dot = lowerName.lastIndexOf('.');
            const ext = dot >= 0 ? lowerName.slice(dot) : '';
            if (!SCAN_EXTENSIONS.has(ext) && !SCAN_FILENAMES.has(lowerName))
                continue;
            files.push({ full, ext, lowerName });
        }
    }
    const hasCss = files.some((f) => CSS_EXT.has(f.ext));
    const hasJs = files.some((f) => JS_EXT.has(f.ext));
    if (hasCss) {
        cssTree = await loadOptionalDep('css-tree');
    }
    if (hasJs) {
        babelParser = await loadOptionalDep('@babel/parser');
        babelTraverse = await loadOptionalDep('@babel/traverse');
    }
    for (const { full, ext, lowerName } of files) {
        let text;
        try {
            const st = statSync(full);
            if (st.size > maxFileBytes)
                continue;
            text = readFileSync(full, 'utf-8');
        }
        catch {
            continue;
        }
        if (CSS_EXT.has(ext)) {
            try {
                const ast = cssTree.parse(text, { positions: false });
                harvestCssAst(cssTree, ast, acc);
            }
            catch {
                // css-tree is permissive; if parse fails (e.g. exotic SCSS syntax)
                // we just skip the file — no rethrow.
            }
        }
        else if (JS_EXT.has(ext) || SCAN_FILENAMES.has(lowerName)) {
            try {
                const ast = babelParser.parse(text, {
                    sourceType: 'module',
                    errorRecovery: true,
                    plugins: ['typescript', 'jsx'],
                });
                harvestJsAst(babelTraverse, ast, acc);
            }
            catch {
                // Babel parser failed (rare — it has errorRecovery enabled).
                // Fall back to raw regex over the text so non-parseable files
                // still contribute colour / spacing signal.
                for (const m of text.matchAll(HEX_COLOR_RE))
                    bumpCount(acc.colorsBabel, m[0].toLowerCase());
                for (const m of text.matchAll(RAW_LENGTH_RE))
                    bumpCount(acc.spacing, m[0]);
            }
        }
        filesScanned++;
    }
    // Merge CSS + Babel colour signal into a single colours list (each source
    // tagged for downstream synthesis heuristics).
    const colors = [];
    for (const [value, weight] of acc.colorsCss) {
        colors.push({ value, weight, source: /** @type {const} */ ('css') });
    }
    for (const [value, weight] of acc.colorsBabel) {
        colors.push({ value, weight, source: /** @type {const} */ ('babel') });
    }
    // Dedupe same-value-across-sources → pick the max weight and the
    // preferred source (css > babel). Stable desc sort by weight, tiebreak
    // alphabetical.
    const colorMerged = new Map();
    for (const c of colors) {
        const prev = colorMerged.get(c.value);
        if (!prev)
            colorMerged.set(c.value, c);
        else {
            colorMerged.set(c.value, {
                value: c.value,
                weight: prev.weight + c.weight,
                source: prev.source === 'css' || c.source === 'css' ? 'css' : 'babel',
            });
        }
    }
    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,
        },
        target: absRoot,
        mode: /** @type {const} */ ('repo'),
        // Extra diagnostic fields — not required by the schema but useful for
        // the CLI envelope summary.
        _filesScanned: filesScanned,
        _fontSizes: toWeighted(acc.fontSizes),
        _lineHeights: toWeighted(acc.lineHeights),
        _twColors: toWeighted(acc.twColors),
    };
}
//# sourceMappingURL=extract-repo.mjs.map