/**
 * design/scan.mjs — Filesystem walker + orchestrator for C27 design-scan.
 *
 * Walks the project root (or a user-supplied subdirectory), reads each UI
 * source file, runs the extractors from `extract.mjs`, and merges their
 * results into a single tokens object that conforms to `DesignTokensSchema`.
 *
 * Design intent:
 *   - Zero external deps — readdirSync + statSync + readFileSync only.
 *   - Bounded: MAX_FILES and MAX_FILE_BYTES caps keep runtime predictable on
 *     large brownfield codebases.
 *   - Conservative exclusions: the usual build-output + VCS + framework state
 *     directories are skipped outright before recursing.
 *   - Deterministic output: every extractor dedups and sorts; merging across
 *     files re-dedups via Set and re-sorts per slice.
 *
 * The orchestrator is pure with respect to the returned value — it does not
 * touch `.aperant/`. Writing `tokens.json` is the command wrapper's job
 * (commands/design-scan.mjs), which also adds the timestamp.
 */

import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve as resolvePath } from 'node:path'
import {
	extractColors,
	extractRadii,
	extractShadows,
	extractSpacing,
	extractTypography,
} from './extract.mjs'

export const SCAN_EXTENSIONS = new Set([
	'.css',
	'.scss',
	'.sass',
	'.less',
	'.ts',
	'.tsx',
	'.js',
	'.jsx',
	'.mjs',
	'.cjs',
	'.html',
	'.vue',
	'.svelte',
	'.mdx',
])

// Additional explicit filenames: Tailwind config variants ship without a
// distinguishing extension convention beyond .js/.ts, which are already in the
// set above. Listed here so readers know we intentionally cover them.
export const SCAN_FILENAMES = new Set([
	'tailwind.config.js',
	'tailwind.config.ts',
	'tailwind.config.mjs',
	'tailwind.config.cjs',
])

export const EXCLUDED_DIRS = new Set([
	'node_modules',
	'dist',
	'build',
	'.next',
	'out',
	'.aperant',
	'.git',
	'coverage',
	'.turbo',
	'.cache',
	'.vercel',
])

export const MAX_FILES = 2000
export const MAX_FILE_BYTES = 256 * 1024 // 256 KB

/**
 * Scan a project directory for design tokens.
 *
 * @param {string} projectRoot absolute or relative path to the project
 * @param {{root?: string, maxFiles?: number, maxFileBytes?: number}} [opts]
 *   root: optional subdirectory under projectRoot to scan (defaults to the
 *     whole project). `node packages/framework/bin/apt-tools.mjs design-scan
 *     . --root packages/ui` limits the scan to that tree.
 * @returns {{
 *   version: 1,
 *   project_root: string,
 *   scan: {files_scanned: number, files_skipped: number},
 *   colors: ReturnType<typeof extractColors>,
 *   spacing: ReturnType<typeof extractSpacing>,
 *   typography: ReturnType<typeof extractTypography>,
 *   radii: ReturnType<typeof extractRadii>,
 *   shadows: ReturnType<typeof extractShadows>,
 * }}
 */
export function scanDesignTokens(projectRoot, opts = {}) {
	const absRoot = resolvePath(projectRoot || '.')
	const startDir = opts.root ? resolvePath(absRoot, opts.root) : absRoot
	const maxFiles = opts.maxFiles ?? MAX_FILES
	const maxFileBytes = opts.maxFileBytes ?? MAX_FILE_BYTES

	const acc = {
		hex: new Set(),
		rgb: new Set(),
		hsl: new Set(),
		tw_colors: new Set(),
		css_vars: new Set(),
		spacing_raw: new Set(),
		tw_spacing: new Set(),
		font_families: new Set(),
		font_sizes_raw: new Set(),
		tw_text_sizes: new Set(),
		line_heights: new Set(),
		radii_raw: new Set(),
		tw_radii: new Set(),
		shadow_raw: new Set(),
		tw_shadows: new Set(),
	}

	let filesScanned = 0
	let filesSkipped = 0

	const queue = [startDir]
	while (queue.length > 0 && filesScanned < maxFiles) {
		const dir = queue.shift()
		let entries
		try {
			entries = readdirSync(dir, { withFileTypes: true })
		} catch {
			continue
		}
		for (const entry of entries) {
			if (filesScanned >= 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') {
					// Skip other dotfile dirs by default (.nuxt, .svelte-kit, etc.)
					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) : ''
			const isScannedExt = SCAN_EXTENSIONS.has(ext)
			const isScannedName = SCAN_FILENAMES.has(lowerName)
			if (!isScannedExt && !isScannedName) continue

			let text
			try {
				const st = statSync(full)
				if (st.size > maxFileBytes) {
					filesSkipped++
					continue
				}
				text = readFileSync(full, 'utf-8')
			} catch {
				filesSkipped++
				continue
			}

			mergeInto(acc, text)
			filesScanned++
		}
	}

	return {
		version: 1,
		project_root: absRoot,
		scan: { files_scanned: filesScanned, files_skipped: filesSkipped },
		colors: {
			hex: sortedUnique(acc.hex),
			rgb: sortedUnique(acc.rgb),
			hsl: sortedUnique(acc.hsl),
			tailwind: sortedUnique(acc.tw_colors),
			css_vars: sortedUnique(acc.css_vars),
		},
		spacing: {
			raw: sortedUnique(acc.spacing_raw, lengthSort),
			tailwind_scale: sortedUnique(acc.tw_spacing, numericScaleSort),
		},
		typography: {
			font_families: sortedUnique(acc.font_families),
			font_sizes_raw: sortedUnique(acc.font_sizes_raw),
			tailwind_sizes: sortedUnique(acc.tw_text_sizes, twTextSizeSort),
			line_heights: sortedUnique(acc.line_heights),
		},
		radii: {
			raw: sortedUnique(acc.radii_raw),
			tailwind: sortedUnique(acc.tw_radii, radiiSort),
		},
		shadows: {
			raw: sortedUnique(acc.shadow_raw),
			tailwind: sortedUnique(acc.tw_shadows, shadowSort),
		},
	}
}

