package resolver

import (
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/zzet/gortex/internal/graph"
	"github.com/zzet/gortex/internal/parser/languages"
)

// buildMultiLangGraph extracts each fixture file with the extractor
// matching its extension (.go / .py / .ts / .js) and loads the result
// into a fresh graph. Unlike buildGraphFromSources (TS/JS only) this
// builder spans every ecosystem the external-call synthesis pass
// classifies, so one table can exercise Go modules, pip packages, and
// npm packages through the same real extract → resolve pipeline.
func buildMultiLangGraph(t *testing.T, files map[string]string) *graph.Graph {
	t.Helper()
	g := graph.New()
	for path, src := range files {
		var (
			nodes []*graph.Node
			edges []*graph.Edge
		)
		switch {
		case strings.HasSuffix(path, ".go"):
			r, err := languages.NewGoExtractor().Extract(path, []byte(src))
			require.NoError(t, err, "go extract %s", path)
			nodes, edges = r.Nodes, r.Edges
		case strings.HasSuffix(path, ".py"):
			r, err := languages.NewPythonExtractor().Extract(path, []byte(src))
			require.NoError(t, err, "py extract %s", path)
			nodes, edges = r.Nodes, r.Edges
		case strings.HasSuffix(path, ".ts"), strings.HasSuffix(path, ".tsx"):
			r, err := languages.NewTypeScriptExtractor().Extract(path, []byte(src))
			require.NoError(t, err, "ts extract %s", path)
			nodes, edges = r.Nodes, r.Edges
		default:
			r, err := languages.NewJavaScriptExtractor().Extract(path, []byte(src))
			require.NoError(t, err, "js extract %s", path)
			nodes, edges = r.Nodes, r.Edges
		}
		for _, n := range nodes {
			g.AddNode(n)
		}
		for _, e := range edges {
			g.AddEdge(e)
		}
	}
	return g
}

// resolveAndSynthesize runs the production resolution pipeline against g
// — the per-edge ResolveAll pass plus the cross-package guard it ends
// with — and then the opt-in external-call synthesis pass. It mirrors
// the indexer settle point: synthesis runs strictly after resolution +
// guard, so the test exercises the same ordering the daemon uses.
func resolveAndSynthesize(g *graph.Graph, enabled bool) int {
	New(g).ResolveAll()
	return SynthesizeExternalCalls(g, enabled)
}

// callTargetsFrom collects the To-end of every call/reference edge
// leaving fromID, so a test can assert on the post-resolution shape of
// a caller's outbound calls.
func callTargetsFrom(g *graph.Graph, fromID string) []string {
	var out []string
	for _, e := range g.GetOutEdges(fromID) {
		if e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeReferences {
			out = append(out, e.To)
		}
	}
	return out
}

