package service

import (
	"context"
	"encoding/json"
	"strings"
	"testing"
	"time"

	"github.com/shankar0123/certctl/internal/domain"
)

func TestCreateRule(t *testing.T) {
	ctx := context.Background()
	policyRepo := &mockPolicyRepo{
		Rules:      make(map[string]*domain.PolicyRule),
		Violations: []*domain.PolicyViolation{},
	}
	auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
	auditService := NewAuditService(auditRepo)

	policyService := NewPolicyService(policyRepo, auditService)

	config := map[string]interface{}{"issuers": []string{"iss-acme"}}
	configJSON, _ := json.Marshal(config)

	rule := &domain.PolicyRule{
		ID:      "rule-001",
		Name:    "Allowed Issuers",
		Type:    domain.PolicyTypeAllowedIssuers,
		Config:  configJSON,
		Enabled: true,
	}

	err := policyService.CreateRule(ctx, rule, "user-1")
	if err != nil {
		t.Fatalf("CreateRule failed: %v", err)
	}

	if len(policyRepo.Rules) != 1 {
		t.Errorf("expected 1 rule, got %d", len(policyRepo.Rules))
	}

	if len(auditRepo.Events) != 1 {
		t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
	}
}

func TestGetRule(t *testing.T) {
	ctx := context.Background()
	now := time.Now()
	rule := &domain.PolicyRule{
		ID:        "rule-001",
		Name:      "Allowed Issuers",
		Type:      domain.PolicyTypeAllowedIssuers,
		Enabled:   true,
		CreatedAt: now,
		UpdatedAt: now,
	}

	policyRepo := &mockPolicyRepo{
		Rules:      map[string]*domain.PolicyRule{"rule-001": rule},
		Violations: []*domain.PolicyViolation{},
	}
	auditRepo := &mockAuditRepo{}
	auditService := NewAuditService(auditRepo)

	policyService := NewPolicyService(policyRepo, auditService)

	retrieved, err := policyService.GetRule(ctx, "rule-001")
	if err != nil {
		t.Fatalf("GetRule failed: %v", err)
	}

	if retrieved.Name != "Allowed Issuers" {
		t.Errorf("expected name Allowed Issuers, got %s", retrieved.Name)
	}
}

func TestGetRule_NotFound(t *testing.T) {
	ctx := context.Background()
	policyRepo := &mockPolicyRepo{
		Rules:      make(map[string]*domain.PolicyRule),
		Violations: []*domain.PolicyViolation{},
	}
	auditRepo := &mockAuditRepo{}
	auditService := NewAuditService(auditRepo)

	policyService := NewPolicyService(policyRepo, auditService)

	_, err := policyService.GetRule(ctx, "nonexistent")
	if err == nil {
		t.Fatal("expected error for nonexistent rule")
	}
}

func TestListRules(t *testing.T) {
	ctx := context.Background()
	now := time.Now()

	rule1 := &domain.PolicyRule{
		ID:        "rule-001",
		Name:      "Allowed Issuers",
		Type:      domain.PolicyTypeAllowedIssuers,
		Enabled:   true,
		CreatedAt: now,
		UpdatedAt: now,
	}
	rule2 := &domain.PolicyRule{
		ID:        "rule-002",
		Name:      "Required Metadata",
		Type:      domain.PolicyTypeRequiredMetadata,
		Enabled:   true,
		CreatedAt: now,
		UpdatedAt: now,
	}

	policyRepo := &mockPolicyRepo{
		Rules:      map[string]*domain.PolicyRule{"rule-001": rule1, "rule-002": rule2},
		Violations: []*domain.PolicyViolation{},
	}
	auditRepo := &mockAuditRepo{}
	auditService := NewAuditService(auditRepo)

	policyService := NewPolicyService(policyRepo, auditService)

	rules, err := policyService.ListRules(ctx)
	if err != nil {
		t.Fatalf("ListRules failed: %v", err)
	}

	if len(rules) != 2 {
		t.Errorf("expected 2 rules, got %d", len(rules))
	}
}

