package inventory

import (
	"os"
	"path/filepath"
	"reflect"
	"testing"
	"time"

	"github.com/Clyra-AI/wrkr/core/aggregate/exposure"
	"github.com/Clyra-AI/wrkr/core/identity"
	"github.com/Clyra-AI/wrkr/core/manifest"
	"github.com/Clyra-AI/wrkr/core/model"
	"github.com/Clyra-AI/wrkr/core/source"
)

func TestBuildDedupesAcrossReposWithLocationContext(t *testing.T) {
	t.Parallel()
	manifest := source.Manifest{Target: source.Target{Mode: "org", Value: "acme"}, Repos: []source.RepoManifest{{Repo: "acme/a", Location: t.TempDir()}, {Repo: "acme/b", Location: t.TempDir()}}}
	findings := []model.Finding{
		{FindingType: "mcp_server", ToolType: "mcp", Location: ".mcp.json", Repo: "acme/a", Org: "acme", Permissions: []string{"mcp.access"}},
		{FindingType: "mcp_server", ToolType: "mcp", Location: ".mcp.json", Repo: "acme/b", Org: "acme", Permissions: []string{"mcp.access"}},
	}
	ctx := map[string]ToolContext{
		KeyForFinding(findings[0]): {RiskScore: 8.2, EndpointClass: "network_service", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "discovered"},
		KeyForFinding(findings[1]): {RiskScore: 7.9, EndpointClass: "network_service", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "discovered"},
	}
	inv := Build(BuildInput{Manifest: manifest, Findings: findings, Contexts: ctx, RepoExposureSummaries: []exposure.RepoExposureSummary{}, GeneratedAt: time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC)})
	if len(inv.Tools) != 1 {
		t.Fatalf("expected deduped tool count 1, got %d", len(inv.Tools))
	}
	if inv.Tools[0].DiscoveryMethod != model.DiscoveryMethodStatic {
		t.Fatalf("expected default discovery_method static, got %q", inv.Tools[0].DiscoveryMethod)
	}
	if inv.Tools[0].ToolCategory != "mcp_integration" {
		t.Fatalf("expected tool_category=mcp_integration, got %q", inv.Tools[0].ToolCategory)
	}
	if inv.Tools[0].ConfidenceScore != 0.75 {
		t.Fatalf("expected confidence_score=0.75, got %.2f", inv.Tools[0].ConfidenceScore)
	}
	if inv.Tools[0].PermissionTier != "none" || inv.Tools[0].RiskTier != "high" {
		t.Fatalf("unexpected permission/risk tiers: permission=%q risk=%q", inv.Tools[0].PermissionTier, inv.Tools[0].RiskTier)
	}
	if inv.Tools[0].AdoptionPattern != "team_level" {
		t.Fatalf("expected adoption_pattern=team_level, got %q", inv.Tools[0].AdoptionPattern)
	}
	if len(inv.Tools[0].RegulatoryMapping) == 0 {
		t.Fatal("expected non-empty regulatory_mapping")
	}
	if len(inv.RegulatorySummary.ByRegulation) == 0 || len(inv.RegulatorySummary.ByControl) == 0 {
		t.Fatalf("expected non-empty regulatory summary rollups: %+v", inv.RegulatorySummary)
	}
	if len(inv.Tools[0].Locations) != 2 {
		t.Fatalf("expected two location contexts, got %d", len(inv.Tools[0].Locations))
	}
	if inv.Tools[0].ApprovalClass != "unapproved" {
		t.Fatalf("expected approval_classification=unapproved, got %q", inv.Tools[0].ApprovalClass)
	}
	if inv.Methodology.Detectors == nil {
		t.Fatal("expected methodology.detectors to be an empty array, not null")
	}
	if inv.ApprovalSummary.UnapprovedTools != 1 || inv.ApprovalSummary.ApprovedTools != 0 || inv.ApprovalSummary.UnknownTools != 0 {
		t.Fatalf("unexpected approval summary: %+v", inv.ApprovalSummary)
	}
	if inv.ApprovalSummary.UnapprovedPercent != 100 {
		t.Fatalf("expected unapproved_percent=100, got %.2f", inv.ApprovalSummary.UnapprovedPercent)
	}
	if inv.AdoptionSummary.TeamLevel != 1 || inv.AdoptionSummary.OrgWide != 0 || inv.AdoptionSummary.Individual != 0 || inv.AdoptionSummary.OneOff != 0 {
		t.Fatalf("unexpected adoption summary: %+v", inv.AdoptionSummary)
	}
}

