package clusterrecovery

import (
	"bytes"
	"encoding/json"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
	"time"
)

func TestDecodeSeedCanonicalizesAndValidates(t *testing.T) {
	start := time.Date(2026, 7, 20, 1, 0, 0, 0, time.UTC)
	seed := Seed{
		SchemaVersion: SchemaVersion,
		Environment:   StagingEnvironment,
		IncidentStart: start,
		IncidentEnd:   start.Add(2 * time.Hour),
		Candidates: []SeedCandidate{
			{
				ClusterID:      "10914645658854034992",
				TenantID:       10392,
				ClusterType:    "tidbx_instance",
				Evidence:       EvidenceClusterEventDLQ,
				EventCreatedAt: start.Add(time.Hour),
			},
		},
	}
	payload, err := json.Marshal(seed)
	if err != nil {
		t.Fatal(err)
	}
	decoded, err := DecodeSeed(bytes.NewReader(payload))
	if err != nil {
		t.Fatalf("DecodeSeed() error = %v", err)
	}
	if got := decoded.Candidates[0].ClusterID; got != seed.Candidates[0].ClusterID {
		t.Fatalf("cluster id = %s", got)
	}
}

func TestDecodeSeedRejectsUnknownAndOversizedInput(t *testing.T) {
	unknown := `{"schema_version":"clinic-staging-recovery/v1","environment":"staging","incident_start":"2026-07-20T01:00:00Z","incident_end":"2026-07-20T02:00:00Z","candidates":[],"unexpected":true}`
	if _, err := DecodeSeed(strings.NewReader(unknown)); err == nil || !strings.Contains(err.Error(), "unknown field") {
		t.Fatalf("expected unknown-field error, got %v", err)
	}
	if _, err := DecodeSeed(strings.NewReader(strings.Repeat("x", int(MaxSeedBytes)+1))); err == nil || !strings.Contains(err.Error(), "exceeds") {
		t.Fatalf("expected size error, got %v", err)
	}
}

func TestValidateSeedRejectsOutOfWindowCandidate(t *testing.T) {
	start := time.Date(2026, 7, 20, 1, 0, 0, 0, time.UTC)
	seed := &Seed{
		SchemaVersion: SchemaVersion,
		Environment:   StagingEnvironment,
		IncidentStart: start,
		IncidentEnd:   start.Add(time.Hour),
		Candidates: []SeedCandidate{{
			ClusterID:      "1",
			TenantID:       2,
			Evidence:       EvidenceImportDLQ,
			EventCreatedAt: start.Add(2 * time.Hour),
		}},
	}
	if err := ValidateSeed(seed); err == nil || !strings.Contains(err.Error(), "outside") {
		t.Fatalf("expected outside-window error, got %v", err)
	}
}

func TestValidateSeedAllowsTenantZeroOnlyForResourcePoolDLQ(t *testing.T) {
	start := time.Date(2026, 8, 3, 6, 0, 0, 0, time.UTC)
	seed := &Seed{
		SchemaVersion: SchemaVersion,
		Environment:   StagingEnvironment,
		IncidentStart: start,
		IncidentEnd:   start.Add(2 * time.Hour),
		Candidates: []SeedCandidate{{
			ClusterID:      "631176026126286850",
			TenantID:       0,
			ClusterType:    "tidbx_resource_pool",
			Evidence:       EvidenceClusterEventDLQ,
			EventCreatedAt: start.Add(time.Hour),
		}},
	}
	if err := ValidateSeed(seed); err != nil {
		t.Fatalf("shared resource-pool seed rejected: %v", err)
	}

	seed.Candidates[0].ClusterType = "tidbx_instance"
	if err := ValidateSeed(seed); err == nil || !strings.Contains(err.Error(), "resource-pool ClusterEvent DLQ") {
		t.Fatalf("expected tenant-zero instance rejection, got %v", err)
	}

	seed.Candidates[0].ClusterType = "tidbx_resource_pool"
	seed.Candidates[0].Evidence = EvidenceSourceCompare
	if err := ValidateSeed(seed); err == nil || !strings.Contains(err.Error(), "resource-pool ClusterEvent DLQ") {
		t.Fatalf("expected tenant-zero source-compare rejection, got %v", err)
	}
}

