/**
 * design/extract.mjs — Pure, text-only token extractors for C27.
 *
 * Every function in this module takes a single UTF-8 string and returns a
 * plain object of sorted, deduped string arrays matching the relevant slice
 * of DesignTokensSchema. No filesystem access, no side effects — the scan
 * orchestrator in `scan.mjs` is responsible for reading files and merging
 * extractor results across the project.
 *
 * Extraction strategy is deliberately regex-based (not AST / CSS parser) so
 * the command stays zero-dep and runs fast on brownfield codebases. Accuracy
 * vs. completeness trade-offs are documented in `docs/design-tokens.md` under
 * "v1 limits".
 *
 * The regex constants are exported so unit tests can assert against them
 * individually and so downstream skills can re-use them for LLM prompt
 * context without re-typing the patterns.
 */

// --- Colors -----------------------------------------------------------------

// Hex: 3/4/6/8-digit. Anchored to a non-word boundary on the left to avoid
// matching inside identifiers like `#foo123abcdef`; `\b` after the hex chars
// rules out `#abcdefgh`.
export const HEX_COLOR_RE = /(?<![\w#])#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g

// rgb/rgba/hsl/hsla functional forms — match the whole `rgb(...)` expression.
export const RGB_HSL_RE = /\b(rgba?|hsla?)\(\s*[^)]*\)/gi

// Tailwind palette: 13 utility prefixes × 22 canonical colors × 11 shades.
// We also accept prefix+color without a shade (`bg-white`, `text-black`) for
// the neutrals Tailwind names without a number. The capture groups are
// discarded — we store the full `color-shade` fragment (e.g. `slate-900`).
const TW_UTIL_PREFIX =
	'bg|text|border|ring|from|to|via|fill|stroke|outline|divide|placeholder|caret|accent|decoration'
const TW_COLOR_NAME =
	'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'
const TW_SHADE = '50|100|200|300|400|500|600|700|800|900|950'
export const TW_COLOR_RE = new RegExp(
	`\\b(?:${TW_UTIL_PREFIX})-(?:(?:${TW_COLOR_NAME})-(?:${TW_SHADE})|white|black|transparent|current|inherit)\\b`,
	'g',
)

// CSS custom property NAMES that look like color tokens. We only capture the
// identifier (`--color-primary`), not the value, since value shapes vary and
// the color extractor above already catches hex / rgb / hsl values.
export const CSS_COLOR_VAR_RE = /--color-[a-z0-9][a-z0-9-]*/g

/**
 * Extract colors from a text blob.
 * @param {string} text
 * @returns {{hex: string[], rgb: string[], hsl: string[], tailwind: string[], css_vars: string[]}}
 */
export function extractColors(text) {
	const hex = new Set()
	const rgb = new Set()
	const hsl = new Set()
	const tailwind = new Set()
	const cssVars = new Set()

	for (const m of text.matchAll(HEX_COLOR_RE)) hex.add(m[0].toLowerCase())

	for (const m of text.matchAll(RGB_HSL_RE)) {
		// Collapse internal whitespace so `rgb( 0 , 0 , 0 )` and `rgb(0,0,0)`
		// dedup cleanly. Trailing-comma variants (rgb(0 0 0 / 0.5)) are left
		// untouched — the raw matched value is what a reader sees in source.
		const normalized = m[0].replace(/\s+/g, ' ').trim().toLowerCase()
		if (/^hsla?/.test(normalized)) hsl.add(normalized)
		else rgb.add(normalized)
	}

	for (const m of text.matchAll(TW_COLOR_RE)) {
		// Strip the utility prefix so `bg-slate-900` and `text-slate-900` both
		// record as `slate-900` — consumers want the palette token, not every
		// utility that uses it.
		const value = m[0].replace(new RegExp(`^(?:${TW_UTIL_PREFIX})-`), '')
		tailwind.add(value)
	}

	for (const m of text.matchAll(CSS_COLOR_VAR_RE)) cssVars.add(m[0])

	return {
		hex: [...hex].sort(),
		rgb: [...rgb].sort(),
		hsl: [...hsl].sort(),
		tailwind: [...tailwind].sort(),
		css_vars: [...cssVars].sort(),
	}
}

// --- Spacing ----------------------------------------------------------------

// Raw length: integer or decimal, px / rem / em, with optional leading sign.
// The preceding char must not be a word-char so `16px` matches but
// `foo16px` does not. Upper-bounded at 4 digits to skip implausible values.
export const RAW_LENGTH_RE = /(?<![\w.-])-?(?:\d{1,4}(?:\.\d{1,3})?)(px|rem|em)\b/g

// Tailwind spacing utilities. Captures the numeric scale token (`4`, `2.5`,
// `px`, `auto`). Utility prefix set deliberately wider than colors because
// Tailwind uses the spacing scale for many properties.
//
// The class must be preceded by a class-context boundary (start, whitespace,
// or the usual JSX/HTML className delimiters). This stops identifier-embedded
// collisions like `wt-001` (where `w` + `-001` would otherwise match the
// `w-\d+` shape for `width` utilities). The numeric value also rejects
// leading zeros on multi-digit integers (Tailwind uses `0`, not `00`),
// further narrowing false positives.
const TW_SPACING_PREFIX = 'p|m|gap|space|inset|top|right|bottom|left|w|h|min-w|max-w|min-h|max-h'
const TW_SPACING_BOUNDARY = '(?:^|[\\s"\'`{(\\[])'
const TW_SPACING_VALUE = '(?:0(?:\\.\\d+)?|[1-9]\\d*(?:\\.\\d+)?|px|auto|full|screen)'
// Capture group 1: full class; capture group 2: numeric scale token.
export const TW_SPACING_RE = new RegExp(
	`${TW_SPACING_BOUNDARY}((?:${TW_SPACING_PREFIX})(?:[xytrbl])?-(${TW_SPACING_VALUE}))\\b`,
	'g',
)

/**
 * Extract spacing tokens from a text blob.
 * @param {string} text
 * @returns {{raw: string[], tailwind_scale: string[]}}
 */
export function extractSpacing(text) {
	const raw = new Set()
	const tw = new Set()

	for (const m of text.matchAll(RAW_LENGTH_RE)) raw.add(m[0])
	for (const m of text.matchAll(TW_SPACING_RE)) {
		// Drop `full` / `screen` — those are width/height sentinels, not
		// spacing-scale contributors. m[2] is the scale token.
		if (m[2] === 'full' || m[2] === 'screen') continue
		tw.add(m[2])
	}

	return {
		raw: [...raw].sort(lengthSort),
		tailwind_scale: [...tw].sort(numericScaleSort),
	}
}

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
}

