package compat

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"sync"
)

// EnvVar is the environment variable used to locate the Understand-Anything
// checkout in developer and CI environments.
const EnvVar = "UNDERSTAND_ANYTHING_ROOT"

// UARepo holds the resolved paths to a built Understand-Anything checkout.
type UARepo struct {
	// Root is the top-level Understand-Anything repo directory.
	Root string
	// CorePkgDir is the packages/core directory that contains package.json,
	// src/schema.ts, and dist/schema.js (after EnsureBuilt succeeds).
	CorePkgDir string
}

// LocateUARepo resolves an Understand-Anything checkout from candidate sources.
//
// Lookup order:
//  1. $UNDERSTAND_ANYTHING_ROOT if set (root, plugin subdir, or core pkg dir)
//  2. Common sibling development paths relative to the AgentTeam checkout
//
// Returns ok=false when no usable checkout is found. Tests that depend on a
// live UA repo should call this and t.Skip on ok=false.
func LocateUARepo() (UARepo, bool) {
	if env := strings.TrimSpace(os.Getenv(EnvVar)); env != "" {
		if repo, ok := resolveUARepo(env); ok {
			return repo, true
		}
	}
	for _, candidate := range commonCandidateRoots() {
		if repo, ok := resolveUARepo(candidate); ok {
			return repo, true
		}
	}
	return UARepo{}, false
}

func resolveUARepo(path string) (UARepo, bool) {
	cleaned, err := filepath.Abs(filepath.Clean(strings.TrimSpace(path)))
	if err != nil {
		return UARepo{}, false
	}
	for _, candidate := range []string{
		cleaned,
		filepath.Join(cleaned, "understand-anything-plugin"),
		filepath.Join(cleaned, "understand-anything-plugin", "packages", "core"),
		filepath.Join(cleaned, "packages", "core"),
	} {
		if isCorePkgDir(candidate) {
			return UARepo{
				Root:       repoRootFromCore(candidate),
				CorePkgDir: candidate,
			}, true
		}
	}
	return UARepo{}, false
}

func isCorePkgDir(dir string) bool {
	if dir == "" {
		return false
	}
	packageJSON := filepath.Join(dir, "package.json")
	schemaTS := filepath.Join(dir, "src", "schema.ts")
	if _, err := os.Stat(packageJSON); err != nil {
		return false
	}
	if _, err := os.Stat(schemaTS); err != nil {
		return false
	}
	return true
}

func repoRootFromCore(coreDir string) string {
	parent := filepath.Dir(coreDir) // packages
	grand := filepath.Dir(parent)   // understand-anything-plugin
	great := filepath.Dir(grand)    // Understand-Anything repo root
	if isPluginRoot(grand) {
		return great
	}
	return grand
}

func isPluginRoot(dir string) bool {
	if _, err := os.Stat(filepath.Join(dir, "pnpm-workspace.yaml")); err != nil {
		return false
	}
	return true
}

// commonCandidateRoots returns a small list of heuristic lookup paths used
// when the env var is unset. These mirror the UA scanner's install-path search
// so a developer running tests against a typical workspace layout can rely on
// discovery without setting anything.
func commonCandidateRoots() []string {
	candidates := []string{}
	if home := homeDir(); home != "" {
		candidates = append(candidates,
			filepath.Join(home, "devel", "ai", "Understand-Anything"),
			filepath.Join(home, "Understand-Anything"),
		)
	}
	return candidates
}

func homeDir() string {
	if h := os.Getenv("HOME"); h != "" {
		return h
	}
	if h, err := os.UserHomeDir(); err == nil {
		return h
	}
	return ""
}

// EnsureBuilt ensures packages/core/dist exists, running pnpm install + build
// once if missing. The build is gated by a process-wide sync.Once so repeated
// test invocations do not pay the cost.
func (repo UARepo) EnsureBuilt() error {
	distSchema := filepath.Join(repo.CorePkgDir, "dist", "schema.js")
	if _, err := os.Stat(distSchema); err == nil {
		return nil
	}
	ensureBuildOnce.Do(func() {
		ensureBuildErr = runPnpmBuild(repo.CorePkgDir)
	})
	return ensureBuildErr
}

var (
	ensureBuildOnce sync.Once
	ensureBuildErr  error
)

