/**
 * commands/taste-infer.mjs — `apt-tools taste-infer <target>` (C43).
 *
 * Reverse-engineers a fresh DESIGN.md from one of:
 *   - a live URL (uses Playwright — extract-url.mjs)
 *   - a local repo path (uses css-tree + @babel — extract-repo.mjs)
 *   - a GitHub shortcode `owner/repo` (shallow clone → repo extractor)
 *
 * The emitted DESIGN.md is gated through `validateDesignMarkdown` before
 * being written, so no schema-invalid file ever lands on disk (spec I-3).
 * All errors go through `fail()` / `output()` from util/io.mjs — stdout is
 * a single JSON envelope whether the command succeeds or errors.
 *
 * Output envelope (on success, spec §1.2 F-2):
 *
 *   {
 *     status: 'ok',
 *     command: 'taste-infer',
 *     mode: 'url' | 'repo' | 'github',
 *     target: '<canonical target>',
 *     written: '<abs path to DESIGN.md>',
 *     library_entry: '<abs path>' | null,
 *     synthesis: 'llm' | 'fallback' | 'skipped',
 *     summary: {
 *       colors: { raw, tokenized },
 *       typography: { families },
 *       spacing: { raw },
 *       radii: { raw },
 *       shadow: { raw },
 *       screenshots,
 *       files_scanned,
 *     },
 *   }
 */

import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join, resolve as resolvePath } from 'node:path'
import { stringify as stringifyYaml } from 'yaml'
import { extractFromRepo } from '../design/extract-repo.mjs'
import { extractFromUrl } from '../design/extract-url.mjs'
import { renderDesignMarkdown, synthesizeDesign } from '../design/synthesize.mjs'
import { validateDesignMarkdown } from '../design/validate.mjs'
import { parseFlags } from '../util/args.mjs'
import { atomicWriteText } from '../util/atomic-write.mjs'
import { getVersion } from '../util/io.mjs'
import { exitWith, ok } from '../util/result.mjs'

// ---------------------------------------------------------------------------
// Mode detection
// ---------------------------------------------------------------------------

/**
 * Decide which extractor the target resolves to.
 *
 * Rules (spec §1.2 F-1):
 *   - `http://` / `https://` / `file://` → `url`
 *   - `owner/repo` (exactly one `/`, no leading `./ ../ /`, not an existing
 *     filesystem path) → `github`
 *   - anything else → `repo`
 *
 * Exported for unit testing.
 *
 * @param {string} target
 * @returns {'url' | 'repo' | 'github'}
 */
