package service

import (
	"context"
	"errors"
	"log/slog"
	"testing"
	"time"

	"github.com/certctl-io/certctl/internal/domain"
)

// newBulkRenewalTestService spins up a BulkRenewalService wired against
// the in-memory mocks used by every other service test in this package.
// keygenMode defaults to "agent" — production-like routing where renewal
// jobs start as AwaitingCSR.
func newBulkRenewalTestService() (*BulkRenewalService, *mockCertRepo, *mockJobRepo, *mockAuditRepo) {
	certRepo := newMockCertificateRepository()
	jobRepo := &mockJobRepo{Jobs: map[string]*domain.Job{}}
	auditRepo := newMockAuditRepository()
	auditService := NewAuditService(auditRepo)
	svc := NewBulkRenewalService(certRepo, jobRepo, auditService, slog.Default(), "agent")
	return svc, certRepo, jobRepo, auditRepo
}

// addRenewableCert seeds a cert that is eligible for renewal (Active
// status, future expiry).
func addRenewableCert(repo *mockCertRepo, id string) {
	cert := &domain.ManagedCertificate{
		ID:         id,
		CommonName: id + ".example.com",
		Status:     domain.CertificateStatusActive,
		ExpiresAt:  time.Now().AddDate(0, 1, 0),
		IssuerID:   "iss-test",
	}
	repo.AddCert(cert)
}

// TestBulkRenew_ByExplicitIDs — happy path. N IDs in, N jobs enqueued,
// EnqueuedJobs slice carries the {certificate_id, job_id} pairs.
func TestBulkRenew_ByExplicitIDs(t *testing.T) {
	svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
	addRenewableCert(certRepo, "mc-1")
	addRenewableCert(certRepo, "mc-2")
	addRenewableCert(certRepo, "mc-3")

	res, err := svc.BulkRenew(context.Background(),
		domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
		"alice")
	if err != nil {
		t.Fatalf("BulkRenew failed: %v", err)
	}
	if res.TotalMatched != 3 || res.TotalEnqueued != 3 || res.TotalSkipped != 0 || res.TotalFailed != 0 {
		t.Errorf("counts = matched:%d enqueued:%d skipped:%d failed:%d, want 3/3/0/0",
			res.TotalMatched, res.TotalEnqueued, res.TotalSkipped, res.TotalFailed)
	}
	if len(res.EnqueuedJobs) != 3 {
		t.Fatalf("EnqueuedJobs len = %d, want 3", len(res.EnqueuedJobs))
	}
	if len(jobRepo.Jobs) != 3 {
		t.Errorf("jobRepo got %d jobs, want 3 (one per renewable cert)", len(jobRepo.Jobs))
	}
	for _, j := range res.EnqueuedJobs {
		if j.JobID == "" {
			t.Errorf("EnqueuedJob missing job_id for cert %s", j.CertificateID)
		}
	}
}

// TestBulkRenew_ByOwnerCriteria — criteria-mode resolution. The
// criteria-routing path must call resolveCertificates with the filter
// branch (not the explicit-IDs branch). Mocking convention in this
// package: mockCertRepo.List ignores the filter and returns all certs,
// so the test seeds only certs that should match (mirrors
// TestBulkRevoke_ByOwner shape in bulk_revocation_test.go).
func TestBulkRenew_ByOwnerCriteria(t *testing.T) {
	svc, certRepo, _, _ := newBulkRenewalTestService()
	for _, id := range []string{"mc-a1", "mc-a2"} {
		cert := &domain.ManagedCertificate{
			ID: id, CommonName: id, Status: domain.CertificateStatusActive,
			OwnerID: "o-alice", ExpiresAt: time.Now().AddDate(0, 1, 0),
		}
		certRepo.AddCert(cert)
	}

	res, err := svc.BulkRenew(context.Background(),
		domain.BulkRenewalCriteria{OwnerID: "o-alice"}, "alice")
	if err != nil {
		t.Fatalf("BulkRenew failed: %v", err)
	}
	if res.TotalEnqueued != 2 {
		t.Errorf("TotalEnqueued = %d, want 2 (alice's 2 certs)", res.TotalEnqueued)
	}
}