// --- Typography -------------------------------------------------------------

// CSS font-family: capture the declaration's value. Stops at `;` or newline.
export const FONT_FAMILY_RE = /font-family\s*:\s*([^;\n]+)/gi

// Tailwind text size classes: `text-xs` … `text-9xl`. Excludes alignment
// (`text-left`) and color (`text-red-500`) via explicit enum.
const TW_SIZE = 'xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl'
export const TW_TEXT_SIZE_RE = new RegExp(`\\btext-(?:${TW_SIZE})\\b`, 'g')

// CSS font-size values — px / rem / em literal.
export const FONT_SIZE_RAW_RE = /font-size\s*:\s*([^;\n]+)/gi

// Line height — capture declaration value.
export const LINE_HEIGHT_RE = /line-height\s*:\s*([^;\n]+)/gi

/**
 * Extract typography tokens from a text blob.
 * @param {string} text
 * @returns {{font_families: string[], font_sizes_raw: string[], tailwind_sizes: string[], line_heights: string[]}}
 */
export function extractTypography(text) {
	const families = new Set()
	const sizesRaw = new Set()
	const twSizes = new Set()
	const lineHeights = new Set()

	for (const m of text.matchAll(FONT_FAMILY_RE)) {
		// Split on commas to capture each individual family, strip quotes,
		// trim whitespace. `Inter, -apple-system, sans-serif` → three entries.
		for (const part of m[1].split(',')) {
			const cleaned = part.trim().replace(/^['"]|['"]$/g, '')
			if (cleaned.length > 0 && cleaned.length < 64) families.add(cleaned)
		}
	}

	for (const m of text.matchAll(FONT_SIZE_RAW_RE)) {
		const value = m[1].trim()
		if (value.length > 0 && value.length < 64) sizesRaw.add(value)
	}

	for (const m of text.matchAll(TW_TEXT_SIZE_RE)) {
		twSizes.add(m[0].replace(/^text-/, ''))
	}

	for (const m of text.matchAll(LINE_HEIGHT_RE)) {
		const value = m[1].trim()
		if (value.length > 0 && value.length < 64) lineHeights.add(value)
	}

	return {
		font_families: [...families].sort(),
		font_sizes_raw: [...sizesRaw].sort(),
		tailwind_sizes: [...twSizes].sort(typographyScaleSort),
		line_heights: [...lineHeights].sort(),
	}
}

// Tailwind size order for human-readable output.
const TW_SIZE_ORDER = [
	'xs',
	'sm',
	'base',
	'lg',
	'xl',
	'2xl',
	'3xl',
	'4xl',
	'5xl',
	'6xl',
	'7xl',
	'8xl',
	'9xl',
]
function typographyScaleSort(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)
}

// --- Radii ------------------------------------------------------------------

export const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;\n]+)/gi