func TestValidateSeedEnforcesCandidateBound(t *testing.T) {
	start := time.Date(2026, 7, 20, 1, 0, 0, 0, time.UTC)
	seed := &Seed{
		SchemaVersion: SchemaVersion,
		Environment:   StagingEnvironment,
		IncidentStart: start,
		IncidentEnd:   start.Add(time.Hour),
		Candidates:    make([]SeedCandidate, MaxSeedCandidates),
	}
	for i := range seed.Candidates {
		seed.Candidates[i] = SeedCandidate{
			ClusterID:      strconv.Itoa(i + 1),
			TenantID:       2,
			Evidence:       EvidenceClusterEventDLQ,
			EventCreatedAt: start.Add(time.Minute),
		}
	}
	if err := ValidateSeed(seed); err != nil {
		t.Fatalf("expected %d candidates to be accepted, got %v", MaxSeedCandidates, err)
	}
	seed.Candidates = append(seed.Candidates, SeedCandidate{
		ClusterID:      strconv.Itoa(MaxSeedCandidates + 1),
		TenantID:       2,
		Evidence:       EvidenceClusterEventDLQ,
		EventCreatedAt: start.Add(time.Minute),
	})
	if err := ValidateSeed(seed); err == nil || !strings.Contains(err.Error(), "maximum") {
		t.Fatalf("expected candidate-bound error, got %v", err)
	}
}

func TestManifestHashIsDeterministicAndTamperEvident(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest := validTestManifest(now)
	manifest.Actions = append(manifest.Actions, Action{
		Kind:            ObjectKindResourcePool,
		ClusterID:       "631161640854749185",
		TenantID:        10392,
		ClusterType:     "tidbx_resource_pool",
		SourceUpdatedAt: now.Add(-time.Minute),
		DesiredChecksum: strings.Repeat("b", 64),
	})
	if err := FinalizeManifest(&manifest); err != nil {
		t.Fatalf("FinalizeManifest() error = %v", err)
	}
	first := manifest.ManifestHash
	if err := FinalizeManifest(&manifest); err != nil {
		t.Fatalf("FinalizeManifest() second error = %v", err)
	}
	if manifest.ManifestHash != first {
		t.Fatalf("hash changed: %s != %s", manifest.ManifestHash, first)
	}
	if err := VerifyManifest(&manifest, first, now); err != nil {
		t.Fatalf("VerifyManifest() error = %v", err)
	}
	manifest.Actions[0].TenantID++
	if err := VerifyManifest(&manifest, first, now); err == nil || !strings.Contains(err.Error(), "content hash") {
		t.Fatalf("expected tamper error, got %v", err)
	}
}

func TestFinalizeManifestEnforcesActionBound(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest := validTestManifest(now)
	manifest.Actions = make([]Action, MaxManifestActions)
	for i := range manifest.Actions {
		manifest.Actions[i] = Action{
			Kind:            ObjectKindMgmtCluster,
			ClusterID:       strconv.Itoa(i + 1),
			TenantID:        2,
			ClusterType:     "tidbx_instance",
			SourceUpdatedAt: now.Add(-time.Minute),
			DesiredChecksum: strings.Repeat("a", 64),
		}
	}
	if err := FinalizeManifest(&manifest); err != nil {
		t.Fatalf("expected %d actions to be accepted, got %v", MaxManifestActions, err)
	}
	manifest.Actions = append(manifest.Actions, Action{
		Kind:            ObjectKindMgmtCluster,
		ClusterID:       strconv.Itoa(MaxManifestActions + 1),
		TenantID:        2,
		ClusterType:     "tidbx_instance",
		SourceUpdatedAt: now.Add(-time.Minute),
		DesiredChecksum: strings.Repeat("a", 64),
	})
	if err := FinalizeManifest(&manifest); err == nil || !strings.Contains(err.Error(), "maximum") {
		t.Fatalf("expected action-bound error, got %v", err)
	}
}

