package executor

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"

	"webby-builder/internal/models"
)

// QualityCheckExecutor performs static quality analysis on generated source files.
// Not registered as an AI tool — called programmatically by the runner after completion.
type QualityCheckExecutor struct {
	workspacePath string
}

// NewQualityCheckExecutor creates a new QualityCheckExecutor
func NewQualityCheckExecutor(workspacePath string) *QualityCheckExecutor {
	return &QualityCheckExecutor{workspacePath: workspacePath}
}

type sourceFile struct {
	RelPath string
	Content string
	Lines   []string
}

// RunQualityChecks performs all quality checks and returns structured results.
func (e *QualityCheckExecutor) RunQualityChecks() *models.QualityCheckResult {
	files := e.scanSourceFiles()
	if len(files) == 0 {
		return &models.QualityCheckResult{Passed: true, Summary: "No source files to check"}
	}

	var issues []models.QualityIssue

	issues = append(issues, e.checkMissingPageTitles(files)...)
	issues = append(issues, e.checkMissingMetaDescriptions(files)...)
	issues = append(issues, e.checkImagesWithoutAlt(files)...)
	issues = append(issues, e.checkMissingPageImagery(files)...)
	issues = append(issues, e.checkButtonContrast(files)...)
	issues = append(issues, e.checkHeroButtonPairConsistency(files)...)
	issues = append(issues, e.checkDarkModeCSS()...)
	issues = append(issues, e.checkMalformedHSLValues()...)
	issues = append(issues, e.checkHeadingHierarchy(files)...)
	issues = append(issues, e.checkEmptyLinks(files)...)
	issues = append(issues, e.checkConsoleLogStatements(files)...)
	issues = append(issues, e.checkHardcodedLocalhost(files)...)
	issues = append(issues, e.checkMissingFavicon()...)

	passed := len(issues) == 0
	summary := buildSummary(issues)

	return &models.QualityCheckResult{
		Passed:  passed,
		Issues:  issues,
		Summary: summary,
	}
}

func (e *QualityCheckExecutor) scanSourceFiles() []sourceFile {
	var files []sourceFile
	srcDir := filepath.Join(e.workspacePath, "src")

	_ = filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return nil
		}
		if d.IsDir() {
			name := d.Name()
			if name == "node_modules" || name == "dist" || name == ".git" {
				return filepath.SkipDir
			}
			return nil
		}

		ext := strings.ToLower(filepath.Ext(path))
		if ext != ".tsx" && ext != ".ts" && ext != ".jsx" && ext != ".js" {
			return nil
		}

		// Skip files larger than 100KB
		info, err := d.Info()
		if err != nil || info.Size() > 100*1024 {
			return nil
		}

		data, err := os.ReadFile(path)
		if err != nil {
			return nil
		}

		relPath, _ := filepath.Rel(e.workspacePath, path)
		content := string(data)

		files = append(files, sourceFile{
			RelPath: relPath,
			Content: content,
			Lines:   strings.Split(content, "\n"),
		})
		return nil
	})

	return files
}

func (e *QualityCheckExecutor) isPageFile(f sourceFile) bool {
	return strings.HasPrefix(f.RelPath, filepath.Join("src", "pages")+string(filepath.Separator))
}

// Check 1: Missing page titles
func (e *QualityCheckExecutor) checkMissingPageTitles(files []sourceFile) []models.QualityIssue {
	var issues []models.QualityIssue

	// Check index.html for <title>
	indexHTML := e.readFileContent("index.html")
	hasGlobalTitle := strings.Contains(strings.ToLower(indexHTML), "<title")

	titlePatterns := regexp.MustCompile(`(?i)(<title|Helmet|useHead|document\.title|<Head[\s>])`)

	for _, f := range files {
		if !e.isPageFile(f) {
			continue
		}
		if hasGlobalTitle {
			continue
		}
		if titlePatterns.MatchString(f.Content) {
			continue
		}
		issues = append(issues, models.QualityIssue{
			File:     f.RelPath,
			Category: "seo",
			Severity: "warning",
			Message:  "Page has no title management (no <title>, Helmet, useHead, or document.title)",
		})
	}

	return issues
}

// Check 2: Missing meta descriptions
func (e *QualityCheckExecutor) checkMissingMetaDescriptions(files []sourceFile) []models.QualityIssue {
	var issues []models.QualityIssue

	indexHTML := e.readFileContent("index.html")
	hasGlobalMeta := regexp.MustCompile(`(?i)<meta\s+name=["']description["']`).MatchString(indexHTML)
	if hasGlobalMeta {
		return issues
	}

	metaPattern := regexp.MustCompile(`(?i)(<meta\s+name=["']description["']|meta\s*:\s*\{|description\s*:)`)

	hasAnyPageMeta := false
	for _, f := range files {
		if !e.isPageFile(f) {
			continue
		}
		if metaPattern.MatchString(f.Content) {
			hasAnyPageMeta = true
			break
		}
	}

	if !hasAnyPageMeta {
		issues = append(issues, models.QualityIssue{
			File:     "index.html",
			Category: "seo",
			Severity: "warning",
			Message:  "No meta description found in index.html or page components",
		})
	}

	return issues
}

