package main

import (
	"encoding/json"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"
)

// loadFixture loads a testdata/<name>.json fixture along with the
// accompanying .meta.json that records the original ProcessType. Missing
// fixtures call t.Skip so CI stays green when the operator has not dumped
// the full 19-flow corpus.
func loadFixture(t *testing.T, name string) *FlowRecord {
	t.Helper()
	path := filepath.Join("testdata", name+".json")
	b, err := os.ReadFile(path)
	if err != nil {
		t.Skipf("fixture %s not available: %v (dump with scripts/migrate-workflow-flows/testdata dump — see README)", path, err)
	}
	var parsed map[string]any
	if err := json.Unmarshal(b, &parsed); err != nil {
		t.Fatalf("fixture %s malformed: %v", path, err)
	}

	metaPath := filepath.Join("testdata", name+".meta.json")
	metaB, err := os.ReadFile(metaPath)
	if err != nil {
		t.Skipf("fixture meta %s not available: %v", metaPath, err)
	}
	var meta struct {
		DeveloperName string `json:"DeveloperName"`
		ProcessType   string `json:"ProcessType"`
	}
	if err := json.Unmarshal(metaB, &meta); err != nil {
		t.Fatalf("fixture meta %s malformed: %v", metaPath, err)
	}

	return &FlowRecord{
		DeveloperName: meta.DeveloperName,
		ProcessType:   meta.ProcessType,
		Metadata:      parsed,
		MetadataRaw:   string(b),
	}
}

// TestRewriteTokensInString covers the token-rewriter in isolation — this
// is the part with the most edge cases (nested field chains, __r links,
// embedded in formula expressions).
func TestRewriteTokensInString(t *testing.T) {
	cases := []struct {
		name     string
		in       string
		want     string
		wantHits int
	}{
		{"bare current", "myVariable_current", "$Record", 1},
		{"bare old", "myVariable_old", "$Record__Old", 1},
		{"field chain", "myVariable_current.Amount__c", "$Record.Amount__c", 1},
		{"nested chain", "myVariable_current.Order__r.Start_Date__c", "$Record.Order__r.Start_Date__c", 1},
		{"old chain", "myVariable_old.Status__c", "$Record__Old.Status__c", 1},
		{"in formula", "IF(myVariable_current.Amount__c > 0, 'Y', 'N')", "IF($Record.Amount__c > 0, 'Y', 'N')", 1},
		{"both in one", "myVariable_current.A != myVariable_old.A", "$Record.A != $Record__Old.A", 2},
		{"no match",
			"{!Record.Foo}", "{!Record.Foo}", 0},
		{"word-boundary guard",
			"notmyVariable_current.X", // not preceded by letter/digit/_ boundary — but this IS preceded by `t`, so must NOT match
			"notmyVariable_current.X", 0},
		{"preceded by space",
			"x=myVariable_current.X",
			"x=$Record.X", 1},
		{"trailing identifier char — not a real token",
			"myVariable_currentX",
			"myVariable_currentX", 0},
		{"trailing underscore — not a real token",
			"myVariable_current_extra",
			"myVariable_current_extra", 0},
		{"trailing digit — not a real token",
			"myVariable_current1",
			"myVariable_current1", 0},
		{"real token followed by identifier-like string after boundary",
			"myVariable_current, myVariable_currentX",
			"$Record, myVariable_currentX", 1},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, n := rewriteTokensInString(tc.in)
			if got != tc.want {
				t.Errorf("rewrite: got %q, want %q", got, tc.want)
			}
			if n != tc.wantHits {
				t.Errorf("hits: got %d, want %d", n, tc.wantHits)
			}
		})
	}
}

// TestMapTriggerType guards the legacy -> native trigger mapping. If any
// of these mappings change we want the test to fail loudly. The ok
// return flags unknown tokens so callers fail the migration instead of
// emitting an empty trigger spec.
func TestMapTriggerType(t *testing.T) {
	cases := []struct {
		sf, wantTrig, wantRec string
		wantOK                bool
	}{
		{"onCreateOnly", "RecordAfterSave", "Create", true},
		{"onCreateOrTriggeringUpdate", "RecordAfterSave", "CreateAndUpdate", true},
		{"onAllChanges", "RecordAfterSave", "CreateAndUpdate", true},
		{"mystery", "", "", false},
		{"", "", "", false},
	}
	for _, tc := range cases {
		t.Run(tc.sf, func(t *testing.T) {
			trig, rec, ok := mapTriggerType(tc.sf)
			if trig != tc.wantTrig || rec != tc.wantRec || ok != tc.wantOK {
				t.Errorf("map %q: got (%q, %q, %v), want (%q, %q, %v)",
					tc.sf, trig, rec, ok, tc.wantTrig, tc.wantRec, tc.wantOK)
			}
		})
	}
}