func TestBuildApprovalSummaryRatios(t *testing.T) {
	t.Parallel()
	manifest := source.Manifest{Target: source.Target{Mode: "org", Value: "acme"}, Repos: []source.RepoManifest{{Repo: "acme/a", Location: t.TempDir()}, {Repo: "acme/b", Location: t.TempDir()}, {Repo: "acme/c", Location: t.TempDir()}}}
	findings := []model.Finding{
		{FindingType: "tool_config", ToolType: "codex", Location: ".codex/config.toml", Repo: "acme/a", Org: "acme"},
		{FindingType: "tool_config", ToolType: "codex", Location: ".codex/agent.toml", Repo: "acme/b", Org: "acme"},
		{FindingType: "tool_config", ToolType: "codex", Location: ".codex/unknown.toml", Repo: "acme/c", Org: "acme"},
	}
	ctx := map[string]ToolContext{
		KeyForFinding(findings[0]): {RiskScore: 6.2, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "valid", LifecycleState: "approved"},
		KeyForFinding(findings[1]): {RiskScore: 5.1, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "under_review"},
		KeyForFinding(findings[2]): {RiskScore: 4.0, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "pending", LifecycleState: "queued"},
	}
	inv := Build(BuildInput{Manifest: manifest, Findings: findings, Contexts: ctx, RepoExposureSummaries: []exposure.RepoExposureSummary{}, GeneratedAt: time.Date(2026, 2, 20, 12, 0, 0, 0, time.UTC)})
	if inv.ApprovalSummary.ApprovedTools != 1 || inv.ApprovalSummary.UnapprovedTools != 1 || inv.ApprovalSummary.UnknownTools != 1 {
		t.Fatalf("unexpected approval summary counts: %+v", inv.ApprovalSummary)
	}
	if inv.ApprovalSummary.ApprovedPercent != 33.33 || inv.ApprovalSummary.UnapprovedPercent != 33.33 || inv.ApprovalSummary.UnknownPercent != 33.33 {
		t.Fatalf("unexpected approval percentages: %+v", inv.ApprovalSummary)
	}
	if inv.ApprovalSummary.UnapprovedPerApprove == nil || *inv.ApprovalSummary.UnapprovedPerApprove != 1 {
		t.Fatalf("expected unapproved_per_approved=1, got %+v", inv.ApprovalSummary.UnapprovedPerApprove)
	}
}

func TestBuildMethodologyMetadataPassThrough(t *testing.T) {
	t.Parallel()
	manifest := source.Manifest{Target: source.Target{Mode: "repo", Value: "acme/a"}, Repos: []source.RepoManifest{{Repo: "acme/a", Location: t.TempDir()}}}
	findings := []model.Finding{
		{FindingType: "tool_config", ToolType: "codex", Location: ".codex/config.toml", Repo: "acme/a", Org: "acme", Detector: "codex"},
	}
	ctx := map[string]ToolContext{
		KeyForFinding(findings[0]): {RiskScore: 4.8, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "discovered"},
	}
	inv := Build(BuildInput{
		Manifest: manifest,
		Findings: findings,
		Contexts: ctx,
		Methodology: MethodologySummary{
			WrkrVersion:         "v1.0.0",
			ScanStartedAt:       "2026-02-23T10:00:00Z",
			ScanCompletedAt:     "2026-02-23T10:00:02Z",
			ScanDurationSeconds: 2,
			RepoCount:           1,
			FileCountProcessed:  1,
			Detectors: []MethodologyDetector{
				{ID: "codex", Version: "v1", FindingCount: 1},
			},
		},
		RepoExposureSummaries: []exposure.RepoExposureSummary{},
		GeneratedAt:           time.Date(2026, 2, 23, 10, 0, 2, 0, time.UTC),
	})
	if inv.Methodology.WrkrVersion != "v1.0.0" {
		t.Fatalf("expected wrkr_version=v1.0.0, got %q", inv.Methodology.WrkrVersion)
	}
	if inv.Methodology.FileCountProcessed != 1 || len(inv.Methodology.Detectors) != 1 {
		t.Fatalf("unexpected methodology payload: %+v", inv.Methodology)
	}
	if len(inv.Tools) != 1 || inv.Tools[0].ToolCategory != "assistant" || inv.Tools[0].ConfidenceScore != 0.9 {
		t.Fatalf("unexpected tool taxonomy/confidence payload: %+v", inv.Tools)
	}
	if inv.Tools[0].PermissionTier != "none" || inv.Tools[0].RiskTier != "low" {
		t.Fatalf("unexpected permission/risk tiers: %+v", inv.Tools[0])
	}
}

func TestBuildAddsOwnershipProvenanceToToolLocations(t *testing.T) {
	t.Parallel()

	repoRoot := t.TempDir()
	if err := os.WriteFile(filepath.Join(repoRoot, "CODEOWNERS"), []byte(".github/workflows/* @acme/security\n"), 0o600); err != nil {
		t.Fatalf("write CODEOWNERS: %v", err)
	}
	manifest := source.Manifest{
		Target: source.Target{Mode: "repo", Value: "acme/payments-prod"},
		Repos:  []source.RepoManifest{{Repo: "acme/payments-prod", Location: repoRoot}},
	}
	findings := []model.Finding{
		{FindingType: "ci_autonomy", ToolType: "ci_agent", Location: ".github/workflows/release.yml", Repo: "acme/payments-prod", Org: "acme"},
		{FindingType: "compiled_action", ToolType: "compiled_action", Location: ".github/workflows/release.yml", Repo: "acme/payments-prod", Org: "acme"},
	}
	ctx := map[string]ToolContext{
		KeyForFinding(findings[0]): {RiskScore: 8.2, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "headless_auto", ApprovalStatus: "missing", LifecycleState: identity.StateDiscovered},
		KeyForFinding(findings[1]): {RiskScore: 7.8, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "headless_auto", ApprovalStatus: "missing", LifecycleState: identity.StateDiscovered},
	}

	inv := Build(BuildInput{
		Manifest:              manifest,
		Findings:              findings,
		Contexts:              ctx,
		RepoExposureSummaries: []exposure.RepoExposureSummary{},
		GeneratedAt:           time.Date(2026, 3, 25, 12, 0, 0, 0, time.UTC),
	})

	if len(inv.Tools) == 0 || len(inv.Tools[0].Locations) == 0 {
		t.Fatalf("expected tool locations, got %+v", inv.Tools)
	}
	location := inv.Tools[0].Locations[0]
	if location.Owner != "@acme/security" {
		t.Fatalf("expected CODEOWNERS owner, got %+v", location)
	}
	if location.OwnerSource != "codeowners" || location.OwnershipStatus != "explicit" {
		t.Fatalf("expected explicit CODEOWNERS provenance, got %+v", location)
	}
}