// Check 3: Images without alt
func (e *QualityCheckExecutor) checkImagesWithoutAlt(files []sourceFile) []models.QualityIssue {
	var issues []models.QualityIssue
	// Match <img or <Image tags
	imgPattern := regexp.MustCompile(`<(?:img|Image)\s[^>]*>`)
	altPattern := regexp.MustCompile(`\balt\s*=`)

	for _, f := range files {
		for i, line := range f.Lines {
			matches := imgPattern.FindAllString(line, -1)
			for _, match := range matches {
				if !altPattern.MatchString(match) {
					issues = append(issues, models.QualityIssue{
						File:     f.RelPath,
						Line:     i + 1,
						Category: "accessibility",
						Severity: "warning",
						Message:  "Image element missing alt attribute",
					})
				}
			}
		}
	}

	return issues
}

// Check 3b: Marketing/landing pages with insufficient stock-library imagery.
// Flags when:
//   - The src/ tree has fewer than 2 /storage/image-library/ references (hero + section minimum), OR
//   - A page has no imagery at all.
//
// Skipped for single-purpose apps (pages dominated by forms/state/inputs) and
// when the user has opted out via site memory.
func (e *QualityCheckExecutor) checkMissingPageImagery(files []sourceFile) []models.QualityIssue {
	var issues []models.QualityIssue

	imgPattern := regexp.MustCompile(`<(?:img|Image)\s`)
	stockURLPattern := regexp.MustCompile(`/storage/image-library/`)
	singlePurposeHints := regexp.MustCompile(`(?i)(useState|useReducer|onSubmit|<form|<input|<textarea|<select)`)

	stockURLCount := 0
	var pageFiles []sourceFile
	allSinglePurpose := true

	for _, f := range files {
		stockURLCount += len(stockURLPattern.FindAllString(f.Content, -1))
		if e.isPageFile(f) {
			pageFiles = append(pageFiles, f)
			if !singlePurposeHints.MatchString(f.Content) {
				allSinglePurpose = false
			}
		}
	}

	// Count CSS background references to the library too.
	for _, css := range []string{"src/custom.css", "src/index.css"} {
		content := e.readFileContent(css)
		stockURLCount += len(stockURLPattern.FindAllString(content, -1))
	}

	if len(pageFiles) == 0 || allSinglePurpose {
		return issues
	}

	// Respect explicit user opt-out recorded in site memory.
	memory := strings.ToLower(e.readFileContent("memory.json"))
	if memory != "" {
		optOut := []string{
			"\"imagery\":\"none\"", "\"imagery\": \"none\"",
			"\"stock_images\":\"disabled\"", "\"stock_images\": \"disabled\"",
			"\"no_images\":true", "\"no_images\": true",
			"\"icon_only\":true", "\"icon_only\": true",
		}
		for _, s := range optOut {
			if strings.Contains(memory, s) {
				return issues
			}
		}
	}

	// Site-wide imagery count: marketing builds need at least 2 library images
	// (1 hero + 1 section). Warn if under that threshold.
	if stockURLCount < 2 {
		msg := "Marketing build has fewer than 2 stock-library images — required minimum is 1 hero image + 1 section image. Call listImages + getImageUsage and embed real <img src=\"/storage/image-library/...\"> tags (Lucide icons and Tailwind gradients do not satisfy the hero-image requirement)"
		if stockURLCount == 0 {
			msg = "Marketing build has 0 stock-library images — embed at least 1 hero image + 1 section image via listImages + getImageUsage. Icon-only heroes are NOT acceptable on marketing pages"
		}
		target := pageFiles[0].RelPath
		for _, f := range pageFiles {
			if strings.Contains(strings.ToLower(f.RelPath), "home") {
				target = f.RelPath
				break
			}
		}
		issues = append(issues, models.QualityIssue{
			File:     target,
			Category: "design",
			Severity: "warning",
			Message:  msg,
		})
	}

	// Per-page check: any page file with zero <img> tags and no CSS background.
	for _, f := range pageFiles {
		if singlePurposeHints.MatchString(f.Content) {
			continue
		}
		if imgPattern.MatchString(f.Content) {
			continue
		}
		if stockURLPattern.MatchString(f.Content) {
			continue
		}
		issues = append(issues, models.QualityIssue{
			File:     f.RelPath,
			Category: "design",
			Severity: "warning",
			Message:  "Page has no imagery — call listImages + getImageUsage and embed real <img src=\"/storage/image-library/...\"> tags (icons are not a substitute for hero/section photography)",
		})
	}

	return issues
}