// TestSynthesizeExternalCalls drives the pass through a real
// extract → resolve → synthesize pipeline for each ecosystem. Every row
// fixes one caller's call into an un-indexed external target and
// asserts, with the option both on and off, what the call edge lands
// on — and that language built-ins / standard-library calls are
// filtered out as noise.
func TestSynthesizeExternalCalls(t *testing.T) {
	cases := []struct {
		name  string
		files map[string]string
		// callerID identifies the function whose outbound call is
		// under test.
		callerID string
		// wantSyntheticID, when set with the option ON, is the
		// synthetic node ID the call edge must retarget onto.
		wantSyntheticID string
		// wantEcosystem / wantImportPath are asserted on the
		// synthetic node's Meta when wantSyntheticID is set.
		wantEcosystem  string
		wantImportPath string
		// noiseOnly marks a fixture whose only external call is a
		// language built-in / stdlib hop: the filter must synthesize
		// nothing even with the option ON.
		noiseOnly bool
	}{
		{
			// An un-indexed npm package: `axios` is imported and
			// called, but no axios source is in the graph. With the
			// option on, the call must terminate on a synthetic node.
			name: "un-indexed npm package call",
			files: map[string]string{
				"web/api.ts": `import axios from "axios";
export function fetchUser(): void {
  axios.get("/user");
}`,
			},
			callerID:        "web/api.ts::fetchUser",
			wantSyntheticID: externalCallNodeID("stdlib", "axios"),
			wantEcosystem:   "stdlib",
			wantImportPath:  "axios",
		},
		{
			// A sibling microservice's client SDK — a scoped package
			// that is not part of this repo's index. The call into it
			// must be preserved as an explicit external terminal.
			name: "sibling-service client SDK call",
			files: map[string]string{
				"web/orders.ts": `import billing from "@acme/billing-service-client";
export function charge(): void {
  billing.createInvoice();
}`,
			},
			callerID:        "web/orders.ts::charge",
			wantSyntheticID: externalCallNodeID("stdlib", "@acme/billing-service-client"),
			wantEcosystem:   "stdlib",
			wantImportPath:  "@acme/billing-service-client",
		},
		{
			// An un-indexed Go third-party module — a domain-qualified
			// import path, so the resolver lands it on a `dep::`
			// terminal. The synthetic node must carry it through.
			name: "un-indexed go module call",
			files: map[string]string{
				"svc/main.go": `package main

import "github.com/acme/stripe"

func Pay() {
	stripe.New("key")
}`,
			},
			callerID:        "svc/main.go::Pay",
			wantSyntheticID: externalCallNodeID("dep", "github.com/acme/stripe"),
			wantEcosystem:   "dep",
			wantImportPath:  "github.com/acme/stripe",
		},
		{
			// An un-indexed pip package: `requests` is imported and
			// called. Not a stdlib module, so it must be synthesized.
			name: "un-indexed pip package call",
			files: map[string]string{
				"app/client.py": `import requests

def fetch():
    requests.get("/health")
`,
			},
			callerID:        "app/client.py::fetch",
			wantSyntheticID: externalCallNodeID("stdlib", "requests"),
			wantEcosystem:   "stdlib",
			wantImportPath:  "requests",
		},
		{
			// Noise filter — Go standard library. Every Go file calls
			// `fmt`; materialising a node for it would bury the real
			// cross-system edges. Nothing must be synthesized.
			name: "go stdlib call is filtered as noise",
			files: map[string]string{
				"svc/log.go": `package main

import "fmt"

func Log() {
	fmt.Println("hello")
}`,
			},
			callerID:  "svc/log.go::Log",
			noiseOnly: true,
		},
		{
			// Noise filter — Python standard library. `os.getenv` is
			// an interpreter built-in, not a pip dependency.
			name: "python stdlib call is filtered as noise",
			files: map[string]string{
				"app/env.py": `import os

def home():
    os.getenv("HOME")
`,
			},
			callerID:  "app/env.py::home",
			noiseOnly: true,
		},
		{
			// Noise filter — a Node.js core module. `node:fs` is part
			// of the runtime, not an npm package.
			name: "node core module call is filtered as noise",
			files: map[string]string{
				"web/disk.ts": `import fs from "node:fs";
export function read(): void {
  fs.readFileSync("/etc/hosts");
}`,
			},
			callerID:  "web/disk.ts::read",
			noiseOnly: true,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			// Option OFF (the default): the synthesis pass is a pure
			// no-op — the resolved graph is left exactly as resolution
			// produced it, with no synthetic node and no retargeted
			// edge. Snapshot the counts after ResolveAll (which itself
			// mutates the graph) so the assertion isolates the pass.
			gOff := buildMultiLangGraph(t, tc.files)
			New(gOff).ResolveAll()
			nodesAfterResolve := gOff.NodeCount()
			edgesAfterResolve := gOff.EdgeCount()
			synthesizedOff := SynthesizeExternalCalls(gOff, false)
			assert.Zero(t, synthesizedOff, "option OFF must synthesize nothing")
			assert.Equal(t, nodesAfterResolve, gOff.NodeCount(),
				"option OFF must not add any node")
			assert.Equal(t, edgesAfterResolve, gOff.EdgeCount(),
				"option OFF must not add any edge")
			for _, to := range callTargetsFrom(gOff, tc.callerID) {
				assert.NotContains(t, to, externalCallPrefix,
					"option OFF must leave call edges off synthetic nodes")
			}

			// Option ON: genuine externals get a synthetic terminal;
			// noise (builtin / stdlib) is still filtered out.
			gOn := buildMultiLangGraph(t, tc.files)
			synthesizedOn := resolveAndSynthesize(gOn, true)

			if tc.noiseOnly {
				assert.Zero(t, synthesizedOn,
					"noise filter must exclude builtin/stdlib calls")
				for _, to := range callTargetsFrom(gOn, tc.callerID) {
					assert.NotContains(t, to, externalCallPrefix,
						"a builtin/stdlib call must not gain a synthetic node")
				}
				return
			}

			require.Positive(t, synthesizedOn,
				"a genuine external call must be synthesized with the option ON")

			// The synthetic node exists and is marked unmistakably as
			// both synthetic and external so analyzers can filter it.
			node := gOn.GetNode(tc.wantSyntheticID)
			require.NotNil(t, node, "synthetic external node must be added")
			assert.Equal(t, graph.KindModule, node.Kind)
			assert.Equal(t, tc.wantImportPath, node.Name)
			assert.Equal(t, true, node.Meta["synthetic"])
			assert.Equal(t, true, node.Meta["external_call"])
			assert.Equal(t, tc.wantEcosystem, node.Meta["ecosystem"])
			assert.Equal(t, tc.wantImportPath, node.Meta["import_path"])

			// The call edge was retargeted onto the synthetic node and
			// the synthetic node sees the inbound call edge — so a
			// call-chain walk reaches the external terminal.
			targets := callTargetsFrom(gOn, tc.callerID)
			assert.Contains(t, targets, tc.wantSyntheticID,
				"the call edge must retarget onto the synthetic node")
			require.NotEmpty(t, gOn.GetInEdges(tc.wantSyntheticID),
				"synthetic node must see the inbound call edge")

			// The retargeted edge is marked so a query can pick out the
			// cross-system terminals this pass created.
			edge := firstOutEdgeByKind(gOn, tc.callerID, graph.EdgeCalls)
			require.NotNil(t, edge)
			assert.Equal(t, tc.wantSyntheticID, edge.To)
			assert.Equal(t, true, edge.Meta["external_call"])
			assert.Equal(t, graph.OriginTextMatched, edge.Origin)
		})
	}
}

