package topdata

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"strings"

	"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)

type Severity string

const (
	SeverityError   Severity = "error"
	SeverityWarning Severity = "warning"
)

type Diagnostic struct {
	Severity Severity
	Path     string
	Message  string
}

type ValidationReport struct {
	Files       int
	DataFiles   int
	TLKFiles    int
	Diagnostics []Diagnostic
}

func (r ValidationReport) HasErrors() bool {
	for _, diagnostic := range r.Diagnostics {
		if diagnostic.Severity == SeverityError {
			return true
		}
	}
	return false
}

func (r ValidationReport) ErrorCount() int {
	count := 0
	for _, diagnostic := range r.Diagnostics {
		if diagnostic.Severity == SeverityError {
			count++
		}
	}
	return count
}

func (r ValidationReport) WarningCount() int {
	count := 0
	for _, diagnostic := range r.Diagnostics {
		if diagnostic.Severity == SeverityWarning {
			count++
		}
	}
	return count
}

type BuildResult struct {
	Mode     string
	Output2DADir string
	OutputTLKDir string
	Files2DA int
	FilesTLK int
}

type CompareResult struct {
	Mode         string
	Compared2DA  int
	ComparedTLK  int
}

func ValidateProject(p *project.Project) ValidationReport {
	report := ValidationReport{}
	if !p.HasTopData() {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityWarning,
			Path:     project.ConfigFile,
			Message:  "topdata is not configured for this project",
		})
		return report
	}

	sourceDir := p.TopDataSourceDir()
	info, err := os.Stat(sourceDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     sourceDir,
			Message:  err.Error(),
		})
		return report
	}
	if !info.IsDir() {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     sourceDir,
			Message:  "topdata source must be a directory",
		})
		return report
	}

	dataDir := filepath.Join(sourceDir, "data")
	tlkDir := filepath.Join(sourceDir, "tlk")
	baseDialog := filepath.Join(sourceDir, "base_dialog.json")

	for _, requiredDir := range []string{dataDir, tlkDir} {
		info, err := os.Stat(requiredDir)
		if err != nil {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     requiredDir,
				Message:  err.Error(),
			})
			continue
		}
		if !info.IsDir() {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     requiredDir,
				Message:  "must be a directory",
			})
		}
	}

	if _, err := os.Stat(baseDialog); err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityWarning,
			Path:     baseDialog,
			Message:  "base_dialog.json is missing; migrated datasets may rely on reference builder fallback for now",
		})
	} else {
		report.Files++
		if err := validateJSONFile(baseDialog, "base_dialog", &report); err == nil {
			// count only once for base dialog.
		}
	}

	_ = walkJSON(dataDir, func(path string) {
		report.Files++
		report.DataFiles++
		_ = validateJSONFile(path, "data", &report)
	})
	_ = walkJSON(tlkDir, func(path string) {
		report.Files++
		report.TLKFiles++
		_ = validateJSONFile(path, "tlk", &report)
	})

	refDir := p.TopDataReferenceBuilderDir()
	if refDir == "" {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityWarning,
			Path:     project.ConfigFile,
			Message:  "topdata.reference_builder is not configured; build-topdata reference mode will be unavailable",
		})
	} else {
		if _, err := os.Stat(filepath.Join(refDir, "build.py")); err != nil {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     refDir,
				Message:  "reference builder does not contain build.py",
			})
		}
	}

	return report
}

func validateJSONFile(path, domain string, report *ValidationReport) error {
	raw, err := os.ReadFile(path)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  err.Error(),
		})
		return err
	}

	var payload any
	if err := json.Unmarshal(raw, &payload); err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("invalid JSON: %v", err),
		})
		return err
	}

	obj, ok := payload.(map[string]any)
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "topdata files must contain a JSON object at the root",
		})
		return errors.New("topdata file root must be object")
	}

	switch domain {
	case "data":
		validateDataObject(path, obj, report)
	case "tlk":
		validateTLKObject(path, obj, report)
	case "base_dialog":
		// Parseability is enough for now; compatibility is still reference-backed.
	}
	return nil
}