// Check 3f: Hero/CTA section button-pair consistency.
// When a section contains 2+ <Button> tags close together (typical hero pattern:
// "Primary CTA" + "Secondary CTA" side-by-side), require them to share the
// SAME styling. Mixed solid+outline pairs are the #1 source of contrast bugs
// because the outline button reliably ends up invisible against colored
// backgrounds. Same-style pairs make the bug structurally impossible.
func (e *QualityCheckExecutor) checkHeroButtonPairConsistency(files []sourceFile) []models.QualityIssue {
	var issues []models.QualityIssue

	buttonOpenTag := regexp.MustCompile(`(?i)<Button\b[^>]*>`)
	variantAttr := regexp.MustCompile(`(?i)variant=\{?["']([a-z]+)["']\}?`)
	// A colored / non-default section background is what makes an outline
	// button risk becoming invisible. On bg-background a solid+outline pair is
	// the RECOMMENDED pattern, so it must not be flagged there.
	coloredBg := regexp.MustCompile(`(?i)bg-(primary|foreground|gradient-to)`)

	for _, f := range files {
		base := strings.ToLower(filepath.Base(f.RelPath))
		isHeroish := e.isPageFile(f) ||
			strings.Contains(base, "hero") ||
			strings.Contains(base, "cta") ||
			strings.Contains(base, "header") ||
			strings.Contains(base, "banner")
		if !isHeroish {
			continue
		}

		var window []struct {
			line    int
			variant string
		}
		for i, line := range f.Lines {
			for _, m := range buttonOpenTag.FindAllString(line, -1) {
				v := "default"
				if vm := variantAttr.FindStringSubmatch(m); vm != nil {
					v = strings.ToLower(vm[1])
				}
				window = append(window, struct {
					line    int
					variant string
				}{i + 1, v})
			}
		}

		for i := 0; i < len(window)-1; i++ {
			a, b := window[i], window[i+1]
			// Only a TRUE side-by-side pair (within a few lines) is a CTA row.
			if b.line-a.line > 6 {
				continue
			}
			// Only the solid (default) + outline combination is the contrast
			// trap. ghost / destructive / secondary / link are intentionally
			// distinct action types (e.g. a Cancel + Delete pair), not bugs.
			pair := map[string]bool{a.variant: true, b.variant: true}
			if !pair["default"] || !pair["outline"] {
				continue
			}
			// default + outline is the recommended pattern on bg-background.
			// It is only a contrast risk on a colored section background, so
			// require that signal within ~25 lines above the pair.
			start := a.line - 25
			if start < 1 {
				start = 1
			}
			onColoredBg := false
			for ln := start; ln <= a.line && ln-1 < len(f.Lines); ln++ {
				if coloredBg.MatchString(f.Lines[ln-1]) {
					onColoredBg = true
					break
				}
			}
			if !onColoredBg {
				continue
			}

			issues = append(issues, models.QualityIssue{
				File:     f.RelPath,
				Line:     a.line,
				Category: "design",
				Severity: "warning",
				Message: fmt.Sprintf(
					"Adjacent solid + outline buttons (lines %d and %d) sit on a colored section background, where the outline button can become invisible. Give both buttons the same variant + className, or add explicit border-primary-foreground / text-primary-foreground to the outline button.",
					a.line, b.line,
				),
			})
		}
	}

	return issues
}