func TestBuildUsesScanRootForHostedOwnershipResolution(t *testing.T) {
	t.Parallel()

	repoRoot := t.TempDir()
	if err := os.WriteFile(filepath.Join(repoRoot, "CODEOWNERS"), []byte(".github/workflows/* @acme/security\n"), 0o600); err != nil {
		t.Fatalf("write CODEOWNERS: %v", err)
	}
	manifest := source.Manifest{
		Target: source.Target{Mode: "repo", Value: "acme/backend"},
		Repos: []source.RepoManifest{{
			Repo:     "acme/backend",
			Location: "github://acme/backend",
			ScanRoot: repoRoot,
			Source:   "github_repo_materialized",
		}},
	}
	findings := []model.Finding{
		{FindingType: "ci_autonomy", ToolType: "ci_agent", Location: ".github/workflows/release.yml", Repo: "acme/backend", Org: "acme"},
	}
	ctx := map[string]ToolContext{
		KeyForFinding(findings[0]): {RiskScore: 8.2, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "headless_auto", ApprovalStatus: "missing", LifecycleState: identity.StateDiscovered},
	}

	inv := Build(BuildInput{
		Manifest:              manifest,
		Findings:              findings,
		Contexts:              ctx,
		RepoExposureSummaries: []exposure.RepoExposureSummary{},
		GeneratedAt:           time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC),
	})

	if len(inv.Tools) == 0 || len(inv.Tools[0].Locations) == 0 {
		t.Fatalf("expected hosted tool locations, got %+v", inv.Tools)
	}
	location := inv.Tools[0].Locations[0]
	if location.Owner != "@acme/security" {
		t.Fatalf("expected hosted ownership to resolve through ScanRoot, got %+v", location)
	}
	if location.OwnerSource != "codeowners" || location.OwnershipStatus != "explicit" {
		t.Fatalf("expected explicit CODEOWNERS provenance through ScanRoot, got %+v", location)
	}
}

func TestReclassifyApprovalWithMatcherOverridesSummary(t *testing.T) {
	t.Parallel()

	inv := Inventory{
		Tools: []Tool{
			{
				ToolID:         "wrkr:codex:.codex/config.toml",
				AgentID:        "wrkr:wrkr:codex:.codex/config.toml:acme",
				ToolType:       "codex",
				Org:            "acme",
				Repos:          []string{"acme/backend"},
				PermissionTier: "read",
				AutonomyLevel:  "interactive",
				RiskScore:      6.3,
			},
			{
				ToolID:         "wrkr:mcp:.mcp.json",
				AgentID:        "wrkr:wrkr:mcp:.mcp.json:acme",
				ToolType:       "mcp",
				Org:            "acme",
				Repos:          []string{"acme/backend"},
				PermissionTier: "write",
				AutonomyLevel:  "headless_auto",
				RiskScore:      9.1,
			},
		},
	}

	ReclassifyApprovalWithMatcher(&inv, func(tool Tool) bool {
		return tool.ToolType == "codex"
	})

	if inv.Tools[0].ApprovalClass != "approved" || inv.Tools[0].ApprovalStatus != "approved_list" {
		t.Fatalf("expected first tool approved via list, got %+v", inv.Tools[0])
	}
	if inv.Tools[1].ApprovalClass != "unapproved" {
		t.Fatalf("expected second tool unapproved, got %+v", inv.Tools[1])
	}
	if inv.ApprovalSummary.ApprovedTools != 1 || inv.ApprovalSummary.UnapprovedTools != 1 {
		t.Fatalf("unexpected approval summary after reclassify: %+v", inv.ApprovalSummary)
	}
	if len(inv.RegulatorySummary.ByRegulation) == 0 {
		t.Fatalf("expected regulatory summary rollups after reclassify: %+v", inv.RegulatorySummary)
	}
}