// TestSynthesizeExternalCalls_Idempotent pins the full-recompute
// contract: re-running the pass rewrites every edge onto the same
// deterministic synthetic node and accretes no duplicate node or edge.
func TestSynthesizeExternalCalls_Idempotent(t *testing.T) {
	files := map[string]string{
		"web/api.ts": `import axios from "axios";
export function fetchUser(): void {
  axios.get("/user");
}`,
	}
	g := buildMultiLangGraph(t, files)
	New(g).ResolveAll()

	first := SynthesizeExternalCalls(g, true)
	nodesAfterFirst := g.NodeCount()
	second := SynthesizeExternalCalls(g, true)
	third := SynthesizeExternalCalls(g, true)

	assert.Equal(t, 1, first)
	assert.Equal(t, first, second, "re-run must report the same count")
	assert.Equal(t, first, third)
	assert.Equal(t, nodesAfterFirst, g.NodeCount(),
		"re-run must not add a duplicate synthetic node")

	syntheticID := externalCallNodeID("stdlib", "axios")
	require.Len(t, g.GetInEdges(syntheticID), 1,
		"re-run must not accrete a duplicate inbound edge")
}

// TestSynthesizeExternalCalls_DisabledByDefault guards the
// zero-config contract: a graph carrying an un-indexed external call
// is left completely untouched when the option is off — the default.
func TestSynthesizeExternalCalls_DisabledByDefault(t *testing.T) {
	files := map[string]string{
		"app/client.py": `import requests

def fetch():
    requests.get("/health")
`,
	}
	g := buildMultiLangGraph(t, files)
	New(g).ResolveAll()

	nodesBefore := g.NodeCount()
	edgesBefore := g.EdgeCount()
	synthesized := SynthesizeExternalCalls(g, false)

	assert.Zero(t, synthesized)
	assert.Equal(t, nodesBefore, g.NodeCount(), "default-off must add no node")
	assert.Equal(t, edgesBefore, g.EdgeCount(), "default-off must add no edge")
}