// Tailwind `rounded`, `rounded-md`, `rounded-tl-lg`, `rounded-full` etc.
// Captures the size token after the final dash, or "" for bare `rounded`.
export const TW_ROUNDED_RE = /\brounded(?:-(?:t|r|b|l|tl|tr|bl|br))?(?:-([a-z0-9]+))?\b/g

/**
 * Extract border-radius tokens from a text blob.
 * @param {string} text
 * @returns {{raw: string[], tailwind: string[]}}
 */
export function extractRadii(text) {
	const raw = new Set()
	const tw = new Set()

	for (const m of text.matchAll(BORDER_RADIUS_RE)) {
		const value = m[1].trim()
		if (value.length > 0 && value.length < 64) raw.add(value)
	}

	for (const m of text.matchAll(TW_ROUNDED_RE)) {
		// `rounded` with no size token → default size, record as "default" so
		// the scale includes it but doesn't collide with `rounded-md`.
		tw.add(m[1] ?? 'default')
	}

	return {
		raw: [...raw].sort(),
		tailwind: [...tw].sort(radiiScaleSort),
	}
}

const RADII_ORDER = ['none', 'sm', 'default', 'md', 'lg', 'xl', '2xl', '3xl', 'full']
function radiiScaleSort(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)
}

// --- Shadows ----------------------------------------------------------------

export const BOX_SHADOW_RE = /box-shadow\s*:\s*([^;\n]+)/gi

// Tailwind: `shadow`, `shadow-sm`, `shadow-inner`, `shadow-md`. Excludes
// color utilities like `shadow-slate-500` by enumerating size tokens only.
const TW_SHADOW_SIZE = 'sm|md|lg|xl|2xl|inner|none'
export const TW_SHADOW_RE = new RegExp(`\\bshadow(?:-(?:${TW_SHADOW_SIZE}))?\\b`, 'g')

/**
 * Extract shadow tokens from a text blob.
 * @param {string} text
 * @returns {{raw: string[], tailwind: string[]}}
 */
export function extractShadows(text) {
	const raw = new Set()
	const tw = new Set()

	for (const m of text.matchAll(BOX_SHADOW_RE)) {
		const value = m[1].trim()
		if (value.length > 0 && value.length < 128) raw.add(value)
	}

	for (const m of text.matchAll(TW_SHADOW_RE)) {
		const suffix = m[0].includes('-') ? m[0].slice('shadow-'.length) : 'default'
		tw.add(suffix)
	}

	return {
		raw: [...raw].sort(),
		tailwind: [...tw].sort(shadowScaleSort),
	}
}

const SHADOW_ORDER = ['none', 'sm', 'default', 'md', 'lg', 'xl', '2xl', 'inner']
function shadowScaleSort(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)
}