func TestInventoryBuild_EmitsAgentsAdditiveOnly(t *testing.T) {
	t.Parallel()

	manifest := source.Manifest{
		Target: source.Target{Mode: "org", Value: "acme"},
		Repos: []source.RepoManifest{
			{Repo: "acme/backend", Location: t.TempDir()},
			{Repo: "acme/frontend", Location: t.TempDir()},
		},
	}
	findings := []model.Finding{
		{FindingType: "tool_config", ToolType: "codex", Location: "AGENTS.md", Repo: "acme/backend", Org: "acme"},
		{FindingType: "tool_config", ToolType: "cursor", Location: ".cursor/mcp.json", Repo: "acme/frontend", Org: "acme"},
	}
	ctx := map[string]ToolContext{
		KeyForFinding(findings[0]): {RiskScore: 4.1, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "discovered"},
		KeyForFinding(findings[1]): {RiskScore: 4.2, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "discovered"},
	}

	inv := Build(BuildInput{
		Manifest:              manifest,
		Findings:              findings,
		Contexts:              ctx,
		RepoExposureSummaries: []exposure.RepoExposureSummary{},
		GeneratedAt:           time.Date(2026, 2, 26, 10, 0, 0, 0, time.UTC),
	})

	if len(inv.Tools) != 2 {
		t.Fatalf("expected unchanged tools behavior with two entries, got %d", len(inv.Tools))
	}
	if inv.Agents == nil {
		t.Fatal("expected inventory.agents to be a non-nil array")
	}
	if len(inv.Agents) != 2 {
		t.Fatalf("expected two agent entries, got %d", len(inv.Agents))
	}

	expected := []Agent{
		{
			AgentID:         identity.AgentID(identity.ToolID("codex", "AGENTS.md"), "acme"),
			AgentInstanceID: identity.ToolID("codex", "AGENTS.md"),
			ToolFamilyID:    identity.ToolFamilyID("codex", "acme"),
			ToolInstanceID:  identity.ToolInstanceID("codex", "acme/backend", "AGENTS.md", "", 0, 0),
			Framework:       "codex",
			ConfigSource:    "AGENTS.md",
			Org:             "acme",
			Repo:            "acme/backend",
			Location:        "AGENTS.md",
		},
		{
			AgentID:           identity.AgentID(identity.ToolID("cursor", ".cursor/mcp.json"), "acme"),
			AgentInstanceID:   identity.ToolID("cursor", ".cursor/mcp.json"),
			ToolFamilyID:      identity.ToolFamilyID("cursor", "acme"),
			ToolInstanceID:    identity.ToolInstanceID("cursor", "acme/frontend", ".cursor/mcp.json", "", 0, 0),
			Framework:         "cursor",
			Purpose:           "mcp integration",
			PurposeSource:     "location_heuristic",
			PurposeConfidence: "low",
			ConfigSource:      ".cursor/mcp.json",
			Org:               "acme",
			Repo:              "acme/frontend",
			Location:          ".cursor/mcp.json",
		},
	}
	if !reflect.DeepEqual(inv.Agents, expected) {
		t.Fatalf("unexpected inventory.agents ordering or shape\ngot=%+v\nwant=%+v", inv.Agents, expected)
	}
}

func TestInventoryBuild_EmitsEmptyAgentsArray(t *testing.T) {
	t.Parallel()

	inv := Build(BuildInput{
		Manifest: source.Manifest{
			Target: source.Target{Mode: "org", Value: "acme"},
			Repos:  []source.RepoManifest{{Repo: "acme/backend", Location: t.TempDir()}},
		},
		Findings:              []model.Finding{},
		Contexts:              map[string]ToolContext{},
		RepoExposureSummaries: []exposure.RepoExposureSummary{},
		GeneratedAt:           time.Date(2026, 2, 26, 10, 0, 0, 0, time.UTC),
	})

	if inv.Agents == nil {
		t.Fatal("expected inventory.agents to be present as [] and never null")
	}
	if len(inv.Agents) != 0 {
		t.Fatalf("expected zero agents, got %d", len(inv.Agents))
	}
}

func TestInventoryAgents_IncludeRangeWhenAvailable(t *testing.T) {
	t.Parallel()

	manifest := source.Manifest{
		Target: source.Target{Mode: "org", Value: "acme"},
		Repos:  []source.RepoManifest{{Repo: "acme/backend", Location: t.TempDir()}},
	}
	finding := model.Finding{
		FindingType:   "agent_framework",
		ToolType:      "langchain",
		Location:      "agents.py",
		LocationRange: &model.LocationRange{StartLine: 11, EndLine: 18},
		Repo:          "acme/backend",
		Org:           "acme",
		Evidence:      []model.Evidence{{Key: "symbol", Value: "release_agent"}},
	}
	ctx := map[string]ToolContext{
		KeyForFinding(finding): {RiskScore: 5.0, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "discovered"},
	}

	inv := Build(BuildInput{
		Manifest:              manifest,
		Findings:              []model.Finding{finding},
		Contexts:              ctx,
		RepoExposureSummaries: []exposure.RepoExposureSummary{},
		GeneratedAt:           time.Date(2026, 2, 26, 10, 0, 0, 0, time.UTC),
	})

	if len(inv.Agents) != 1 {
		t.Fatalf("expected one agent, got %d", len(inv.Agents))
	}
	if inv.Agents[0].LocationRange == nil {
		t.Fatalf("expected location range on inventory agent, got %+v", inv.Agents[0])
	}
	if inv.Agents[0].LocationRange.StartLine != 11 || inv.Agents[0].LocationRange.EndLine != 18 {
		t.Fatalf("unexpected location range payload: %+v", inv.Agents[0].LocationRange)
	}
	if inv.Agents[0].Symbol != "release_agent" {
		t.Fatalf("expected symbol=release_agent, got %+v", inv.Agents[0])
	}
}

