package executor

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"time"
)

// ValidateWordPressTheme is the WordPress-target replacement for VerifyBuild
// (which runs npm for the website target). FSE block themes are declarative —
// there is nothing to compile — so "verification" is a structural check split
// into errors (Success=false, the agent must fix) and advisory warnings:
//
// Errors:
//   - style.css exists and carries a "Theme Name:" header (WP theme requirement)
//   - templates/index.html exists (the minimum FSE template)
//   - theme.json, if present, is valid JSON
//   - every .php file passes `php -l` when a php binary is available on the
//     host (skipped silently otherwise — never blocks a host without PHP)
//   - block markup is well-formed: balanced <!-- wp:* --> comments, valid
//     attribute JSON, no hardcoded wp:navigation refs, inherit:true main loop
//   - patterns carry Title/Slug/Categories headers and escape PHP output
//   - every wp:pattern / wp:template-part slug reference resolves
//
// Warnings (advisory only — most are auto-fixed by the packaging finalize
// step): missing screenshot/readme/404/search templates, incomplete style.css
// header fields, unregistered template parts, non-namespaced pattern slugs.
//
// The context bounds the PHP lint subprocesses (the executor passes its tool
// deadline).
func ValidateWordPressTheme(ctx context.Context, workspacePath string) *ToolResult {
	var errs, warns []string

	stylePath := filepath.Join(workspacePath, "style.css")
	if style, err := os.ReadFile(stylePath); err != nil {
		errs = append(errs, "missing style.css (required, must contain the theme header)")
	} else if !strings.Contains(string(style), "Theme Name:") {
		errs = append(errs, "style.css is missing the required \"Theme Name:\" header")
	}
	if _, err := os.Stat(filepath.Join(workspacePath, "templates", "index.html")); err != nil {
		errs = append(errs, "missing templates/index.html (the minimum FSE block template)")
	}
	if data, err := os.ReadFile(filepath.Join(workspacePath, "theme.json")); err == nil {
		var js any
		if jErr := json.Unmarshal(data, &js); jErr != nil {
			errs = append(errs, fmt.Sprintf("theme.json is not valid JSON: %v", jErr))
		}
	}

	errs = append(errs, lintThemePHP(ctx, workspacePath)...)
	errs = append(errs, checkBlockMarkup(workspacePath)...)
	patternSlugs, pErrs, pWarns := checkPatterns(workspacePath, readThemeSlug(workspacePath))
	errs = append(errs, pErrs...)
	warns = append(warns, pWarns...)
	errs = append(errs, checkReferences(workspacePath, patternSlugs)...)
	warns = append(warns, advisoryWarnings(workspacePath)...)

	var b strings.Builder
	if len(errs) > 0 {
		b.WriteString("WordPress theme validation failed.\nErrors:\n- " + strings.Join(errs, "\n- "))
	} else {
		b.WriteString("WordPress theme is valid (structure, block markup, references, PHP syntax all OK).")
	}
	if len(warns) > 0 {
		b.WriteString("\n\nWarnings: (advisory — most are auto-fixed at packaging)\n- " + strings.Join(warns, "\n- "))
	}
	return &ToolResult{Success: len(errs) == 0, Content: b.String()}
}

// readThemeSlug returns the theme's slug: the Text Domain header field, else
// the slugified Theme Name.
func readThemeSlug(workspacePath string) string {
	fields, _, ok := parseStyleHeader(workspacePath)
	if !ok {
		return ""
	}
	if td := fields["Text Domain"]; td != "" {
		return td
	}
	return slugifyThemeName(fields["Theme Name"])
}

var blockTokenRe = regexp.MustCompile(`(?s)<!--\s*(/?)wp:([a-z][a-z0-9/-]*)\s*(\{.*?\})?\s*(/?)\s*-->`)

// Resource bounds for the markup/pattern scanners, mirroring lintThemePHP's
// file cap — a pathological workspace (a compromised AI provider emitting
// hundreds of multi-megabyte files) must never exhaust the builder's memory
// during validation. Files beyond these limits are skipped, not read.
const (
	maxMarkupFiles    = 400
	maxMarkupFileSize = 1 << 20 // 1 MiB; real templates/patterns are a few KB
)