// Check 3c: Button contrast anti-patterns.
// Detects dangerous Button color combinations that produce invisible / unreadable
// buttons. These patterns appear even though the prompt forbids them, because
// weaker models ignore long rule lists.
func (e *QualityCheckExecutor) checkButtonContrast(files []sourceFile) []models.QualityIssue {
	var issues []models.QualityIssue

	// Patterns that always produce invisible buttons:
	//   - Same bg and text color family (bg-X text-X)
	//   - Button on bg-primary section using variant="secondary" without an
	//     explicit contrasting className
	// RE2-compatible: match "same-color bg+text" combos that produce invisible buttons.
	// Uses character-class separators instead of lookahead (Go regexp has no (?!)).
	sameColorPatterns := []*regexp.Regexp{
		// bg-primary ... text-primary (but NOT text-primary-foreground)
		regexp.MustCompile(`(?i)className="[^"]*\bbg-primary\b[^"]*\btext-primary([\s"/]|$)`),
		// bg-white ... text-white
		regexp.MustCompile(`(?i)className="[^"]*\bbg-white\b[^"]*\btext-white\b`),
		// bg-black ... text-black
		regexp.MustCompile(`(?i)className="[^"]*\bbg-black\b[^"]*\btext-black\b`),
		// bg-background ... text-background
		regexp.MustCompile(`(?i)className="[^"]*\bbg-background\b[^"]*\btext-background\b`),
		// bg-foreground ... text-foreground
		regexp.MustCompile(`(?i)className="[^"]*\bbg-foreground\b[^"]*\btext-foreground\b`),
	}

	// Semantic-bg + hardcoded-text mixing (invisible in one theme mode).
	// Order matters: both orderings (bg first vs text first) need detection.
	// Example bug: "bg-background ... text-white" → white text on white bg in
	// light mode (white-on-white), readable only in dark mode.
	mixedThemeClassPatterns := []*regexp.Regexp{
		// bg-background / bg-card / bg-muted / bg-popover  +  text-white
		regexp.MustCompile(`(?i)className="[^"]*\b(bg-background|bg-card|bg-muted|bg-popover)\b[^"]*\btext-(white|black|gray-[0-9]+|slate-[0-9]+|zinc-[0-9]+|neutral-[0-9]+)\b`),
		regexp.MustCompile(`(?i)className="[^"]*\btext-(white|black|gray-[0-9]+|slate-[0-9]+|zinc-[0-9]+|neutral-[0-9]+)\b[^"]*\b(bg-background|bg-card|bg-muted|bg-popover)\b`),
		// bg-foreground / bg-primary  +  text-black  (flip scenario)
		regexp.MustCompile(`(?i)className="[^"]*\b(bg-foreground|bg-primary)\b[^"]*\btext-(black|gray-9\d{2,}|slate-9\d{2,}|zinc-9\d{2,})\b`),
	}
	buttonTag := regexp.MustCompile(`(?is)<Button\b[^>]*>`)
	secondaryOnPrimary := regexp.MustCompile(`(?i)variant=\{?['"]secondary['"]\}?`)
	outlineNoText := regexp.MustCompile(`(?i)variant=\{?['"]outline['"]\}?`)
	hasTextClass := regexp.MustCompile(`(?i)text-(primary-foreground|background|foreground|white|black|[a-z]+-\d{2,3})`)
	// Match bg-primary (NOT bg-primary-foreground) or a dark gradient.
	// RE2 has no lookahead — use a trailing character class and exclude word-continuation.
	bgPrimarySection := regexp.MustCompile(`(?i)(bg-primary([\s"/]|$)|bg-gradient-to-[a-z]+\s+from-(primary|slate-9|gray-9|black))`)

	for _, f := range files {
		// Fast filter: only inspect files that contain a <Button>
		if !buttonTag.MatchString(f.Content) {
			continue
		}

		// Check every matching <Button> tag, and look at the enclosing section
		// context for bg-primary/dark cues.
		for i, line := range f.Lines {
			if !buttonTag.MatchString(line) {
				continue
			}
			match := buttonTag.FindString(line)

			// Same-color-family antipattern (invisible button)
			for _, p := range sameColorPatterns {
				if p.MatchString(match) {
					issues = append(issues, models.QualityIssue{
						File:     f.RelPath,
						Line:     i + 1,
						Category: "design",
						Severity: "warning",
						Message:  "Button has same bg and text color family (e.g. bg-primary text-primary) — will be invisible. Use text-primary-foreground on bg-primary, or vice versa.",
					})
					break
				}
			}

			// Semantic-bg + hardcoded-text mixing (invisible in one theme mode)
			for _, p := range mixedThemeClassPatterns {
				if p.MatchString(match) {
					issues = append(issues, models.QualityIssue{
						File:     f.RelPath,
						Line:     i + 1,
						Category: "design",
						Severity: "warning",
						Message:  "Button mixes a theme-aware background class (bg-background/bg-card/bg-primary/etc.) with a hardcoded text color (text-white/text-black). These collide in one of the two theme modes — e.g. bg-background + text-white = invisible in LIGHT mode (white-on-white). Use paired semantic classes (bg-background + text-foreground, bg-primary + text-primary-foreground) or paired hardcoded classes (bg-transparent + text-white for buttons over dark images).",
					})
					break
				}
			}

			// Look backwards up to 40 lines for the nearest <section> or bg class
			onPrimaryBg := false
			start := i - 40
			if start < 0 {
				start = 0
			}
			for j := i; j >= start; j-- {
				prev := f.Lines[j]
				if bgPrimarySection.MatchString(prev) {
					onPrimaryBg = true
					break
				}
				if regexp.MustCompile(`(?i)<section\b|className="[^"]*bg-background`).MatchString(prev) {
					break
				}
			}
			if !onPrimaryBg {
				continue
			}

			// On a bg-primary section, variant="secondary" is dangerous without
			// an explicit contrasting className, and variant="outline" without
			// a text-primary-foreground override is also dangerous.
			if secondaryOnPrimary.MatchString(match) && !hasTextClass.MatchString(match) {
				issues = append(issues, models.QualityIssue{
					File:     f.RelPath,
					Line:     i + 1,
					Category: "design",
					Severity: "warning",
					Message:  "Button variant=\"secondary\" on bg-primary/dark section — often invisible. Prefer explicit className=\"bg-primary-foreground text-primary hover:bg-primary-foreground/90\" or verify the template's secondary is the inverse of primary.",
				})
			}
			if outlineNoText.MatchString(match) && !hasTextClass.MatchString(match) {
				issues = append(issues, models.QualityIssue{
					File:     f.RelPath,
					Line:     i + 1,
					Category: "design",
					Severity: "warning",
					Message:  "Button variant=\"outline\" on bg-primary/dark section without explicit text color — border blends into background. Add className=\"border-primary-foreground text-primary-foreground hover:bg-primary-foreground/10\".",
				})
			}
		}
	}

	return issues
}

