package cmd

import (
	"encoding/json"
	"errors"
	"fmt"
	"sort"
	"strings"
	"testing"
	"time"
)

func TestDiagnoseV2FixtureSuite(t *testing.T) {
	fixtureNow := time.Date(2026, time.April, 22, 9, 30, 0, 0, time.UTC)

	baseOptions := func() diagnoseOptions {
		return diagnoseOptions{
			Seed:      "Request range exceeds bound",
			ClusterID: "cluster-42",
			Start:     "1712123000",
			End:       "1712123600",
			Limit:     100,
			NoExpand:  true,
			Output:    "json",
		}
	}

	testCases := []struct {
		name                  string
		opts                  diagnoseOptions
		responses             []clinicResponse
		wantSeedMatches       int
		wantRelatedLogs       int
		wantEvents            int
		wantGroupKinds        []string
		wantWarningSignatures []string
		assert                func(t *testing.T, bundle map[string]any)
	}{
		{
			name: "no seed matches",
			opts: baseOptions(),
			responses: diagnoseFixtureResponses(
				clinicResponse{body: diagnoseFixtureLokiResponse(t)},
				clinicResponse{body: diagnoseFixtureDefaultTopology()},
				clinicResponse{body: diagnoseFixtureEventsResponse()},
			),
			wantSeedMatches:       0,
			wantRelatedLogs:       0,
			wantEvents:            0,
			wantGroupKinds:        []string{},
			wantWarningSignatures: []string{},
		},
		{
			name: "one seed match with no anchors",
			opts: baseOptions(),
			responses: diagnoseFixtureResponses(
				clinicResponse{body: diagnoseFixtureLokiResponse(t, diagnoseFixtureStream{
					Labels: map[string]string{"component": "tidb"},
					Values: [][]string{{"1712123000000000000", "plain unstructured seed match"}},
				})},
				clinicResponse{body: diagnoseFixtureDefaultTopology()},
				clinicResponse{body: diagnoseFixtureEventsResponse()},
			),
			wantSeedMatches: 1,
			wantRelatedLogs: 0,
			wantEvents:      0,
			wantGroupKinds:  []string{"unknown_scope"},
			wantWarningSignatures: []string{
				"expansion_skipped:related_logs",
				"parse_failure:log-001",
			},
		},
		{
			name: "one seed match with conn and same-connection related logs",
			opts: diagnoseOptions{
				Seed:      "Request range exceeds bound",
				ClusterID: "cluster-42",
				Start:     "1712123000",
				End:       "1712123600",
				Limit:     100,
				Output:    "json",
			},
			responses: diagnoseFixtureResponses(
				clinicResponse{body: diagnoseFixtureLokiResponse(t, diagnoseFixtureStream{
					Labels: map[string]string{
						"component": "tidb",
						"namespace": "tidbcluster-42",
					},
					Values: [][]string{{"1712123000000000000", "[conn=281072972] [keyspaceName=cluster-42] Request range exceeds bound"}},
				})},
				clinicResponse{body: diagnoseFixtureDefaultTopology()},
				clinicResponse{body: diagnoseFixtureEventsResponse()},
				clinicResponse{body: diagnoseFixtureLokiResponse(t, diagnoseFixtureStream{
					Labels: map[string]string{
						"component": "tidb",
						"namespace": "tidbcluster-42",
					},
					Values: [][]string{{"1712123010000000000", "[conn=281072972] same connection follow-up"}},
				})},
				clinicResponse{body: diagnoseFixtureLokiResponse(t)},
			),
			wantSeedMatches:       1,
			wantRelatedLogs:       1,
			wantEvents:            0,
			wantGroupKinds:        []string{"active_profile_keyspace"},
			wantWarningSignatures: []string{},
			assert: func(t *testing.T, bundle map[string]any) {
				entry := mapValueFromAny(t, sliceValue(t, bundle, "related_logs")[0], "related_logs[0]")
				relations := sliceValue(t, entry, "relations")
				relation := mapValueFromAny(t, relations[0], "related_logs[0].relations[0]")
				if got, want := stringValue(t, relation, "type"), "same_connection"; got != want {
					t.Fatalf("relation.type = %q, want %q", got, want)
				}
			},
		},
		{
			name: "multiple seed matches in active keyspace",
			opts: baseOptions(),
			responses: diagnoseFixtureResponses(
				clinicResponse{body: diagnoseFixtureLokiResponse(t, diagnoseFixtureStream{
					Labels: map[string]string{"component": "tidb", "namespace": "tidbcluster-42"},
					Values: [][]string{
						{"1712123000000000000", "[keyspaceName=cluster-42] seed one"},
						{"1712123001000000000", "[keyspaceName=cluster-42] seed two"},
					},
				})},
				clinicResponse{body: diagnoseFixtureDefaultTopology()},
				clinicResponse{body: diagnoseFixtureEventsResponse()},
			),
			wantSeedMatches: 2,
			wantRelatedLogs: 0,
			wantEvents:      0,
			wantGroupKinds:  []string{"active_profile_keyspace"},
			wantWarningSignatures: []string{
				"expansion_skipped:related_logs",
			},
		},
		{
			name: "seed matches in active keyspace and another keyspace",
			opts: baseOptions(),
			responses: diagnoseFixtureResponses(
				clinicResponse{body: diagnoseFixtureLokiResponse(t, diagnoseFixtureStream{
					Labels: map[string]string{"component": "tidb", "namespace": "tidbcluster-42"},
					Values: [][]string{
						{"1712123000000000000", "[keyspaceName=cluster-42] active keyspace seed"},
						{"1712123001000000000", "[keyspaceName=tenant-b] other keyspace seed"},
					},
				})},
				clinicResponse{body: diagnoseFixtureDefaultTopology()},
				clinicResponse{body: diagnoseFixtureEventsResponse()},
			),
			wantSeedMatches: 2,
			wantRelatedLogs: 0,
			wantEvents:      0,
			wantGroupKinds:  []string{"active_profile_keyspace", "other_keyspace"},
			wantWarningSignatures: []string{
				"expansion_skipped:related_logs",
				"multi_keyspace:seed_matches",
			},
		},
		{
			name: "shared component logs with cluster_id=unknown",
			opts: baseOptions(),
			responses: diagnoseFixtureResponses(
				clinicResponse{body: diagnoseFixtureLokiResponse(t, diagnoseFixtureStream{
					Labels: map[string]string{
						"component":  "pd",
						"cluster_id": "unknown",
						"namespace":  "tidbcluster-42",
					},
					Values: [][]string{{"1712123000000000000", "[detail=shared] Request range exceeds bound"}},
				})},
				clinicResponse{body: diagnoseFixtureDefaultTopology()},
				clinicResponse{body: diagnoseFixtureEventsResponse()},
			),
			wantSeedMatches: 1,
			wantRelatedLogs: 0,
			wantEvents:      0,
			wantGroupKinds:  []string{"shared_component"},
			wantWarningSignatures: []string{
				"expansion_skipped:related_logs",
			},
		},
		{
			name: "query hits result limit",
			opts: func() diagnoseOptions {
				opts := baseOptions()
				opts.Limit = 2
				return opts
			}(),
			responses: diagnoseFixtureResponses(
				clinicResponse{body: diagnoseFixtureLokiResponse(t, diagnoseFixtureStream{
					Labels: map[string]string{"component": "tidb", "namespace": "tidbcluster-42"},
					Values: [][]string{
						{"1712123000000000000", "[keyspaceName=cluster-42] seed one"},
						{"1712123001000000000", "[keyspaceName=cluster-42] seed two"},
					},
				})},
				clinicResponse{body: diagnoseFixtureDefaultTopology()},
				clinicResponse{body: diagnoseFixtureEventsResponse()},
			),
			wantSeedMatches: 2,
			wantRelatedLogs: 0,
			wantEvents:      0,
			wantGroupKinds:  []string{"active_profile_keyspace"},
			wantWarningSignatures: []string{
				"expansion_skipped:related_logs",
			},
			assert: func(t *testing.T, bundle map[string]any) {
				record := mapValueFromAny(t, sliceValue(t, bundle, "query_plan")[0], "query_plan[0]")
				if got, want := boolValue(t, record, "hit_limit"), true; got != want {
					t.Fatalf("query_plan[0].hit_limit = %t, want %t", got, want)
				}
				summaries := mapValue(t, bundle, "summaries")
				queryLimits := mapValue(t, summaries, "query_limits")
				if got, want := boolValue(t, queryLimits, "any_query_hit_limit"), true; got != want {
					t.Fatalf("summaries.query_limits.any_query_hit_limit = %t, want %t", got, want)
				}
				if got, want := strings.Join(stringSliceValue(t, queryLimits, "hit_limit_query_ids"), ","), "q1"; got != want {
					t.Fatalf("summaries.query_limits.hit_limit_query_ids = %q, want %q", got, want)
				}
			},
		},
		{
			name: "redacted fields in logs",
			opts: baseOptions(),
			responses: diagnoseFixtureResponses(
				clinicResponse{body: diagnoseFixtureLokiResponse(t, diagnoseFixtureStream{
					Labels: map[string]string{"component": "tidb", "namespace": "tidbcluster-42"},
					Values: [][]string{
						{"1712123000000000000", `[keyspaceName=cluster-42] [regionID=6500] ranges=[?, ?) values="[?,?]" Request range exceeds bound ?`},
					},
				})},
				clinicResponse{body: diagnoseFixtureDefaultTopology()},
				clinicResponse{body: diagnoseFixtureEventsResponse()},
			),
			wantSeedMatches: 1,
			wantRelatedLogs: 0,
			wantEvents:      0,
			wantGroupKinds:  []string{"active_profile_keyspace"},
			wantWarningSignatures: []string{
				"expansion_skipped:related_logs",
				"redaction:log-001",
			},
		},
		{
			name: "topology query failure with partial output",
			opts: baseOptions(),
			responses: diagnoseFixtureResponses(
				clinicResponse{body: diagnoseFixtureLokiResponse(t, diagnoseFixtureStream{
					Labels: map[string]string{"component": "tidb", "namespace": "tidbcluster-42"},
					Values: [][]string{
						{"1712123000000000000", "[keyspaceName=cluster-42] seed one"},
					},
				})},
				clinicResponse{err: errors.New("topology unavailable")},
				clinicResponse{body: diagnoseFixtureEventsResponse(map[string]any{
					"id":        "evt-1",
					"createdAt": "2024-04-03T05:44:00Z",
					"message":   "scale out",
				})},
			),
			wantSeedMatches: 1,
			wantRelatedLogs: 0,
			wantEvents:      1,
			wantGroupKinds:  []string{"active_profile_keyspace"},
			wantWarningSignatures: []string{
				"expansion_skipped:related_logs",
				"missing_source:topology",
			},
			assert: func(t *testing.T, bundle map[string]any) {
				topology := mapValue(t, bundle, "topology")
				if got, want := len(topology), 0; got != want {
					t.Fatalf("topology field count = %d, want %d: %s", got, want, mustMarshalForTest(t, topology))
				}
			},
		},
		{
			name: "events after seed matches, timing classification only",
			opts: baseOptions(),
			responses: diagnoseFixtureResponses(
				clinicResponse{body: diagnoseFixtureLokiResponse(t, diagnoseFixtureStream{
					Labels: map[string]string{"component": "tidb", "namespace": "tidbcluster-42"},
					Values: [][]string{
						{"1712123000000000000", "[keyspaceName=cluster-42] seed one"},
					},
				})},
				clinicResponse{body: diagnoseFixtureDefaultTopology()},
				clinicResponse{body: diagnoseFixtureEventsResponse(map[string]any{
					"id":        "evt-after",
					"createdAt": "2024-04-03T05:45:00Z",
					"message":   "scale out",
				})},
			),
			wantSeedMatches: 1,
			wantRelatedLogs: 0,
			wantEvents:      1,
			wantGroupKinds:  []string{"active_profile_keyspace"},
			wantWarningSignatures: []string{
				"expansion_skipped:related_logs",
			},
			assert: func(t *testing.T, bundle map[string]any) {
				timelines := sliceValue(t, bundle, "timelines")
				if got, want := len(timelines), 1; got != want {
					t.Fatalf("timeline count = %d, want %d", got, want)
				}
				timeline := mapValueFromAny(t, timelines[0], "timelines[0]")
				entries := sliceValue(t, timeline, "entries")
				var sawSeed, sawEvent bool
				for i, item := range entries {
					entry := mapValueFromAny(t, item, fmt.Sprintf("timelines[0].entries[%d]", i))
					switch stringValue(t, entry, "source_kind") {
					case "log":
						if got, want := stringValue(t, entry, "relative_to_seed_window"), "during"; got != want {
							t.Fatalf("seed relative_to_seed_window = %q, want %q", got, want)
						}
						sawSeed = true
					case "event":
						if got, want := stringValue(t, entry, "relative_to_seed_window"), "after"; got != want {
							t.Fatalf("event relative_to_seed_window = %q, want %q", got, want)
						}
						sawEvent = true
					}
				}
				if !sawSeed || !sawEvent {
					t.Fatalf("timeline entries missing seed/event entries: %s", mustMarshalForTest(t, entries))
				}
			},
		},
	}

	for _, tc := range testCases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			client := &recordingClinicClient{responses: tc.responses}
			ctx := newDiagnoseTestRuntimeContext(client)

			bundle := runDiagnoseBundleJSONForTest(t, ctx, tc.opts, fixtureNow)
			assertDiagnoseFixtureTopLevelSchema(t, bundle, fixtureNow)
			assertDiagnoseFixtureQueryPlanFields(t, bundle)
			assertDiagnoseFixtureReferenceQueryIDs(t, bundle)
			assertDiagnoseFixtureRawLogLines(t, bundle)
			assertDiagnoseFixtureNoRootCauseFields(t, bundle)

			if got, want := len(sliceValue(t, bundle, "seed_matches")), tc.wantSeedMatches; got != want {
				t.Fatalf("seed_matches length = %d, want %d", got, want)
			}
			if got, want := len(sliceValue(t, bundle, "related_logs")), tc.wantRelatedLogs; got != want {
				t.Fatalf("related_logs length = %d, want %d", got, want)
			}
			if got, want := len(sliceValue(t, bundle, "events")), tc.wantEvents; got != want {
				t.Fatalf("events length = %d, want %d", got, want)
			}

			assertDiagnoseFixtureGroupKinds(t, bundle, tc.wantGroupKinds)
			assertDiagnoseFixtureWarnings(t, bundle, tc.wantWarningSignatures)

			if tc.assert != nil {
				tc.assert(t, bundle)
			}
		})
	}
}