export function detectMode(target) {
	if (typeof target !== 'string' || target.length === 0) return 'repo'
	if (/^https?:\/\//i.test(target) || /^file:\/\//i.test(target)) return 'url'
	// Existing local path (absolute or relative) → repo.
	const looksLikePath =
		target.startsWith('/') ||
		target.startsWith('./') ||
		target.startsWith('../') ||
		target.startsWith('~')
	if (looksLikePath) return 'repo'
	if (existsSync(target)) {
		try {
			if (statSync(target).isDirectory()) return 'repo'
		} catch {
			/* fall through */
		}
	}
	// `owner/repo` — exactly one `/`, both halves look like identifiers.
	const parts = target.split('/')
	if (
		parts.length === 2 &&
		parts[0].length > 0 &&
		parts[1].length > 0 &&
		!parts[0].includes('\\')
	) {
		// Reject anything containing characters a GitHub slug can't have.
		if (/^[\w.-]+\/[\w.-]+$/.test(target)) return 'github'
	}
	return 'repo'
}

// ---------------------------------------------------------------------------
// GitHub-mode fetch (shallow clone into tmp dir)
// ---------------------------------------------------------------------------

/**
 * Clone `owner/repo` (shallow) into a tmp dir and return the dir path. Caller
 * is responsible for `rmSync(..., {recursive:true, force:true})` when done.
 *
 * @param {string} shortcode — e.g. `linear/linear`
 * @param {{ timeout?: number }} [opts]
 * @returns {string}
 */
export function cloneGithubShortcode(shortcode, opts = {}) {
	const timeout = Number(opts.timeout ?? 60_000)
	const tmpRoot = mkdtempSync(join(tmpdir(), 'apt-taste-infer-'))
	const res = spawnSync(
		'git',
		['clone', '--depth', '1', `https://github.com/${shortcode}.git`, tmpRoot],
		{ timeout, stdio: 'pipe', encoding: 'utf-8' },
	)
	if (res.status !== 0) {
		try {
			rmSync(tmpRoot, { recursive: true, force: true })
		} catch {
			/* best effort */
		}
		const stderr = (res.stderr || '').toString().slice(0, 500)
		const err = /** @type {any} */ (new Error(`git clone failed: ${stderr.trim() || 'unknown'}`))
		err.code = 'GIT_CLONE_FAILED'
		throw err
	}
	return tmpRoot
}

// ---------------------------------------------------------------------------
// Library writer (--add-to-library)
// ---------------------------------------------------------------------------

function libraryRootForTesting() {
	return process.env.APT_TASTE_LIBRARY_HOME || join(homedir(), '.aperant', 'design-library')
}

/**
 * Write DESIGN.md + provenance.yaml under `~/.aperant/design-library/<slug>/`.
 * Refuses on existing dir without `force: true`.
 *
 * @param {{ slug: string, designMd: string, target: string, mode: string, force: boolean }} args
 * @returns {string} absolute path to the written DESIGN.md
 */
export function writeLibraryEntry({ slug, designMd, target, mode, force }) {
	const root = libraryRootForTesting()
	const dir = resolvePath(root, slug)
	const existed = existsSync(dir)
	if (existed && !force) {
		const err = /** @type {any} */ (
			new Error(`library entry already exists at ${dir}. Pass --force to overwrite.`)
		)
		err.code = 'LIBRARY_ENTRY_EXISTS'
		throw err
	}
	const designPath = join(dir, 'DESIGN.md')
	atomicWriteText(designPath, designMd)

	const provenancePath = join(dir, 'provenance.yaml')
	const versionLine = getVersion()
	const provenance = {
		source: 'taste-infer',
		target,
		mode,
		generated_at: new Date().toISOString(),
		framework_version: versionLine,
	}
	atomicWriteText(
		provenancePath,
		`# Written by apt-tools taste-infer (C43).\n${stringifyYaml(provenance, { indent: 2, lineWidth: 0 })}`,
	)
	return designPath
}

// ---------------------------------------------------------------------------
// Error envelope helpers
// ---------------------------------------------------------------------------

/**
 * Build the custom taste-infer error envelope. Unlike util/result.mjs's
 * generic `err()`, taste-infer's pre-refactor envelope shape is
 * `{status:'error', command:'taste-infer', error, hint?}` — preserved
 * verbatim here for byte-identical D-04 backward-compat.
 *
 * Returns the {envelope, exitCode} tuple wrap() consumes. The cmd body
 * collects these and `return`s them; nothing exits the process directly.
 *
 * @param {{ error: string, hint?: string }} args
 */
function tasteInferErr({ error, hint }) {
	/** @type {any} */
	const env = {
		status: 'error',
		command: 'taste-infer',
		error,
	}
	if (hint) env.hint = hint
	return exitWith(env, 1)
}

/**
 * Map an extractor/synthesis error to the envelope shape. Preserves any
 * `hint` already attached (e.g. TasteInferMissingDep.hint).
 *
 * @param {unknown} err
 */
function describeError(err) {
	if (!err || typeof err !== 'object') {
		return { error: String(err) }
	}
	/** @type {any} */
	const e = err
	const out = /** @type {{error: string, hint?: string}} */ ({ error: '' })
	if (e.name === 'TasteInferMissingDep') {
		out.error = `${e.dep} is not installed`
		out.hint = e.hint
	} else if (e.name === 'TasteInferAuthWall') {
		out.error = `target_requires_auth: ${e.reason ?? 'auth wall detected'}`
		out.hint = e.hint ?? 'Point taste-infer at a public URL.'
	} else if (e.code === 'GIT_CLONE_FAILED') {
		out.error = e.message
		out.hint = 'Check network + shortcode spelling. Private repos are not supported in v1.'
	} else if (e.code === 'LIBRARY_ENTRY_EXISTS') {
		out.error = e.message
		out.hint = 'Pass --force to overwrite.'
	} else {
		out.error = e.message ?? String(e)
	}
	return out
}

// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------

function defaultOutPath() {
	return resolvePath(process.cwd(), 'DESIGN.md')
}

function resolveScreenshotsDir(outPath) {
	// Per spec D-9: prefer `$APT_TASK_DIR/taste-infer/screenshots`, else fall
	// back to `<out-dir>/taste-infer-screenshots/`.
	const taskDir = process.env.APT_TASK_DIR
	if (taskDir && existsSync(taskDir)) {
		return resolvePath(taskDir, 'taste-infer', 'screenshots')
	}
	const outDir = resolvePath(outPath, '..')
	return resolvePath(outDir, 'taste-infer-screenshots')
}

// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------

/**
 * Best-effort recursive remove. Never throws — caller is already on an error
 * path or in the happy-path cleanup.
 *
 * @param {string | null | undefined} dir
 */
function safeRmTmp(dir) {
	if (!dir) return
	try {
		rmSync(dir, { recursive: true, force: true })
	} catch {
		/* best-effort — we're cleaning up a shallow clone we made ourselves */
	}
}

/**
 * @param {string | undefined} targetArg
 * @param {string[] | undefined} extraArgs
 */
export async function cmdTasteInfer(targetArg, extraArgs) {
	if (!targetArg || targetArg.startsWith('--')) {
		return tasteInferErr({
			error:
				'Usage: apt-tools taste-infer <url|repo-path|owner/repo> [--out <path>] [--add-to-library <slug>] [--timeout <ms>] [--no-synthesize] [--root <subdir>] [--force] [--no-screenshots]',
		})
	}

	const flags = parseFlags(extraArgs ?? [])
	const mode = detectMode(targetArg)
	const timeout = Number(flags.get('timeout') ?? 30_000)
	const force = flags.get('force') === true || flags.get('force') === 'true'
	const noSynthesize = flags.get('no-synthesize') === true || flags.get('no-synthesize') === 'true'
	const noScreenshots =
		flags.get('no-screenshots') === true || flags.get('no-screenshots') === 'true'
	const rootFlag = flags.get('root')
	const outFlag = flags.get('out')
	const slugFlag = flags.get('add-to-library')

	const outPath =
		typeof outFlag === 'string' && outFlag.length > 0 ? resolvePath(outFlag) : defaultOutPath()

	// Refuse to overwrite an existing DESIGN.md unless --force.
	if (existsSync(outPath) && !force) {
		return tasteInferErr({
			error: `refusing to overwrite existing file at ${outPath}`,
			hint: 'Pass --force to overwrite, or set --out to a different path.',
		})
	}

	// `tmpCloneDir` is only populated in github mode. The pipeline runs
	// inside `runPipeline`, which returns the {envelope, exitCode} tuple
	// directly. We clean up the shallow clone synchronously via `safeRmTmp`
	// AFTER the pipeline settles in a finally block so cleanup is guaranteed
	// regardless of outcome.
	/** @type {{ current: string | null }} */
	const tmpCloneRef = { current: null }
	try {
		return await runPipeline({
			targetArg,
			mode,
			timeout,
			force,
			noSynthesize,
			noScreenshots,
			rootFlag,
			outPath,
			slugFlag,
			tmpCloneRef,
		})
	} finally {
		safeRmTmp(tmpCloneRef.current)
	}
}

/**
 * Run the extract → synthesize → render → write pipeline. Separated from
 * `cmdTasteInfer` so a single try/finally there guarantees tmp-dir cleanup
 * regardless of which pipeline stage fails.
 *
 * @param {{
 *   targetArg: string,
 *   mode: 'url' | 'repo' | 'github',
 *   timeout: number,
 *   force: boolean,
 *   noSynthesize: boolean,
 *   noScreenshots: boolean,
 *   rootFlag: unknown,
 *   outPath: string,
 *   slugFlag: unknown,
 *   tmpCloneRef: { current: string | null },
 * }} args
 * @returns {Promise<{envelope: object, exitCode: number}>} — wrap()-consumable result.
 */
async function runPipeline({
	targetArg,
	mode,
	timeout,
	force,
	noSynthesize,
	noScreenshots,
	rootFlag,
	outPath,
	slugFlag,
	tmpCloneRef,
}) {
	// ---------------------------------------------------------------------
	// 1. Extract
	// ---------------------------------------------------------------------

	/** @type {any} */
	let extracted
	try {
		if (mode === 'url') {
			extracted = await extractFromUrl(targetArg, {
				timeout,
				screenshots: !noScreenshots,
				screenshotsDir: noScreenshots ? undefined : resolveScreenshotsDir(outPath),
			})
		} else if (mode === 'github') {
			tmpCloneRef.current = cloneGithubShortcode(targetArg, { timeout })
			extracted = await extractFromRepo(tmpCloneRef.current, {
				root: typeof rootFlag === 'string' ? rootFlag : undefined,
			})
			extracted.target = targetArg
			extracted.mode = 'github'
		} else {
			extracted = await extractFromRepo(targetArg, {
				root: typeof rootFlag === 'string' ? rootFlag : undefined,
			})
		}
	} catch (e) {
		return tasteInferErr(describeError(e))
	}

	// ---------------------------------------------------------------------
	// 2. Synthesize
	// ---------------------------------------------------------------------

	let synth
	try {
		synth = await synthesizeDesign(extracted, {
			provider: noSynthesize ? 'none' : undefined,
		})
	} catch (e) {
		return tasteInferErr(describeError(e))
	}

	// ---------------------------------------------------------------------
	// 3. Render + schema-gate the DESIGN.md
	// ---------------------------------------------------------------------

	let designMd
	try {
		designMd = await renderDesignMarkdown(synth.frontmatter, synth.body)
	} catch (e) {
		return tasteInferErr(describeError(e))
	}

	const gate = validateDesignMarkdown(designMd)
	if (!gate.ok) {
		return tasteInferErr({
			error: `taste-infer produced an invalid DESIGN.md: ${gate.errors.join('; ')}`,
			hint: 'This is a bug in synthesize.mjs. Re-run with --no-synthesize to use the deterministic fallback.',
		})
	}

	// ---------------------------------------------------------------------
	// 4. Write + (optional) library copy
	// ---------------------------------------------------------------------

	try {
		atomicWriteText(outPath, designMd)
	} catch (e) {
		return tasteInferErr(describeError(e))
	}

	let libraryEntry = null
	if (typeof slugFlag === 'string' && slugFlag.length > 0) {
		try {
			libraryEntry = writeLibraryEntry({
				slug: slugFlag,
				designMd,
				target: extracted.target,
				mode,
				force,
			})
		} catch (e) {
			return tasteInferErr(describeError(e))
		}
	}

	// ---------------------------------------------------------------------
	// 5. Report
	// ---------------------------------------------------------------------

	return buildSuccessEnvelope({
		mode,
		extracted,
		outPath,
		libraryEntry,
		synth,
		noSynthesize,
	})
}

/**
 * Build the success envelope. Extracted so `cmdTasteInfer` only orchestrates.
 *
 * @param {{
 *   mode: 'url' | 'repo' | 'github',
 *   extracted: any,
 *   outPath: string,
 *   libraryEntry: string | null,
 *   synth: { frontmatter: object, body: string, path: 'llm' | 'fallback' },
 *   noSynthesize: boolean,
 * }} args
 * @returns {{envelope: object, exitCode: number}}
 */
function buildSuccessEnvelope({ mode, extracted, outPath, libraryEntry, synth, noSynthesize }) {
	const colorsRaw = Array.isArray(extracted.colors) ? extracted.colors.length : 0
	const fontsRaw = Array.isArray(extracted.fonts) ? extracted.fonts.length : 0
	const spacingRaw = Array.isArray(extracted.spacing) ? extracted.spacing.length : 0
	const radiiRaw = Array.isArray(extracted.radii) ? extracted.radii.length : 0
	const shadowRaw = Array.isArray(extracted.shadows) ? extracted.shadows.length : 0
	const screenshotsCount = Array.isArray(extracted.screenshots) ? extracted.screenshots.length : 0

	const tokens = /** @type {any} */ (synth.frontmatter).tokens ?? {}
	const tokensColorCount = tokens.colors ? Object.keys(tokens.colors).length : 0

	const synthesisLabel = noSynthesize ? 'skipped' : synth.path

	return ok({
		status: 'ok',
		command: 'taste-infer',
		mode,
		target: extracted.target,
		written: outPath,
		library_entry: libraryEntry,
		synthesis: synthesisLabel,
		summary: {
			colors: { raw: colorsRaw, tokenized: tokensColorCount },
			typography: { families: fontsRaw },
			spacing: { raw: spacingRaw },
			radii: { raw: radiiRaw },
			shadow: { raw: shadowRaw },
			screenshots: screenshotsCount,
			files_scanned: /** @type {any} */ (extracted)._filesScanned ?? 0,
		},
	})
}
