package generator

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

	"gopkg.in/yaml.v3"
)

func TestExtractDescriptivePitfall_PasswordConstraint(t *testing.T) {
	detail := `Error: scaleway_redis_cluster.main: password does not respect constraint: minimum 8 characters`
	got := ExtractDescriptivePitfall(detail, "redis-paris")
	if got == nil {
		t.Fatal("expected pitfall, got nil")
	}
	if got.Resource != "scaleway_redis_cluster" {
		t.Errorf("resource = %q, want scaleway_redis_cluster", got.Resource)
	}
	if got.DiscoveredFrom != "redis-paris" {
		t.Errorf("discovered_from = %q, want redis-paris", got.DiscoveredFrom)
	}
	if got.Rule == "" {
		t.Error("expected non-empty rule")
	}
}

func TestExtractDescriptivePitfall_K8sVersionAutoUpgrade(t *testing.T) {
	detail := `exit status 1 | stderr: Error: minor version x.y must only be used with auto upgrade enabled with scaleway_k8s_cluster.main`
	got := ExtractDescriptivePitfall(detail, "k8s-cluster-paris")
	if got == nil {
		t.Fatal("expected pitfall, got nil")
	}
	if got.Resource != "scaleway_k8s_cluster" {
		t.Errorf("resource = %q, want scaleway_k8s_cluster", got.Resource)
	}
	if got.Rule == "" {
		t.Error("expected non-empty rule")
	}
}

// TestExtractDescriptivePitfall_GoogleResource pins multi-cloud regex
// support: a GCP-flavoured failure detail must extract the google_*
// resource type so the run-loop's cross-cloud guard accepts the
// pitfall on a `cloud: gcp` scenario.
func TestExtractDescriptivePitfall_GoogleResource(t *testing.T) {
	detail := `Error: Unsupported argument "labels" on google_container_cluster.main`
	got := ExtractDescriptivePitfall(detail, "gcp-gke-cluster")
	if got == nil {
		t.Fatal("expected pitfall, got nil")
	}
	if got.Resource != "google_container_cluster" {
		t.Errorf("resource = %q, want google_container_cluster", got.Resource)
	}
}

// TestExtractDescriptivePitfall_PasswordWithoutResourceReturnsNil pins the
// review-11 fix: when the password failure detail names no resource,
// the function must NOT fabricate a `scaleway_redis_cluster` default.
// Otherwise the run loop's cross-cloud guard would silently drop the
// learning on GCP.
func TestExtractDescriptivePitfall_PasswordWithoutResourceReturnsNil(t *testing.T) {
	detail := `password does not respect constraint`
	if got := ExtractDescriptivePitfall(detail, "any-scenario"); got != nil {
		t.Fatalf("expected nil for resource-less password failure, got %+v", got)
	}
}

func TestExtractDescriptivePitfall_UnsupportedArgument(t *testing.T) {
	detail := `Error: Unsupported argument "zone" on scaleway_lb_backend.main`
	got := ExtractDescriptivePitfall(detail, "web-app-paris")
	if got == nil {
		t.Fatal("expected pitfall, got nil")
	}
	if got.Resource != "scaleway_lb_backend" {
		t.Errorf("resource = %q, want scaleway_lb_backend", got.Resource)
	}
	if got.DiscoveredFrom != "web-app-paris" {
		t.Errorf("discovered_from = %q, want web-app-paris", got.DiscoveredFrom)
	}
	if got.Rule == "" {
		t.Error("expected non-empty rule")
	}
}

func TestExtractDescriptivePitfall_AtLeastOneOf(t *testing.T) {
	detail := `Error: scaleway_rdb_instance.main: at least one of 'ip_net' or 'enable_ipam' (set to true) must be set`
	got := ExtractDescriptivePitfall(detail, "rdb-paris")
	if got == nil {
		t.Fatal("expected pitfall, got nil")
	}
	if got.Resource != "scaleway_rdb_instance" {
		t.Errorf("resource = %q, want scaleway_rdb_instance", got.Resource)
	}
	if got.Rule == "" {
		t.Error("expected non-empty rule")
	}
}

// TestExtractDescriptivePitfall_M97Templates pins each of the 5 M97
// prescriptive-rule templates. Each test feeds a real-shape failure
// detail and asserts the produced rule is PRESCRIPTIVE — contains a
// verb the LLM can act on ("Set", "Add", "Omit", "Do NOT use") —
// not just a descriptive echo of the failure.
func TestExtractDescriptivePitfall_M97_MissingSubnetwork(t *testing.T) {
	cases := []string{
		`google_compute_instance.api_server has no network_interface.subnetwork — must be attached to an explicit VPC subnetwork`,
		`google_container_cluster.primary has no network or subnetwork — GKE clusters must reference an explicit VPC`,
	}
	for _, detail := range cases {
		got := ExtractDescriptivePitfall(detail, "gcp-full-stack")
		if got == nil {
			t.Errorf("nil for %q", detail[:50])
			continue
		}
		if !strings.Contains(got.Rule, "google_compute_subnetwork") {
			t.Errorf("rule not prescriptive enough — missing 'google_compute_subnetwork' for %q", detail[:50])
		}
	}
}