// Check 3e: Malformed HSL CSS variable values.
// shadcn CSS variables expect "H S% L%" format (space-separated, % on
// saturation and lightness). When the AI writes "166 74 46" (no percent
// signs) or "#hex" / "rgb(...)" in a color-variable slot, the rendered
// color is wildly wrong — e.g. a declared terracotta becomes neon teal.
func (e *QualityCheckExecutor) checkMalformedHSLValues() []models.QualityIssue {
	var issues []models.QualityIssue

	content := e.readFileContent("src/custom.css")
	if content == "" {
		return issues
	}

	// Known shadcn color-variable names (the ones Tailwind wraps in hsl())
	colorVarRE := regexp.MustCompile(`(?m)^\s*--(primary|primary-foreground|secondary|secondary-foreground|accent|accent-foreground|muted|muted-foreground|card|card-foreground|popover|popover-foreground|background|foreground|border|input|ring|destructive|destructive-foreground)\s*:\s*([^;]+);`)

	// Correct form: "H S% L%" or "H S% L% / alpha"
	correctHSL := regexp.MustCompile(`^\s*\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%(\s*/\s*\d+(?:\.\d+)?%?)?\s*$`)

	for _, m := range colorVarRE.FindAllStringSubmatch(content, -1) {
		varName := m[1]
		val := strings.TrimSpace(m[2])

		// Skip if it references another variable (var(--x)) — valid alias.
		if strings.HasPrefix(val, "var(") {
			continue
		}

		// Skip if it's a well-known keyword like "transparent" / "currentColor".
		lowerVal := strings.ToLower(val)
		if lowerVal == "transparent" || lowerVal == "currentcolor" || lowerVal == "inherit" {
			continue
		}

		// Bad form: hex
		if strings.HasPrefix(val, "#") {
			issues = append(issues, models.QualityIssue{
				File:     "src/custom.css",
				Category: "design",
				Severity: "warning",
				Message:  fmt.Sprintf("--%s uses hex (%s) — shadcn CSS variables must be HSL components in \"H S%% L%%\" form (e.g. \"20 75%% 45%%\"). Tailwind wraps them as hsl(var(--%s)), so hex values break rendering.", varName, val, varName),
			})
			continue
		}

		// Bad form: rgb(...) or hsl(...) wrapper
		if strings.HasPrefix(lowerVal, "rgb(") || strings.HasPrefix(lowerVal, "rgba(") || strings.HasPrefix(lowerVal, "hsl(") || strings.HasPrefix(lowerVal, "hsla(") {
			issues = append(issues, models.QualityIssue{
				File:     "src/custom.css",
				Category: "design",
				Severity: "warning",
				Message:  fmt.Sprintf("--%s uses a color function wrapper (%s) — shadcn CSS variables must be bare HSL components in \"H S%% L%%\" form. Tailwind adds the hsl() wrapper itself.", varName, val),
			})
			continue
		}

		// Bad form: negative hue (shadcn HSL vars must be 0–360). Detect here
		// because `correctHSL` rejects the leading minus and `threeNums` does too.
		negHue := regexp.MustCompile(`^\s*-\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%?\s+\d+(?:\.\d+)?%?`)
		if negHue.MatchString(val) {
			hueRE := regexp.MustCompile(`^\s*(-\d+(?:\.\d+)?)`)
			hueMatch := hueRE.FindStringSubmatch(val)
			hueStr := ""
			if hueMatch != nil {
				hueStr = hueMatch[1]
			}
			issues = append(issues, models.QualityIssue{
				File:     "src/custom.css",
				Category: "design",
				Severity: "warning",
				Message:  fmt.Sprintf("--%s hue %s° is out of 0–360 range (%s). shadcn HSL vars must use a hue in 0–360; negative values indicate a mis-computed colour.", varName, hueStr, val),
			})
			continue
		}

		// Bad form: `deg` suffix inside the hue slot (shadcn vars are bare numbers;
		// Tailwind wraps them with hsl() and adds the deg unit itself).
		degSuffix := regexp.MustCompile(`^\s*-?\d+(?:\.\d+)?\s*deg\b`)
		if degSuffix.MatchString(val) {
			issues = append(issues, models.QualityIssue{
				File:     "src/custom.css",
				Category: "design",
				Severity: "warning",
				Message:  fmt.Sprintf("--%s uses a `deg` suffix (%s) — shadcn HSL vars are bare numbers. Write \"20 75%% 45%%\", not \"20deg 75%% 45%%\". Tailwind adds the hsl() wrapper (which carries the deg unit).", varName, val),
			})
			continue
		}

		// Bad form: three numbers but missing % signs (the terracotta→teal bug)
		if correctHSL.MatchString(val) {
			// Hue range check: 0–360. Values like 846 indicate a mis-computed
			// colour (browsers normalize via modulo, but it's a correctness smell).
			hueRE := regexp.MustCompile(`^\s*(-?\d+(?:\.\d+)?)`)
			if m := hueRE.FindStringSubmatch(val); m != nil {
				if hue, err := strconv.ParseFloat(m[1], 64); err == nil {
					if hue < 0 || hue > 360 {
						issues = append(issues, models.QualityIssue{
							File:     "src/custom.css",
							Category: "design",
							Severity: "warning",
							Message:  fmt.Sprintf("--%s hue %s° is out of 0–360 range (e.g. %s). shadcn HSL vars must use a hue in 0–360; values outside this range indicate a mis-computed colour. Use e.g. 20 for terracotta, 160 for teal.", varName, m[1], val),
						})
						continue
					}
				}
			}
			continue // correct
		}
		// If it looks like three space-separated numbers at all, flag it.
		threeNums := regexp.MustCompile(`^\s*\d+(?:\.\d+)?\s+\d+(?:\.\d+)?\s+\d+(?:\.\d+)?\s*$`)
		if threeNums.MatchString(val) {
			issues = append(issues, models.QualityIssue{
				File:     "src/custom.css",
				Category: "design",
				Severity: "warning",
				Message:  fmt.Sprintf("--%s value \"%s\" is missing %% signs on saturation and lightness. Correct form is \"H S%% L%%\" (e.g. \"20 75%% 45%%\"). Without %%, the browser interprets the raw numbers wrong and the site renders in a completely different color (e.g. \"166 74 46\" meant as terracotta will render as neon teal).", varName, val),
			})
			continue
		}
		// Comma-separated (also wrong for shadcn)
		if strings.Contains(val, ",") {
			issues = append(issues, models.QualityIssue{
				File:     "src/custom.css",
				Category: "design",
				Severity: "warning",
				Message:  fmt.Sprintf("--%s uses commas (%s) — shadcn CSS variables must be space-separated HSL components in \"H S%% L%%\" form (no commas, no wrapper).", varName, val),
			})
		}
	}

	return issues
}