// readBoundedMarkup reads a markup file unless it exceeds maxMarkupFileSize, in
// which case it returns ok=false so the caller skips it (a giant file is a DoS
// vector, not legitimate theme content).
func readBoundedMarkup(absPath string) (string, bool) {
	info, err := os.Stat(absPath)
	if err != nil || info.Size() > maxMarkupFileSize {
		return "", false
	}
	data, err := os.ReadFile(absPath)
	if err != nil {
		return "", false
	}
	return string(data), true
}

// themeMarkupFiles returns the block-markup files to scan: templates/*.html,
// parts/*.html, patterns/*.php (workspace-relative slash paths). Capped at
// maxMarkupFiles so an unbounded workspace can't drive the scanners.
func themeMarkupFiles(workspacePath string) []string {
	var out []string
	for _, dir := range []string{"templates", "parts", "patterns"} {
		if len(out) >= maxMarkupFiles {
			break
		}
		entries, err := os.ReadDir(filepath.Join(workspacePath, dir))
		if err != nil {
			continue
		}
		for _, e := range entries {
			if len(out) >= maxMarkupFiles {
				break
			}
			if e.IsDir() {
				continue
			}
			if (dir == "patterns" && strings.HasSuffix(e.Name(), ".php")) ||
				(dir != "patterns" && strings.HasSuffix(e.Name(), ".html")) {
				out = append(out, dir+"/"+e.Name())
			}
		}
	}
	return out
}

// checkBlockMarkup validates block-comment balance, attribute JSON, navigation
// refs and the main-loop query in every markup file.
func checkBlockMarkup(workspacePath string) []string {
	var errs []string
	for _, rel := range themeMarkupFiles(workspacePath) {
		content, ok := readBoundedMarkup(filepath.Join(workspacePath, filepath.FromSlash(rel)))
		if !ok {
			continue
		}
		isMainTemplate := rel == "templates/home.html" || rel == "templates/index.html"
		var stack []string
		for _, m := range blockTokenRe.FindAllStringSubmatch(content, -1) {
			closing, name, attrs, selfClose := m[1] == "/", m[2], m[3], m[4] == "/"
			if attrs != "" && !json.Valid([]byte(attrs)) {
				errs = append(errs, fmt.Sprintf("%s: wp:%s has invalid JSON attributes", rel, name))
			}
			if name == "navigation" && strings.Contains(attrs, `"ref"`) {
				errs = append(errs, fmt.Sprintf("%s: wp:navigation must not set a hardcoded \"ref\" (no saved menu exists at build time) — nest <!-- wp:page-list /--> instead", rel))
			}
			if isMainTemplate && name == "query" && attrs != "" {
				var parsed map[string]interface{}
				if json.Unmarshal([]byte(attrs), &parsed) == nil {
					if q, qOK := parsed["query"].(map[string]interface{}); qOK {
						_, hasPerPage := q["perPage"]
						inherit, _ := q["inherit"].(bool)
						if hasPerPage && !inherit {
							errs = append(errs, fmt.Sprintf("%s: the main posts loop must use {\"query\":{\"inherit\":true}} — a fixed perPage breaks blog pagination", rel))
						}
					}
				}
			}
			switch {
			case closing:
				if len(stack) == 0 || stack[len(stack)-1] != name {
					errs = append(errs, fmt.Sprintf("%s: unexpected closing tag <!-- /wp:%s -->", rel, name))
				} else {
					stack = stack[:len(stack)-1]
				}
			case selfClose:
				// no push
			default:
				stack = append(stack, name)
			}
		}
		for _, open := range stack {
			errs = append(errs, fmt.Sprintf("%s: unclosed block <!-- wp:%s --> (missing <!-- /wp:%s -->)", rel, open, open))
		}
	}
	return errs
}