func TestUpdateRule(t *testing.T) {
	ctx := context.Background()
	now := time.Now()

	originalRule := &domain.PolicyRule{
		ID:        "rule-001",
		Name:      "Allowed Issuers",
		Type:      domain.PolicyTypeAllowedIssuers,
		Enabled:   true,
		CreatedAt: now,
		UpdatedAt: now,
	}

	policyRepo := &mockPolicyRepo{
		Rules:      map[string]*domain.PolicyRule{"rule-001": originalRule},
		Violations: []*domain.PolicyViolation{},
	}
	auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
	auditService := NewAuditService(auditRepo)

	policyService := NewPolicyService(policyRepo, auditService)

	updatedRule := *originalRule
	updatedRule.Enabled = false

	err := policyService.UpdateRule(ctx, &updatedRule, "user-1")
	if err != nil {
		t.Fatalf("UpdateRule failed: %v", err)
	}

	stored := policyRepo.Rules["rule-001"]
	if stored.Enabled {
		t.Error("expected rule to be disabled")
	}

	if len(auditRepo.Events) != 1 {
		t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
	}
}

func TestDeleteRule(t *testing.T) {
	ctx := context.Background()
	now := time.Now()

	rule := &domain.PolicyRule{
		ID:        "rule-001",
		Name:      "Allowed Issuers",
		Type:      domain.PolicyTypeAllowedIssuers,
		Enabled:   true,
		CreatedAt: now,
		UpdatedAt: now,
	}

	policyRepo := &mockPolicyRepo{
		Rules:      map[string]*domain.PolicyRule{"rule-001": rule},
		Violations: []*domain.PolicyViolation{},
	}
	auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
	auditService := NewAuditService(auditRepo)

	policyService := NewPolicyService(policyRepo, auditService)

	err := policyService.DeleteRule(ctx, "rule-001", "user-1")
	if err != nil {
		t.Fatalf("DeleteRule failed: %v", err)
	}

	if len(policyRepo.Rules) != 0 {
		t.Errorf("expected 0 rules, got %d", len(policyRepo.Rules))
	}

	if len(auditRepo.Events) != 1 {
		t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
	}
}

func TestValidateCertificate(t *testing.T) {
	ctx := context.Background()
	now := time.Now()

	rule := &domain.PolicyRule{
		ID:        "rule-001",
		Name:      "Allowed Issuers",
		Type:      domain.PolicyTypeAllowedIssuers,
		Enabled:   true,
		CreatedAt: now,
		UpdatedAt: now,
	}

	policyRepo := &mockPolicyRepo{
		Rules:      map[string]*domain.PolicyRule{"rule-001": rule},
		Violations: []*domain.PolicyViolation{},
	}
	auditRepo := &mockAuditRepo{}
	auditService := NewAuditService(auditRepo)

	policyService := NewPolicyService(policyRepo, auditService)

	cert := &domain.ManagedCertificate{
		ID:         "cert-001",
		CommonName: "example.com",
		IssuerID:   "iss-acme",
		Status:     domain.CertificateStatusActive,
		ExpiresAt:  now.AddDate(1, 0, 0),
		CreatedAt:  now,
		UpdatedAt:  now,
	}

	violations, err := policyService.ValidateCertificate(ctx, cert)
	if err != nil {
		t.Fatalf("ValidateCertificate failed: %v", err)
	}

	if len(violations) > 0 {
		t.Errorf("expected no violations, got %d", len(violations))
	}
}

func TestValidateCertificate_WithViolation(t *testing.T) {
	ctx := context.Background()
	now := time.Now()

	rule := &domain.PolicyRule{
		ID:        "rule-001",
		Name:      "Allowed Issuers",
		Type:      domain.PolicyTypeAllowedIssuers,
		Enabled:   true,
		CreatedAt: now,
		UpdatedAt: now,
	}

	policyRepo := &mockPolicyRepo{
		Rules:      map[string]*domain.PolicyRule{"rule-001": rule},
		Violations: []*domain.PolicyViolation{},
	}
	auditRepo := &mockAuditRepo{}
	auditService := NewAuditService(auditRepo)

	policyService := NewPolicyService(policyRepo, auditService)

	cert := &domain.ManagedCertificate{
		ID:         "cert-001",
		CommonName: "example.com",
		IssuerID:   "", // Missing issuer
		Status:     domain.CertificateStatusActive,
		ExpiresAt:  now.AddDate(1, 0, 0),
		CreatedAt:  now,
		UpdatedAt:  now,
	}

	violations, err := policyService.ValidateCertificate(ctx, cert)
	if err != nil {
		t.Fatalf("ValidateCertificate failed: %v", err)
	}

	if len(violations) != 1 {
		t.Errorf("expected 1 violation, got %d", len(violations))
	}

	if violations[0].CertificateID != "cert-001" {
		t.Errorf("expected violation for cert-001, got %s", violations[0].CertificateID)
	}
}