// Check 3d: Dark-mode CSS hygiene.
// Flags two common anti-patterns in src/custom.css that break theme switching:
//  1. !important overrides on Tailwind semantic classes (.bg-primary, .text-foreground, etc.)
//     — these freeze the color and make .dark toggling useless.
//  2. :root defines color variables but .dark does not — toggling .dark produces no visible change.
func (e *QualityCheckExecutor) checkDarkModeCSS() []models.QualityIssue {
	var issues []models.QualityIssue

	content := e.readFileContent("src/custom.css")
	if content == "" {
		return issues
	}

	// Anti-pattern 1: !important overrides on Tailwind semantic classes
	// Tailwind slash-opacity modifiers (e.g. ".bg-primary/50") are encoded in
	// generated CSS as ".bg-primary\/50" (the slash is escaped). Match both
	// the plain and escaped forms without requiring the escape.
	badOverride := regexp.MustCompile(`(?m)^\s*\.(bg-primary|bg-primary-foreground|bg-secondary|bg-muted|bg-card|bg-accent|bg-background|bg-foreground|text-primary|text-primary-foreground|text-foreground|text-muted-foreground|text-background|text-card-foreground|border-primary|border-foreground)(\\?/[0-9]+)?\s*\{[^}]*!important`)
	// Allow both escaped and unescaped slash forms via a separate matcher.
	badOverrideSlash := regexp.MustCompile(`(?m)^\s*\.(bg-primary|bg-primary-foreground|bg-secondary|bg-muted|bg-card|bg-accent|bg-background|bg-foreground|text-primary|text-primary-foreground|text-foreground|text-muted-foreground|text-background|text-card-foreground|border-primary|border-foreground)/[0-9]+\s*\{[^}]*!important`)
	if badOverride.MatchString(content) || badOverrideSlash.MatchString(content) {
		issues = append(issues, models.QualityIssue{
			File:     "src/custom.css",
			Category: "design",
			Severity: "warning",
			Message:  "custom.css overrides Tailwind semantic classes with !important — this freezes the color and breaks dark-mode toggling. Override the underlying CSS variables (--primary, --background, etc.) inside :root and .dark instead.",
		})
	}

	// Anti-pattern 2: :root has color-variable overrides but .dark block is
	// missing or doesn't redefine them.
	rootBlock := regexp.MustCompile(`(?s):root\s*\{([^}]*)\}`).FindStringSubmatch(content)
	darkBlock := regexp.MustCompile(`(?s)\.dark\s*\{([^}]*)\}`).FindStringSubmatch(content)

	if len(rootBlock) >= 2 {
		// Collect variable names declared under :root
		varPattern := regexp.MustCompile(`--([a-zA-Z0-9_-]+)\s*:`)
		rootVars := map[string]bool{}
		for _, m := range varPattern.FindAllStringSubmatch(rootBlock[1], -1) {
			// Only care about color-like variables (contain letter, not arbitrary spacing)
			name := m[1]
			if isThemeColorVar(name) {
				rootVars[name] = true
			}
		}

		if len(rootVars) > 0 {
			if len(darkBlock) < 2 {
				issues = append(issues, models.QualityIssue{
					File:     "src/custom.css",
					Category: "design",
					Severity: "warning",
					Message:  "custom.css has a :root block with color variables but no .dark block — dark-mode toggle will have no effect. Add a matching .dark { ... } block with dark-mode values for each variable.",
				})
			} else {
				darkVars := map[string]bool{}
				for _, m := range varPattern.FindAllStringSubmatch(darkBlock[1], -1) {
					darkVars[m[1]] = true
				}
				var missing []string
				for v := range rootVars {
					if !darkVars[v] {
						missing = append(missing, "--"+v)
					}
				}
				if len(missing) > 0 {
					limit := 4
					if len(missing) < limit {
						limit = len(missing)
					}
					issues = append(issues, models.QualityIssue{
						File:     "src/custom.css",
						Category: "design",
						Severity: "warning",
						Message:  fmt.Sprintf("custom.css has %d color variable(s) in :root that are missing from .dark (e.g. %s) — dark-mode toggle will be inconsistent. Add matching overrides to the .dark block.", len(missing), strings.Join(missing[:limit], ", ")),
					})
				}
			}
		}
	}

	return issues
}

