package compat

import (
	"fmt"
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/graphbuilder/schema"
)

// TestLocateUARepoDoesNotPanic is the always-on smoke test: locating the UA
// repo must always return a deterministic ok flag without panicking, regardless
// of whether UA is actually checked out on the host.
func TestLocateUARepoDoesNotPanic(t *testing.T) {
	repo, ok := LocateUARepo()
	if !ok {
		return
	}
	require.DirExists(t, repo.Root)
	require.DirExists(t, repo.CorePkgDir)
}

// TestLocateUARepoRespectsEnvVar verifies the env var path resolution. It sets
// the env var to a non-existent path and confirms LocateUARepo falls through to
// common candidates; it then points the env var at the located repo (if any)
// and confirms round-trip resolution succeeds.
func TestLocateUARepoRespectsEnvVar(t *testing.T) {
	t.Setenv(EnvVar, "/definitely/not/a/ua/repo")
	_, ok := LocateUARepo()
	// We do not assert ok=false here because a real UA checkout may still be
	// discovered via common candidate paths. The point is that the bogus env
	// value is silently ignored rather than producing a fatal error.
	_ = ok

	repo, ok := LocateUARepo()
	if !ok {
		return
	}
	t.Setenv(EnvVar, repo.Root)
	again, ok := LocateUARepo()
	require.True(t, ok)
	require.Equal(t, repo.Root, again.Root)
}

// TestRunUAValidateDiffOverlayAcceptsExpectedShape is the always-on Go-side
// parity check for the diff-overlay dashboard contract. UA's dashboard reads
// changedNodeIds/affectedNodeIds (case-sensitive) as arrays of strings.
func TestRunUAValidateDiffOverlayAcceptsExpectedShape(t *testing.T) {
	repo := UARepo{}
	valid := []byte(`{"changedNodeIds":["file:src/a.ts"],"affectedNodeIds":["file:src/b.ts"]}`)
	result, err := repo.RunUAValidateDiffOverlay(valid)
	require.NoError(t, err)
	require.True(t, result.Success)
	require.Empty(t, result.Fatal)
}

// TestRunUAValidateDiffOverlayRejectsCapitalDVariant confirms the dashboard's
// case sensitivity: affectedNodeIDs (capital D) must be rejected because the
// dashboard reads affectedNodeIds. This is the exact regression that broke
// AgentTeam Code's first DiffOverlay implementation.
func TestRunUAValidateDiffOverlayRejectsCapitalDVariant(t *testing.T) {
	repo := UARepo{}
	invalid := []byte(`{"changedNodeIds":["file:src/a.ts"],"affectedNodeIDs":["file:src/b.ts"]}`)
	result, err := repo.RunUAValidateDiffOverlay(invalid)
	require.NoError(t, err)
	require.False(t, result.Success)
	require.Contains(t, result.Fatal, "affectedNodeIds")
}

// TestRunUAValidateDiffOverlayRejectsNonStringElement verifies the element
// type check matches UA's dashboard expectations.
func TestRunUAValidateDiffOverlayRejectsNonStringElement(t *testing.T) {
	repo := UARepo{}
	invalid := []byte(`{"changedNodeIds":["file:src/a.ts", 42],"affectedNodeIds":[]}`)
	result, err := repo.RunUAValidateDiffOverlay(invalid)
	require.NoError(t, err)
	require.False(t, result.Success)
	require.Contains(t, result.Fatal, "changedNodeIds[1]")
}

// TestRunUAValidateDiffOverlayRejectsInvalidJSON verifies malformed input is
// reported as a fatal validation result rather than a harness error.
func TestRunUAValidateDiffOverlayRejectsInvalidJSON(t *testing.T) {
	repo := UARepo{}
	result, err := repo.RunUAValidateDiffOverlay([]byte(`{not json`))
	require.NoError(t, err)
	require.False(t, result.Success)
	require.Contains(t, result.Fatal, "Invalid JSON")
}

// fixtureCase is a single golden fixture's expected Go-validator outcome.
type fixtureCase struct {
	name             string
	filename         string
	expectSuccess    bool
	expectFatal      string // substring; empty means no fatal assertion
	expectLevels     map[schema.IssueLevel]int
	expectCategories map[string]bool
}

// goldenFixtureCases captures the four graph fixtures the parity gate cares
// about. Counts are lower bounds (>=) because auto-fix messages can mention
// multiple fields per node/edge; the parity gate verifies the verdict and the
// category presence rather than exact counts to stay maintainable.
//
// Note: NormalizeAliases is silent in the Go validator (it rewrites types in
// place without emitting issues), so alias-input has no expected category —
// the parity check covers alias behavior by comparing the surviving typed
// graph's node/edge types against UA's normalized output.
var goldenFixtureCases = []fixtureCase{
	{
		name:          "expected-codebase",
		filename:      "canonical-codebase.json",
		expectSuccess: true,
	},
	{
		name:          "alias-input",
		filename:      "alias-input.json",
		expectSuccess: true,
	},
	{
		name:          "autofix-input",
		filename:      "autofix-input.json",
		expectSuccess: true,
		expectCategories: map[string]bool{
			"missing-field": true,
		},
	},
	{
		name:          "drop-input",
		filename:      "drop-input.json",
		expectSuccess: true, // drop fixture: invalid items dropped, no fatal
		expectCategories: map[string]bool{
			"invalid-node":      true,
			"invalid-reference": true,
			"invalid-layer":     true,
		},
	},
}

