/**
 * design/synthesize.mjs — design-synthesis surface (C43 + C44).
 *
 * This module ships TWO public entry points that together cover both the
 * C43 URL/repo extraction path and the C44 product-context-aware
 * `/apt:design --new` path:
 *
 *   1. `synthesizeDesignMd({ productContext, taste, observedTokens,
 *      referenceDesignMd, styleSeed, llmClient })`
 *      — the canonical v1 LLM-backed entry point. Reads the shared prompt
 *      at `packages/framework/prompts/design-synthesize.md`, fills the
 *      five named input slots (YAML `inputs:` block contract), calls the
 *      injected `llmClient.generateText`, validates the returned DESIGN.md
 *      via `validateDesignMarkdown`, one retry on validation failure.
 *      Returns `{ text, provenance }`. Provider-agnostic; tests pass a
 *      fake client.
 *
 *   2. `synthesizeDesign(extracted, opts)`
 *      — C43 convenience wrapper. Takes an `ExtractedTaste` intermediate
 *      (the output of `extract-url.mjs` / `extract-repo.mjs`) and returns
 *      `{ frontmatter, body, path: 'llm' | 'fallback' }`. When
 *      `opts.provider === 'none'` or `opts.dry` is set, runs the
 *      deterministic fallback (`synthesizeDeterministic`). Otherwise
 *      delegates to `synthesizeDesignMd` via an internal Google Gen AI
 *      client built from `@google/genai`, parses the returned DESIGN.md,
 *      and degrades to the deterministic fallback on any error so the
 *      caller always gets a schema-valid result.
 *
 * LLM isolation (I-2):
 *   - Both entry points keep LLM SDKs out of module scope. `@google/genai`
 *     is dynamic-imported inside the provider-call helper; tests inject a
 *     mock client instead, so the real SDK is never loaded during unit
 *     runs. Extractor modules (`extract-url.mjs`, `extract-repo.mjs`)
 *     MUST stay LLM-free per I-1 — enforced by an AST invariant test.
 *
 * Deterministic branch heuristic (spec §4.4): cluster colours in RGB space
 * with Δ < 4, sort by weight, assign bg/surface/accent/text/border via a
 * luminance table (with a light-mode inversion when the heaviest colour is
 * itself light). Single-colour input produces a safe
 * high-contrast-complement fallback so `bg` and `text` never collapse to
 * the same hex.
 */

import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
import { validateDesignFrontmatter } from './frontmatter-schema.mjs'
import { validateDesignMarkdown } from './validate.mjs'

// --- Prompt template path --------------------------------------------------

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

// packages/framework/src/cli/design/synthesize.mjs → up 3 levels → packages/framework/
const PROMPT_PATH = resolve(__dirname, '..', '..', '..', 'prompts', 'design-synthesize.md')

const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/

// --- Types (JSDoc) ---------------------------------------------------------

/**
 * @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
 */

/**
 * @typedef {Object} LlmClient
 * @property {(input: { system: string, prompt: string }) => Promise<{ text: string, model?: string }>} generateText
 */

/**
 * @typedef {Object} SynthesizeOpts
 * @property {unknown} [productContext]     — populated by /apt:design --new
 * @property {unknown} [taste]               — parsed .aperant/design/taste.yaml, or a C43 ExtractedTaste intermediate
 * @property {unknown} [observedTokens]      — parsed .aperant/design/tokens.json
 * @property {string | null} [referenceDesignMd]  — C43 URL-extracted reference DESIGN.md text
 * @property {string | null} [styleSeed]     — typeui-sh slug
 * @property {LlmClient} llmClient           — required; test-mockable
 */

/**
 * @typedef {Object} SynthesisResult
 * @property {string} text                   — the emitted DESIGN.md
 * @property {{ inputs: string[], model: string, warnings: string[] }} provenance
 */

// --- Color utilities -------------------------------------------------------