var (
	patternHeaderTitleRe = regexp.MustCompile(`(?m)^\s*\*\s*Title:\s*(.+)$`)
	patternHeaderSlugRe  = regexp.MustCompile(`(?m)^\s*\*\s*Slug:\s*(.+)$`)
	patternHeaderCatsRe  = regexp.MustCompile(`(?m)^\s*\*\s*Categories:\s*(.+)$`)
	echoLineRe           = regexp.MustCompile(`(?m)^.*\becho\b.*$`)
)

// checkPatterns validates pattern file headers + escaping; returns the set of
// registered pattern slugs for the reference check.
func checkPatterns(workspacePath, themeSlug string) (slugs map[string]bool, errs, warns []string) {
	slugs = map[string]bool{}
	entries, err := os.ReadDir(filepath.Join(workspacePath, "patterns"))
	if err != nil {
		return slugs, nil, nil
	}
	scanned := 0
	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".php") {
			continue
		}
		if scanned >= maxMarkupFiles {
			break
		}
		scanned++
		rel := "patterns/" + e.Name()
		content, ok := readBoundedMarkup(filepath.Join(workspacePath, "patterns", e.Name()))
		if !ok {
			continue
		}
		if patternHeaderTitleRe.FindStringSubmatch(content) == nil {
			errs = append(errs, rel+": pattern header is missing the Title: field")
		}
		if patternHeaderCatsRe.FindStringSubmatch(content) == nil {
			errs = append(errs, rel+": pattern header is missing the Categories: field")
		}
		slugM := patternHeaderSlugRe.FindStringSubmatch(content)
		if slugM == nil {
			errs = append(errs, rel+": pattern header is missing the Slug: field")
		} else {
			slug := strings.TrimSpace(slugM[1])
			slugs[slug] = true
			if themeSlug != "" && !strings.HasPrefix(slug, themeSlug+"/") {
				warns = append(warns, fmt.Sprintf("%s: slug %q is not namespaced under the theme slug %q", rel, slug, themeSlug))
			}
		}
		for _, line := range echoLineRe.FindAllString(content, -1) {
			// Only PHP output statements are escaping-relevant; prose copy
			// containing the word "echo" is fine.
			if !strings.Contains(line, "<?php") && !strings.Contains(line, "<?=") {
				continue
			}
			if !strings.Contains(line, "esc_html") && !strings.Contains(line, "esc_attr") &&
				!strings.Contains(line, "esc_url") && !strings.Contains(line, "wp_kses") {
				errs = append(errs, fmt.Sprintf("%s: unescaped echo — wrap output in esc_html()/esc_attr()/esc_url(): %s", rel, strings.TrimSpace(line)))
			}
		}
	}
	return slugs, errs, warns
}

var slugAttrRe = regexp.MustCompile(`"slug"\s*:\s*"([^"]+)"`)

// checkReferences verifies every wp:pattern / wp:template-part slug used in
// templates+parts resolves to a real pattern / part file.
func checkReferences(workspacePath string, patternSlugs map[string]bool) []string {
	parts := map[string]bool{}
	if entries, err := os.ReadDir(filepath.Join(workspacePath, "parts")); err == nil {
		for _, e := range entries {
			if !e.IsDir() && strings.HasSuffix(e.Name(), ".html") {
				parts[strings.TrimSuffix(e.Name(), ".html")] = true
			}
		}
	}
	var errs []string
	for _, rel := range themeMarkupFiles(workspacePath) {
		content, ok := readBoundedMarkup(filepath.Join(workspacePath, filepath.FromSlash(rel)))
		if !ok {
			continue
		}
		for _, m := range blockTokenRe.FindAllStringSubmatch(content, -1) {
			name, attrs := m[2], m[3]
			slugM := slugAttrRe.FindStringSubmatch(attrs)
			if slugM == nil {
				continue
			}
			slug := slugM[1]
			switch name {
			case "pattern":
				if !strings.HasPrefix(slug, "core/") && !patternSlugs[slug] {
					errs = append(errs, fmt.Sprintf("%s: references pattern %q but no patterns/*.php registers that slug", rel, slug))
				}
			case "template-part":
				if !parts[slug] {
					errs = append(errs, fmt.Sprintf("%s: references template part %q but parts/%s.html does not exist", rel, slug, slug))
				}
			}
		}
	}
	return errs
}