// TestGoValidatorGoldenFixtures is the always-on half of the parity gate: it
// runs each fixture through the Go schema validator and checks the verdict
// matches the documented UA semantics (resolved passes, alias/autofix
// auto-correct, drop removes broken items without going fatal).
func TestGoValidatorGoldenFixtures(t *testing.T) {
	for _, fixture := range goldenFixtureCases {
		t.Run(fixture.name, func(t *testing.T) {
			data, err := loadFixture(fixture.filename)
			require.NoError(t, err, "fixture %s not found", fixture.filename)

			result := schema.ValidateGraphJSON(data)
			require.Equal(t, fixture.expectSuccess, result.Success, "verdict mismatch for %s", fixture.filename)
			if fixture.expectFatal != "" {
				require.Contains(t, result.Fatal, fixture.expectFatal)
			}
			if !fixture.expectSuccess {
				return
			}
			require.NotNil(t, result.Data, "expected parsed graph for %s", fixture.filename)

			categories := map[string]bool{}
			for _, issue := range result.Issues {
				categories[issue.Category] = true
			}
			for category := range fixture.expectCategories {
				require.True(t, categories[category], "expected category %q in issues for %s", category, fixture.filename)
			}
		})
	}
}

// TestGoValidatorCompleteCoversAllNodeTypes guards against silent regression
// in the canonical fixture: it must exercise all 21 UA node types so that a
// parity run actually validates the full alias/autofix/drop matrix.
func TestGoValidatorCompleteCoversAllNodeTypes(t *testing.T) {
	data, err := loadFixture("canonical-codebase.json")
	require.NoError(t, err)

	result := schema.ValidateGraphJSON(data)
	require.True(t, result.Success)
	require.NotNil(t, result.Data)

	seen := map[string]bool{}
	for _, node := range result.Data.Nodes {
		seen[node.Type] = true
	}
	for _, nt := range schema.NodeTypes {
		require.True(t, seen[nt], "resolved fixture missing node type %q", nt)
	}
}

// TestDiffOverlayGoParity runs both diff-overlay fixtures through the UA
// dashboard-shape helper and the Go schema validator, asserting they now
// agree on both the resolved and the capital-D variants. The Go schema
// validator enforces case-sensitive key matching against the dashboard's
// changedNodeIds/affectedNodeIds contract, so capital-D `affectedNodeIDs`
// is rejected by both sides.
func TestDiffOverlayGoParity(t *testing.T) {
	t.Run("valid", func(t *testing.T) {
		data, err := loadFixture("diff-overlay-valid.json")
		require.NoError(t, err)
		require.NoError(t, schema.ValidateDiffOverlayJSON(data))

		var repo UARepo
		uaResult, err := repo.RunUAValidateDiffOverlay(data)
		require.NoError(t, err)
		require.True(t, uaResult.Success)
	})
	t.Run("invalid-shape", func(t *testing.T) {
		data, err := loadFixture("diff-overlay-invalid-shape.json")
		require.NoError(t, err)

		// Go schema validator must reject the capital-D variant to match the
		// dashboard's strict case-sensitivity on changedNodeIds/affectedNodeIds.
		err = schema.ValidateDiffOverlayJSON(data)
		require.Error(t, err)
		require.Contains(t, err.Error(), "affectedNodeIDs")

		var repo UARepo
		uaResult, err := repo.RunUAValidateDiffOverlay(data)
		require.NoError(t, err)
		require.False(t, uaResult.Success)
	})
}

// strictTallyFixtures is the subset of golden fixtures for which the Go port
// and UA's validator produce identical issue-level tallies. The remaining
// fixtures (currently alias-input) have documented drift: UA emits alias and
// type-coercion issues that the Go port handles silently. We assert verdict
// and surviving-graph parity for those fixtures but not the tally.
var strictTallyFixtures = map[string]bool{
	"expected-codebase": true,
	"autofix-input":     true,
	"drop-input":        true,
}