func TestFinalizeManifestAllowsTenantZeroOnlyForResourcePoolAction(t *testing.T) {
	now := time.Date(2026, 8, 3, 8, 0, 0, 0, time.UTC)
	manifest := validTestManifest(now)
	manifest.Actions[0] = Action{
		Kind:            ObjectKindResourcePool,
		ClusterID:       "631176026126286850",
		TenantID:        0,
		ClusterType:     "tidbx_resource_pool",
		SourceUpdatedAt: now.Add(-time.Minute),
		DesiredChecksum: strings.Repeat("a", 64),
	}
	if err := FinalizeManifest(&manifest); err != nil {
		t.Fatalf("shared resource-pool action rejected: %v", err)
	}

	manifest.Actions[0].Kind = ObjectKindMgmtCluster
	manifest.ManifestHash = ""
	if err := FinalizeManifest(&manifest); err == nil || !strings.Contains(err.Error(), "resource-pool action") {
		t.Fatalf("expected tenant-zero mgmt action rejection, got %v", err)
	}
}

func TestVerifyManifestRejectsExpiredManifest(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest := validTestManifest(now)
	if err := FinalizeManifest(&manifest); err != nil {
		t.Fatal(err)
	}
	if err := VerifyManifest(&manifest, manifest.ManifestHash, manifest.ExpiresAt); err == nil || !strings.Contains(err.Error(), "expired") {
		t.Fatalf("expected expiry error, got %v", err)
	}
}

func TestVerifyManifestRejectsNoncanonicalActionOrder(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest := validTestManifest(now)
	manifest.Actions = append(manifest.Actions, Action{
		Kind:            ObjectKindResourcePool,
		ClusterID:       "631161640854749185",
		TenantID:        10392,
		ClusterType:     "tidbx_resource_pool",
		SourceUpdatedAt: now.Add(-time.Minute),
		DesiredChecksum: strings.Repeat("b", 64),
	})
	if err := FinalizeManifest(&manifest); err != nil {
		t.Fatal(err)
	}
	manifest.Actions[0], manifest.Actions[1] = manifest.Actions[1], manifest.Actions[0]
	if err := VerifyManifest(&manifest, manifest.ManifestHash, now); err == nil || !strings.Contains(err.Error(), "canonical execution order") {
		t.Fatalf("expected reordered manifest rejection, got %v", err)
	}
}

func TestVerifyManifestRejectsDuplicateUpdateFields(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest := validTestManifest(now)
	manifest.MutationClass = MutationUpdate
	manifest.Actions[0].BeforeChecksum = strings.Repeat("b", 64)
	manifest.Actions[0].Fields = []string{"status", "status"}
	manifest.ManifestHash = strings.Repeat("c", 64)
	if err := VerifyManifest(&manifest, manifest.ManifestHash, now); err == nil || !strings.Contains(err.Error(), "sorted and unique") {
		t.Fatalf("expected duplicate-field error, got %v", err)
	}
}

func TestWriteJSONArtifactUses0600AndDoesNotOverwrite(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "manifest.json")
	if err := WriteJSONArtifact(path, map[string]string{"status": "ok"}); err != nil {
		t.Fatalf("WriteJSONArtifact() error = %v", err)
	}
	info, err := os.Stat(path)
	if err != nil {
		t.Fatal(err)
	}
	if got := info.Mode().Perm(); got != 0o600 {
		t.Fatalf("mode = %o, want 600", got)
	}
	if err := WriteJSONArtifact(path, map[string]string{"status": "changed"}); err == nil || !strings.Contains(err.Error(), "already exists") {
		t.Fatalf("expected no-overwrite error, got %v", err)
	}
}

func validTestManifest(now time.Time) Manifest {
	return Manifest{
		SchemaVersion: SchemaVersion,
		Environment:   StagingEnvironment,
		RunID:         "run-20260720",
		MutationClass: MutationCreate,
		IncidentStart: now.Add(-2 * time.Hour),
		IncidentEnd:   now.Add(-time.Hour),
		GeneratedAt:   now,
		ExpiresAt:     now.Add(time.Hour),
		Actions: []Action{{
			Kind:            ObjectKindMgmtCluster,
			ClusterID:       "10914645658854034992",
			TenantID:        10392,
			ClusterType:     "tidbx_instance",
			SourceUpdatedAt: now.Add(-time.Minute),
			DesiredChecksum: strings.Repeat("a", 64),
		}},
	}
}