// TestParseExternalCallTarget unit-tests the terminal classifier: the
// three external bookkeeping-string shapes yield the right ecosystem +
// import path, while real node IDs, bare placeholders, `builtin::`
// terminals, and already-synthesised nodes are rejected.
func TestParseExternalCallTarget(t *testing.T) {
	cases := []struct {
		target        string
		wantOK        bool
		wantEcosystem string
		wantPath      string
	}{
		{"dep::github.com/foo/bar::Baz", true, "dep", "github.com/foo/bar"},
		{"stdlib::axios::get", true, "stdlib", "axios"},
		{"stdlib::@acme/svc-client::call", true, "stdlib", "@acme/svc-client"},
		{"external::lodash", true, "external", "lodash"},
		// Rejected: real node IDs and non-external placeholders.
		{"pkg/foo.go::Bar", false, "", ""},
		{"unresolved::Foo", false, "", ""},
		{"unresolved::*.foo", false, "", ""},
		{"builtin::js::array::push", false, "", ""},
		{externalCallPrefix + "dep::x", false, "", ""},
		// `dep::` / `stdlib::` with no `::<symbol>` separator is malformed.
		{"dep::", false, "", ""},
		{"stdlib::axios", false, "", ""},
		{"external::", false, "", ""},
	}
	for _, tc := range cases {
		t.Run(tc.target, func(t *testing.T) {
			eco, path, ok := parseExternalCallTarget(tc.target)
			assert.Equal(t, tc.wantOK, ok)
			assert.Equal(t, tc.wantEcosystem, eco)
			assert.Equal(t, tc.wantPath, path)
		})
	}
}

// TestIsLanguageStdlib unit-tests the language-aware noise filter: the
// same import-path shape is stdlib or third-party depending on the
// caller's language.
func TestIsLanguageStdlib(t *testing.T) {
	cases := []struct {
		lang string
		path string
		want bool
	}{
		// Go: un-dotted first segment is stdlib; domain-led is a module.
		{"go", "fmt", true},
		{"go", "net/http", true},
		{"go", "encoding/json", true},
		{"go", "github.com/stripe/stripe-go", false},
		{"go", "gopkg.in/yaml.v3", false},
		// Python: interpreter modules vs pip packages.
		{"python", "os", true},
		{"python", "os.path", true},
		{"python", "collections", true},
		{"python", "requests", false},
		{"python", "numpy", false},
		// Node: core modules (bare and node:-prefixed) vs npm packages.
		{"javascript", "fs", true},
		{"typescript", "node:crypto", true},
		{"typescript", "stream/promises", true},
		{"typescript", "axios", false},
		{"typescript", "@acme/billing-client", false},
		// Rust: the std distribution vs crates.
		{"rust", "std", true},
		{"rust", "core::mem", true},
		{"rust", "tokio", false},
		// Unknown language: treat the path as external so a real edge
		// is never dropped by a missing rule.
		{"", "anything", false},
	}
	for _, tc := range cases {
		t.Run(tc.lang+":"+tc.path, func(t *testing.T) {
			assert.Equal(t, tc.want, isLanguageStdlib(tc.lang, tc.path))
		})
	}
}