// TestMigrate_RejectsMalformedPMV ensures unknown / missing trigger
// metadata fails the migration instead of producing an inert flow.
func TestMigrate_RejectsMalformedPMV(t *testing.T) {
	cases := []struct {
		name     string
		metadata map[string]any
		wantErr  string
	}{
		{
			"missing ObjectType",
			map[string]any{
				"processType": "Workflow",
				"processMetadataValues": []any{
					map[string]any{"name": "TriggerType", "value": map[string]any{"stringValue": "onCreateOnly"}},
				},
			},
			"ObjectType",
		},
		{
			"unknown TriggerType",
			map[string]any{
				"processType": "Workflow",
				"processMetadataValues": []any{
					map[string]any{"name": "ObjectType", "value": map[string]any{"stringValue": "Order"}},
					map[string]any{"name": "TriggerType", "value": map[string]any{"stringValue": "onSpaceRayHit"}},
				},
			},
			"TriggerType",
		},
		{
			"missing TriggerType",
			map[string]any{
				"processType": "Workflow",
				"processMetadataValues": []any{
					map[string]any{"name": "ObjectType", "value": map[string]any{"stringValue": "Order"}},
				},
			},
			"TriggerType",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			rec := &FlowRecord{
				DeveloperName: "test_flow",
				ProcessType:   "Workflow",
				Metadata:      tc.metadata,
			}
			_, err := Migrate(rec)
			if err == nil {
				t.Fatalf("expected error containing %q, got nil", tc.wantErr)
			}
			if !strings.Contains(err.Error(), tc.wantErr) {
				t.Errorf("error %q does not mention %q", err.Error(), tc.wantErr)
			}
		})
	}
}

// (TestIsAlreadyMigrated removed — idempotency is now decided inside
// Migrate by DeepEqual-comparing the fully migrated metadata against
// the input, not by a heuristic presence check. See
// TestMigrate_Idempotent for the behavioural equivalent.)

// TestMigrate_Roundtrip guarantees migrate output roundtrips through
// json.Marshal → json.Unmarshal → DeepEqual for every fixture we have.
// This is the "structural equivalence" check called out in the task.
func TestMigrate_Roundtrip(t *testing.T) {
	for _, name := range keepFlows {
		t.Run(name, func(t *testing.T) {
			rec := loadFixture(t, name)
			result, err := Migrate(rec)
			if err != nil {
				t.Fatalf("migrate %s: %v", name, err)
			}
			var roundtrip map[string]any
			if err := json.Unmarshal(result.MigratedCompact, &roundtrip); err != nil {
				t.Fatalf("unmarshal migrated: %v", err)
			}
			// The pretty and compact forms must parse to the same structure.
			var pretty map[string]any
			if err := json.Unmarshal(result.MigratedPretty, &pretty); err != nil {
				t.Fatalf("unmarshal pretty: %v", err)
			}
			if !reflect.DeepEqual(roundtrip, pretty) {
				t.Errorf("pretty / compact mismatch for %s", name)
			}
		})
	}
}

// TestMigrate_RewritesEliminateLegacyTokens verifies that after migration
// no `myVariable_current` or `myVariable_old` tokens remain in string
// leaves OUTSIDE the processMetadataValues block, while `$Record` / or
// callable-only marker appears somewhere.
func TestMigrate_RewritesEliminateLegacyTokens(t *testing.T) {
	for _, name := range keepFlows {
		t.Run(name, func(t *testing.T) {
			rec := loadFixture(t, name)
			result, err := Migrate(rec)
			if err != nil {
				t.Fatalf("migrate %s: %v", name, err)
			}
			var mig map[string]any
			if err := json.Unmarshal(result.MigratedCompact, &mig); err != nil {
				t.Fatalf("unmarshal migrated: %v", err)
			}
			// Strip processMetadataValues (provenance block, intentionally unchanged).
			delete(mig, "processMetadataValues")
			b, err := json.Marshal(mig)
			if err != nil {
				t.Fatalf("re-marshal: %v", err)
			}
			s := string(b)
			if strings.Contains(s, "myVariable_current") {
				t.Errorf("%s: myVariable_current token still present outside processMetadataValues", name)
			}
			if strings.Contains(s, "myVariable_old") {
				t.Errorf("%s: myVariable_old token still present outside processMetadataValues", name)
			}
			// Flows with RecursiveCountVariable may still reference that
			// name, which is fine — it's not a my* token.
		})
	}
}