func TestInventoryBuild_IgnoresCorrelationOnlyFindings(t *testing.T) {
	t.Parallel()

	manifest := source.Manifest{
		Target: source.Target{Mode: "org", Value: "acme"},
		Repos:  []source.RepoManifest{{Repo: "acme/backend", Location: t.TempDir()}},
	}
	findings := []model.Finding{
		{
			FindingType: "tool_config",
			ToolType:    "codex",
			Location:    "AGENTS.md",
			Repo:        "acme/backend",
			Org:         "acme",
		},
		{
			FindingType: "skill_metrics",
			ToolType:    "skill",
			Location:    ".agents/skills",
			Repo:        "acme/backend",
			Org:         "acme",
		},
		{
			FindingType: "source_discovery",
			ToolType:    "source_repo",
			Location:    "acme/backend",
			Repo:        "acme/backend",
			Org:         "acme",
		},
		{
			FindingType: "secret_presence",
			ToolType:    "secret",
			Location:    ".env",
			Repo:        "acme/backend",
			Org:         "acme",
		},
	}
	contexts := map[string]ToolContext{
		KeyForFinding(findings[0]): {RiskScore: 4.0, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "discovered"},
		KeyForFinding(findings[1]): {RiskScore: 9.0, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "discovered"},
		KeyForFinding(findings[2]): {RiskScore: 2.0, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "discovered"},
		KeyForFinding(findings[3]): {RiskScore: 8.0, EndpointClass: "workspace", DataClass: "secrets", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "discovered"},
	}

	inv := Build(BuildInput{
		Manifest:              manifest,
		Findings:              findings,
		Contexts:              contexts,
		RepoExposureSummaries: []exposure.RepoExposureSummary{},
		GeneratedAt:           time.Date(2026, 2, 26, 10, 0, 0, 0, time.UTC),
	})

	if len(inv.Tools) != 1 || inv.Tools[0].ToolType != "codex" {
		t.Fatalf("expected only canonical finding to materialize tool entry, got %+v", inv.Tools)
	}
	if len(inv.Agents) != 1 || inv.Agents[0].Framework != "codex" {
		t.Fatalf("expected only canonical finding to materialize agent entry, got %+v", inv.Agents)
	}
}

func TestApplySecurityVisibilitySeparatesApprovedKnownAndUnknown(t *testing.T) {
	t.Parallel()

	inv := Inventory{
		Agents: []Agent{
			{AgentID: "wrkr:agent-approved:acme", AgentInstanceID: "agent-approved", Framework: "crewai", Location: "agents/a.py"},
			{AgentID: "wrkr:agent-known:acme", AgentInstanceID: "agent-known", Framework: "crewai", Location: "agents/b.py"},
			{AgentID: "wrkr:agent-unknown:acme", AgentInstanceID: "agent-unknown", Framework: "crewai", Location: "agents/c.py"},
		},
		Tools: []Tool{
			{ToolID: identity.ToolID("crewai", "agents/a.py"), ToolType: "crewai", ApprovalClass: "approved"},
			{ToolID: identity.ToolID("crewai", "agents/b.py"), ToolType: "crewai", ApprovalClass: "unapproved"},
			{ToolID: identity.ToolID("crewai", "agents/c.py"), ToolType: "crewai", ApprovalClass: "unapproved"},
		},
	}

	ref := SecurityVisibilityReference{
		ReferenceBasis:        "baseline_snapshot",
		ReferencePath:         ".wrkr/last-scan.json",
		KnownToolIDs:          map[string]struct{}{identity.ToolID("crewai", "agents/b.py"): {}},
		KnownAgentInstanceIDs: map[string]struct{}{"agent-known": {}},
	}
	ApplySecurityVisibility(&inv, ref)

	if inv.Agents[0].SecurityVisibilityStatus != SecurityVisibilityApproved {
		t.Fatalf("expected approved agent visibility, got %+v", inv.Agents[0])
	}
	if inv.Agents[1].SecurityVisibilityStatus != SecurityVisibilityKnownUnapproved {
		t.Fatalf("expected known_unapproved agent visibility, got %+v", inv.Agents[1])
	}
	if inv.Agents[2].SecurityVisibilityStatus != SecurityVisibilityUnknownToSecurity {
		t.Fatalf("expected unknown_to_security agent visibility, got %+v", inv.Agents[2])
	}
	if inv.SecurityVisibility.ReferenceBasis != "baseline_snapshot" {
		t.Fatalf("unexpected security visibility summary: %+v", inv.SecurityVisibility)
	}
	if inv.SecurityVisibility.UnknownToSecurityAgents != 1 || inv.SecurityVisibility.KnownUnapprovedAgents != 1 || inv.SecurityVisibility.ApprovedAgents != 1 {
		t.Fatalf("unexpected security visibility summary counts: %+v", inv.SecurityVisibility)
	}
}