func validateDataObject(path string, obj map[string]any, report *ValidationReport) {
	base := filepath.Base(path)
	slashed := filepath.ToSlash(path)
	inModules := strings.Contains(slashed, "/modules/")

	if base == "lock.json" {
		validateLockObject(path, obj, report)
		return
	}
	if base == "base.json" {
		validateRowsFile(path, obj, report, true)
		return
	}
	if inModules {
		if _, hasEntries := obj["entries"]; hasEntries {
			validateEntriesFile(path, obj, report)
			return
		}
		if _, hasOverrides := obj["overrides"]; hasOverrides {
			validateOverridesFile(path, obj, report)
			return
		}
		if _, hasRows := obj["rows"]; hasRows {
			validateRowsFile(path, obj, report, false)
			return
		}
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityWarning,
			Path:     path,
			Message:  "module file does not use a recognized canonical shape yet; expected one of entries, overrides, or rows",
		})
		return
	}

	if _, hasRows := obj["rows"]; hasRows {
		validateRowsFile(path, obj, report, false)
		return
	}
	report.Diagnostics = append(report.Diagnostics, Diagnostic{
		Severity: SeverityWarning,
		Path:     path,
		Message:  "unrecognized data file shape; parseable for compatibility, but not canonical yet",
	})
}

func validateTLKObject(path string, obj map[string]any, report *ValidationReport) {
	if filepath.Base(path) == "lock.json" {
		validateLockObject(path, obj, report)
		return
	}
	entries, ok := obj["entries"]
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "TLK file must contain an entries object",
		})
		return
	}
	if _, ok := entries.(map[string]any); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "TLK entries must be a JSON object",
		})
	}
	if language, ok := obj["language"]; ok {
		if _, ok := language.(string); !ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  "TLK language must be a string when present",
			})
		}
	}
}

func validateLockObject(path string, obj map[string]any, report *ValidationReport) {
	for key, value := range obj {
		switch value.(type) {
		case float64:
			// JSON numbers land here.
		default:
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("lock entry %q must be numeric", key),
			})
		}
	}
}

func validateRowsFile(path string, obj map[string]any, report *ValidationReport, requireColumns bool) {
	rows, ok := obj["rows"]
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "rows file must contain a rows array",
		})
		return
	}
	if _, ok := rows.([]any); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "rows must be a JSON array",
		})
	}
	if columns, ok := obj["columns"]; ok {
		if _, ok := columns.([]any); !ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  "columns must be a JSON array when present",
			})
		}
	} else if requireColumns {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "base dataset file must contain columns",
		})
	}
}

func validateEntriesFile(path string, obj map[string]any, report *ValidationReport) {
	entries, ok := obj["entries"]
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "entries file must contain an entries object",
		})
		return
	}
	if _, ok := entries.(map[string]any); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "entries must be a JSON object",
		})
	}
}

func validateOverridesFile(path string, obj map[string]any, report *ValidationReport) {
	overrides, ok := obj["overrides"]
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "override file must contain an overrides array",
		})
		return
	}
	if _, ok := overrides.([]any); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "overrides must be a JSON array",
		})
	}
}

func BuildReference(p *project.Project, progress func(string)) (BuildResult, error) {
	if progress == nil {
		progress = func(string) {}
	}
	if !p.HasTopData() {
		return BuildResult{}, errors.New("topdata is not configured for this project")
	}

	report := ValidateProject(p)
	if report.HasErrors() {
		return BuildResult{}, fmt.Errorf("topdata validation failed with %d error(s)", report.ErrorCount())
	}

	refDir := p.TopDataReferenceBuilderDir()
	if refDir == "" {
		return BuildResult{}, errors.New("topdata.reference_builder is not configured")
	}

	buildDir := p.TopDataBuildDir()
	output2DA := filepath.Join(buildDir, "2da")
	outputTLK := filepath.Join(buildDir, "tlk")
	progress("Preparing topdata build directories...")
	if err := os.RemoveAll(buildDir); err != nil {
		return BuildResult{}, fmt.Errorf("clean topdata build dir: %w", err)
	}
	if err := os.MkdirAll(output2DA, 0o755); err != nil {
		return BuildResult{}, fmt.Errorf("create 2da output dir: %w", err)
	}
	if err := os.MkdirAll(outputTLK, 0o755); err != nil {
		return BuildResult{}, fmt.Errorf("create tlk output dir: %w", err)
	}

	progress("Building topdata outputs through the Python reference builder...")
	bridgeDir, cleanup, err := prepareReferenceBridge(refDir)
	if err != nil {
		return BuildResult{}, err
	}
	defer cleanup()

	cmd := exec.Command("python3", "build.py", "--out", output2DA, "--tlk-dir", outputTLK)
	cmd.Dir = bridgeDir
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	cmd.Stdout = ioDiscardProgress(progress)
	if err := cmd.Run(); err != nil {
		message := strings.TrimSpace(stderr.String())
		if message == "" {
			message = err.Error()
		}
		return BuildResult{}, fmt.Errorf("reference builder failed: %s", message)
	}

	files2DA, err := countFiles(output2DA)
	if err != nil {
		return BuildResult{}, err
	}
	filesTLK, err := countFiles(outputTLK)
	if err != nil {
		return BuildResult{}, err
	}

	return BuildResult{
		Mode:       "reference",
		Output2DADir: output2DA,
		OutputTLKDir: outputTLK,
		Files2DA:   files2DA,
		FilesTLK:   filesTLK,
	}, nil
}