func TestValidateCertificate_MultipleViolations(t *testing.T) {
	ctx := context.Background()
	now := time.Now()

	rule1 := &domain.PolicyRule{
		ID:        "rule-001",
		Name:      "Allowed Issuers",
		Type:      domain.PolicyTypeAllowedIssuers,
		Enabled:   true,
		CreatedAt: now,
		UpdatedAt: now,
	}
	rule2 := &domain.PolicyRule{
		ID:        "rule-002",
		Name:      "Required Metadata",
		Type:      domain.PolicyTypeRequiredMetadata,
		Enabled:   true,
		CreatedAt: now,
		UpdatedAt: now,
	}

	policyRepo := &mockPolicyRepo{
		Rules:      map[string]*domain.PolicyRule{"rule-001": rule1, "rule-002": rule2},
		Violations: []*domain.PolicyViolation{},
	}
	auditRepo := &mockAuditRepo{}
	auditService := NewAuditService(auditRepo)

	policyService := NewPolicyService(policyRepo, auditService)

	cert := &domain.ManagedCertificate{
		ID:         "cert-001",
		CommonName: "example.com",
		IssuerID:   "",  // Missing issuer
		Tags:       nil, // Missing metadata
		Status:     domain.CertificateStatusActive,
		ExpiresAt:  now.AddDate(1, 0, 0),
		CreatedAt:  now,
		UpdatedAt:  now,
	}

	violations, err := policyService.ValidateCertificate(ctx, cert)
	if err != nil {
		t.Fatalf("ValidateCertificate failed: %v", err)
	}

	if len(violations) != 2 {
		t.Errorf("expected 2 violations, got %d", len(violations))
	}
}

func TestListPolicies(t *testing.T) {
	now := time.Now()
	rule1 := &domain.PolicyRule{
		ID:        "rule-001",
		Name:      "Rule 1",
		Type:      domain.PolicyTypeAllowedIssuers,
		Enabled:   true,
		CreatedAt: now,
		UpdatedAt: now,
	}
	rule2 := &domain.PolicyRule{
		ID:        "rule-002",
		Name:      "Rule 2",
		Type:      domain.PolicyTypeRequiredMetadata,
		Enabled:   true,
		CreatedAt: now,
		UpdatedAt: now,
	}

	policyRepo := &mockPolicyRepo{
		Rules:      map[string]*domain.PolicyRule{"rule-001": rule1, "rule-002": rule2},
		Violations: []*domain.PolicyViolation{},
	}
	auditRepo := &mockAuditRepo{}
	auditService := NewAuditService(auditRepo)

	policyService := NewPolicyService(policyRepo, auditService)

	policies, total, err := policyService.ListPolicies(context.Background(), 1, 50)
	if err != nil {
		t.Fatalf("ListPolicies failed: %v", err)
	}

	if len(policies) != 2 {
		t.Errorf("expected 2 policies, got %d", len(policies))
	}
	if total != 2 {
		t.Errorf("expected total 2, got %d", total)
	}
}

func TestCreatePolicy(t *testing.T) {
	now := time.Now()
	policyRepo := &mockPolicyRepo{
		Rules:      make(map[string]*domain.PolicyRule),
		Violations: []*domain.PolicyViolation{},
	}
	auditRepo := &mockAuditRepo{}
	auditService := NewAuditService(auditRepo)

	policyService := NewPolicyService(policyRepo, auditService)

	policy := domain.PolicyRule{
		Name:      "Test Policy",
		Type:      domain.PolicyTypeAllowedIssuers,
		Enabled:   true,
		CreatedAt: now,
	}

	created, err := policyService.CreatePolicy(context.Background(), policy)
	if err != nil {
		t.Fatalf("CreatePolicy failed: %v", err)
	}

	if created.ID == "" {
		t.Fatal("expected non-empty policy ID")
	}

	if len(policyRepo.Rules) != 1 {
		t.Errorf("expected 1 rule in repo, got %d", len(policyRepo.Rules))
	}
}