function mergeInto(acc, text) {
	const c = extractColors(text)
	for (const v of c.hex) acc.hex.add(v)
	for (const v of c.rgb) acc.rgb.add(v)
	for (const v of c.hsl) acc.hsl.add(v)
	for (const v of c.tailwind) acc.tw_colors.add(v)
	for (const v of c.css_vars) acc.css_vars.add(v)

	const s = extractSpacing(text)
	for (const v of s.raw) acc.spacing_raw.add(v)
	for (const v of s.tailwind_scale) acc.tw_spacing.add(v)

	const t = extractTypography(text)
	for (const v of t.font_families) acc.font_families.add(v)
	for (const v of t.font_sizes_raw) acc.font_sizes_raw.add(v)
	for (const v of t.tailwind_sizes) acc.tw_text_sizes.add(v)
	for (const v of t.line_heights) acc.line_heights.add(v)

	const r = extractRadii(text)
	for (const v of r.raw) acc.radii_raw.add(v)
	for (const v of r.tailwind) acc.tw_radii.add(v)

	const sh = extractShadows(text)
	for (const v of sh.raw) acc.shadow_raw.add(v)
	for (const v of sh.tailwind) acc.tw_shadows.add(v)
}

function sortedUnique(set, cmp) {
	return [...set].sort(cmp)
}

// --- Shared sort comparators (mirror extract.mjs) ---------------------------

function lengthSort(a, b) {
	const na = parseFloat(a)
	const nb = parseFloat(b)
	if (Number.isNaN(na) || Number.isNaN(nb)) return a.localeCompare(b)
	if (na !== nb) return na - nb
	return a.localeCompare(b)
}

function numericScaleSort(a, b) {
	const na = parseFloat(a)
	const nb = parseFloat(b)
	if (Number.isNaN(na) && Number.isNaN(nb)) return a.localeCompare(b)
	if (Number.isNaN(na)) return 1
	if (Number.isNaN(nb)) return -1
	return na - nb
}

const TW_SIZE_ORDER = [
	'xs',
	'sm',
	'base',
	'lg',
	'xl',
	'2xl',
	'3xl',
	'4xl',
	'5xl',
	'6xl',
	'7xl',
	'8xl',
	'9xl',
]
function twTextSizeSort(a, b) {
	const ia = TW_SIZE_ORDER.indexOf(a)
	const ib = TW_SIZE_ORDER.indexOf(b)
	if (ia >= 0 && ib >= 0) return ia - ib
	if (ia >= 0) return -1
	if (ib >= 0) return 1
	return a.localeCompare(b)
}

const RADII_ORDER = ['none', 'sm', 'default', 'md', 'lg', 'xl', '2xl', '3xl', 'full']
function radiiSort(a, b) {
	const ia = RADII_ORDER.indexOf(a)
	const ib = RADII_ORDER.indexOf(b)
	if (ia >= 0 && ib >= 0) return ia - ib
	if (ia >= 0) return -1
	if (ib >= 0) return 1
	return a.localeCompare(b)
}

const SHADOW_ORDER = ['none', 'sm', 'default', 'md', 'lg', 'xl', '2xl', 'inner']
function shadowSort(a, b) {
	const ia = SHADOW_ORDER.indexOf(a)
	const ib = SHADOW_ORDER.indexOf(b)
	if (ia >= 0 && ib >= 0) return ia - ib
	if (ia >= 0) return -1
	if (ib >= 0) return 1
	return a.localeCompare(b)
}