// advisoryWarnings reports quality items that never block (most are auto-fixed
// by the packaging finalize step).
func advisoryWarnings(workspacePath string) []string {
	var warns []string
	if _, err := os.Stat(filepath.Join(workspacePath, "screenshot.png")); err != nil {
		warns = append(warns, "screenshot.png is missing (a branding card is auto-generated at packaging)")
	}
	if _, err := os.Stat(filepath.Join(workspacePath, "readme.txt")); err != nil {
		warns = append(warns, "readme.txt is missing (auto-generated at packaging)")
	}
	for _, tpl := range []string{"404.html", "search.html"} {
		if _, err := os.Stat(filepath.Join(workspacePath, "templates", tpl)); err != nil {
			warns = append(warns, "templates/"+tpl+" is missing (recommended)")
		}
	}
	if fields, _, ok := parseStyleHeader(workspacePath); ok {
		for _, key := range []string{"Description", "Version", "Text Domain", "License"} {
			if fields[key] == "" {
				warns = append(warns, "style.css header is missing "+key+": (completed at packaging)")
			}
		}
	}
	if entries, err := os.ReadDir(filepath.Join(workspacePath, "parts")); err == nil {
		registered := map[string]bool{}
		if theme, tErr := ReadJSONFile(filepath.Join(workspacePath, "theme.json")); tErr == nil {
			if tp, tpOK := theme["templateParts"].([]interface{}); tpOK {
				for _, e := range tp {
					if m, mOK := e.(map[string]interface{}); mOK {
						if name, _ := m["name"].(string); name != "" {
							registered[name] = true
						}
					}
				}
			}
		}
		for _, e := range entries {
			if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") {
				continue
			}
			name := strings.TrimSuffix(e.Name(), ".html")
			if !registered[name] {
				warns = append(warns, "parts/"+e.Name()+" is not registered in theme.json templateParts (registered automatically at packaging)")
			}
		}
	}
	return warns
}

// lintThemePHP runs `php -l` over every .php file in the theme (functions.php,
// patterns/*.php, …). A theme with a PHP parse error passes the structural
// checks but white-screens on a real install, so this catches the most common
// fatal class. Skipped entirely when no php binary is on PATH. The context
// bounds every subprocess (with a hard backstop) so a pathological php
// invocation can never hang the executor goroutine past the tool deadline.
func lintThemePHP(ctx context.Context, workspacePath string) []string {
	phpBin, err := exec.LookPath("php")
	if err != nil {
		return nil
	}

	// Backstop deadline even when the caller's context has none — php -l is a
	// parse-only check that completes in milliseconds per file.
	lintCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
	defer cancel()

	var problems []string
	linted := 0
	_ = filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return nil
		}
		if lintCtx.Err() != nil {
			return filepath.SkipAll
		}
		if info.IsDir() {
			switch info.Name() {
			case "node_modules", ".git", ".webby", ".revisions":
				return filepath.SkipDir
			}
			return nil
		}
		if !strings.EqualFold(filepath.Ext(path), ".php") {
			return nil
		}
		// Safety cap — themes ship a handful of PHP files; never lint unbounded.
		if linted >= 50 {
			return filepath.SkipAll
		}
		linted++

		// -n skips php.ini so lint behavior is host-config independent.
		out, lintErr := exec.CommandContext(lintCtx, phpBin, "-n", "-l", path).CombinedOutput()
		if lintErr != nil {
			// A killed-by-deadline process is a timeout, not a syntax error —
			// don't report a false positive against whatever file was current.
			if lintCtx.Err() != nil {
				return filepath.SkipAll
			}
			rel, relErr := filepath.Rel(workspacePath, path)
			if relErr != nil {
				rel = info.Name()
			}
			msg := strings.TrimSpace(string(out))
			if len(msg) > 300 {
				msg = msg[:300] + "…"
			}
			problems = append(problems, fmt.Sprintf("%s has a PHP syntax error: %s", rel, msg))
		}
		return nil
	})
	return problems
}