func runPnpmBuild(corePkgDir string) error {
	pluginRoot := filepath.Dir(filepath.Dir(corePkgDir))
	workspace := filepath.Join(pluginRoot, "pnpm-workspace.yaml")
	if _, err := os.Stat(workspace); err != nil {
		return fmt.Errorf("compat: pnpm-workspace.yaml missing under %s", pluginRoot)
	}
	pnpm, err := lookPath("pnpm")
	if err != nil {
		return fmt.Errorf("compat: pnpm not found in PATH (install pnpm >= 10): %w", err)
	}
	install := exec.Command(pnpm, "install", "--frozen-lockfile")
	install.Dir = pluginRoot
	if _, err := install.CombinedOutput(); err != nil {
		out, retryErr := retryPnpmInstall(pnpm, pluginRoot)
		if retryErr != nil {
			return fmt.Errorf("compat: pnpm install failed: %w\n%s", retryErr, string(out))
		}
	}
	build := exec.Command(pnpm, "--filter", "@understand-anything/core", "build")
	build.Dir = pluginRoot
	if out, err := build.CombinedOutput(); err != nil {
		return fmt.Errorf("compat: pnpm build failed: %w\n%s", err, string(out))
	}
	distSchema := filepath.Join(corePkgDir, "dist", "schema.js")
	if _, err := os.Stat(distSchema); err != nil {
		return fmt.Errorf("compat: %s still missing after build", distSchema)
	}
	return nil
}

func retryPnpmInstall(pnpm, pluginRoot string) ([]byte, error) {
	install := exec.Command(pnpm, "install")
	install.Dir = pluginRoot
	out, err := install.CombinedOutput()
	return out, err
}

func lookPath(file string) (string, error) {
	if runtime.GOOS == "windows" {
		// Allowing both .cmd and bare names.
		if path, err := exec.LookPath(file); err == nil {
			return path, nil
		}
		return exec.LookPath(file + ".cmd")
	}
	return exec.LookPath(file)
}

// UAValidationResult mirrors the subset of UA's `ValidationResult` that the
// dashboard and parity gate care about. The full UA shape also includes
// `errors`; we drop that to keep payloads small. DataSummary carries the
// surviving graph shape from UA's `data` so the parity test can compare it
// against the Go validator's parsed graph without re-running the Go validator.
type UAValidationResult struct {
	Success     bool           `json:"success"`
	Fatal       string         `json:"fatal,omitempty"`
	Issues      []UAGraphIssue `json:"issues"`
	DataSummary *UADataSummary `json:"dataSummary,omitempty"`
}

// UADataSummary is the compact view of UA's validated KnowledgeGraph. Counts
// and canonical id/type vectors are sufficient to catch alias normalization,
// drop, and auto-fix drift between the two implementations.
type UADataSummary struct {
	NodeCount  int              `json:"nodeCount"`
	EdgeCount  int              `json:"edgeCount"`
	LayerCount int              `json:"layerCount"`
	TourCount  int              `json:"tourCount"`
	Nodes      []UANodeSummary  `json:"nodes"`
	Edges      []UAEdgeSummary  `json:"edges"`
	Layers     []UALayerSummary `json:"layers"`
	Tour       []UATourSummary  `json:"tour"`
}

type UANodeSummary struct {
	ID   string `json:"id"`
	Type string `json:"type"`
}

type UAEdgeSummary struct {
	Source    string `json:"source"`
	Target    string `json:"target"`
	Type      string `json:"type"`
	Direction string `json:"direction"`
}

type UALayerSummary struct {
	ID      string   `json:"id"`
	NodeIDs []string `json:"nodeIds"`
}

type UATourSummary struct {
	Order   int      `json:"order"`
	NodeIDs []string `json:"nodeIds"`
}

// UAGraphIssue mirrors UA's GraphIssue type.
type UAGraphIssue struct {
	Level    string `json:"level"`
	Category string `json:"category"`
	Message  string `json:"message"`
	Path     string `json:"path,omitempty"`
}

// CountsByLevel returns the number of issues per level (auto-corrected /
// dropped / fatal). Useful when comparing parity with the Go validator's
// ValidationResult.Issues in tolerant mode.
func (r UAValidationResult) CountsByLevel() map[string]int {
	counts := map[string]int{}
	for _, issue := range r.Issues {
		counts[issue.Level]++
	}
	return counts
}

// RunUAValidateGraph invokes the upstream UA `validateGraph()` on the supplied
// graph JSON bytes by spawning the bundled validate-ua-graph.mjs script under
// node. The script returns a single JSON line on stdout; this helper parses
// it into UAValidationResult.
//
// EnsureBuilt must have succeeded before calling this, otherwise the Node
// import of dist/schema.js will fail.
func (repo UARepo) RunUAValidateGraph(graphJSON []byte) (UAValidationResult, error) {
	return repo.runValidateScript(graphJSON)
}