// ============================================================================
// D-008 regression tests
//
// These pin the behavior that closes the D-006 loop:
//   1. evaluateRule copies rule.Severity onto every violation (pre-D-008 the
//      engine hardcoded Warning regardless of the rule's configured severity).
//   2. evaluateRule parses rule.Config per-arm so rules enforce real thresholds
//      and allowlists (pre-D-008 the configs were ignored; rules fired only on
//      the missing-field shape).
//   3. An empty/zero Config preserves the pre-D-008 missing-field violation
//      (backward-compat invariant).
//   4. Malformed Config returns an error; the caller logs and skips the rule
//      instead of producing a zero-value violation.
//   5. CertificateLifetime (new 6th arm) reads NotBefore/NotAfter from the
//      latest CertificateVersion via the cert repo wired with SetCertRepo.
// ============================================================================

// mkRule is a tiny constructor used by the D-008 tests to keep the table rows
// readable. Every rule is enabled; test-specific fields layer on top.
func mkRule(id string, t domain.PolicyType, sev domain.PolicySeverity, cfg string) *domain.PolicyRule {
	return &domain.PolicyRule{
		ID:       id,
		Name:     id,
		Type:     t,
		Config:   json.RawMessage(cfg),
		Enabled:  true,
		Severity: sev,
	}
}

// evalCert is a minimal cert used by the arms that don't look at much beyond
// the shape of the field they're testing. Tests shadow fields as needed.
func evalCert() *domain.ManagedCertificate {
	return &domain.ManagedCertificate{
		ID:         "cert-001",
		CommonName: "example.com",
		Status:     domain.CertificateStatusActive,
		ExpiresAt:  time.Now().AddDate(1, 0, 0),
	}
}