func TestExtractDescriptivePitfall_M97_MissingEncryption_Disabled(t *testing.T) {
	// M97 follow-up: this template is intentionally a no-op until M98
	// lands cross-policy awareness. Previous version told the LLM to
	// "omit CMEK" but policies/gcp/encryption.rego REQUIRES CMEK —
	// giving the LLM the opposite of what the gate enforces poisoned
	// the learning loop. Pin the disabled state so we don't silently
	// re-enable wrong advice.
	detail := `google_storage_bucket.app_assets has no encryption.default_kms_key_name — customer-managed encryption not configured`
	got := matchMissingEncryption(detail, "gcp-storage")
	if got != nil {
		t.Fatalf("CMEK template should be disabled (M97 follow-up); got: %+v", got)
	}
	// The descriptive fallback still produces SOMETHING for this detail
	// (so the LLM still sees the failure in context); just not the
	// wrong prescriptive form.
	fallback := ExtractDescriptivePitfall(detail, "gcp-storage")
	if fallback == nil {
		t.Fatal("expected descriptive fallback for encryption detail when template disabled")
	}
}

func TestExtractDescriptivePitfall_M97_NotImplemented(t *testing.T) {
	detail := `exit status 1 | stderr: Error creating instance template: googleapi: Error 501: Not implemented for google_compute_instance_template`
	got := ExtractDescriptivePitfall(detail, "gcp-iam")
	if got == nil {
		t.Fatal("nil for 501 shape")
	}
	if !strings.Contains(got.Rule, "Do NOT use") {
		t.Errorf("rule not prescriptive: %q", got.Rule)
	}
}

func TestExtractDescriptivePitfall_M97_OAuthEscape(t *testing.T) {
	detail := `Error creating google_service_account: googleapi: Error 401: Request had invalid authentication credentials. Expected OAuth 2 access token`
	got := ExtractDescriptivePitfall(detail, "gcp-iam")
	if got == nil {
		t.Fatal("nil for OAuth-escape shape")
	}
	if !strings.Contains(got.Rule, "custom_endpoint") {
		t.Errorf("rule not prescriptive: %q", got.Rule)
	}
}

func TestExtractDescriptivePitfall_M97_DestroyBlockers(t *testing.T) {
	// deletion_protection
	got := ExtractDescriptivePitfall(`Error destroying aws_db_instance.main: deletion_protection is enabled, set to true`, "aws-rds")
	if got == nil || !strings.Contains(got.Rule, "deletion_protection = false") {
		t.Errorf("deletion_protection template failed: %+v", got)
	}
	// BucketNotEmpty
	got = ExtractDescriptivePitfall(`Error destroying aws_s3_bucket.assets: BucketNotEmpty: The bucket you tried to delete is not empty`, "aws-s3")
	if got == nil || !strings.Contains(got.Rule, "force_destroy = true") {
		t.Errorf("force_destroy template failed: %+v", got)
	}
	// skip_final_snapshot
	got = ExtractDescriptivePitfall(`Error destroying aws_db_instance.main: final snapshot must be specified or skip_final_snapshot = true`, "aws-rds")
	if got == nil || !strings.Contains(got.Rule, "skip_final_snapshot") {
		t.Errorf("skip_final_snapshot template failed: %+v", got)
	}
}

// TestExtractDescriptivePitfall_M97_UnsupportedAttribute_AllClouds —
// terraform "Unsupported attribute" / "no exported attribute named"
// errors are the most common cross-cloud failure shape we were
// missing before. The template fires for scaleway_*, google_*,
// aws_* identically and produces a prescriptive rule. When the
// provider supplies a "Did you mean Y" suggestion we must forward
// it into the rule.
func TestExtractDescriptivePitfall_M97_UnsupportedAttribute_AllClouds(t *testing.T) {
	cases := []struct {
		name        string
		detail      string
		wantRes     string
		wantAttr    string
		wantSuggest string
	}{
		{
			name:        "scaleway with suggestion",
			detail:      "Error: Unsupported attribute\n  on loadbalancer.tf line 21, in resource \"scaleway_lb_backend\" \"web\":\n  21:   server_ips = scaleway_instance_server.web[*].private_ip\nThis object has no argument, nested block, or exported attribute named \"private_ip\". Did you mean \"private_ips\"?",
			wantRes:     "scaleway_instance_server",
			wantAttr:    "private_ip",
			wantSuggest: "private_ips",
		},
		{
			name:     "google without suggestion",
			detail:   "Error: Unsupported attribute on google_compute_instance.web.bogus_attr — This object does not have an attribute named \"bogus_attr\".",
			wantRes:  "google_compute_instance",
			wantAttr: "bogus_attr",
		},
		{
			name:        "aws with suggestion",
			detail:      "Error: Unsupported attribute on aws_instance.web.arn_name — This object has no argument, nested block, or exported attribute named \"arn_name\". Did you mean \"arn\"?",
			wantRes:     "aws_instance",
			wantAttr:    "arn_name",
			wantSuggest: "arn",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := ExtractDescriptivePitfall(tc.detail, "scenario-"+tc.name)
			if got == nil {
				t.Fatalf("nil pitfall for %s", tc.name)
			}
			if got.Resource != tc.wantRes {
				t.Errorf("resource = %q, want %q", got.Resource, tc.wantRes)
			}
			if !strings.Contains(got.Rule, tc.wantAttr) {
				t.Errorf("rule missing bad attribute %q: %q", tc.wantAttr, got.Rule)
			}
			if tc.wantSuggest != "" && !strings.Contains(got.Rule, tc.wantSuggest) {
				t.Errorf("rule missing 'Did you mean' suggestion %q: %q", tc.wantSuggest, got.Rule)
			}
		})
	}
}