/**
 * Parse any of `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `rgba(...)`.
 * Returns null on anything we can't confidently map to an 8-bit triplet.
 * HSL is normalised by converting through `rgb()`-ish arithmetic — we keep a
 * simple HSL-to-RGB converter for palette coverage.
 *
 * @param {string} value
 * @returns {{ r: number, g: number, b: number, a: number } | null}
 */
export function parseColor(value) {
	if (typeof value !== 'string') return null
	const v = value.trim().toLowerCase()
	// Hex variants.
	if (v.startsWith('#')) {
		let hex = v.slice(1)
		if (hex.length === 3) {
			hex = hex
				.split('')
				.map((ch) => ch + ch)
				.join('')
		}
		if (hex.length === 4) {
			hex = hex
				.split('')
				.map((ch) => ch + ch)
				.join('')
		}
		if (hex.length !== 6 && hex.length !== 8) return null
		const r = Number.parseInt(hex.slice(0, 2), 16)
		const g = Number.parseInt(hex.slice(2, 4), 16)
		const b = Number.parseInt(hex.slice(4, 6), 16)
		if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null
		const a = hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1
		return { r, g, b, a }
	}
	// rgb / rgba
	const rgbMatch = v.match(/^rgba?\(\s*([^)]+)\)$/i)
	if (rgbMatch) {
		const parts = rgbMatch[1].split(/[,/ ]+/).filter(Boolean)
		if (parts.length < 3) return null
		const r = numberFromColorPart(parts[0], 255)
		const g = numberFromColorPart(parts[1], 255)
		const b = numberFromColorPart(parts[2], 255)
		const a = parts[3] !== undefined ? numberFromColorPart(parts[3], 1) : 1
		if (r === null || g === null || b === null) return null
		return { r, g, b, a: a ?? 1 }
	}
	// hsl / hsla
	const hslMatch = v.match(/^hsla?\(\s*([^)]+)\)$/i)
	if (hslMatch) {
		const parts = hslMatch[1].split(/[,/ ]+/).filter(Boolean)
		if (parts.length < 3) return null
		const h = numberFromColorPart(parts[0].replace('deg', ''), 360)
		const s = numberFromColorPart(parts[1].replace('%', ''), 100)
		const l = numberFromColorPart(parts[2].replace('%', ''), 100)
		const a = parts[3] !== undefined ? numberFromColorPart(parts[3].replace('%', ''), 1) : 1
		if (h === null || s === null || l === null) return null
		const { r, g, b } = hslToRgb(h, (s ?? 0) / 100, (l ?? 0) / 100)
		return { r, g, b, a: a ?? 1 }
	}
	return null
}

function numberFromColorPart(raw, scale) {
	const t = raw.trim().toLowerCase()
	if (t.endsWith('%')) {
		const n = Number.parseFloat(t)
		if (Number.isNaN(n)) return null
		return Math.round((n / 100) * (scale === 1 ? 1 : scale))
	}
	const n = Number.parseFloat(t)
	if (Number.isNaN(n)) return null
	return scale === 1 ? n : Math.round(n)
}

function hslToRgb(h, s, l) {
	const hue = (((h % 360) + 360) % 360) / 360
	if (s === 0) {
		const v = Math.round(l * 255)
		return { r: v, g: v, b: v }
	}
	const q = l < 0.5 ? l * (1 + s) : l + s - l * s
	const p = 2 * l - q
	const hueToRgb = (t) => {
		if (t < 0) t += 1
		if (t > 1) t -= 1
		if (t < 1 / 6) return p + (q - p) * 6 * t
		if (t < 1 / 2) return q
		if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
		return p
	}
	return {
		r: Math.round(hueToRgb(hue + 1 / 3) * 255),
		g: Math.round(hueToRgb(hue) * 255),
		b: Math.round(hueToRgb(hue - 1 / 3) * 255),
	}
}

/**
 * Relative luminance per WCAG 2.1. Input is sRGB (0-255).
 */