type diagnoseFixtureStream struct {
	Labels map[string]string
	Values [][]string
}

func diagnoseFixtureLokiResponse(t *testing.T, streams ...diagnoseFixtureStream) []byte {
	t.Helper()

	type lokiStreamResult struct {
		Stream map[string]string `json:"stream"`
		Values [][]string        `json:"values"`
	}
	payload := struct {
		Status string `json:"status"`
		Data   struct {
			ResultType string             `json:"resultType"`
			Result     []lokiStreamResult `json:"result"`
		} `json:"data"`
	}{
		Status: "success",
	}
	payload.Data.ResultType = "streams"
	payload.Data.Result = make([]lokiStreamResult, 0, len(streams))
	for _, stream := range streams {
		payload.Data.Result = append(payload.Data.Result, lokiStreamResult{
			Stream: stream.Labels,
			Values: stream.Values,
		})
	}

	raw, err := json.Marshal(payload)
	if err != nil {
		t.Fatalf("json.Marshal(loki fixture response) error = %v", err)
	}
	return raw
}

func diagnoseFixtureDefaultTopology() []byte {
	return []byte(`{"components":{"tidb":[{"host":"tidb-0"}]}}`)
}

func diagnoseFixtureEventsResponse(items ...map[string]any) []byte {
	if len(items) == 0 {
		return []byte(`{"items":[]}`)
	}

	raw, err := json.Marshal(map[string]any{"items": items})
	if err != nil {
		panic(err)
	}
	return raw
}