// TestEvaluateRule_SeverityPassThrough pins invariant #1 — every arm stamps
// rule.Severity onto the violation. The pre-D-008 bug was that arms
// independently hardcoded PolicySeverityWarning. We test each arm with a
// severity that isn't the legacy default so a regression would be visible.
func TestEvaluateRule_SeverityPassThrough(t *testing.T) {
	ctx := context.Background()

	// Cert shaped to fail every non-empty-config check via the backward-compat
	// missing-field path. Each row picks a severity intentionally ≠ Warning to
	// make a stray hardcoded default obvious.
	cases := []struct {
		name     string
		rule     *domain.PolicyRule
		cert     *domain.ManagedCertificate
		setupFn  func(svc *PolicyService)
		expected domain.PolicySeverity
	}{
		{
			name: "AllowedIssuers Critical via missing IssuerID",
			rule: mkRule("r-ai", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityCritical, ""),
			cert: func() *domain.ManagedCertificate {
				c := evalCert()
				c.IssuerID = ""
				return c
			}(),
			expected: domain.PolicySeverityCritical,
		},
		{
			name: "AllowedDomains Error via empty SANs",
			rule: mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityError, ""),
			cert: func() *domain.ManagedCertificate {
				c := evalCert()
				c.SANs = nil
				return c
			}(),
			expected: domain.PolicySeverityError,
		},
		{
			name: "RequiredMetadata Critical via empty Tags",
			rule: mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityCritical, ""),
			cert: func() *domain.ManagedCertificate {
				c := evalCert()
				c.Tags = nil
				return c
			}(),
			expected: domain.PolicySeverityCritical,
		},
		{
			name: "AllowedEnvironments Warning via empty Environment",
			rule: mkRule("r-ae", domain.PolicyTypeAllowedEnvironments, domain.PolicySeverityWarning, ""),
			cert: func() *domain.ManagedCertificate {
				c := evalCert()
				c.Environment = ""
				return c
			}(),
			expected: domain.PolicySeverityWarning,
		},
		{
			name: "RenewalLeadTime Critical via short remaining validity",
			rule: mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityCritical, `{"lead_time_days": 60}`),
			cert: func() *domain.ManagedCertificate {
				c := evalCert()
				c.ExpiresAt = time.Now().AddDate(0, 0, 30) // 30d remaining < 60d lead
				return c
			}(),
			expected: domain.PolicySeverityCritical,
		},
		{
			name: "CertificateLifetime Error via 365d span vs 90d max",
			rule: mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError, `{"max_days": 90}`),
			cert: evalCert(),
			setupFn: func(svc *PolicyService) {
				// Seed a version with 365d lifetime on the same cert ID used
				// by evalCert().
				cr := &mockCertRepo{
					Certs:    map[string]*domain.ManagedCertificate{},
					Versions: map[string][]*domain.CertificateVersion{},
				}
				now := time.Now()
				cr.Versions["cert-001"] = []*domain.CertificateVersion{{
					ID:            "ver-001",
					CertificateID: "cert-001",
					NotBefore:     now.AddDate(0, 0, -10),
					NotAfter:      now.AddDate(1, 0, -10), // ~365d lifetime
				}}
				svc.SetCertRepo(cr)
			},
			expected: domain.PolicySeverityError,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			policyRepo := &mockPolicyRepo{
				Rules:      map[string]*domain.PolicyRule{tc.rule.ID: tc.rule},
				Violations: []*domain.PolicyViolation{},
			}
			auditService := NewAuditService(&mockAuditRepo{})
			svc := NewPolicyService(policyRepo, auditService)
			if tc.setupFn != nil {
				tc.setupFn(svc)
			}

			violations, err := svc.ValidateCertificate(ctx, tc.cert)
			if err != nil {
				t.Fatalf("ValidateCertificate failed: %v", err)
			}
			if len(violations) != 1 {
				t.Fatalf("expected 1 violation, got %d", len(violations))
			}
			if violations[0].Severity != tc.expected {
				t.Errorf("expected severity %q, got %q", tc.expected, violations[0].Severity)
			}
			if violations[0].RuleID != tc.rule.ID {
				t.Errorf("expected rule ID %q, got %q", tc.rule.ID, violations[0].RuleID)
			}
		})
	}
}