export function luminance({ r, g, b }) {
	const toLin = (c) => {
		const v = c / 255
		return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
	}
	return 0.2126 * toLin(r) + 0.7152 * toLin(g) + 0.0722 * toLin(b)
}

/**
 * Chroma-ish measure: max RGB channel minus min RGB channel, normalised.
 * High values = saturated colour. Low values = neutral (gray).
 */
export function chroma({ r, g, b }) {
	const max = Math.max(r, g, b)
	const min = Math.min(r, g, b)
	return (max - min) / 255
}

function toHex6({ r, g, b }) {
	const clip = (n) => Math.max(0, Math.min(255, Math.round(n)))
	return `#${clip(r).toString(16).padStart(2, '0')}${clip(g).toString(16).padStart(2, '0')}${clip(b).toString(16).padStart(2, '0')}`
}

/**
 * Cluster colours by Δ in RGB space. Two colours within Δ threshold collapse
 * into one named token (weight sums, representative = highest-weight member).
 *
 * @param {{value: string, weight: number, parsed: {r:number,g:number,b:number,a:number}}[]} entries
 * @param {number} threshold
 */
export function clusterColors(entries, threshold = 4) {
	/** @type {{members: typeof entries, rep: typeof entries[number], totalWeight: number}[]} */
	const clusters = []
	for (const e of entries) {
		let placed = false
		for (const c of clusters) {
			const dr = e.parsed.r - c.rep.parsed.r
			const dg = e.parsed.g - c.rep.parsed.g
			const db = e.parsed.b - c.rep.parsed.b
			if (Math.hypot(dr, dg, db) <= threshold) {
				c.members.push(e)
				c.totalWeight += e.weight
				if (e.weight > c.rep.weight) c.rep = e
				placed = true
				break
			}
		}
		if (!placed) {
			clusters.push({ members: [e], rep: e, totalWeight: e.weight })
		}
	}
	return clusters.sort((a, b) => b.totalWeight - a.totalWeight)
}

// --- Deterministic synthesis ------------------------------------------------

const DEFAULT_SCALE = ['14px', '16px', '20px']
const DEFAULT_SPACING = ['8px', '16px']

/**
 * Given enriched colour clusters, assign bg/surface/accent/text/border roles
 * by luminance + chroma. Returns a `colors` object matching ColorsSchema.
 *
 * Light-mode sites (heaviest colour has L > 0.85) invert the heuristic:
 * background is light, text is dark.
 *
 * When enriched has < 2 entries, we synthesize a complementary polarity
 * (black/white) for the missing side so `bg` and `text` never collapse to
 * the same hex (which would produce invisible text).
 */