// TestUAGraphValidatorParity is the manual gate: when UA is locatable, run each
// golden fixture through UA's real validateGraph() and confirm the verdict,
// surviving graph shape (counts + resolved id/type vectors), and — for
// fixtures without documented drift — issue-level tallies all agree with the
// Go validator. Skipped when UA is absent so AgentTeam CI is not tied to an
// external checkout.
func TestUAGraphValidatorParity(t *testing.T) {
	repo, ok := LocateUARepo()
	if !ok {
		t.Skipf("set %s to run UA parity gate (UA checkout not discovered)", EnvVar)
	}
	if err := repo.EnsureBuilt(); err != nil {
		t.Skipf("UA build failed (%v); fix UA checkout or run pnpm build manually", err)
	}

	for _, fixture := range goldenFixtureCases {
		t.Run(fixture.name, func(t *testing.T) {
			data, err := loadFixture(fixture.filename)
			require.NoError(t, err)

			goResult := schema.ValidateGraphJSON(data)
			uaResult, err := repo.RunUAValidateGraph(data)
			require.NoError(t, err)

			require.Equal(t, goResult.Success, uaResult.Success, "verdict mismatch vs UA for %s", fixture.filename)

			if !goResult.Success || !uaResult.Success {
				return
			}

			require.NotNil(t, goResult.Data, "Go validator must return parsed data on success")
			require.NotNil(t, uaResult.DataSummary, "UA harness must return data summary on success")

			// Counts must match — drift here means a node/edge/layer/tour was
			// dropped or kept on only one side.
			require.Equal(t, len(goResult.Data.Nodes), uaResult.DataSummary.NodeCount, "node count drift for %s", fixture.filename)
			require.Equal(t, len(goResult.Data.Edges), uaResult.DataSummary.EdgeCount, "edge count drift for %s", fixture.filename)
			require.Equal(t, len(goResult.Data.Layers), uaResult.DataSummary.LayerCount, "layer count drift for %s", fixture.filename)
			require.Equal(t, len(goResult.Data.Tour), uaResult.DataSummary.TourCount, "tour count drift for %s", fixture.filename)

			// Canonical id/type vectors must match — drift here means alias
			// normalization, node-id expectedization, or auto-fix defaults
			// diverged between Go and UA on the surviving items.
			require.Equal(t, goNodeSummaries(goResult.Data.Nodes), uaResult.DataSummary.Nodes, "node resolved vector drift for %s", fixture.filename)
			require.Equal(t, goEdgeSummaries(goResult.Data.Edges), uaResult.DataSummary.Edges, "edge resolved vector drift for %s", fixture.filename)

			if !strictTallyFixtures[fixture.name] {
				t.Logf("%s: skipping strict tally (UA emits alias/type-coercion issues that Go handles silently)", fixture.name)
				return
			}

			goCounts := goIssueLevelCounts(goResult.Issues)
			uaCounts := map[string]int{}
			for _, issue := range uaResult.Issues {
				uaCounts[string(issue.Level)]++
			}
			require.Equal(t, goCounts["dropped"], uaCounts["dropped"], "dropped-issue tally drift for %s", fixture.filename)
			require.Equal(t, goCounts["auto-corrected"], uaCounts["auto-corrected"], "auto-corrected tally drift for %s", fixture.filename)
		})
	}
}

// TestUAGraphValidatorAliasInputDrift is a focused test that documents the
// known divergence on alias-input: UA's validateGraph emits auto-corrected
// issues for alias normalization (e.g. complexity "low" → "simple") and type
// coercion (string weight → number); the Go port rewrites those silently.
// The parity gate treats this as a follow-up for the schema package rather
// than a Batch 3 blocker, since the surviving graph shape still matches.
func TestUAGraphValidatorAliasInputDrift(t *testing.T) {
	repo, ok := LocateUARepo()
	if !ok {
		t.Skipf("set %s to run UA parity gate (UA checkout not discovered)", EnvVar)
	}
	if err := repo.EnsureBuilt(); err != nil {
		t.Skipf("UA build failed (%v); fix UA checkout or run pnpm build manually", err)
	}

	data, err := loadFixture("alias-input.json")
	require.NoError(t, err)

	uaResult, err := repo.RunUAValidateGraph(data)
	require.NoError(t, err)
	require.True(t, uaResult.Success)

	categories := map[string]int{}
	for _, issue := range uaResult.Issues {
		categories[issue.Category]++
	}
	require.Greater(t, categories["alias"], 0, "UA should emit alias-category issues for alias-input")
	require.Greater(t, categories["type-coercion"], 0, "UA should emit type-coercion issues for string weight")
}

func loadFixture(filename string) ([]byte, error) {
	path := filepath.Join("testdata", "fixtures", filename)
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read fixture %s: %w", path, err)
	}
	return data, nil
}

func goIssueLevelCounts(issues []schema.GraphIssue) map[string]int {
	counts := map[string]int{}
	for _, issue := range issues {
		counts[string(issue.Level)]++
	}
	return counts
}

// goNodeSummaries projects the Go validator's surviving nodes into the same
// resolved shape that validate-ua-graph.mjs emits, so the parity test can
// compare apples to apples.
func goNodeSummaries(nodes []schema.GraphNode) []UANodeSummary {
	out := make([]UANodeSummary, len(nodes))
	for i, node := range nodes {
		out[i] = UANodeSummary{ID: node.ID, Type: node.Type}
	}
	return out
}

// goEdgeSummaries projects the Go validator's surviving edges into the same
// resolved shape that validate-ua-graph.mjs emits.
func goEdgeSummaries(edges []schema.GraphEdge) []UAEdgeSummary {
	out := make([]UAEdgeSummary, len(edges))
	for i, edge := range edges {
		out[i] = UAEdgeSummary{
			Source:    edge.Source,
			Target:    edge.Target,
			Type:      edge.Type,
			Direction: edge.Direction,
		}
	}
	return out
}