func TestApplySecurityVisibilityDoesNotBorrowApprovalAcrossOrgs(t *testing.T) {
	t.Parallel()

	inv := Inventory{
		Agents: []Agent{
			{AgentID: "wrkr:agent-a:acme", AgentInstanceID: "agent-a", Framework: "crewai", Org: "acme", Location: "agents/shared.py"},
			{AgentID: "wrkr:agent-b:globex", AgentInstanceID: "agent-b", Framework: "crewai", Org: "globex", Location: "agents/shared.py"},
		},
		Tools: []Tool{
			{ToolID: identity.ToolID("crewai", "agents/shared.py"), ToolType: "crewai", Org: "acme", ApprovalClass: "approved"},
			{ToolID: identity.ToolID("crewai", "agents/shared.py"), ToolType: "crewai", Org: "globex", ApprovalClass: "unapproved"},
		},
	}

	ApplySecurityVisibility(&inv, SecurityVisibilityReference{
		ReferenceBasis:        "baseline_snapshot",
		KnownToolIDs:          map[string]struct{}{},
		KnownAgentInstanceIDs: map[string]struct{}{},
	})

	if inv.Agents[0].SecurityVisibilityStatus != SecurityVisibilityApproved {
		t.Fatalf("expected acme agent approved, got %+v", inv.Agents[0])
	}
	if inv.Agents[1].SecurityVisibilityStatus != SecurityVisibilityUnknownToSecurity {
		t.Fatalf("expected globex agent to stay unknown_to_security, got %+v", inv.Agents[1])
	}
}

func TestRefreshIdentityGovernancePreservesApprovedListForUnchangedTool(t *testing.T) {
	t.Parallel()

	inv := &Inventory{
		Tools: []Tool{
			{
				ToolID:                   "codex-aa",
				AgentID:                  "wrkr:codex-aa:acme",
				ToolType:                 "codex",
				Org:                      "acme",
				ApprovalStatus:           "approved_list",
				ApprovalClass:            "approved",
				SecurityVisibilityStatus: SecurityVisibilityKnownApproved,
				LifecycleState:           identity.StateDiscovered,
				PermissionTier:           "read",
				AutonomyLevel:            "interactive",
				Locations:                []ToolLocation{{Repo: "acme/repo", Location: "AGENTS.md"}},
			},
		},
		Agents: []Agent{{AgentID: "wrkr:codex-aa:acme", AgentInstanceID: "codex-aa", Framework: "codex", Location: "AGENTS.md", Org: "acme"}},
		AgentPrivilegeMap: []AgentPrivilegeMapEntry{{
			AgentID:                  "wrkr:codex-aa:acme",
			AgentInstanceID:          "codex-aa",
			ToolID:                   "codex-aa",
			ToolType:                 "codex",
			ApprovalClassification:   "approved",
			SecurityVisibilityStatus: SecurityVisibilityKnownApproved,
			Repos:                    []string{"acme/repo"},
		}},
		SecurityVisibility: SecurityVisibilitySummary{ReferenceBasis: "baseline_snapshot"},
	}

	RefreshIdentityGovernance(inv, []manifest.IdentityRecord{{
		AgentID:       "wrkr:codex-aa:acme",
		ToolID:        "codex-aa",
		ToolType:      "codex",
		Org:           "acme",
		Repo:          "acme/repo",
		Location:      "AGENTS.md",
		Status:        identity.StateDiscovered,
		ApprovalState: "missing",
		Present:       true,
	}})

	if inv.Tools[0].ApprovalStatus != "approved_list" || inv.Tools[0].ApprovalClass != "approved" {
		t.Fatalf("expected approved_list posture to be preserved, got %+v", inv.Tools[0])
	}
	if inv.AgentPrivilegeMap[0].ApprovalClassification != "approved" {
		t.Fatalf("expected privilege map approval classification to stay approved, got %+v", inv.AgentPrivilegeMap[0])
	}
	if inv.Tools[0].SecurityVisibilityStatus != SecurityVisibilityKnownApproved {
		t.Fatalf("expected known_approved visibility to be preserved, got %+v", inv.Tools[0])
	}
}

func TestGovernanceSecurityVisibilityMapsApprovalStates(t *testing.T) {
	t.Parallel()

	if got := GovernanceSecurityVisibilityStatus(SecurityVisibilityApproved, "valid", identity.StateActive); got != SecurityVisibilityKnownApproved {
		t.Fatalf("expected known_approved, got %q", got)
	}
	if got := GovernanceSecurityVisibilityStatus(SecurityVisibilityApproved, "expired", identity.StateUnderReview); got != SecurityVisibilityNeedsReview {
		t.Fatalf("expected needs_review, got %q", got)
	}
	if got := GovernanceSecurityVisibilityStatus(SecurityVisibilityKnownUnapproved, "missing", identity.StateRevoked); got != SecurityVisibilityRevoked {
		t.Fatalf("expected revoked, got %q", got)
	}
}

func TestDeriveWritePathClassesAndGovernanceControls(t *testing.T) {
	t.Parallel()

	classes := DeriveWritePathClasses(
		[]string{"pull_request.write", "secret.read", "package.write"},
		true,
		true,
		false,
		false,
		true,
		false,
		".github/workflows/release.yml",
		"ci_agent",
	)
	for _, want := range []string{WritePathPullRequestWrite, WritePathPackagePublish, WritePathSecretBearingExec, WritePathReleaseWrite} {
		if !containsString(classes, want) {
			t.Fatalf("expected write-path class %q in %v", want, classes)
		}
	}
	controls := BuildGovernanceControls(GovernanceControlInput{
		Owner:                    "@acme/app",
		OwnershipStatus:          "explicit",
		ApprovalStatus:           "missing",
		SecurityVisibilityStatus: SecurityVisibilityUnknownToSecurity,
		WritePathClasses:         classes,
		CredentialAccess:         true,
		EvidenceBasis:            []string{"workflow_permission"},
	})
	if !hasControlStatus(controls, GovernanceControlApproval, ControlStatusGap) {
		t.Fatalf("expected approval gap control, got %+v", controls)
	}
	if !hasControlStatus(controls, GovernanceControlOwnerAssigned, ControlStatusSatisfied) {
		t.Fatalf("expected owner control satisfied, got %+v", controls)
	}
}