function assignColorRoles(clusters) {
	// Parse representative → hex → luminance/chroma per cluster.
	const enriched = clusters
		.map((c) => ({
			hex: toHex6(c.rep.parsed),
			lum: luminance(c.rep.parsed),
			chr: chroma(c.rep.parsed),
			weight: c.totalWeight,
			alpha: c.rep.parsed.a,
		}))
		.filter((c) => c.alpha > 0.1) // drop fully transparent accidents

	if (enriched.length === 0) {
		// No colour signal at all — fall back to the scaffold palette so the
		// output still validates. The body note `path: 'fallback'` surfaces
		// the provenance.
		return {
			bg: '#0B0B0F',
			surface: '#16161D',
			accent: '#7C5CFF',
			text: '#F5F5F7',
			border: '#2A2A32',
		}
	}

	// Pick the heaviest colour. If it's bright (lum > 0.85), we're in light
	// mode; otherwise dark mode.
	const heaviest = enriched[0]
	const isLightMode = heaviest.lum > 0.85

	// Single-colour input → synthesize a contrasting complement so bg !== text.
	// Observed-input discipline (the "no inventing hexes" rule) only applies
	// to the accent slot; bg/text need a legible polarity pair to render
	// anything at all.
	if (enriched.length === 1) {
		const only = enriched[0]
		const onlyIsDark = only.lum < 0.5
		return {
			bg: onlyIsDark ? only.hex : '#FFFFFF',
			surface: onlyIsDark ? only.hex : '#F5F5F7',
			accent: only.hex,
			text: onlyIsDark ? '#F5F5F7' : '#0B0B0F',
			border: onlyIsDark ? '#2A2A32' : '#E5E5EA',
		}
	}

	const sortedByLum = [...enriched].sort((a, b) => a.lum - b.lum) // darkest first
	const darkest = sortedByLum[0]
	const lightest = sortedByLum[sortedByLum.length - 1]

	// Second-darkest (not same as darkest) → surface candidate in dark mode;
	// second-lightest in light mode.
	const surfaceCand = isLightMode
		? (sortedByLum[sortedByLum.length - 2] ?? lightest)
		: (sortedByLum[1] ?? darkest)

	// Mid-neutral for border. Range: 0.2 - 0.4 luminance, chroma < 0.08.
	const borderCand =
		enriched.find((c) => c.lum >= 0.2 && c.lum <= 0.4 && c.chr < 0.08) ??
		enriched.find((c) => c.chr < 0.08) ??
		surfaceCand

	// Most-chromatic × weight for accent.
	const accentCand =
		[...enriched]
			.filter((c) => c.chr > 0.1)
			.sort((a, b) => b.chr * b.weight - a.chr * a.weight)[0] ??
		enriched[Math.min(enriched.length - 1, 2)]

	return {
		bg: isLightMode ? lightest.hex : darkest.hex,
		surface: surfaceCand.hex,
		accent: accentCand.hex,
		text: isLightMode ? darkest.hex : lightest.hex,
		border: borderCand.hex,
	}
}

/**
 * Ensure a non-empty primary font family. Pads with a system sans-serif
 * fallback when the extractor found nothing.
 */
function primaryFontFamily(fonts) {
	const first = fonts[0]?.family?.trim()
	if (first && first.length > 0 && first.length < 64) {
		// Append sensible fallbacks if the user declared a bare family.
		if (!/,/.test(first)) return `${first}, ui-sans-serif, system-ui, sans-serif`
		return first
	}
	return 'ui-sans-serif, system-ui, sans-serif'
}

function padScale(values, min = 3) {
	const set = new Set(values)
	for (const v of DEFAULT_SCALE) {
		if (set.size >= min) break
		set.add(v)
	}
	// Sort by parsed numeric length when possible.
	return [...set].sort((a, b) => {
		const na = Number.parseFloat(a)
		const nb = Number.parseFloat(b)
		if (Number.isNaN(na) || Number.isNaN(nb)) return a.localeCompare(b)
		return na - nb
	})
}

function padSpacing(values, min = 2) {
	const set = new Set(values)
	for (const v of DEFAULT_SPACING) {
		if (set.size >= min) break
		set.add(v)
	}
	return [...set].sort((a, b) => {
		const na = Number.parseFloat(a)
		const nb = Number.parseFloat(b)
		if (Number.isNaN(na) || Number.isNaN(nb)) return a.localeCompare(b)
		return na - nb
	})
}

/**
 * Deterministic ExtractedTaste → frontmatter + body synthesis.
 *
 * @param {ExtractedTaste} extracted
 * @returns {{ frontmatter: object, body: string, path: 'fallback' }}
 */