// RunUAValidateDiffOverlay runs UA's dashboard-side shape expectations on a
// diff-overlay JSON payload. UA does not ship a dedicated validator for
// diff-overlay.json; the dashboard inline-checks `{changedNodeIds,affectedNodeIds}`
// are arrays of strings. This helper returns the equivalent inline check shape
// so parity tests can compare it against the Go schema validator's verdict.
//
// The implementation never invokes Node — diff-overlay parity is fully Go-side
// for now. The function exists so future UA additions can hook in here without
// breaking callers.
func (repo UARepo) RunUAValidateDiffOverlay(overlayJSON []byte) (UAValidationResult, error) {
	var raw map[string]any
	if err := json.Unmarshal(overlayJSON, &raw); err != nil {
		return UAValidationResult{Success: false, Fatal: "Invalid JSON: " + err.Error()}, nil
	}
	changed, changedOK := raw["changedNodeIds"].([]any)
	affected, affectedOK := raw["affectedNodeIds"].([]any)
	if !changedOK {
		// Dashboard source (App.tsx) is case-sensitive on changedNodeIds.
		return UAValidationResult{
			Success: false,
			Fatal:   "changedNodeIds must be an array",
		}, nil
	}
	if !affectedOK {
		return UAValidationResult{
			Success: false,
			Fatal:   "affectedNodeIds must be an array",
		}, nil
	}
	for i, value := range changed {
		if _, ok := value.(string); !ok {
			return UAValidationResult{
				Success: false,
				Fatal:   fmt.Sprintf("changedNodeIds[%d] must be a string", i),
			}, nil
		}
	}
	for i, value := range affected {
		if _, ok := value.(string); !ok {
			return UAValidationResult{
				Success: false,
				Fatal:   fmt.Sprintf("affectedNodeIds[%d] must be a string", i),
			}, nil
		}
	}
	return UAValidationResult{Success: true}, nil
}

func (repo UARepo) runValidateScript(graphJSON []byte) (UAValidationResult, error) {
	scriptPath, err := resolveScriptPath()
	if err != nil {
		return UAValidationResult{}, err
	}
	node, err := lookPath("node")
	if err != nil {
		return UAValidationResult{}, fmt.Errorf("compat: node not found in PATH: %w", err)
	}
	tmpFile, err := os.CreateTemp("", "ua-graph-*.json")
	if err != nil {
		return UAValidationResult{}, fmt.Errorf("compat: create temp graph: %w", err)
	}
	defer func() { _ = os.Remove(tmpFile.Name()) }()
	if _, err := tmpFile.Write(graphJSON); err != nil {
		_ = tmpFile.Close()
		return UAValidationResult{}, fmt.Errorf("compat: write temp graph: %w", err)
	}
	if err := tmpFile.Close(); err != nil {
		return UAValidationResult{}, fmt.Errorf("compat: close temp graph: %w", err)
	}
	args := []string{scriptPath, repo.Root, tmpFile.Name()}
	cmd := exec.Command(node, args...)
	cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", EnvVar, repo.Root))
	var stdout, stderr strings.Builder
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) {
			// Exit code 1 means validateGraph returned success=false — that is
			// a normal "graph rejected" result, not a harness error.
			if exitErr.ExitCode() == 1 {
				return parseScriptOutput(stdout.String(), stderr.String())
			}
		}
		return UAValidationResult{}, fmt.Errorf("compat: node %s: %w\n%s", scriptPath, err, stderr.String())
	}
	return parseScriptOutput(stdout.String(), stderr.String())
}

func resolveScriptPath() (string, error) {
	_, file, _, ok := runtime.Caller(0)
	if !ok {
		return "", errors.New("compat: cannot resolve caller path")
	}
	compatDir := filepath.Dir(file)              // .../internal/graphbuilder/compat
	graphbuilderDir := filepath.Dir(compatDir)   // .../internal/graphbuilder
	internalDir := filepath.Dir(graphbuilderDir) // .../internal
	codeDir := filepath.Dir(internalDir)         // .../agentteam-insight
	script := filepath.Join(codeDir, "scripts", "validate-ua-graph.mjs")
	if _, err := os.Stat(script); err != nil {
		return "", fmt.Errorf("compat: validate script missing: %s", script)
	}
	return script, nil
}

func parseScriptOutput(stdout, stderr string) (UAValidationResult, error) {
	trimmed := strings.TrimSpace(stdout)
	if trimmed == "" {
		return UAValidationResult{}, fmt.Errorf("compat: empty script stdout\nstderr: %s", stderr)
	}
	var result UAValidationResult
	if err := json.Unmarshal([]byte(trimmed), &result); err != nil {
		return UAValidationResult{}, fmt.Errorf("compat: parse script output: %w\nstdout: %s\nstderr: %s", err, stdout, stderr)
	}
	return result, nil
}