func TestToolGovernanceControlsDoNotBorrowGlobalProductionConfiguration(t *testing.T) {
	t.Parallel()

	inv := Inventory{
		PrivilegeBudget: PrivilegeBudget{
			ProductionWrite: ProductionWriteBudget{Configured: true, Status: ProductionTargetsStatusConfigured},
		},
		Tools: []Tool{{
			ToolID:                   "wrkr:ci:.github/workflows/release.yml",
			ToolType:                 "ci_agent",
			ApprovalStatus:           "missing",
			ApprovalClass:            "unapproved",
			SecurityVisibilityStatus: SecurityVisibilityUnknownToSecurity,
			WritePathClasses:         []string{WritePathDeployWrite},
			Locations: []ToolLocation{{
				Repo:            "acme/app",
				Location:        ".github/workflows/release.yml",
				Owner:           "@acme/app",
				OwnershipStatus: "explicit",
			}},
		}},
	}

	ApplySecurityVisibility(&inv, SecurityVisibilityReference{ReferenceBasis: "baseline_snapshot"})
	if !hasControlStatus(inv.Tools[0].GovernanceControls, GovernanceControlProduction, ControlStatusGap) {
		t.Fatalf("expected per-tool production classification gap despite global production config, got %+v", inv.Tools[0].GovernanceControls)
	}
}

func TestInventoryBuildAddsNonHumanIdentitiesAdditively(t *testing.T) {
	t.Parallel()

	manifest := source.Manifest{
		Target: source.Target{Mode: "repo", Value: "acme/backend"},
		Repos:  []source.RepoManifest{{Repo: "acme/backend", Location: t.TempDir()}},
	}
	findings := []model.Finding{
		{FindingType: "tool_config", ToolType: "codex", Location: "AGENTS.md", Repo: "acme/backend", Org: "acme"},
		{
			FindingType: "non_human_identity",
			ToolType:    "non_human_identity",
			Location:    ".github/workflows/release.yml",
			Repo:        "acme/backend",
			Org:         "acme",
			Evidence: []model.Evidence{
				{Key: "identity_type", Value: "github_app"},
				{Key: "subject", Value: "github_app"},
				{Key: "source", Value: "workflow_static_signal"},
				{Key: "confidence", Value: "high"},
			},
		},
	}
	ctx := map[string]ToolContext{
		KeyForFinding(findings[0]): {RiskScore: 4.1, EndpointClass: "workspace", DataClass: "code", AutonomyLevel: "interactive", ApprovalStatus: "missing", LifecycleState: "discovered"},
	}

	inv := Build(BuildInput{
		Manifest:              manifest,
		Findings:              findings,
		Contexts:              ctx,
		RepoExposureSummaries: []exposure.RepoExposureSummary{},
		GeneratedAt:           time.Date(2026, 3, 26, 10, 0, 0, 0, time.UTC),
	})

	if len(inv.Tools) != 1 {
		t.Fatalf("expected tool inventory unchanged, got %+v", inv.Tools)
	}
	if len(inv.NonHumanIdentities) != 1 || inv.NonHumanIdentities[0].IdentityType != "github_app" {
		t.Fatalf("expected additive non-human identity inventory, got %+v", inv.NonHumanIdentities)
	}
}