export function synthesizeDeterministic(extracted) {
	// Prepare colour clusters.
	const parsed = (extracted.colors ?? [])
		.map((c) => ({ value: c.value, weight: c.weight, parsed: parseColor(c.value) }))
		.filter((c) => c.parsed !== null)
	/** @type {ReturnType<typeof clusterColors>} */
	const clusters = clusterColors(/** @type {any} */ (parsed), 4)

	const colors = assignColorRoles(clusters)
	const primary = primaryFontFamily(extracted.fonts ?? [])
	const spacing = padSpacing((extracted.spacing ?? []).map((s) => s.value))
	const scale = padScale(
		// Harvest font sizes from extractor if present, else default.
		/** @type {any} */ (extracted)?._fontSizes?.map((f) => f.value) ??
			(extracted.fonts ?? []).map(() => '').filter(Boolean),
	)

	const radius = {}
	for (const r of (extracted.radii ?? []).slice(0, 3)) {
		const name = radiusNameFor(r.value)
		if (!radius[name]) radius[name] = r.value
	}
	if (Object.keys(radius).length === 0) {
		radius.md = '8px'
	}

	const shadow = {}
	const shadowEntries = (extracted.shadows ?? []).slice(0, 3)
	shadowEntries.forEach((s, i) => {
		const key = i === 0 ? 'sm' : i === 1 ? 'md' : 'lg'
		shadow[key] = s.value
	})
	if (Object.keys(shadow).length === 0) {
		shadow.sm = '0 1px 2px rgba(0,0,0,0.30)'
		shadow.md = '0 4px 12px rgba(0,0,0,0.35)'
	}

	const frontmatter = {
		meta: {
			name: deriveNameFromTarget(extracted.target),
			version: '0.1.0',
			updated: new Date().toISOString().slice(0, 10),
			description: `Reverse-engineered from ${extracted.target} via taste-infer (${extracted.mode} mode).`,
		},
		tokens: {
			colors,
			typography: {
				families: { primary },
				scale,
			},
			spacing,
			radius,
			shadow,
		},
	}

	const body = renderDeterministicBody(frontmatter, extracted)

	return { frontmatter, body, path: /** @type {const} */ ('fallback') }
}

function radiusNameFor(value) {
	const n = Number.parseFloat(value)
	if (Number.isNaN(n)) return 'md'
	if (n <= 4) return 'sm'
	if (n <= 8) return 'md'
	if (n <= 16) return 'lg'
	return 'xl'
}

function deriveNameFromTarget(target) {
	if (typeof target !== 'string' || target.length === 0) return 'Inferred design system'
	try {
		if (/^https?:\/\//.test(target)) {
			const u = new URL(target)
			return u.hostname.replace(/^www\./, '')
		}
	} catch {
		/* ignore */
	}
	// repo path → last path segment.
	const seg = target.split(/[\\/]/).filter(Boolean).pop()
	return seg ?? 'Inferred design system'
}

function renderDeterministicBody(frontmatter, extracted) {
	const { colors, typography, spacing } = frontmatter.tokens
	const familyBits = typography.families.primary.split(',').map((s) => s.trim())
	return `# Design System — ${frontmatter.meta.name}

> Reverse-engineered from ${extracted.target} via \`apt-tools taste-infer\` (${extracted.mode} mode). Synthesis path: deterministic fallback.

## 1. Visual Theme

${extracted.mode === 'url' ? 'URL-derived' : 'Repo-derived'} token set. The heuristic picks the darkest observed colour as \`bg\`, the lightest as \`text\`, and the most chromatic as \`accent\`. Light-mode sources flip the bg/text polarity. Edit this section to add a narrative.

## 2. Color Palette

Colours: bg \`${colors.bg}\` · surface \`${colors.surface}\` · accent \`${colors.accent}\` · text \`${colors.text}\` · border \`${colors.border}\`. See \`tokens.colors\` in the frontmatter for exact values.

## 3. Typography

Primary family: \`${familyBits[0] ?? typography.families.primary}\` (with fallbacks to \`${familyBits.slice(1).join(', ') || 'system-ui'}\`). Scale: ${typography.scale.map((s) => `\`${s}\``).join(' / ')}.

## 4. Component Stylings

Narrative skipped — deterministic fallback keeps the minimum-viable DESIGN.md. Populate from domain knowledge after running \`/apt:design --new\` against this file.

## 5. Layout

Spacing scale: ${spacing.map((s) => `\`${s}\``).join(' / ')}.

## 6. Depth