// TestExtractDescriptivePitfall_M97_UnsupportedArgument pins the
// matchUnsupportedArgument template (Ticket 6 — gcp-cloud-run
// deletion_protection). The legacy unsupportedArgRe missed wrapped
// diagnostics so the descriptive fallback fired with a verbatim
// stderr dump; the new template extracts both the resource and the
// arg, and produces a PRESCRIPTIVE rule the LLM can act on.
func TestExtractDescriptivePitfall_M97_UnsupportedArgument(t *testing.T) {
	detail := "exit status 1 | stderr: ╷\n│ Error: Unsupported argument\n│ \n│   on main.tf line 6, in resource \"google_cloud_run_v2_service\" \"api\":\n│    6:   deletion_protection = false\n│ \n│ An argument named \"deletion_protection\" is not expected here.\n╵"
	got := ExtractDescriptivePitfall(detail, "gcp-cloud-run")
	if got == nil {
		t.Fatal("expected pitfall for Unsupported argument wrapped diagnostic")
	}
	if got.Resource != "google_cloud_run_v2_service" {
		t.Errorf("resource = %q, want google_cloud_run_v2_service", got.Resource)
	}
	if !strings.Contains(got.Rule, "deletion_protection") {
		t.Errorf("rule missing arg name: %q", got.Rule)
	}
	if !strings.Contains(got.Rule, "Remove") && !strings.Contains(got.Rule, "does NOT accept") {
		t.Errorf("rule not prescriptive: %q", got.Rule)
	}
	// Rule must NOT be a raw stderr dump.
	if isVerbatimFallback(got.Rule) {
		t.Errorf("rule is verbatim fallback, expected prescriptive: %q", got.Rule)
	}
}