// TestEvaluateRule_ConfigConsumed pins invariant #2 — non-empty Config drives
// arm behavior (allowlists, thresholds, keys). Each subtest supplies a config
// that the cert would satisfy under the backward-compat missing-field path
// but violates under the config-aware path. A regression to the pre-D-008
// "config silently dropped" behavior would make these pass with 0 violations.
func TestEvaluateRule_ConfigConsumed(t *testing.T) {
	ctx := context.Background()

	t.Run("AllowedIssuers rejects issuer not in allowlist", func(t *testing.T) {
		rule := mkRule("r-ai", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityWarning,
			`{"allowed_issuer_ids": ["iss-acme"]}`)
		cert := evalCert()
		cert.IssuerID = "iss-wrong"

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 1 {
			t.Fatalf("expected 1 violation for disallowed issuer, got %d", len(violations))
		}
		if !strings.Contains(violations[0].Message, "iss-wrong") {
			t.Errorf("expected message to mention issuer ID, got %q", violations[0].Message)
		}
	})

	t.Run("AllowedIssuers accepts issuer in allowlist", func(t *testing.T) {
		rule := mkRule("r-ai", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityWarning,
			`{"allowed_issuer_ids": ["iss-acme"]}`)
		cert := evalCert()
		cert.IssuerID = "iss-acme"

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 0 {
			t.Errorf("expected 0 violations for allowed issuer, got %d", len(violations))
		}
	})

	t.Run("AllowedDomains rejects SAN outside allowlist", func(t *testing.T) {
		rule := mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityWarning,
			`{"allowed_domains": ["*.foo.com"]}`)
		cert := evalCert()
		cert.SANs = []string{"bar.elsewhere.com"}

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 1 {
			t.Fatalf("expected 1 violation for disallowed SAN, got %d", len(violations))
		}
	})

	t.Run("AllowedDomains wildcard matches single-label subdomain", func(t *testing.T) {
		rule := mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityWarning,
			`{"allowed_domains": ["*.foo.com"]}`)
		cert := evalCert()
		cert.SANs = []string{"bar.foo.com"}

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 0 {
			t.Errorf("expected 0 violations for single-label wildcard match, got %d", len(violations))
		}
	})

	t.Run("AllowedDomains wildcard rejects multi-label subdomain", func(t *testing.T) {
		// X.509 wildcard semantics: *.foo consumes exactly one label.
		rule := mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityWarning,
			`{"allowed_domains": ["*.foo.com"]}`)
		cert := evalCert()
		cert.SANs = []string{"baz.bar.foo.com"}

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 1 {
			t.Errorf("expected 1 violation for multi-label wildcard (X.509 semantics), got %d", len(violations))
		}
	})

	t.Run("RequiredMetadata rejects missing key", func(t *testing.T) {
		rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityWarning,
			`{"required_keys": ["owner"]}`)
		cert := evalCert()
		cert.Tags = map[string]string{"team": "platform"}

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 1 {
			t.Fatalf("expected 1 violation for missing owner key, got %d", len(violations))
		}
		if !strings.Contains(violations[0].Message, "owner") {
			t.Errorf("expected message to mention the missing key, got %q", violations[0].Message)
		}
	})

	t.Run("RequiredMetadata accepts all required keys present", func(t *testing.T) {
		rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityWarning,
			`{"required_keys": ["owner"]}`)
		cert := evalCert()
		cert.Tags = map[string]string{"owner": "alice"}

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 0 {
			t.Errorf("expected 0 violations when all required keys present, got %d", len(violations))
		}
	})

	t.Run("AllowedEnvironments rejects env outside allowlist", func(t *testing.T) {
		rule := mkRule("r-ae", domain.PolicyTypeAllowedEnvironments, domain.PolicySeverityWarning,
			`{"allowed": ["production", "staging"]}`)
		cert := evalCert()
		cert.Environment = "wild-west"

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 1 {
			t.Fatalf("expected 1 violation for disallowed env, got %d", len(violations))
		}
	})

	t.Run("RenewalLeadTime fires when remaining < configured lead", func(t *testing.T) {
		rule := mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityWarning,
			`{"lead_time_days": 60}`)
		cert := evalCert()
		cert.ExpiresAt = time.Now().AddDate(0, 0, 30) // 30d < 60d lead

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 1 {
			t.Fatalf("expected 1 violation for 30d remaining vs 60d lead, got %d", len(violations))
		}
	})

	t.Run("RenewalLeadTime quiet when remaining > configured lead", func(t *testing.T) {
		rule := mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityWarning,
			`{"lead_time_days": 14}`)
		cert := evalCert()
		cert.ExpiresAt = time.Now().AddDate(0, 0, 60) // 60d > 14d lead

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 0 {
			t.Errorf("expected 0 violations when plenty of runway remains, got %d", len(violations))
		}
	})

	t.Run("CertificateLifetime fires when lifetime exceeds max", func(t *testing.T) {
		rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityWarning,
			`{"max_days": 90}`)
		cert := evalCert()
		now := time.Now()

		certRepo := &mockCertRepo{
			Certs:    map[string]*domain.ManagedCertificate{},
			Versions: map[string][]*domain.CertificateVersion{},
		}
		certRepo.Versions["cert-001"] = []*domain.CertificateVersion{{
			ID:            "ver-001",
			CertificateID: "cert-001",
			NotBefore:     now.AddDate(0, 0, -1),
			NotAfter:      now.AddDate(1, 0, -1), // ~365d > 90d
		}}

		violations := runEval(ctx, t, rule, cert, certRepo)
		if len(violations) != 1 {
			t.Fatalf("expected 1 violation for 365d lifetime vs 90d max, got %d", len(violations))
		}
		if !strings.Contains(violations[0].Message, "90 days") {
			t.Errorf("expected message to mention max_days threshold, got %q", violations[0].Message)
		}
	})

	t.Run("CertificateLifetime quiet when lifetime within max", func(t *testing.T) {
		rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityWarning,
			`{"max_days": 90}`)
		cert := evalCert()
		now := time.Now()

		certRepo := &mockCertRepo{
			Certs:    map[string]*domain.ManagedCertificate{},
			Versions: map[string][]*domain.CertificateVersion{},
		}
		certRepo.Versions["cert-001"] = []*domain.CertificateVersion{{
			ID:            "ver-001",
			CertificateID: "cert-001",
			NotBefore:     now.AddDate(0, 0, -10),
			NotAfter:      now.AddDate(0, 0, 60), // 70d lifetime < 90d
		}}

		violations := runEval(ctx, t, rule, cert, certRepo)
		if len(violations) != 0 {
			t.Errorf("expected 0 violations for 70d lifetime under 90d max, got %d", len(violations))
		}
	})
}