// TestBulkRenew_SkipsRenewalInProgress — a cert already in the renewal
// flow must NOT get a second job. This is the no-double-enqueue
// contract: dispatch the bulk-renew button twice in quick succession
// and the second call cleanly skips.
func TestBulkRenew_SkipsRenewalInProgress(t *testing.T) {
	svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
	cert := &domain.ManagedCertificate{
		ID: "mc-rip", Status: domain.CertificateStatusRenewalInProgress,
		ExpiresAt: time.Now().AddDate(0, 1, 0),
	}
	certRepo.AddCert(cert)

	res, err := svc.BulkRenew(context.Background(),
		domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-rip"}}, "alice")
	if err != nil {
		t.Fatalf("BulkRenew failed: %v", err)
	}
	if res.TotalSkipped != 1 || res.TotalEnqueued != 0 {
		t.Errorf("counts wrong: skipped=%d enqueued=%d, want 1/0",
			res.TotalSkipped, res.TotalEnqueued)
	}
	if len(jobRepo.Jobs) != 0 {
		t.Errorf("no job should be created for already-in-progress cert; got %d jobs", len(jobRepo.Jobs))
	}
}

// TestBulkRenew_SkipsRevokedAndArchived — terminal states are silent
// no-ops, not errors. Operator selecting a mix of live and revoked certs
// shouldn't see "ERROR: revoked cert can't be renewed" 50 times.
func TestBulkRenew_SkipsRevokedAndArchived(t *testing.T) {
	svc, certRepo, _, _ := newBulkRenewalTestService()
	addRenewableCert(certRepo, "mc-live")
	for _, st := range []domain.CertificateStatus{
		domain.CertificateStatusRevoked,
		domain.CertificateStatusArchived,
		domain.CertificateStatusExpired,
	} {
		cert := &domain.ManagedCertificate{
			ID: "mc-" + string(st), Status: st, ExpiresAt: time.Now().AddDate(0, 1, 0),
		}
		certRepo.AddCert(cert)
	}

	res, err := svc.BulkRenew(context.Background(),
		domain.BulkRenewalCriteria{CertificateIDs: []string{
			"mc-live", "mc-Revoked", "mc-Archived", "mc-Expired",
		}}, "alice")
	if err != nil {
		t.Fatalf("BulkRenew failed: %v", err)
	}
	if res.TotalEnqueued != 1 || res.TotalSkipped != 3 {
		t.Errorf("counts = enqueued:%d skipped:%d, want 1/3 (only mc-live qualifies)",
			res.TotalEnqueued, res.TotalSkipped)
	}
	if len(res.Errors) != 0 {
		t.Errorf("status-skip should NOT populate Errors; got %v", res.Errors)
	}
}

// TestBulkRenew_EmptyCriteria_Error — defensive contract. Mirrors
// BulkRevocationCriteria.IsEmpty rejection so a stray empty POST
// doesn't try to renew the entire fleet.
func TestBulkRenew_EmptyCriteria_Error(t *testing.T) {
	svc, _, _, _ := newBulkRenewalTestService()
	_, err := svc.BulkRenew(context.Background(),
		domain.BulkRenewalCriteria{}, "alice")
	if err == nil {
		t.Fatal("expected error for empty criteria, got nil")
	}
}

// TestBulkRenew_PartialFailure — N=3, jobRepo.Create injected to fail
// on one of them. Response carries 2 enqueued + 1 error; no panic, no
// abort.
func TestBulkRenew_PartialFailure(t *testing.T) {
	svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
	addRenewableCert(certRepo, "mc-1")
	addRenewableCert(certRepo, "mc-2")
	addRenewableCert(certRepo, "mc-3")

	// Make Create fail uniformly. Two of the three certs will still
	// have their status flipped (because Update happened first), so
	// the failure manifests as "I tried to enqueue, the job-create
	// failed". Per-cert error string surfaced.
	jobRepo.CreateErr = errors.New("simulated DB outage")

	res, err := svc.BulkRenew(context.Background(),
		domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
		"alice")
	if err != nil {
		t.Fatalf("BulkRenew should not propagate per-cert errors as a top-level error; got: %v", err)
	}
	if res.TotalFailed != 3 || res.TotalEnqueued != 0 {
		t.Errorf("counts = failed:%d enqueued:%d, want 3/0", res.TotalFailed, res.TotalEnqueued)
	}
	if len(res.Errors) != 3 {
		t.Errorf("Errors len = %d, want 3", len(res.Errors))
	}
}

// TestBulkRenew_AuditEventEmitted — exactly ONE bulk audit event for
// the operation, NOT N. This is the audit-volume contract that makes
// bulk endpoints scalable.
func TestBulkRenew_AuditEventEmitted(t *testing.T) {
	svc, certRepo, _, auditRepo := newBulkRenewalTestService()
	addRenewableCert(certRepo, "mc-1")
	addRenewableCert(certRepo, "mc-2")
	addRenewableCert(certRepo, "mc-3")

	_, err := svc.BulkRenew(context.Background(),
		domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
		"alice")
	if err != nil {
		t.Fatalf("BulkRenew failed: %v", err)
	}

	// audit_events count must be exactly 1 — the bulk-renewal envelope.
	// Per-cert renewal events come from CertificateService.TriggerRenewal,
	// which the bulk path bypasses for exactly this reason.
	if len(auditRepo.Events) != 1 {
		t.Errorf("audit events count = %d, want exactly 1 (one bulk event, NOT N per-cert events)", len(auditRepo.Events))
	}
	if len(auditRepo.Events) > 0 && auditRepo.Events[0].Action != "bulk_renewal_initiated" {
		t.Errorf("audit action = %q, want 'bulk_renewal_initiated'", auditRepo.Events[0].Action)
	}
}