func diagnoseFixtureResponses(seed, topology, events clinicResponse, related ...clinicResponse) []clinicResponse {
	out := []clinicResponse{seed, topology, events}
	out = append(out, related...)
	return out
}

func assertDiagnoseFixtureTopLevelSchema(t *testing.T, bundle map[string]any, now time.Time) {
	t.Helper()

	if got, want := stringValue(t, bundle, "schema_version"), "diagnose.v2"; got != want {
		t.Fatalf("schema_version = %q, want %q", got, want)
	}
	if got, want := stringValue(t, bundle, "generated_at_utc"), now.UTC().Format(time.RFC3339); got != want {
		t.Fatalf("generated_at_utc = %q, want %q", got, want)
	}
	if _, ok := bundle["incidents"]; ok {
		t.Fatalf("incidents key exists in diagnose.v2 bundle: %s", mustMarshalForTest(t, bundle))
	}

	requiredKeys := []string{
		"schema_version",
		"generated_at_utc",
		"scope",
		"query_plan",
		"seed_matches",
		"related_logs",
		"events",
		"topology",
		"groups",
		"timelines",
		"summaries",
		"warnings",
	}
	for _, key := range requiredKeys {
		if _, ok := bundle[key]; !ok {
			t.Fatalf("missing top-level key %q in bundle %s", key, mustMarshalForTest(t, bundle))
		}
	}
	if got, want := len(bundle), len(requiredKeys); got != want {
		t.Fatalf("top-level key count = %d, want %d; keys=%s", got, want, mustMarshalForTest(t, bundle))
	}
}