// TestAppendPitfall_VerbatimUpgrade pins the
// verbatim→prescriptive upgrade path. When a same-resource entry
// exists as a raw stderr dump and a later prescriptive rule arrives,
// AppendPitfall REPLACES the verbatim entry rather than dedup-skipping.
func TestAppendPitfall_VerbatimUpgrade(t *testing.T) {
	dir := t.TempDir()

	// Seed with a verbatim fallback entry (real shape from gcp.yaml).
	initial := PitfallsFile{
		Provider: "gcp",
		Pitfalls: []PitfallEntry{
			{
				Resource:       "google_cloud_run_v2_service",
				Rule:           "exit status 1 | stderr: ╷\n│ Error: Unsupported argument\n│ \n│   on main.tf line 6, in resource \"google_cloud_run_v2_service\" \"api\":\n│    6:   deletion_protection = false\n│ \n│ An argument named \"deletion_protection\" is not expected here.\n╵",
				Source:         "descriptive",
				DiscoveredFrom: "gcp-cloud-run",
			},
		},
	}
	data, err := yaml.Marshal(&initial)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "gcp.yaml"), data, 0o644); err != nil {
		t.Fatal(err)
	}

	// Append a prescriptive rule for the same resource — should REPLACE,
	// not dedup-skip, even though the verbatim entry shares many
	// significant words with the prescriptive form.
	prescriptive := LearnedPitfall{
		Resource:       "google_cloud_run_v2_service",
		Rule:           "`google_cloud_run_v2_service` does NOT accept the argument `deletion_protection` — Remove the line from every block.",
		DiscoveredFrom: "gcp-cloud-run",
	}
	if err := AppendPitfall(dir, "gcp", prescriptive); err != nil {
		t.Fatal(err)
	}

	result, err := os.ReadFile(filepath.Join(dir, "gcp.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	var pf PitfallsFile
	if err := yaml.Unmarshal(result, &pf); err != nil {
		t.Fatal(err)
	}
	if len(pf.Pitfalls) != 1 {
		t.Fatalf("expected 1 pitfall after upgrade, got %d", len(pf.Pitfalls))
	}
	if isVerbatimFallback(pf.Pitfalls[0].Rule) {
		t.Errorf("expected prescriptive rule after upgrade, still verbatim: %q", pf.Pitfalls[0].Rule)
	}
	if !strings.Contains(pf.Pitfalls[0].Rule, "Remove") {
		t.Errorf("rule lost prescription on write: %q", pf.Pitfalls[0].Rule)
	}
}

// TestAppendPitfall_LearnedToPrescriptiveUpgrade pins the
// learned → fix replacement path. A descriptive
// `source: learned` symptom-only rule shares enough significant words
// with N10's prescriptive HCL-snippet rule for the same resource that
// isDuplicate would silently drop the new entry. The fix is an
// explicit upgrade: when a FixSource candidate sees any
// same-resource non-prescriptive entry, REPLACE in place.
func TestAppendPitfall_LearnedToPrescriptiveUpgrade(t *testing.T) {
	dir := t.TempDir()

	// Seed with a learned (descriptive, not verbatim) entry — real
	// shape from the 2026-06-02 sweep's pitfalls/gcp.yaml.
	initial := PitfallsFile{
		Provider: "gcp",
		Pitfalls: []PitfallEntry{
			{
				Resource:       "google_storage_bucket",
				Rule:           "google_storage_bucket.app_assets has no encryption.default_kms_key_name — customer-managed encryption not configured",
				Source:         "descriptive",
				DiscoveredFrom: "gcp-storage",
			},
		},
	}
	data, err := yaml.Marshal(&initial)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "gcp.yaml"), data, 0o644); err != nil {
		t.Fatal(err)
	}

	// Append a prescriptive N10-shape rule for the same resource.
	prescriptive := LearnedPitfall{
		Resource:       "google_storage_bucket",
		Rule:           "google_storage_bucket: Minimal HCL:\nresource \"google_storage_bucket\" \"app_assets\" {\n  encryption {\n    default_kms_key_name = google_kms_crypto_key.bucket_key.id\n  }\n}",
		Source:         FixSource,
		DiscoveredFrom: "gcp-full-stack",
	}
	if err := AppendPitfall(dir, "gcp", prescriptive); err != nil {
		t.Fatal(err)
	}

	result, err := os.ReadFile(filepath.Join(dir, "gcp.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	var pf PitfallsFile
	if err := yaml.Unmarshal(result, &pf); err != nil {
		t.Fatal(err)
	}
	if len(pf.Pitfalls) != 1 {
		t.Fatalf("expected 1 pitfall after upgrade, got %d", len(pf.Pitfalls))
	}
	if pf.Pitfalls[0].Source != FixSource {
		t.Errorf("expected source=%q after upgrade, got %q", FixSource, pf.Pitfalls[0].Source)
	}
	if !strings.Contains(pf.Pitfalls[0].Rule, "default_kms_key_name = google_kms_crypto_key") {
		t.Errorf("rule lost prescriptive HCL on write: %q", pf.Pitfalls[0].Rule)
	}
	if pf.Pitfalls[0].DiscoveredFrom != "gcp-full-stack" {
		t.Errorf("expected DiscoveredFrom=gcp-full-stack, got %q", pf.Pitfalls[0].DiscoveredFrom)
	}
}

// TestAppendPitfall_LearnedFromDiffDuplicatesDedupe pins that two
// FixSource entries for the same resource still dedupe — the
// upgrade path only replaces non-prescriptive predecessors. Without
// this guard, every iter-pair would re-write the same snippet.
func TestAppendPitfall_LearnedFromDiffDuplicatesDedupe(t *testing.T) {
	dir := t.TempDir()

	first := LearnedPitfall{
		Resource:       "google_storage_bucket",
		Rule:           "google_storage_bucket: Minimal HCL:\nresource \"google_storage_bucket\" \"x\" {\n  encryption {\n    default_kms_key_name = google_kms_crypto_key.k.id\n  }\n}",
		Source:         FixSource,
		DiscoveredFrom: "gcp-storage",
	}
	if err := AppendPitfall(dir, "gcp", first); err != nil {
		t.Fatal(err)
	}
	second := first
	second.DiscoveredFrom = "gcp-full-stack"
	if err := AppendPitfall(dir, "gcp", second); err != nil {
		t.Fatal(err)
	}

	result, err := os.ReadFile(filepath.Join(dir, "gcp.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	var pf PitfallsFile
	if err := yaml.Unmarshal(result, &pf); err != nil {
		t.Fatal(err)
	}
	if len(pf.Pitfalls) != 1 {
		t.Fatalf("expected dedupe to keep 1 entry, got %d", len(pf.Pitfalls))
	}
}

// TestExtractDescriptivePitfall_UnsupportedArgument_WrappedDiagnostic pins
// the box-drawing-strip fix in ExtractDescriptivePitfall. Real terraform
// diagnostics wrap the bad-argument name onto a separate line framed
// by `│`, so `Unsupported argument.*"X"` (which uses `.*` and cannot
// span newlines) never matched the multi-line shape before. The
// single-line shape continued to work and masked the latent bug.
func TestExtractDescriptivePitfall_UnsupportedArgument_WrappedDiagnostic(t *testing.T) {
	// Real-shape multi-line diagnostic for an unsupported argument on
	// google_compute_instance. The bad arg name `bogus_field` lives on
	// a different line than the "Unsupported argument" marker.
	detail := "exit status 1 | stderr: ╷\n│ Error: Unsupported argument\n│ \n│   on main.tf line 12, in resource \"google_compute_instance\" \"web\":\n│   12:   bogus_field = \"v\"\n│ \n│ An argument named \"bogus_field\" is not expected here.\n╵"
	got := ExtractDescriptivePitfall(detail, "gcp-vm-network")
	if got == nil {
		t.Fatal("expected pitfall for wrapped Unsupported argument shape (regression: pre-fix code returned nil)")
	}
	if got.Resource != "google_compute_instance" {
		t.Errorf("resource = %q, want google_compute_instance", got.Resource)
	}
	if !strings.Contains(got.Rule, "bogus_field") {
		t.Errorf("rule missing arg name in body: %q", got.Rule)
	}
}

// TestExtractDescriptivePitfall_AtLeastOneOf_WrappedDiagnostic — companion
// pin for the at-least-one-of regex. Same latent bug.
func TestExtractDescriptivePitfall_AtLeastOneOf_WrappedDiagnostic(t *testing.T) {
	detail := "exit status 1 | stderr: ╷\n│ Error: Invalid combination of arguments\n│ \n│   with scaleway_rdb_instance.main,\n│   on db.tf line 4, in resource \"scaleway_rdb_instance\" \"main\":\n│    4:   name = \"app\"\n│ \n│ at least one of 'ip_net' or 'enable_ipam' (set to true) must be set\n╵"
	got := ExtractDescriptivePitfall(detail, "rdb-paris")
	if got == nil {
		t.Fatal("expected pitfall for wrapped at-least-one-of shape (regression: pre-fix code returned nil)")
	}
	if got.Resource != "scaleway_rdb_instance" {
		t.Errorf("resource = %q, want scaleway_rdb_instance", got.Resource)
	}
	if !strings.Contains(got.Rule, "ip_net") {
		t.Errorf("rule missing constraint body: %q", got.Rule)
	}
}

// TestExtractDescriptivePitfall_AwsResource pins the M92 fix: the resource
// regex was scaleway_*|google_* only, so every AWS failure detail
// produced "no resource extracted" and the learning loop silently
// dropped it. M88's sweep showed 11/11 AWS scenarios failed without
// growing pitfalls/aws.yaml even with M86+M90 active.
func TestExtractDescriptivePitfall_AwsResource(t *testing.T) {
	detail := "exit status 1 | stderr: Error: invalid value for aws_db_instance.main: deletion_protection must be false to destroy. Resource: aws_db_instance.main"
	got := ExtractDescriptivePitfall(detail, "aws-rds")
	if got == nil {
		t.Fatal("M92 regression: AWS resource not extracted from tofu envelope")
	}
	if got.Resource != "aws_db_instance" {
		t.Errorf("resource = %q, want aws_db_instance", got.Resource)
	}
}

// TestExtractDescriptivePitfall_TofuEnvelopeWithResource regression-pins
// the M86 bug fix. Every tofu apply failure detail starts with
// "exit status 1 | stderr: ..." — "exit status" is in genericPatterns.
// The prior ordering substring-rejected the whole detail before the
// resource-extraction fallback could fire, so every apply-time
// learning was silently dropped. The fix runs the resource-extraction
// fallback BEFORE the generic-pattern rejection so the actionable
// google_*/scaleway_* substring is honored.
func TestExtractDescriptivePitfall_TofuEnvelopeWithResource(t *testing.T) {
	// Real M83 iter-1 failure detail (gcp-memorystore run).
	detail := "exit status 1 | stderr: Error when reading or editing Project Service infrafactory-test/redis.googleapis.com: googleapi: Error 401: Request had invalid authentication credentials. Resource: google_project_service.redis"
	got := ExtractDescriptivePitfall(detail, "gcp-memorystore")
	if got == nil {
		t.Fatal("expected learned pitfall for tofu envelope with google_project_service in detail, got nil — M86 ordering bug has regressed")
	}
	if got.Resource != "google_project_service" {
		t.Errorf("resource = %q, want google_project_service", got.Resource)
	}
	if got.Rule == "" {
		t.Error("expected non-empty rule")
	}
}

func TestExtractDescriptivePitfall_GenericError(t *testing.T) {
	cases := []string{
		"test checks failed",
		"validation failed with exit status 1",
		"exit status 1",
		"context deadline exceeded",
		"command failed",
	}
	for _, detail := range cases {
		got := ExtractDescriptivePitfall(detail, "test-scenario")
		if got != nil {
			t.Errorf("detail=%q: expected nil, got %+v", detail, got)
		}
	}
}

func TestExtractDescriptivePitfall_ResourceNotFound(t *testing.T) {
	detail := "resource with ID abc-123 not found"
	got := ExtractDescriptivePitfall(detail, "test-scenario")
	if got != nil {
		t.Errorf("expected nil for generic not found, got %+v", got)
	}
}

func TestExtractDescriptivePitfall_EmptyDetail(t *testing.T) {
	got := ExtractDescriptivePitfall("", "test-scenario")
	if got != nil {
		t.Errorf("expected nil for empty detail, got %+v", got)
	}
}

func TestAppendPitfall_NewPitfall(t *testing.T) {
	dir := t.TempDir()

	// Seed with an existing pitfall.
	initial := PitfallsFile{
		Provider: "scaleway",
		Pitfalls: []PitfallEntry{
			{Resource: "scaleway_instance_server", Rule: "Use exact instance type.", Source: "static"},
		},
	}
	data, err := yaml.Marshal(&initial)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "scaleway.yaml"), data, 0o644); err != nil {
		t.Fatal(err)
	}

	pitfall := LearnedPitfall{
		Resource:       "scaleway_redis_cluster",
		Rule:           "The password must meet complexity requirements.",
		DiscoveredFrom: "redis-paris",
	}
	if err := AppendPitfall(dir, "scaleway", pitfall); err != nil {
		t.Fatal(err)
	}

	// Verify file is valid YAML with 2 entries.
	result, err := os.ReadFile(filepath.Join(dir, "scaleway.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	var pf PitfallsFile
	if err := yaml.Unmarshal(result, &pf); err != nil {
		t.Fatalf("invalid YAML after append: %v", err)
	}
	if len(pf.Pitfalls) != 2 {
		t.Fatalf("expected 2 pitfalls, got %d", len(pf.Pitfalls))
	}
	added := pf.Pitfalls[1]
	if added.Source != "descriptive" {
		t.Errorf("source = %q, want learned", added.Source)
	}
	if added.DiscoveredFrom != "redis-paris" {
		t.Errorf("discovered_from = %q, want redis-paris", added.DiscoveredFrom)
	}
}

func TestAppendPitfall_Duplicate(t *testing.T) {
	dir := t.TempDir()

	initial := PitfallsFile{
		Provider: "scaleway",
		Pitfalls: []PitfallEntry{
			{
				Resource: "scaleway_redis_cluster",
				Rule:     "The password must meet complexity requirements including uppercase, lowercase, digit, special character.",
				Source:   "descriptive",
			},
		},
	}
	data, err := yaml.Marshal(&initial)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "scaleway.yaml"), data, 0o644); err != nil {
		t.Fatal(err)
	}

	// Try to append a similar pitfall — should be deduplicated.
	pitfall := LearnedPitfall{
		Resource:       "scaleway_redis_cluster",
		Rule:           "The password must meet the provider's complexity requirements (minimum length, uppercase, lowercase, digit, and special character).",
		DiscoveredFrom: "redis-paris-2",
	}
	if err := AppendPitfall(dir, "scaleway", pitfall); err != nil {
		t.Fatal(err)
	}

	result, err := os.ReadFile(filepath.Join(dir, "scaleway.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	var pf PitfallsFile
	if err := yaml.Unmarshal(result, &pf); err != nil {
		t.Fatal(err)
	}
	if len(pf.Pitfalls) != 1 {
		t.Fatalf("expected 1 pitfall (duplicate skipped), got %d", len(pf.Pitfalls))
	}
}

func TestAppendPitfall_CreatesFile(t *testing.T) {
	dir := t.TempDir()
	subDir := filepath.Join(dir, "pitfalls")

	pitfall := LearnedPitfall{
		Resource:       "scaleway_redis_cluster",
		Rule:           "The password must meet complexity requirements.",
		DiscoveredFrom: "redis-paris",
	}
	if err := AppendPitfall(subDir, "scaleway", pitfall); err != nil {
		t.Fatal(err)
	}

	result, err := os.ReadFile(filepath.Join(subDir, "scaleway.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	var pf PitfallsFile
	if err := yaml.Unmarshal(result, &pf); err != nil {
		t.Fatalf("invalid YAML: %v", err)
	}
	if pf.Provider != "scaleway" {
		t.Errorf("provider = %q, want scaleway", pf.Provider)
	}
	if len(pf.Pitfalls) != 1 {
		t.Fatalf("expected 1 pitfall, got %d", len(pf.Pitfalls))
	}
}

func TestAppendPitfall_EmptyDirOrCloud(t *testing.T) {
	if err := AppendPitfall("", "scaleway", LearnedPitfall{Rule: "test"}); err != nil {
		t.Errorf("empty dir should be no-op, got error: %v", err)
	}
	if err := AppendPitfall("/tmp", "", LearnedPitfall{Rule: "test"}); err != nil {
		t.Errorf("empty cloud should be no-op, got error: %v", err)
	}
}

// N3-T12: IsMockServerBug classifier. Pinning the five signal
// classes so future signal-list edits don't accidentally narrow the
// predicate. Each test gives a realistic failure detail; the
// predicate should fire.
func TestIsMockServerBug_FivePositiveSignalClasses(t *testing.T) {
	cases := []struct {
		name   string
		detail string
	}{
		{
			name:   "501 not implemented from fakegcp",
			detail: `googleapi: Error 501: Not implemented: GET /v1/v1/projects/.../keyRings, notImplemented`,
		},
		{
			name:   "plugin did not respond panic",
			detail: "Error: Plugin did not respond\n\nThe plugin encountered an error, and failed to respond to the plugin.(*GRPCProvider).ApplyResourceChange call.",
		},
		{
			name:   "OAuth escape from provider v5 v3-endpoint miss",
			detail: `googleapi: Error 401: Request had invalid authentication credentials. ... "reason": "ACCESS_TOKEN_TYPE_UNSUPPORTED"`,
		},
		{
			name:   "couldn't find resource wait-loop",
			detail: "Error: waiting for IAM User Policy create: couldn't find resource (10 retries)",
		},
		{
			name:   "ResourceNotFoundException from Describe* path",
			detail: `Error: removing IAM User access keys: ListSSHPublicKeys, ResourceNotFoundException: The specified resource does not exist`,
		},
		{
			name:   "S68: AWS KMS rotation update timeout (mock doesn't persist update)",
			detail: `Error: waiting for KMS Key (9c3c2d1c-caa5-b730-1fea-d936e0dc2a6e) rotation update: timeout while waiting for state to become 'TRUE' (last state: 'FALSE', timeout: 10m0s)`,
		},
		{
			name:   "S68: AWS Subnet MapPublicIpOnLaunch update timeout (lower-case state)",
			detail: `Error: waiting for EC2 Subnet (subnet-6cc5499a5e7589f9) MapPublicIpOnLaunch update: timeout while waiting for state to become 'true' (last state: 'false', timeout: 5m0s)`,
		},
		{
			name:   "S68: AWS Route 53 record empty result (mock acks create, read returns 0 rows)",
			detail: `Error: reading Route 53 Record (Z95765F2BFAKEAWS_test.example.invalid_A): empty result`,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if !IsMockServerBug(tc.detail) {
				t.Errorf("expected IsMockServerBug=true for %s, got false", tc.name)
			}
			if FirstMockSignal(tc.detail) == "" {
				t.Errorf("FirstMockSignal returned empty for %s", tc.name)
			}
		})
	}
}

// Negative cases — pure LLM mistakes must NOT be classified as
// mock-actionable, otherwise the existing learning path silently
// stops writing legitimate pitfalls.
func TestIsMockServerBug_NegativesStayOnPitfallPath(t *testing.T) {
	cases := []struct {
		name   string
		detail string
	}{
		{
			name:   "Unsupported argument (LLM typo)",
			detail: `Error: Unsupported argument\n\nAn argument named "deletion_protection" is not expected here.`,
		},
		{
			name:   "policy gate (LLM omitted required field)",
			detail: "google_storage_bucket.app_assets has no encryption.default_kms_key_name",
		},
		{
			name:   "password complexity (LLM picked a weak default)",
			detail: "scaleway_redis_cluster.main: password does not respect constraint: minimum 8 characters",
		},
		{
			name:   "empty detail",
			detail: "",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if IsMockServerBug(tc.detail) {
				t.Errorf("expected IsMockServerBug=false for %s, got true", tc.name)
			}
		})
	}
}

// S78-T2: N3 carve-out — ACCESS_TOKEN_TYPE_UNSUPPORTED is normally a
// mock-actionable signal (the v5 GCP provider escaped to real cloud
// because fakegcp lacks the route). But for a fixed set of resource
// types — google_project_service, google_service_networking_connection,
// google_project_iam_member / _binding / _policy — the LLM-side fix
// (drop the resource) IS attainable and the mock-side fix is not
// (these resources can't be modeled cleanly in fakegcp). For those,
// the classifier must return false so the N13 deletion-as-fix
// extractor can learn the avoid pattern via ExtractDescriptivePitfall.
//
// Without this carve-out, S73's retirement of GCP phase2 rules 9 + 12
// leaves no mechanism for the system to re-learn the rule on a fresh
// scenario — failures would route to docs/mock-gaps.md and N13 would
// never see them.
func TestIsMockServerBug_GCPEscapeCarveOut(t *testing.T) {
	cases := []struct {
		name   string
		detail string
		want   bool
	}{
		{
			name:   "carve-out: google_project_service escape",
			detail: `google_project_service.compute: googleapi: Error 401: Request had invalid authentication credentials. ... "reason": "ACCESS_TOKEN_TYPE_UNSUPPORTED"`,
			want:   false,
		},
		{
			name:   "carve-out: google_service_networking_connection escape",
			detail: `Error: Error creating google_service_networking_connection: googleapi: Error 401: Request had invalid authentication credentials. ... "reason": "ACCESS_TOKEN_TYPE_UNSUPPORTED"`,
			want:   false,
		},
		{
			name:   "carve-out: google_project_iam_member escape",
			detail: `google_project_iam_member.cluster_node_sa: googleapi: Error 401: ... "reason": "ACCESS_TOKEN_TYPE_UNSUPPORTED"`,
			want:   false,
		},
		{
			name:   "carve-out: google_project_iam_binding escape",
			detail: `google_project_iam_binding.editors: googleapi: Error 401: ... "reason": "ACCESS_TOKEN_TYPE_UNSUPPORTED"`,
			want:   false,
		},
		{
			name:   "carve-out: google_project_iam_policy escape",
			detail: `google_project_iam_policy.this: googleapi: Error 401: ... "reason": "ACCESS_TOKEN_TYPE_UNSUPPORTED"`,
			want:   false,
		},
		{
			name:   "non-carve-out: bare ACCESS_TOKEN_TYPE_UNSUPPORTED stays mock-actionable",
			detail: `googleapi: Error 401: Request had invalid authentication credentials. ... "reason": "ACCESS_TOKEN_TYPE_UNSUPPORTED"`,
			want:   true,
		},
		{
			name:   "non-carve-out: ACCESS_TOKEN_TYPE_UNSUPPORTED on a non-escape resource stays mock-actionable",
			detail: `google_compute_firewall.allow_ssh: googleapi: Error 401: ... "reason": "ACCESS_TOKEN_TYPE_UNSUPPORTED"`,
			want:   true,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := IsMockServerBug(tc.detail)
			if got != tc.want {
				t.Errorf("IsMockServerBug: got %v, want %v", got, tc.want)
			}
		})
	}
}

// AppendMockGap writes to docs/mock-gaps.md, dedups on
// (cloud, signal, resource), groups by cloud heading.
func TestAppendMockGap_CreatesFileAndDedups(t *testing.T) {
	dir := t.TempDir()
	gap := MockGap{
		Cloud:     "gcp",
		Signal:    "501 not implemented",
		Resource:  "google_kms_key_ring",
		Scenario:  "gcp-storage",
		Detail:    "googleapi: Error 501: Not implemented: POST /projects/test/locations/us/keyRings",
		Timestamp: "20260601T120000Z",
	}
	if err := AppendMockGap(dir, gap); err != nil {
		t.Fatalf("first append: %v", err)
	}
	body, err := os.ReadFile(filepath.Join(dir, "mock-gaps.md"))
	if err != nil {
		t.Fatalf("read: %v", err)
	}
	bodyStr := string(body)
	// Header + per-cloud section present.
	if !strings.Contains(bodyStr, "# Mock-server gaps") {
		t.Errorf("missing header")
	}
	if !strings.Contains(bodyStr, "## gcp") {
		t.Errorf("missing gcp section")
	}
	if !strings.Contains(bodyStr, "google_kms_key_ring") {
		t.Errorf("missing resource row")
	}

	// Re-append the identical gap. Should not duplicate.
	if err := AppendMockGap(dir, gap); err != nil {
		t.Fatalf("second append: %v", err)
	}
	body2, _ := os.ReadFile(filepath.Join(dir, "mock-gaps.md"))
	if strings.Count(string(body2), "google_kms_key_ring") != 1 {
		t.Errorf("duplicate row created on re-append, got %d occurrences", strings.Count(string(body2), "google_kms_key_ring"))
	}

	// A different resource under the same cloud appends to the same section.
	gap2 := gap
	gap2.Resource = "google_dns_record_set"
	if err := AppendMockGap(dir, gap2); err != nil {
		t.Fatalf("third append: %v", err)
	}
	body3, _ := os.ReadFile(filepath.Join(dir, "mock-gaps.md"))
	if !strings.Contains(string(body3), "google_dns_record_set") {
		t.Errorf("second resource not appended")
	}
	if strings.Count(string(body3), "## gcp") != 1 {
		t.Errorf("gcp heading duplicated, expected 1 got %d", strings.Count(string(body3), "## gcp"))
	}

	// Different cloud adds a new section.
	gap3 := MockGap{
		Cloud:     "aws",
		Signal:    "plugin did not respond",
		Resource:  "aws_subnet",
		Scenario:  "aws-vpc",
		Detail:    "Plugin did not respond",
		Timestamp: "20260601T120000Z",
	}
	if err := AppendMockGap(dir, gap3); err != nil {
		t.Fatalf("aws append: %v", err)
	}
	body4, _ := os.ReadFile(filepath.Join(dir, "mock-gaps.md"))
	if !strings.Contains(string(body4), "## aws") {
		t.Errorf("aws section missing")
	}
}