// isThemeColorVar returns true when a CSS custom property name looks like a
// theme color variable we expect to vary between light and dark modes.
func isThemeColorVar(name string) bool {
	lower := strings.ToLower(name)
	colorHints := []string{
		"primary", "secondary", "muted", "card", "accent", "background",
		"foreground", "border", "input", "ring", "destructive", "color",
		"bg", "text", "warm", "cool", "cream", "brown", "gold", "amber",
	}
	for _, hint := range colorHints {
		if strings.Contains(lower, hint) {
			return true
		}
	}
	return false
}

// Check 4: Heading hierarchy
func (e *QualityCheckExecutor) checkHeadingHierarchy(files []sourceFile) []models.QualityIssue {
	var issues []models.QualityIssue
	headingPattern := regexp.MustCompile(`<h([1-6])[\s>]`)

	for _, f := range files {
		if !e.isPageFile(f) {
			continue
		}

		// Track highest heading levels seen
		seen := map[int]bool{}
		for _, line := range f.Lines {
			matches := headingPattern.FindAllStringSubmatch(line, -1)
			for _, m := range matches {
				level := int(m[1][0] - '0')
				seen[level] = true
			}
		}

		// Check for gaps: if h3 exists but h2 doesn't, that's a hierarchy issue
		for level := 3; level <= 6; level++ {
			if seen[level] && !seen[level-1] {
				issues = append(issues, models.QualityIssue{
					File:     f.RelPath,
					Category: "accessibility",
					Severity: "info",
					Message:  "Heading hierarchy gap: <h" + string(rune('0'+level)) + "> used without <h" + string(rune('0'+level-1)) + ">",
				})
				break // One issue per file is enough
			}
		}
	}

	return issues
}