func assertDiagnoseFixtureQueryPlanFields(t *testing.T, bundle map[string]any) {
	t.Helper()

	plan := sliceValue(t, bundle, "query_plan")
	if len(plan) == 0 {
		t.Fatalf("query_plan is empty: %s", mustMarshalForTest(t, bundle))
	}

	for index, item := range plan {
		record := mapValueFromAny(t, item, fmt.Sprintf("query_plan[%d]", index))
		if got, want := stringValue(t, record, "id"), fmt.Sprintf("q%d", index+1); got != want {
			t.Fatalf("query_plan[%d].id = %q, want %q", index, got, want)
		}

		typ := stringValue(t, record, "type")
		switch typ {
		case "loki":
			if got, want := stringValue(t, record, "direction"), "backward"; got != want {
				t.Fatalf("query_plan[%d].direction = %q, want %q", index, got, want)
			}
		case "clinic_api":
			if got, want := stringValue(t, record, "direction"), ""; got != want {
				t.Fatalf("query_plan[%d].direction = %q, want %q", index, got, want)
			}
		default:
			t.Fatalf("query_plan[%d].type = %q, want loki or clinic_api", index, typ)
		}
		if got := strings.TrimSpace(stringValue(t, record, "purpose")); got == "" {
			t.Fatalf("query_plan[%d].purpose is empty", index)
		}
		if got := strings.TrimSpace(stringValue(t, record, "query")); got == "" {
			t.Fatalf("query_plan[%d].query is empty", index)
		}

		startRaw := stringValue(t, record, "start_utc")
		endRaw := stringValue(t, record, "end_utc")
		start, err := time.Parse(time.RFC3339, startRaw)
		if err != nil {
			t.Fatalf("query_plan[%d].start_utc parse error: %v (value=%q)", index, err, startRaw)
		}
		end, err := time.Parse(time.RFC3339, endRaw)
		if err != nil {
			t.Fatalf("query_plan[%d].end_utc parse error: %v (value=%q)", index, err, endRaw)
		}
		if end.Before(start) {
			t.Fatalf("query_plan[%d] end_utc before start_utc: start=%q end=%q", index, startRaw, endRaw)
		}

		if got := intValue(t, record, "limit"); got < 0 {
			t.Fatalf("query_plan[%d].limit = %d, want >= 0", index, got)
		}
		status := stringValue(t, record, "status")
		if status != "success" && status != "failed" {
			t.Fatalf("query_plan[%d].status = %q, want success or failed", index, status)
		}
		if got := intValue(t, record, "result_count"); got < 0 {
			t.Fatalf("query_plan[%d].result_count = %d, want >= 0", index, got)
		}
		if got := intValue(t, record, "duration_ms"); got < 0 {
			t.Fatalf("query_plan[%d].duration_ms = %d, want >= 0", index, got)
		}
	}
}