// TestEvaluateRule_EmptyConfig_BackCompat pins invariant #3 — a rule with no
// Config (e.g., a legacy row from a pre-D-008 migration) still fires on the
// pre-D-008 missing-field shape using its configured severity. This is how
// we let existing deployments migrate without a schema rewrite.
func TestEvaluateRule_EmptyConfig_BackCompat(t *testing.T) {
	ctx := context.Background()

	t.Run("RequiredMetadata fires on zero tags", func(t *testing.T) {
		rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityError, "")
		cert := evalCert()
		cert.Tags = nil

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 1 {
			t.Fatalf("expected 1 backcompat violation, got %d", len(violations))
		}
		if violations[0].Severity != domain.PolicySeverityError {
			t.Errorf("expected severity Error (passed through from rule), got %q", violations[0].Severity)
		}
	})

	t.Run("RequiredMetadata quiet when any tags present under empty config", func(t *testing.T) {
		// Empty config means "only fire on missing-field shape" — so a cert
		// with any tags (even not what a human would call meaningful) passes.
		rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityError, "")
		cert := evalCert()
		cert.Tags = map[string]string{"arbitrary": "value"}

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 0 {
			t.Errorf("expected 0 violations under backcompat shape w/ tags set, got %d", len(violations))
		}
	})

	t.Run("RenewalLeadTime uses 30d default under empty/zero config", func(t *testing.T) {
		rule := mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityWarning, "")
		cert := evalCert()
		cert.ExpiresAt = time.Now().AddDate(0, 0, 15) // 15d < 30d default

		violations := runEval(ctx, t, rule, cert, nil)
		if len(violations) != 1 {
			t.Errorf("expected 1 violation under 30d backcompat default, got %d", len(violations))
		}
	})
}

// TestEvaluateRule_BadConfig_SkipsRule pins invariant #4 — malformed JSON in
// Config returns an error from evaluateRule, which ValidateCertificate logs
// and swallows. The pass continues; no zero-value violation is emitted.
// Co-located rules still fire normally.
func TestEvaluateRule_BadConfig_SkipsRule(t *testing.T) {
	ctx := context.Background()

	// Rule 1 has malformed JSON — should log+skip.
	// Rule 2 is a healthy AllowedIssuers rule that should still emit its
	// violation on the missing-IssuerID cert. If the bad rule poisoned the
	// loop, we'd see 0 or 2 violations instead of exactly 1.
	badRule := mkRule("r-bad", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityError,
		`{"allowed_issuer_ids": [`) // unterminated JSON
	goodRule := mkRule("r-good", domain.PolicyTypeAllowedEnvironments, domain.PolicySeverityWarning, "")

	policyRepo := &mockPolicyRepo{
		Rules: map[string]*domain.PolicyRule{
			badRule.ID:  badRule,
			goodRule.ID: goodRule,
		},
		Violations: []*domain.PolicyViolation{},
	}
	auditService := NewAuditService(&mockAuditRepo{})
	svc := NewPolicyService(policyRepo, auditService)

	cert := evalCert()
	cert.IssuerID = ""    // would trigger the bad rule if it wasn't skipped
	cert.Environment = "" // triggers goodRule via missing-field backcompat

	violations, err := svc.ValidateCertificate(ctx, cert)
	if err != nil {
		t.Fatalf("ValidateCertificate should swallow rule-eval errors, got %v", err)
	}
	if len(violations) != 1 {
		t.Fatalf("expected exactly 1 violation (bad rule skipped, good rule fires), got %d", len(violations))
	}
	if violations[0].RuleID != goodRule.ID {
		t.Errorf("expected violation from r-good, got %q", violations[0].RuleID)
	}
}