// Check 5: Empty links
func (e *QualityCheckExecutor) checkEmptyLinks(files []sourceFile) []models.QualityIssue {
	var issues []models.QualityIssue
	// Match <a tags
	linkPattern := regexp.MustCompile(`<a\s[^>]*>`)
	hrefEmpty := regexp.MustCompile(`href\s*=\s*["']\s*["']`)
	hrefHash := regexp.MustCompile(`href\s*=\s*["']#["']`)
	hrefJS := regexp.MustCompile(`href\s*=\s*["']javascript:`)
	hrefPresent := regexp.MustCompile(`href\s*=`)

	for _, f := range files {
		for i, line := range f.Lines {
			matches := linkPattern.FindAllString(line, -1)
			for _, match := range matches {
				if !hrefPresent.MatchString(match) || hrefEmpty.MatchString(match) || hrefHash.MatchString(match) || hrefJS.MatchString(match) {
					issues = append(issues, models.QualityIssue{
						File:     f.RelPath,
						Line:     i + 1,
						Category: "accessibility",
						Severity: "warning",
						Message:  "Link element has missing or empty href",
					})
				}
			}
		}
	}

	return issues
}

// Check 6: Console.log statements
func (e *QualityCheckExecutor) checkConsoleLogStatements(files []sourceFile) []models.QualityIssue {
	var issues []models.QualityIssue
	consolePattern := regexp.MustCompile(`console\.(log|debug)\(`)

	for _, f := range files {
		for i, line := range f.Lines {
			trimmed := strings.TrimSpace(line)
			// Skip commented-out lines
			if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "/*") {
				continue
			}
			if consolePattern.MatchString(line) {
				issues = append(issues, models.QualityIssue{
					File:     f.RelPath,
					Line:     i + 1,
					Category: "code_quality",
					Severity: "info",
					Message:  "console.log/debug statement in production code",
				})
			}
		}
	}

	return issues
}

// Check 7: Hardcoded localhost
func (e *QualityCheckExecutor) checkHardcodedLocalhost(files []sourceFile) []models.QualityIssue {
	var issues []models.QualityIssue
	localhostPattern := regexp.MustCompile(`(localhost|127\.0\.0\.1)(:\d+)?`)

	for _, f := range files {
		for i, line := range f.Lines {
			trimmed := strings.TrimSpace(line)
			if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") {
				continue
			}
			if localhostPattern.MatchString(line) {
				issues = append(issues, models.QualityIssue{
					File:     f.RelPath,
					Line:     i + 1,
					Category: "security",
					Severity: "warning",
					Message:  "Hardcoded localhost/127.0.0.1 reference",
				})
			}
		}
	}

	return issues
}

// Check 8: Missing favicon
func (e *QualityCheckExecutor) checkMissingFavicon() []models.QualityIssue {
	var issues []models.QualityIssue

	indexHTML := e.readFileContent("index.html")
	hasFaviconLink := regexp.MustCompile(`(?i)<link[^>]+rel=["'](icon|shortcut icon)["']`).MatchString(indexHTML)

	if hasFaviconLink {
		return issues
	}

	// Check for favicon files in public/
	faviconFiles := []string{"favicon.ico", "favicon.svg", "favicon.png"}
	for _, f := range faviconFiles {
		if _, err := os.Stat(filepath.Join(e.workspacePath, "public", f)); err == nil {
			return issues
		}
	}

	issues = append(issues, models.QualityIssue{
		File:     "index.html",
		Category: "seo",
		Severity: "info",
		Message:  "No favicon configured (no <link rel=\"icon\"> and no favicon file in public/)",
	})

	return issues
}

func (e *QualityCheckExecutor) readFileContent(relPath string) string {
	data, err := os.ReadFile(filepath.Join(e.workspacePath, relPath))
	if err != nil {
		return ""
	}
	return string(data)
}

func buildSummary(issues []models.QualityIssue) string {
	if len(issues) == 0 {
		return "All quality checks passed"
	}

	counts := map[string]int{}
	for _, issue := range issues {
		counts[issue.Category]++
	}

	var parts []string
	categories := []struct {
		key   string
		label string
	}{
		{"accessibility", "accessibility"},
		{"seo", "SEO"},
		{"design", "design"},
		{"code_quality", "code quality"},
		{"security", "security"},
	}

	for _, cat := range categories {
		if n, ok := counts[cat.key]; ok {
			label := cat.label + " issue"
			if n > 1 {
				label += "s"
			}
			parts = append(parts, fmt.Sprintf("%d %s", n, label))
		}
	}

	if len(parts) == 0 {
		return "Quality checks completed"
	}

	// Build properly with fmt
	var sb strings.Builder
	sb.WriteString("Found ")
	for i, p := range parts {
		if i > 0 && i == len(parts)-1 {
			sb.WriteString(", and ")
		} else if i > 0 {
			sb.WriteString(", ")
		}
		sb.WriteString(p)
	}
	return sb.String()
}