// TestMigrate_CallableOnlyHasNoStart covers the InvocableProcess special
// case: the 5 Order_Expiration_Alert_* flows must emerge as
// AutoLaunchedFlow with NO `start` key in the migrated metadata.
func TestMigrate_CallableOnlyHasNoStart(t *testing.T) {
	callees := []string{
		"Order_Expiration_Alert_7",
		"Order_Expiration_Alert_15",
		"Order_Expiration_Alert_30",
		"Order_Expiration_Alert_60",
		"Order_Expiration_Alert_90",
	}
	for _, name := range callees {
		t.Run(name, func(t *testing.T) {
			rec := loadFixture(t, name)
			if rec.ProcessType != "InvocableProcess" {
				t.Fatalf("%s fixture ProcessType=%q, expected InvocableProcess", name, rec.ProcessType)
			}
			result, err := Migrate(rec)
			if err != nil {
				t.Fatalf("migrate %s: %v", name, err)
			}
			if result.NewProcessType != "AutoLaunchedFlow" {
				t.Errorf("%s: NewProcessType=%q, want AutoLaunchedFlow", name, result.NewProcessType)
			}
			var mig map[string]any
			if err := json.Unmarshal(result.MigratedCompact, &mig); err != nil {
				t.Fatalf("unmarshal: %v", err)
			}
			if _, has := mig["start"]; has {
				t.Errorf("%s: callable-only flow must not have `start`, found one: %#v", name, mig["start"])
			}
			// FlowDefinition updates for callable-only flows must NOT bind
			// a trigger object or type.
			if result.NewTriggerType != "" {
				t.Errorf("%s: NewTriggerType=%q, want empty (callable-only)", name, result.NewTriggerType)
			}
			if result.NewTriggerObject != "" {
				t.Errorf("%s: NewTriggerObject=%q, want empty", name, result.NewTriggerObject)
			}
		})
	}
}

// TestMigrate_ScheduledSpecialCase covers Order_Expiration_Notice_90: it
// must emerge as AutoLaunchedFlow + start.triggerType=Scheduled per the
// design doc Wave 3 activation plan.
func TestMigrate_ScheduledSpecialCase(t *testing.T) {
	rec := loadFixture(t, "Order_Expiration_Notice_90")
	result, err := Migrate(rec)
	if err != nil {
		t.Fatalf("migrate: %v", err)
	}
	if result.NewProcessType != "AutoLaunchedFlow" {
		t.Errorf("NewProcessType=%q, want AutoLaunchedFlow", result.NewProcessType)
	}
	if result.NewTriggerType != "Scheduled" {
		t.Errorf("NewTriggerType=%q, want Scheduled", result.NewTriggerType)
	}
	var mig map[string]any
	if err := json.Unmarshal(result.MigratedCompact, &mig); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	start, ok := mig["start"].(map[string]any)
	if !ok {
		t.Fatalf("no start element in migrated Order_Expiration_Notice_90")
	}
	if start["triggerType"] != "Scheduled" {
		t.Errorf("start.triggerType=%v, want Scheduled", start["triggerType"])
	}
	if start["object"] != "Invoice_Collection__c" {
		t.Errorf("start.object=%v, want Invoice_Collection__c", start["object"])
	}
}