Shadows from the extracted source are preserved under \`tokens.shadow\`. Adjust semantics after reviewing.

## 7. Do's and Don'ts

- Prefer tokens from the frontmatter over ad-hoc values.
- When in doubt, re-run \`apt-tools taste-infer\` with a different target.

## 8. Responsive

Breakpoints were not extracted in v1. Add domain-specific breakpoints here.

## 9. Agent Prompt Guide

Copy-paste starter: "Render a button using \`tokens.colors.accent\` on \`tokens.colors.bg\`, radius from \`components.button.radius\`, spacing from \`tokens.spacing\`."
`
}

// --- Shared prompt template (YAML inputs: contract — C43 ↔ C44) ------------

/**
 * Render a slot value into prompt-template form. `null` / `undefined` /
 * empty string → literal `(not provided)`. Objects are JSON-stringified
 * with two-space indentation so the model sees structured context.
 *
 * @param {unknown} value
 * @returns {string}
 */
function renderSlot(value) {
	if (value === null || value === undefined) return '(not provided)'
	if (typeof value === 'string') return value.length === 0 ? '(not provided)' : value
	try {
		return JSON.stringify(value, null, 2)
	} catch {
		return '(not provided)'
	}
}

/**
 * Load + split the shared design-synthesize prompt into a system-level
 * description (from YAML frontmatter) and a body that still contains
 * `{{slot}}` placeholders.
 *
 * @returns {{ system: string, body: string }}
 */
function loadPromptTemplate() {
	const text = readFileSync(PROMPT_PATH, 'utf8')
	const m = text.match(FRONTMATTER_RE)
	if (!m) {
		throw new Error(`design-synthesize.md: no YAML frontmatter block at ${PROMPT_PATH}`)
	}
	let fm
	try {
		fm = parseYaml(m[1])
	} catch (err) {
		const msg = err instanceof Error ? err.message : String(err)
		throw new Error(`design-synthesize.md: frontmatter YAML parse error — ${msg}`)
	}
	const description =
		fm && typeof fm === 'object' && typeof (/** @type any */ (fm).description) === 'string'
			? /** @type any */ (fm).description
			: 'Synthesize a project-root DESIGN.md from the provided inputs.'
	return { system: description, body: m[2] }
}

/**
 * Replace `{{slot_name}}` placeholders in the prompt body.
 *
 * @param {string} body
 * @param {Record<string, string>} rendered
 * @returns {string}
 */
function fillSlots(body, rendered) {
	let out = body
	for (const [slot, text] of Object.entries(rendered)) {
		const re = new RegExp(`\\{\\{\\s*${slot}\\s*\\}\\}`, 'g')
		out = out.replace(re, text)
	}
	return out
}

// --- Public API: synthesizeDesignMd (C44 canonical entry point) ------------

/**
 * v1 entry point — synthesize a DESIGN.md from whatever inputs are provided.
 *
 * This matches the C44 contract and is the canonical surface going forward.
 * C43's `taste-infer` CLI wraps this through `synthesizeDesign` below for
 * backward compatibility with the existing `{frontmatter, body, path}` shape.
 *
 * @param {SynthesizeOpts} opts
 * @returns {Promise<SynthesisResult>}
 */