// TestEvaluateRule_CertificateLifetime_RepoScenarios pins the setter-injection
// pattern for the 6th arm. SetCertRepo wires the dependency; without it the
// arm errors (logged+skipped by the caller). With it but no version present,
// the arm silently returns nil (matching the missing-field backcompat shape).
func TestEvaluateRule_CertificateLifetime_RepoScenarios(t *testing.T) {
	ctx := context.Background()

	t.Run("repo not wired logs and skips", func(t *testing.T) {
		rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError,
			`{"max_days": 90}`)
		policyRepo := &mockPolicyRepo{
			Rules:      map[string]*domain.PolicyRule{rule.ID: rule},
			Violations: []*domain.PolicyViolation{},
		}
		svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
		// deliberately do NOT call SetCertRepo

		violations, err := svc.ValidateCertificate(ctx, evalCert())
		if err != nil {
			t.Fatalf("ValidateCertificate should swallow the nil-repo error, got %v", err)
		}
		if len(violations) != 0 {
			t.Errorf("expected 0 violations when repo unwired (rule skipped), got %d", len(violations))
		}
	})

	t.Run("version missing silently skips", func(t *testing.T) {
		rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError,
			`{"max_days": 90}`)
		policyRepo := &mockPolicyRepo{
			Rules:      map[string]*domain.PolicyRule{rule.ID: rule},
			Violations: []*domain.PolicyViolation{},
		}
		svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
		// Empty Versions map — GetLatestVersion returns errNotFound, arm skips.
		svc.SetCertRepo(&mockCertRepo{
			Certs:    map[string]*domain.ManagedCertificate{},
			Versions: map[string][]*domain.CertificateVersion{},
		})

		violations, err := svc.ValidateCertificate(ctx, evalCert())
		if err != nil {
			t.Fatalf("ValidateCertificate failed: %v", err)
		}
		if len(violations) != 0 {
			t.Errorf("expected 0 violations when no version exists (nothing to measure), got %d", len(violations))
		}
	})

	t.Run("max_days zero/absent means no enforcement", func(t *testing.T) {
		// Even with a version, max_days=0 is a no-op (matches the
		// no-threshold-configured guard in the arm).
		rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError, "")
		policyRepo := &mockPolicyRepo{
			Rules:      map[string]*domain.PolicyRule{rule.ID: rule},
			Violations: []*domain.PolicyViolation{},
		}
		svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
		now := time.Now()
		svc.SetCertRepo(&mockCertRepo{
			Certs: map[string]*domain.ManagedCertificate{},
			Versions: map[string][]*domain.CertificateVersion{
				"cert-001": {{
					CertificateID: "cert-001",
					NotBefore:     now.AddDate(0, 0, -1),
					NotAfter:      now.AddDate(10, 0, 0), // 10 years — huge but unchecked
				}},
			},
		})

		violations, err := svc.ValidateCertificate(ctx, evalCert())
		if err != nil {
			t.Fatalf("ValidateCertificate failed: %v", err)
		}
		if len(violations) != 0 {
			t.Errorf("expected 0 violations when max_days absent (no enforcement), got %d", len(violations))
		}
	})
}

// runEval is a test helper that exercises ValidateCertificate against a
// single-rule configuration and returns the violation slice. Optionally
// wires a cert repo for the CertificateLifetime arm.
func runEval(ctx context.Context, t *testing.T, rule *domain.PolicyRule, cert *domain.ManagedCertificate, certRepo *mockCertRepo) []*domain.PolicyViolation {
	t.Helper()
	policyRepo := &mockPolicyRepo{
		Rules:      map[string]*domain.PolicyRule{rule.ID: rule},
		Violations: []*domain.PolicyViolation{},
	}
	svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
	if certRepo != nil {
		svc.SetCertRepo(certRepo)
	}
	violations, err := svc.ValidateCertificate(ctx, cert)
	if err != nil {
		t.Fatalf("ValidateCertificate failed: %v", err)
	}
	return violations
}