// TestMigrate_RecordTriggered covers the default case — 17 flows end up
// with start.triggerType=RecordAfterSave, a non-empty object, and a
// recordTriggerType.
func TestMigrate_RecordTriggered(t *testing.T) {
	recordTriggered := []string{
		"Contract_PingCAP_Contracting_Entity",
		"Order_PingCAP_RevRec_Entity",
		"Order_Account_Name",
		"Order_Partner_Name",
		"Order_Start_Date_End_Date",
		"Collections_Region_PingCAP_Contracting_Entity_Marketplace",
		"Collections",
		"Collections_Cash_Collected",
		"Collections_Invoice_Status_marketplace",
		"Invoice_Notice",
		"Collections_Account_ID",
		"Collection_Partner_Name",
		"Account_PingCAP_Contracting_Entity",
		// 4 active Workflow flows added by issue #1817 — all RecordAfterSave.
		"Account_PoC_Status",
		"Partner_Account_CM_Approval",
		"Quote_Account_Opportunity_field",
		"RevRec_Account",
	}
	for _, name := range recordTriggered {
		t.Run(name, func(t *testing.T) {
			rec := loadFixture(t, name)
			result, err := Migrate(rec)
			if err != nil {
				t.Fatalf("migrate: %v", err)
			}
			if result.NewProcessType != "AutoLaunchedFlow" {
				t.Errorf("NewProcessType=%q, want AutoLaunchedFlow", result.NewProcessType)
			}
			if result.NewTriggerType != "RecordAfterSave" {
				t.Errorf("NewTriggerType=%q, want RecordAfterSave", result.NewTriggerType)
			}
			if result.NewTriggerObject == "" {
				t.Error("NewTriggerObject empty (expected non-empty ObjectType from pmv)")
			}
			var mig map[string]any
			if err := json.Unmarshal(result.MigratedCompact, &mig); err != nil {
				t.Fatalf("unmarshal: %v", err)
			}
			start, ok := mig["start"].(map[string]any)
			if !ok {
				t.Fatalf("no start in migrated")
			}
			if start["triggerType"] != "RecordAfterSave" {
				t.Errorf("start.triggerType=%v", start["triggerType"])
			}
			if start["object"] != result.NewTriggerObject {
				t.Errorf("start.object %v != NewTriggerObject %q", start["object"], result.NewTriggerObject)
			}
			rtt, _ := start["recordTriggerType"].(string)
			if rtt != "Create" && rtt != "CreateAndUpdate" {
				t.Errorf("start.recordTriggerType=%q, want Create or CreateAndUpdate", rtt)
			}
		})
	}
}

// TestMigrate_Idempotent runs Migrate twice on a fixture; the second run
// must detect AlreadyMigrated and produce byte-identical output.
func TestMigrate_Idempotent(t *testing.T) {
	rec := loadFixture(t, "Order_Account_Name")
	first, err := Migrate(rec)
	if err != nil {
		t.Fatalf("first migrate: %v", err)
	}
	// Feed the migrated compact back as the input to Migrate — simulating
	// a re-run against a flow already in the migrated state.
	var parsed map[string]any
	if err := json.Unmarshal(first.MigratedCompact, &parsed); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	rec2 := &FlowRecord{
		DeveloperName: rec.DeveloperName,
		ProcessType:   first.NewProcessType,
		Metadata:      parsed,
		MetadataRaw:   string(first.MigratedCompact),
	}
	second, err := Migrate(rec2)
	if err != nil {
		t.Fatalf("second migrate: %v", err)
	}
	if !second.AlreadyMigrated {
		t.Error("second run did not detect AlreadyMigrated")
	}
}

// TestParseProcessMetadataValues spot-checks the typed-value extractor
// against the documented legacy shape.
func TestParseProcessMetadataValues(t *testing.T) {
	md := map[string]any{
		"processMetadataValues": []any{
			map[string]any{
				"name": "ObjectType",
				"value": map[string]any{
					"stringValue":      "Order",
					"elementReference": nil,
				},
			},
			map[string]any{
				"name": "ObjectVariable",
				"value": map[string]any{
					"stringValue":      nil,
					"elementReference": "myVariable_current",
				},
			},
			map[string]any{
				"name": "TriggerType",
				"value": map[string]any{
					"stringValue": "onCreateOnly",
				},
			},
		},
	}
	got := parseProcessMetadataValues(md)
	if got.ObjectType != "Order" || got.ObjectVariable != "myVariable_current" || got.TriggerType != "onCreateOnly" {
		t.Errorf("parsed: %+v", got)
	}
}

// TestCheckTestDBGate verifies the allow-list.
func TestCheckTestDBGate(t *testing.T) {
	old := os.Getenv("TIDB_USER")
	t.Cleanup(func() { os.Setenv("TIDB_USER", old) })

	cases := []struct {
		user    string
		allow   bool
		wantErr bool
	}{
		{testDBUserPrefix + ".root", false, false},
		{testDBUserPrefix + ".root", true, false},
		{"2JuXyXHYxHrLAUr.root", false, true}, // staging — must refuse
		{"2JuXyXHYxHrLAUr.root", true, false}, // escape hatch
		{"", false, true},
	}
	for _, tc := range cases {
		t.Run(tc.user+"-allow-"+boolStr(tc.allow), func(t *testing.T) {
			os.Setenv("TIDB_USER", tc.user)
			err := checkTestDBGate(tc.allow)
			if (err != nil) != tc.wantErr {
				t.Errorf("user=%q allow=%v: err=%v, wantErr=%v", tc.user, tc.allow, err, tc.wantErr)
			}
		})
	}
}

func boolStr(b bool) string {
	if b {
		return "true"
	}
	return "false"
}