func assertDiagnoseFixtureReferenceQueryIDs(t *testing.T, bundle map[string]any) {
	t.Helper()

	planIDs := make(map[string]struct{})
	for i, item := range sliceValue(t, bundle, "query_plan") {
		record := mapValueFromAny(t, item, fmt.Sprintf("query_plan[%d]", i))
		planIDs[stringValue(t, record, "id")] = struct{}{}
	}

	for _, section := range []string{"seed_matches", "related_logs", "events"} {
		for i, item := range sliceValue(t, bundle, section) {
			entry := mapValueFromAny(t, item, fmt.Sprintf("%s[%d]", section, i))
			queryIDs := stringSliceValue(t, entry, "query_ids")
			if len(queryIDs) == 0 {
				t.Fatalf("%s[%d].query_ids is empty", section, i)
			}
			for _, queryID := range queryIDs {
				if _, ok := planIDs[queryID]; !ok {
					t.Fatalf("%s[%d].query_ids contains %q not present in query_plan IDs %v", section, i, queryID, planIDs)
				}
			}
		}
	}
}

func assertDiagnoseFixtureRawLogLines(t *testing.T, bundle map[string]any) {
	t.Helper()

	for _, section := range []string{"seed_matches", "related_logs"} {
		for i, item := range sliceValue(t, bundle, section) {
			entry := mapValueFromAny(t, item, fmt.Sprintf("%s[%d]", section, i))
			if got := strings.TrimSpace(stringValue(t, entry, "raw")); got == "" {
				t.Fatalf("%s[%d].raw is empty", section, i)
			}
		}
	}
}