func CompareReference(p *project.Project, progress func(string)) (CompareResult, error) {
	if progress == nil {
		progress = func(string) {}
	}
	buildDir := p.TopDataBuildDir()
	output2DA := filepath.Join(buildDir, "2da")
	outputTLK := filepath.Join(buildDir, "tlk")
	if _, err := os.Stat(output2DA); err != nil {
		return CompareResult{}, fmt.Errorf("topdata build output missing: run build-topdata first")
	}

	tempDir, err := os.MkdirTemp("", "sow-topdata-compare-*")
	if err != nil {
		return CompareResult{}, fmt.Errorf("create compare temp dir: %w", err)
	}
	defer os.RemoveAll(tempDir)

	ref2DA := filepath.Join(tempDir, "2da")
	refTLK := filepath.Join(tempDir, "tlk")
	if err := os.MkdirAll(ref2DA, 0o755); err != nil {
		return CompareResult{}, err
	}
	if err := os.MkdirAll(refTLK, 0o755); err != nil {
		return CompareResult{}, err
	}

	progress("Building fresh reference topdata outputs for comparison...")
	bridgeDir, cleanup, err := prepareReferenceBridge(p.TopDataReferenceBuilderDir())
	if err != nil {
		return CompareResult{}, err
	}
	defer cleanup()

	cmd := exec.Command("python3", "build.py", "--out", ref2DA, "--tlk-dir", refTLK)
	cmd.Dir = bridgeDir
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	cmd.Stdout = ioDiscardProgress(progress)
	if err := cmd.Run(); err != nil {
		message := strings.TrimSpace(stderr.String())
		if message == "" {
			message = err.Error()
		}
		return CompareResult{}, fmt.Errorf("reference builder failed: %s", message)
	}

	compared2DA, err := compareDirs(output2DA, ref2DA)
	if err != nil {
		return CompareResult{}, err
	}
	comparedTLK, err := compareDirs(outputTLK, refTLK)
	if err != nil {
		return CompareResult{}, err
	}

	return CompareResult{
		Mode:        "reference",
		Compared2DA: compared2DA,
		ComparedTLK: comparedTLK,
	}, nil
}

func compareDirs(actualRoot, expectedRoot string) (int, error) {
	expectedFiles := make([]string, 0)
	err := filepath.WalkDir(expectedRoot, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		rel, err := filepath.Rel(expectedRoot, path)
		if err != nil {
			return err
		}
		expectedFiles = append(expectedFiles, filepath.ToSlash(rel))
		return nil
	})
	if err != nil {
		return 0, err
	}
	slices.Sort(expectedFiles)

	actualFiles := make([]string, 0)
	err = filepath.WalkDir(actualRoot, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		rel, err := filepath.Rel(actualRoot, path)
		if err != nil {
			return err
		}
		actualFiles = append(actualFiles, filepath.ToSlash(rel))
		return nil
	})
	if err != nil {
		return 0, err
	}
	slices.Sort(actualFiles)
	if !slices.Equal(actualFiles, expectedFiles) {
		return 0, fmt.Errorf("topdata output file list differs from reference")
	}

	for _, rel := range expectedFiles {
		actualBytes, err := os.ReadFile(filepath.Join(actualRoot, rel))
		if err != nil {
			return 0, err
		}
		expectedBytes, err := os.ReadFile(filepath.Join(expectedRoot, rel))
		if err != nil {
			return 0, err
		}
		if !bytes.Equal(actualBytes, expectedBytes) {
			return 0, fmt.Errorf("topdata output differs from reference: %s", rel)
		}
	}
	return len(expectedFiles), nil
}