export async function synthesizeDesignMd(opts) {
	if (!opts || typeof opts !== 'object') {
		throw new Error('synthesizeDesignMd: opts is required')
	}
	const { llmClient } = opts
	if (!llmClient || typeof llmClient.generateText !== 'function') {
		throw new Error('synthesizeDesignMd: opts.llmClient with generateText is required')
	}

	const slots = {
		product_context: opts.productContext,
		taste: opts.taste,
		observed_tokens: opts.observedTokens,
		reference_design_md: opts.referenceDesignMd ?? null,
		style_seed: opts.styleSeed ?? null,
	}

	const rendered = /** @type {Record<string, string>} */ ({})
	const populatedInputs = /** @type {string[]} */ ([])
	for (const [name, value] of Object.entries(slots)) {
		const text = renderSlot(value)
		rendered[name] = text
		if (text !== '(not provided)') populatedInputs.push(name)
	}

	const { system, body } = loadPromptTemplate()
	const prompt = fillSlots(body, rendered)

	// First attempt.
	const first = await llmClient.generateText({ system, prompt })
	const firstText = first.text ?? ''
	const firstValidation = validateDesignMarkdown(firstText)
	const model = first.model && typeof first.model === 'string' ? first.model : 'unknown'

	if (firstValidation.ok) {
		return {
			text: firstText,
			provenance: {
				inputs: populatedInputs,
				model,
				warnings: firstValidation.warnings ?? [],
			},
		}
	}

	// One retry — inject the validator errors so the model knows what to fix.
	const retryPrompt =
		prompt +
		'\n\n# Validator feedback (retry)\n' +
		'Previous attempt failed validation with:\n' +
		firstValidation.errors.map((e) => `- ${e}`).join('\n') +
		'\n\nEmit a corrected DESIGN.md that passes the validator.'

	const second = await llmClient.generateText({ system, prompt: retryPrompt })
	const secondText = second.text ?? ''
	const secondValidation = validateDesignMarkdown(secondText)
	const modelFinal = second.model && typeof second.model === 'string' ? second.model : model

	if (secondValidation.ok) {
		return {
			text: secondText,
			provenance: {
				inputs: populatedInputs,
				model: modelFinal,
				warnings: secondValidation.warnings ?? [],
			},
		}
	}

	// Second failure — return the better-of-two with warnings. Callers decide
	// whether to reject, fall back, or surface to the user.
	return {
		text: secondText,
		provenance: {
			inputs: populatedInputs,
			model: modelFinal,
			warnings: [
				'design-synthesize: validator failed twice; output is the best-effort second attempt',
				...secondValidation.errors.map((e) => `validator: ${e}`),
			],
		},
	}
}

// --- C43 convenience wrapper (ExtractedTaste → {frontmatter, body, path}) ---

/**
 * Resolve the provider name for the C43 wrapper. `opts.provider === 'none'`
 * short-circuits to deterministic. Otherwise we consult env:
 * APT_SYNTHESIZE_PROVIDER wins; else @google/genai presence (via env
 * GOOGLE_API_KEY) is the fallback; else 'none'.
 *
 * @returns {'google' | 'anthropic' | 'none'}
 */
function resolveProvider(opts) {
	if (opts?.provider === 'none') return 'none'
	if (opts?.provider === 'google' || opts?.provider === 'anthropic') return opts.provider
	const envProvider = process.env.APT_SYNTHESIZE_PROVIDER
	if (envProvider === 'google' || envProvider === 'anthropic' || envProvider === 'none') {
		return envProvider
	}
	if (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY) return 'google'
	return 'none'
}

/**
 * Build a default LlmClient for the C43 wrapper. Dynamic-imports the SDK
 * lazily so this module never touches `@google/genai` at import time.
 * Returns null when the SDK isn't installed or the required env var is
 * missing — the caller then falls back to deterministic synthesis.
 *
 * @param {'google'|'anthropic'} provider
 * @returns {Promise<LlmClient | null>}
 */
async function defaultLlmClient(provider) {
	if (provider !== 'google') return null
	try {
		const mod = /** @type {any} */ (await import('@google/genai'))
		const apiKey = process.env.GOOGLE_API_KEY ?? process.env.GEMINI_API_KEY
		if (!apiKey) return null
		const GoogleGenAI = mod.GoogleGenAI ?? mod.default?.GoogleGenAI
		if (!GoogleGenAI) return null
		const client = new GoogleGenAI({ apiKey })
		return {
			async generateText({ prompt }) {
				const res = await client.models.generateContent({
					model: 'gemini-2.0-flash-exp',
					contents: prompt,
				})
				return { text: res?.text ?? '', model: 'gemini-2.0-flash-exp' }
			},
		}
	} catch {
		return null
	}
}