func assertDiagnoseFixtureNoRootCauseFields(t *testing.T, bundle map[string]any) {
	t.Helper()

	forbidden := map[string]struct{}{
		"root_cause":   {},
		"likely_cause": {},
		"culprit":      {},
		"benign":       {},
		"incidents":    {},
	}
	assertDiagnoseFixtureNoForbiddenKeys(t, bundle, forbidden, "bundle")
}

func assertDiagnoseFixtureNoForbiddenKeys(t *testing.T, value any, forbidden map[string]struct{}, path string) {
	t.Helper()

	switch typed := value.(type) {
	case map[string]any:
		for key, nested := range typed {
			if _, blocked := forbidden[key]; blocked {
				t.Fatalf("forbidden key %q found at %s", key, path)
			}
			assertDiagnoseFixtureNoForbiddenKeys(t, nested, forbidden, path+"."+key)
		}
	case []any:
		for i, nested := range typed {
			assertDiagnoseFixtureNoForbiddenKeys(t, nested, forbidden, fmt.Sprintf("%s[%d]", path, i))
		}
	}
}

func assertDiagnoseFixtureGroupKinds(t *testing.T, bundle map[string]any, want []string) {
	t.Helper()

	got := make([]string, 0, len(sliceValue(t, bundle, "groups")))
	for i, item := range sliceValue(t, bundle, "groups") {
		group := mapValueFromAny(t, item, fmt.Sprintf("groups[%d]", i))
		got = append(got, stringValue(t, group, "kind"))
	}
	sort.Strings(got)

	wantCopy := append([]string(nil), want...)
	sort.Strings(wantCopy)

	if !equalStringSlices(got, wantCopy) {
		t.Fatalf("group kinds = %#v, want %#v", got, wantCopy)
	}
}

func assertDiagnoseFixtureWarnings(t *testing.T, bundle map[string]any, want []string) {
	t.Helper()

	got := make([]string, 0, len(sliceValue(t, bundle, "warnings")))
	for i, item := range sliceValue(t, bundle, "warnings") {
		warning := mapValueFromAny(t, item, fmt.Sprintf("warnings[%d]", i))
		got = append(got, stringValue(t, warning, "code")+":"+stringValue(t, warning, "source"))
	}
	sort.Strings(got)

	wantCopy := append([]string(nil), want...)
	sort.Strings(wantCopy)
	if !equalStringSlices(got, wantCopy) {
		t.Fatalf("warning signatures = %#v, want %#v", got, wantCopy)
	}
}