func walkJSON(root string, fn func(path string)) error {
	return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		if strings.HasSuffix(strings.ToLower(d.Name()), ".json") {
			fn(path)
		}
		return nil
	})
}

func countFiles(root string) (int, error) {
	count := 0
	err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() {
			count++
		}
		return nil
	})
	return count, err
}

type progressWriter struct {
	progress func(string)
}

func (w progressWriter) Write(p []byte) (int, error) {
	text := strings.TrimSpace(string(p))
	if text != "" {
		w.progress(text)
	}
	return len(p), nil
}

func ioDiscardProgress(progress func(string)) *progressWriter {
	return &progressWriter{progress: progress}
}

func prepareReferenceBridge(referenceBuilderDir string) (string, func(), error) {
	tempRoot, err := os.MkdirTemp("", "sow-topdata-ref-*")
	if err != nil {
		return "", nil, fmt.Errorf("create reference bridge dir: %w", err)
	}

	cleanup := func() {
		_ = os.RemoveAll(tempRoot)
	}

	builderLink := filepath.Join(tempRoot, "tools", "2dabuilder")
	if err := os.MkdirAll(filepath.Dir(builderLink), 0o755); err != nil {
		cleanup()
		return "", nil, fmt.Errorf("create reference bridge tools dir: %w", err)
	}
	if err := os.MkdirAll(filepath.Join(tempRoot, "assets"), 0o755); err != nil {
		cleanup()
		return "", nil, fmt.Errorf("create reference bridge assets dir: %w", err)
	}
	if err := os.MkdirAll(filepath.Join(tempRoot, "staging"), 0o755); err != nil {
		cleanup()
		return "", nil, fmt.Errorf("create reference bridge staging dir: %w", err)
	}
	if err := copyReferenceBuilder(referenceBuilderDir, builderLink); err != nil {
		cleanup()
		return "", nil, err
	}
	if err := rewriteBridgeDefaults(filepath.Join(builderLink, "build.py"), filepath.Join(tempRoot, "assets"), filepath.Join(tempRoot, "staging")); err != nil {
		cleanup()
		return "", nil, err
	}
	return builderLink, cleanup, nil
}

func copyReferenceBuilder(src, dest string) error {
	return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(src, path)
		if err != nil {
			return err
		}
		if rel == "." {
			return os.MkdirAll(dest, 0o755)
		}
		name := d.Name()
		if d.IsDir() {
			if name == ".venv" || name == ".pytest_cache" || name == "__pycache__" {
				return filepath.SkipDir
			}
			return os.MkdirAll(filepath.Join(dest, rel), 0o755)
		}
		if strings.HasSuffix(name, ".pyc") || name == "2dabuilder.log" {
			return nil
		}
		target := filepath.Join(dest, rel)
		info, err := d.Info()
		if err != nil {
			return err
		}
		data, err := os.ReadFile(path)
		if err != nil {
			return err
		}
		return os.WriteFile(target, data, info.Mode())
	})
}

func rewriteBridgeDefaults(buildScriptPath, assetsDir, stagingDir string) error {
	raw, err := os.ReadFile(buildScriptPath)
	if err != nil {
		return fmt.Errorf("read bridge build.py: %w", err)
	}
	text := string(raw)
	text = strings.Replace(text, `DEFAULT_ASSETS_DIR = "../../assets"`, fmt.Sprintf("DEFAULT_ASSETS_DIR = %q", assetsDir), 1)
	text = strings.Replace(text, `DEFAULT_STAGING_DIR = "../../staging"`, fmt.Sprintf("DEFAULT_STAGING_DIR = %q", stagingDir), 1)
	if err := os.WriteFile(buildScriptPath, []byte(text), 0o644); err != nil {
		return fmt.Errorf("rewrite bridge build.py defaults: %w", err)
	}
	return nil
}