func TestInventoryBuildDerivesPurposeFromWorkflowName(t *testing.T) {
	t.Parallel()

	root := t.TempDir()
	if err := os.MkdirAll(filepath.Join(root, ".github", "workflows"), 0o755); err != nil {
		t.Fatalf("mkdir workflow dir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(root, ".github", "workflows", "release.yml"), []byte("name: Release pipeline\njobs: {}\n"), 0o644); err != nil {
		t.Fatalf("write workflow: %v", err)
	}

	inv := Build(BuildInput{
		Manifest: source.Manifest{
			Target: source.Target{Mode: "repo", Value: "acme/release"},
			Repos:  []source.RepoManifest{{Repo: "acme/release", Location: root}},
		},
		Findings: []model.Finding{{
			FindingType: "compiled_action",
			ToolType:    "compiled_action",
			Location:    ".github/workflows/release.yml",
			Repo:        "acme/release",
			Org:         "acme",
			Evidence: []model.Evidence{
				{Key: "workflow_name", Value: "Release pipeline"},
			},
		}},
		Contexts: map[string]ToolContext{
			KeyForFinding(model.Finding{FindingType: "compiled_action", ToolType: "compiled_action", Location: ".github/workflows/release.yml", Repo: "acme/release", Org: "acme"}): {
				RiskScore:      7.5,
				EndpointClass:  "workspace",
				DataClass:      "code",
				AutonomyLevel:  "headless_gated",
				ApprovalStatus: "missing",
				LifecycleState: "discovered",
			},
		},
		RepoExposureSummaries: []exposure.RepoExposureSummary{},
		GeneratedAt:           time.Date(2026, 5, 10, 18, 0, 0, 0, time.UTC),
	})

	if len(inv.Tools) != 1 || len(inv.Agents) != 1 {
		t.Fatalf("expected one tool and one agent, got tools=%d agents=%d", len(inv.Tools), len(inv.Agents))
	}
	if inv.Tools[0].Purpose != "Release pipeline" || inv.Tools[0].PurposeSource != "workflow_name" {
		t.Fatalf("expected workflow purpose metadata on tool, got %+v", inv.Tools[0])
	}
	if inv.Agents[0].Purpose != "Release pipeline" || inv.Agents[0].ConfigFingerprint == "" {
		t.Fatalf("expected workflow purpose and config fingerprint on agent, got %+v", inv.Agents[0])
	}
}

func TestInventoryBuildPurposeAnnotationOverridesWorkflowName(t *testing.T) {
	t.Parallel()

	root := t.TempDir()
	if err := os.MkdirAll(filepath.Join(root, ".github", "workflows"), 0o755); err != nil {
		t.Fatalf("mkdir workflow dir: %v", err)
	}
	workflow := []byte("# wrkr:purpose: Customer refund release guard\nname: Release pipeline\njobs: {}\n")
	if err := os.WriteFile(filepath.Join(root, ".github", "workflows", "release.yml"), workflow, 0o644); err != nil {
		t.Fatalf("write workflow: %v", err)
	}

	finding := model.Finding{
		FindingType: "compiled_action",
		ToolType:    "compiled_action",
		Location:    ".github/workflows/release.yml",
		Repo:        "acme/release",
		Org:         "acme",
		Evidence: []model.Evidence{
			{Key: "workflow_name", Value: "Release pipeline"},
		},
	}
	inv := Build(BuildInput{
		Manifest: source.Manifest{
			Target: source.Target{Mode: "repo", Value: "acme/release"},
			Repos:  []source.RepoManifest{{Repo: "acme/release", Location: root}},
		},
		Findings: []model.Finding{finding},
		Contexts: map[string]ToolContext{
			KeyForFinding(finding): {
				RiskScore:      7.5,
				EndpointClass:  "workspace",
				DataClass:      "code",
				AutonomyLevel:  "headless_gated",
				ApprovalStatus: "missing",
				LifecycleState: "discovered",
			},
		},
		GeneratedAt: time.Date(2026, 5, 10, 18, 0, 0, 0, time.UTC),
	})

	if len(inv.Tools) != 1 || len(inv.Agents) != 1 {
		t.Fatalf("expected one tool and one agent, got tools=%d agents=%d", len(inv.Tools), len(inv.Agents))
	}
	if inv.Tools[0].Purpose != "Customer refund release guard" || inv.Tools[0].PurposeSource != "annotation" || inv.Tools[0].PurposeConfidence != "high" {
		t.Fatalf("expected annotated purpose metadata on tool, got %+v", inv.Tools[0])
	}
	if inv.Agents[0].Purpose != "Customer refund release guard" || inv.Agents[0].PurposeSource != "annotation" {
		t.Fatalf("expected annotated purpose metadata on agent, got %+v", inv.Agents[0])
	}
}

func TestConfigFingerprintForFileIsPortableAcrossRootsAndLineEndings(t *testing.T) {
	t.Parallel()

	rootA := t.TempDir()
	rootB := t.TempDir()
	rel := ".github/workflows/release.yml"
	if err := os.MkdirAll(filepath.Join(rootA, ".github", "workflows"), 0o755); err != nil {
		t.Fatalf("mkdir rootA workflow dir: %v", err)
	}
	if err := os.MkdirAll(filepath.Join(rootB, ".github", "workflows"), 0o755); err != nil {
		t.Fatalf("mkdir rootB workflow dir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(rootA, ".github", "workflows", "release.yml"), []byte("name: Release\njobs:\n  publish: {}\n"), 0o644); err != nil {
		t.Fatalf("write rootA workflow: %v", err)
	}
	if err := os.WriteFile(filepath.Join(rootB, ".github", "workflows", "release.yml"), []byte("name: Release\r\njobs:\r\n  publish: {}\r\n"), 0o644); err != nil {
		t.Fatalf("write rootB workflow: %v", err)
	}

	left := configFingerprintForFile(rootA, rel)
	right := configFingerprintForFile(rootB, rel)
	if left == "" || right == "" || left != right {
		t.Fatalf("expected portable config fingerprint, got left=%q right=%q", left, right)
	}
}

func TestConfigFingerprintForFileRejectsPathsOutsideRoot(t *testing.T) {
	t.Parallel()

	root := t.TempDir()
	outside := filepath.Join(root, "..", "release.yml")
	if err := os.WriteFile(outside, []byte("name: outside\n"), 0o644); err != nil {
		t.Fatalf("write outside file: %v", err)
	}

	if got := configFingerprintForFile(root, "../release.yml"); got != "" {
		t.Fatalf("expected empty fingerprint for path outside root, got %q", got)
	}
}

func containsString(values []string, want string) bool {
	for _, value := range values {
		if value == want {
			return true
		}
	}
	return false
}

func hasControlStatus(values []GovernanceControlMapping, control string, status string) bool {
	for _, value := range values {
		if value.Control == control && value.Status == status {
			return true
		}
	}
	return false
}