/**
 * Parse a full DESIGN.md text (frontmatter + body) into the
 * `{frontmatter, body}` pair the C43 CLI expects. Returns null if the text
 * can't be split cleanly — caller falls back to deterministic synthesis.
 *
 * @param {string} text
 * @returns {{ frontmatter: object, body: string } | null}
 */
function splitDesignMd(text) {
	if (typeof text !== 'string' || text.length === 0) return null
	const m = text.match(FRONTMATTER_RE)
	if (!m) return null
	let parsed
	try {
		parsed = parseYaml(m[1])
	} catch {
		return null
	}
	if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
	const validation = validateDesignFrontmatter(parsed)
	if (!validation.success) return null
	return { frontmatter: parsed, body: m[2] }
}

/**
 * C43 convenience entry point — `ExtractedTaste → {frontmatter, body, path}`.
 *
 * Two branches:
 *   - `provider === 'none'` or `dry` → synthesizeDeterministic (no LLM call).
 *   - otherwise → delegate to `synthesizeDesignMd`, either with a
 *     caller-supplied `llmClient`, or a caller-supplied legacy
 *     `providerCall(prompt) → Promise<string>` shim (used by existing tests),
 *     or the default `@google/genai`-backed client when `GOOGLE_API_KEY` is
 *     available. On any LLM / validation failure we degrade to the
 *     deterministic branch so the caller always gets a schema-valid result.
 *
 * @param {ExtractedTaste} extracted
 * @param {{
 *   productContext?: unknown,
 *   dry?: boolean,
 *   provider?: 'google' | 'anthropic' | 'none',
 *   providerCall?: (prompt: string) => Promise<string>,
 *   llmClient?: LlmClient,
 * }} [opts]
 * @returns {Promise<{ frontmatter: object, body: string, path: 'llm'|'fallback' }>}
 */
export async function synthesizeDesign(extracted, opts = {}) {
	const provider = resolveProvider(opts)
	if (provider === 'none' || opts.dry) {
		return synthesizeDeterministic(extracted)
	}

	// Resolve the LlmClient in priority order: explicit llmClient → legacy
	// providerCall shim → default @google/genai-backed client.
	let client = opts.llmClient ?? null
	if (!client && typeof opts.providerCall === 'function') {
		const providerCall = opts.providerCall
		client = {
			async generateText({ prompt }) {
				const text = await providerCall(prompt)
				return { text: typeof text === 'string' ? text : '', model: provider }
			},
		}
	}
	if (!client) {
		client = await defaultLlmClient(/** @type {'google'|'anthropic'} */ (provider))
	}
	if (!client) {
		return synthesizeDeterministic(extracted)
	}

	// Render the `taste` slot from the ExtractedTaste intermediate so the
	// C44 YAML `inputs:` contract drives the downstream prompt.
	try {
		const result = await synthesizeDesignMd({
			productContext: opts.productContext ?? null,
			taste: extracted,
			observedTokens: null,
			referenceDesignMd: null,
			styleSeed: null,
			llmClient: client,
		})
		const split = splitDesignMd(result.text)
		if (!split) return synthesizeDeterministic(extracted)
		if ((result.provenance.warnings ?? []).some((w) => /validator failed twice/.test(w))) {
			return synthesizeDeterministic(extracted)
		}
		return { frontmatter: split.frontmatter, body: split.body, path: /** @type {const} */ ('llm') }
	} catch {
		return synthesizeDeterministic(extracted)
	}
}

/**
 * Render a complete DESIGN.md string from the frontmatter + body parts. The
 * CLI wrapper (taste-infer.mjs) uses this before running
 * `validateDesignMarkdown` and writing the file.
 *
 * @param {object} frontmatter
 * @param {string} body
 * @returns {Promise<string>}
 */
export async function renderDesignMarkdown(frontmatter, body) {
	const fm = stringifyYaml(frontmatter, { indent: 2, lineWidth: 0 }).trimEnd()
	return `---\n${fm}\n---\n\n${body.trimStart()}\n`
}
