package service

import (
	"context"
	"database/sql"
	"errors"
	"sort"
	"sync"
	"time"

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

var errNotFound = errors.New("not found")

// testEncryptionKey is a deterministic passphrase for unit tests that
// exercise IssuerService/TargetService write paths. After the C-2 remediation
// these services fail closed when no key is configured, so happy-path tests
// must supply a real passphrase. M-8 reshaped the type from []byte to string
// because services now hold the raw passphrase and delegate PBKDF2 to
// crypto.EncryptIfKeySet / crypto.DecryptIfKeySet (which apply a fresh random
// salt per ciphertext). Using a constant keeps wire-format assertions stable
// across runs.
var testEncryptionKey = "0123456789abcdef0123456789abcdef"

// mockCertRepo is a test implementation of CertificateRepository
type mockCertRepo struct {
	Certs              map[string]*domain.ManagedCertificate
	Versions           map[string][]*domain.CertificateVersion
	CreateErr          error
	UpdateErr          error
	GetErr             error
	ListErr            error
	ListVersionsErr    error
	ListVersionsResult []*domain.CertificateVersion
	CreateVersionErr   error
	ArchiveErr         error
	Updated            []*domain.ManagedCertificate
	MockGetExpiring    []*domain.ManagedCertificate
}

func (m *mockCertRepo) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
	if m.ListErr != nil {
		return nil, 0, m.ListErr
	}
	var certs []*domain.ManagedCertificate
	for _, c := range m.Certs {
		certs = append(certs, c)
	}
	return certs, len(certs), nil
}

func (m *mockCertRepo) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
	if m.GetErr != nil {
		return nil, m.GetErr
	}
	cert, ok := m.Certs[id]
	if !ok {
		return nil, errNotFound
	}
	return cert, nil
}

func (m *mockCertRepo) Create(ctx context.Context, cert *domain.ManagedCertificate) error {
	if m.CreateErr != nil {
		return m.CreateErr
	}
	m.Certs[cert.ID] = cert
	return nil
}

func (m *mockCertRepo) Update(ctx context.Context, cert *domain.ManagedCertificate) error {
	if m.UpdateErr != nil {
		return m.UpdateErr
	}
	m.Certs[cert.ID] = cert
	m.Updated = append(m.Updated, cert)
	return nil
}

func (m *mockCertRepo) Archive(ctx context.Context, id string) error {
	if m.ArchiveErr != nil {
		return m.ArchiveErr
	}
	cert, ok := m.Certs[id]
	if !ok {
		return errNotFound
	}
	cert.Status = domain.CertificateStatusArchived
	return nil
}

func (m *mockCertRepo) ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
	if m.ListVersionsErr != nil {
		return nil, m.ListVersionsErr
	}
	if m.ListVersionsResult != nil {
		return m.ListVersionsResult, nil
	}
	return m.Versions[certID], nil
}

func (m *mockCertRepo) CreateVersion(ctx context.Context, version *domain.CertificateVersion) error {
	if m.CreateVersionErr != nil {
		return m.CreateVersionErr
	}
	m.Versions[version.CertificateID] = append(m.Versions[version.CertificateID], version)
	return nil
}

func (m *mockCertRepo) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) {
	// Return MockGetExpiring if set, for test control
	if m.MockGetExpiring != nil {
		return m.MockGetExpiring, nil
	}
	var expiring []*domain.ManagedCertificate
	for _, c := range m.Certs {
		if c.ExpiresAt.Before(before) {
			expiring = append(expiring, c)
		}
	}
	return expiring, nil
}

func (m *mockCertRepo) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
	versions := m.Versions[certID]
	if len(versions) == 0 {
		return nil, errNotFound
	}
	return versions[len(versions)-1], nil
}

// GetByIssuerAndSerial emulates the PostgreSQL JOIN:
// SELECT mc.* FROM managed_certificates mc JOIN certificate_versions cv
// ON cv.certificate_id = mc.id WHERE mc.issuer_id = $1 AND cv.serial_number = $2.
// Returns sql.ErrNoRows (the sentinel the real repo surfaces) when no match
// exists, so callers that branch on errors.Is(err, sql.ErrNoRows) behave the
// same in-memory as they do against PostgreSQL.
func (m *mockCertRepo) GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.ManagedCertificate, error) {
	for _, cert := range m.Certs {
		if cert.IssuerID != issuerID {
			continue
		}
		for _, v := range m.Versions[cert.ID] {
			if v.SerialNumber == serial {
				return cert, nil
			}
		}
	}
	return nil, sql.ErrNoRows
}

func (m *mockCertRepo) AddCert(cert *domain.ManagedCertificate) {
	m.Certs[cert.ID] = cert
}

// mockJobRepo is a test implementation of JobRepository
type mockJobRepo struct {
	mu                sync.Mutex
	Jobs              map[string]*domain.Job
	StatusUpdates     map[string]domain.JobStatus
	CreateErr         error
	UpdateErr         error
	UpdateErrorByID   map[string]error
	UpdateErrorByIDMu sync.Mutex
	UpdateStatusErr   error
	GetErr            error
	ListErr           error
	ListByStatusErr   error
	DeleteErr         error
	ListTimedOutErr   error
	Updated           []*domain.Job
}

func (m *mockJobRepo) List(ctx context.Context) ([]*domain.Job, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	var jobs []*domain.Job
	for _, j := range m.Jobs {
		jobs = append(jobs, j)
	}
	return jobs, nil
}

func (m *mockJobRepo) Get(ctx context.Context, id string) (*domain.Job, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.GetErr != nil {
		return nil, m.GetErr
	}
	job, ok := m.Jobs[id]
	if !ok {
		return nil, errNotFound
	}
	return job, nil
}

func (m *mockJobRepo) Create(ctx context.Context, job *domain.Job) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.CreateErr != nil {
		return m.CreateErr
	}
	m.Jobs[job.ID] = job
	return nil
}

func (m *mockJobRepo) Update(ctx context.Context, job *domain.Job) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.UpdateErr != nil {
		return m.UpdateErr
	}
	// Check per-ID error injection
	m.UpdateErrorByIDMu.Lock()
	idErr, ok := m.UpdateErrorByID[job.ID]
	m.UpdateErrorByIDMu.Unlock()
	if ok && idErr != nil {
		return idErr
	}
	m.Jobs[job.ID] = job
	m.Updated = append(m.Updated, job)
	return nil
}

func (m *mockJobRepo) Delete(ctx context.Context, id string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.DeleteErr != nil {
		return m.DeleteErr
	}
	delete(m.Jobs, id)
	return nil
}

func (m *mockJobRepo) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListByStatusErr != nil {
		return nil, m.ListByStatusErr
	}
	var jobs []*domain.Job
	for _, j := range m.Jobs {
		if j.Status == status {
			jobs = append(jobs, j)
		}
	}
	return jobs, nil
}

func (m *mockJobRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	var jobs []*domain.Job
	for _, j := range m.Jobs {
		if j.CertificateID == certID {
			jobs = append(jobs, j)
		}
	}
	return jobs, nil
}

func (m *mockJobRepo) UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.UpdateStatusErr != nil {
		return m.UpdateStatusErr
	}
	job, ok := m.Jobs[id]
	if !ok {
		return errNotFound
	}
	job.Status = status
	if errMsg != "" {
		job.LastError = &errMsg
	}
	m.StatusUpdates[id] = status
	return nil
}

func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	var jobs []*domain.Job
	for _, j := range m.Jobs {
		if j.Type == jobType && j.Status == domain.JobStatusPending {
			jobs = append(jobs, j)
		}
	}
	return jobs, nil
}

func (m *mockJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	var result []*domain.Job
	for _, j := range m.Jobs {
		if j.AgentID != nil && *j.AgentID == agentID {
			if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
				result = append(result, j)
			} else if j.Status == domain.JobStatusAwaitingCSR {
				result = append(result, j)
			}
		}
	}
	return result, nil
}

// ClaimPendingJobs simulates the H-6 atomic claim semantics: matching rows are transitioned
// Pending → Running before being returned. The in-memory mock has no concurrency primitives
// beyond the existing mutex, which is sufficient for single-goroutine service tests.
func (m *mockJobRepo) ClaimPendingJobs(ctx context.Context, jobType domain.JobType, limit int) ([]*domain.Job, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	var claimed []*domain.Job
	for _, j := range m.Jobs {
		if j.Status != domain.JobStatusPending {
			continue
		}
		if jobType != "" && j.Type != jobType {
			continue
		}
		j.Status = domain.JobStatusRunning
		claimed = append(claimed, j)
		if limit > 0 && len(claimed) >= limit {
			break
		}
	}
	return claimed, nil
}

// ClaimPendingByAgentID simulates the H-6 per-agent claim: Pending deployment rows scoped
// to the agent flip to Running; AwaitingCSR rows are returned but keep their state.
func (m *mockJobRepo) ClaimPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	var result []*domain.Job
	for _, j := range m.Jobs {
		if j.AgentID == nil || *j.AgentID != agentID {
			continue
		}
		switch {
		case j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment:
			j.Status = domain.JobStatusRunning
			result = append(result, j)
		case j.Status == domain.JobStatusAwaitingCSR:
			result = append(result, j)
		}
	}
	return result, nil
}

// ListTimedOutAwaitingJobs returns jobs stuck in AwaitingCSR/AwaitingApproval past the
// respective cutoffs. I-003 coverage-gap closure.
func (m *mockJobRepo) ListTimedOutAwaitingJobs(ctx context.Context, csrCutoff, approvalCutoff time.Time) ([]*domain.Job, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListTimedOutErr != nil {
		return nil, m.ListTimedOutErr
	}
	var jobs []*domain.Job
	for _, j := range m.Jobs {
		switch j.Status {
		case domain.JobStatusAwaitingCSR:
			if j.CreatedAt.Before(csrCutoff) {
				jobs = append(jobs, j)
			}
		case domain.JobStatusAwaitingApproval:
			if j.CreatedAt.Before(approvalCutoff) {
				jobs = append(jobs, j)
			}
		}
	}
	return jobs, nil
}

func (m *mockJobRepo) AddJob(job *domain.Job) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Jobs[job.ID] = job
}

// mockNotifRepo is a test implementation of NotificationRepository.
//
// I-005 extensions (ListRetryEligible / RecordFailedAttempt / MarkAsDead /
// Requeue) mutate the seeded *domain.NotificationEvent pointers in place.
// The service tests in notification_test.go assert against those same
// pointers (via notifRepo.Notifications or the local `row` handle), so
// in-place mutation is the contract — not a copy-and-replace pattern.
//
// Error fields are layered:
//   - Per-method errors (ListRetryEligibleErr, RecordFailedAttemptErr, etc.)
//     for fine-grained failure injection when a test targets exactly one
//     method.
//   - Shared legacy errors (ListErr for list-shaped reads, UpdateErr for
//     update-shaped writes) so the pre-I-005 tests that configure ListErr
//     or UpdateErr continue to short-circuit the new methods too. The
//     RequeueNotification_RepoError test deliberately relies on this by
//     setting UpdateErr rather than RequeueErr.
type mockNotifRepo struct {
	mu            sync.Mutex
	Notifications []*domain.NotificationEvent
	CreateErr     error
	ListErr       error
	UpdateErr     error

	// I-005 per-method failure injection.
	ListRetryEligibleErr   error
	RecordFailedAttemptErr error
	MarkAsDeadErr          error
	RequeueErr             error
	CountByStatusErr       error
}

func (m *mockNotifRepo) Create(ctx context.Context, notif *domain.NotificationEvent) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.CreateErr != nil {
		return m.CreateErr
	}
	m.Notifications = append(m.Notifications, notif)
	return nil
}

func (m *mockNotifRepo) List(ctx context.Context, filter *repository.NotificationFilter) ([]*domain.NotificationEvent, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	return m.Notifications, nil
}

func (m *mockNotifRepo) UpdateStatus(ctx context.Context, id string, status string, sentAt time.Time) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.UpdateErr != nil {
		return m.UpdateErr
	}
	for _, n := range m.Notifications {
		if n.ID == id {
			n.Status = status
			return nil
		}
	}
	return errNotFound
}

// ListRetryEligible returns failed rows whose NextRetryAt is non-nil, at or
// before beforeTime, AND whose RetryCount is strictly less than maxAttempts,
// ordered oldest-due first, capped at limit. Signature matches the postgres-
// canonical shape pinned by notification_test.go:118 ("repo.ListRetryEligible
// (ctx, now, 5, 100)") and the NotificationRepository interface at
// interfaces.go:308 — a row at retry_count == maxAttempts is NOT returned
// because the service has already exhausted its attempt budget and the row
// must be MarkAsDead'd by whichever tick last touched it, not re-swept here.
// Mirrors the partial-index predicate
// `WHERE status='failed' AND next_retry_at IS NOT NULL AND next_retry_at <= $1`
// that migration 000016's retry-sweep index makes cheap to scan; the
// retry_count filter is an extra Go-side guard so the mock behaves
// identically to the postgres `AND retry_count < $2` clause.
func (m *mockNotifRepo) ListRetryEligible(ctx context.Context, beforeTime time.Time, maxAttempts, limit int) ([]*domain.NotificationEvent, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListRetryEligibleErr != nil {
		return nil, m.ListRetryEligibleErr
	}
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	eligible := make([]*domain.NotificationEvent, 0)
	for _, n := range m.Notifications {
		if n.Status != string(domain.NotificationStatusFailed) {
			continue
		}
		if n.NextRetryAt == nil {
			continue
		}
		if n.NextRetryAt.After(beforeTime) {
			continue
		}
		if n.RetryCount >= maxAttempts {
			continue
		}
		eligible = append(eligible, n)
	}
	// Oldest-due first so the service processes the most-overdue row first,
	// matching how an ORDER BY next_retry_at ASC query would behave.
	sort.Slice(eligible, func(i, j int) bool {
		return eligible[i].NextRetryAt.Before(*eligible[j].NextRetryAt)
	})
	if limit > 0 && len(eligible) > limit {
		eligible = eligible[:limit]
	}
	return eligible, nil
}

// RecordFailedAttempt mutates the matched row in place: increments
// retry_count, pins next_retry_at, stores last_error, and keeps the row in
// 'failed' state so the next retry-sweep tick picks it up again. Service-
// level backoff math happens before the call; the repo is a dumb setter.
// Signature matches the postgres-canonical shape pinned by
// notification_test.go:184 ("repo.RecordFailedAttempt(ctx, 'notif-attempt-1',
// 'connection refused', nextTry)") and the NotificationRepository interface
// at interfaces.go:315 — id, then lastError, then nextRetryAt. The earlier
// (id, nextRetryAt, lastError) ordering from the Phase 1 Red seed was wrong
// and is corrected here in Phase 2 Green.
func (m *mockNotifRepo) RecordFailedAttempt(ctx context.Context, id string, lastError string, nextRetryAt time.Time) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.RecordFailedAttemptErr != nil {
		return m.RecordFailedAttemptErr
	}
	if m.UpdateErr != nil {
		return m.UpdateErr
	}
	for _, n := range m.Notifications {
		if n.ID == id {
			n.RetryCount++
			next := nextRetryAt
			n.NextRetryAt = &next
			le := lastError
			n.LastError = &le
			n.Status = string(domain.NotificationStatusFailed)
			return nil
		}
	}
	return errNotFound
}

// MarkAsDead flips the row into the terminal DLQ state. next_retry_at is
// cleared so the partial retry-sweep index no longer touches this row —
// otherwise RetryFailedNotifications would loop over it forever without
// making any state change.
func (m *mockNotifRepo) MarkAsDead(ctx context.Context, id string, lastError string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.MarkAsDeadErr != nil {
		return m.MarkAsDeadErr
	}
	if m.UpdateErr != nil {
		return m.UpdateErr
	}
	for _, n := range m.Notifications {
		if n.ID == id {
			n.Status = string(domain.NotificationStatusDead)
			n.NextRetryAt = nil
			le := lastError
			n.LastError = &le
			return nil
		}
	}
	return errNotFound
}

// Requeue is the operator-driven escape hatch from 'dead' back to 'pending'.
// Clears retry bookkeeping entirely so ProcessPendingNotifications treats
// the requeued row as a fresh attempt — identical on the wire to a freshly-
// created notification.
func (m *mockNotifRepo) Requeue(ctx context.Context, id string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.RequeueErr != nil {
		return m.RequeueErr
	}
	if m.UpdateErr != nil {
		return m.UpdateErr
	}
	for _, n := range m.Notifications {
		if n.ID == id {
			n.Status = string(domain.NotificationStatusPending)
			n.RetryCount = 0
			n.NextRetryAt = nil
			n.LastError = nil
			return nil
		}
	}
	return errNotFound
}

func (m *mockNotifRepo) AddNotification(notif *domain.NotificationEvent) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Notifications = append(m.Notifications, notif)
}

// CountByStatus counts in-memory rows whose Status field matches exactly.
// Dedicated error injection via CountByStatusErr so a test can assert the
// StatsService wrap-path ("failed to count dead notifications: …") without
// also tripping ListErr or other shared fields. I-005 Phase 2 Green.
func (m *mockNotifRepo) CountByStatus(ctx context.Context, status string) (int64, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.CountByStatusErr != nil {
		return 0, m.CountByStatusErr
	}
	var count int64
	for _, n := range m.Notifications {
		if n.Status == status {
			count++
		}
	}
	return count, nil
}

// mockAuditRepo is a test implementation of AuditRepository
type mockAuditRepo struct {
	mu        sync.Mutex
	Events    []*domain.AuditEvent
	CreateErr error
	ListErr   error
}

func (m *mockAuditRepo) Create(ctx context.Context, event *domain.AuditEvent) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.CreateErr != nil {
		return m.CreateErr
	}
	m.Events = append(m.Events, event)
	return nil
}

func (m *mockAuditRepo) List(ctx context.Context, filter *repository.AuditFilter) ([]*domain.AuditEvent, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	// Apply filtering like the real repo
	var filtered []*domain.AuditEvent
	for _, e := range m.Events {
		if filter != nil {
			if filter.ResourceType != "" && e.ResourceType != filter.ResourceType {
				continue
			}
			if filter.ResourceID != "" && e.ResourceID != filter.ResourceID {
				continue
			}
			if filter.Actor != "" && e.Actor != filter.Actor {
				continue
			}
			if !filter.From.IsZero() && e.Timestamp.Before(filter.From) {
				continue
			}
			if !filter.To.IsZero() && e.Timestamp.After(filter.To) {
				continue
			}
		}
		filtered = append(filtered, e)
	}
	return filtered, nil
}

func (m *mockAuditRepo) AddEvent(event *domain.AuditEvent) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Events = append(m.Events, event)
}

// mockPolicyRepo is a test implementation of PolicyRepository
type mockPolicyRepo struct {
	Rules              map[string]*domain.PolicyRule
	Violations         []*domain.PolicyViolation
	CreateRuleErr      error
	UpdateRuleErr      error
	DeleteRuleErr      error
	GetRuleErr         error
	ListRulesErr       error
	CreateViolationErr error
	ListViolationsErr  error
}

func (m *mockPolicyRepo) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) {
	if m.ListRulesErr != nil {
		return nil, m.ListRulesErr
	}
	var rules []*domain.PolicyRule
	for _, r := range m.Rules {
		rules = append(rules, r)
	}
	return rules, nil
}

func (m *mockPolicyRepo) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) {
	if m.GetRuleErr != nil {
		return nil, m.GetRuleErr
	}
	rule, ok := m.Rules[id]
	if !ok {
		return nil, errNotFound
	}
	return rule, nil
}

func (m *mockPolicyRepo) CreateRule(ctx context.Context, rule *domain.PolicyRule) error {
	if m.CreateRuleErr != nil {
		return m.CreateRuleErr
	}
	m.Rules[rule.ID] = rule
	return nil
}

func (m *mockPolicyRepo) UpdateRule(ctx context.Context, rule *domain.PolicyRule) error {
	if m.UpdateRuleErr != nil {
		return m.UpdateRuleErr
	}
	m.Rules[rule.ID] = rule
	return nil
}

func (m *mockPolicyRepo) DeleteRule(ctx context.Context, id string) error {
	if m.DeleteRuleErr != nil {
		return m.DeleteRuleErr
	}
	delete(m.Rules, id)
	return nil
}

func (m *mockPolicyRepo) CreateViolation(ctx context.Context, violation *domain.PolicyViolation) error {
	if m.CreateViolationErr != nil {
		return m.CreateViolationErr
	}
	m.Violations = append(m.Violations, violation)
	return nil
}

func (m *mockPolicyRepo) ListViolations(ctx context.Context, filter *repository.AuditFilter) ([]*domain.PolicyViolation, error) {
	if m.ListViolationsErr != nil {
		return nil, m.ListViolationsErr
	}
	return m.Violations, nil
}

func (m *mockPolicyRepo) AddRule(rule *domain.PolicyRule) {
	m.Rules[rule.ID] = rule
}

// mockRenewalPolicyRepo is a test implementation of RenewalPolicyRepository.
//
// G-1: repo contract extended with Create/Update/Delete to support the
// /api/v1/renewal-policies CRUD endpoints. Per-method *Err fields let tests
// force specific repo failures (duplicate name → 23505, FK RESTRICT on Delete
// → 23503) without standing up a real Postgres connection. The sentinel
// errors `ErrRenewalPolicyDuplicateName` and `ErrRenewalPolicyInUse` are the
// typed envelopes the service / handler layers translate into 409 Conflict.
type mockRenewalPolicyRepo struct {
	Policies  map[string]*domain.RenewalPolicy
	GetErr    error
	ListErr   error
	CreateErr error
	UpdateErr error
	DeleteErr error
}

func (m *mockRenewalPolicyRepo) Get(ctx context.Context, id string) (*domain.RenewalPolicy, error) {
	if m.GetErr != nil {
		return nil, m.GetErr
	}
	policy, ok := m.Policies[id]
	if !ok {
		return nil, errNotFound
	}
	return policy, nil
}

func (m *mockRenewalPolicyRepo) List(ctx context.Context) ([]*domain.RenewalPolicy, error) {
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	var policies []*domain.RenewalPolicy
	for _, p := range m.Policies {
		policies = append(policies, p)
	}
	// Deterministic ordering mirrors the production repo's ORDER BY name,
	// so pagination-boundary assertions don't become flaky under map
	// iteration randomness.
	sort.Slice(policies, func(i, j int) bool {
		return policies[i].Name < policies[j].Name
	})
	return policies, nil
}

func (m *mockRenewalPolicyRepo) Create(ctx context.Context, policy *domain.RenewalPolicy) error {
	if m.CreateErr != nil {
		return m.CreateErr
	}
	if _, exists := m.Policies[policy.ID]; exists {
		return m.CreateErr
	}
	m.Policies[policy.ID] = policy
	return nil
}

func (m *mockRenewalPolicyRepo) Update(ctx context.Context, id string, policy *domain.RenewalPolicy) error {
	if m.UpdateErr != nil {
		return m.UpdateErr
	}
	if _, exists := m.Policies[id]; !exists {
		return errNotFound
	}
	policy.ID = id
	m.Policies[id] = policy
	return nil
}

func (m *mockRenewalPolicyRepo) Delete(ctx context.Context, id string) error {
	if m.DeleteErr != nil {
		return m.DeleteErr
	}
	if _, exists := m.Policies[id]; !exists {
		return errNotFound
	}
	delete(m.Policies, id)
	return nil
}

func (m *mockRenewalPolicyRepo) AddPolicy(policy *domain.RenewalPolicy) {
	m.Policies[policy.ID] = policy
}

// mockAgentRepo is a test implementation of AgentRepository.
//
// I-004: ActiveTargetCounts / ActiveCertCounts / PendingJobCounts are keyed by
// agent ID and read back verbatim by the Count* methods — the retirement
// service's preflight pokes these maps to simulate "agent has N active
// deployments / M deployed certs / K pending jobs" without having to seed
// real target/cert/job rows across multiple mock repos. An unset key means
// zero, matching the production repo behavior on an agent with no deps.
type mockAgentRepo struct {
	mu                 sync.Mutex
	Agents             map[string]*domain.Agent
	HeartbeatUpdates   map[string]time.Time
	CreateErr          error
	UpdateErr          error
	DeleteErr          error
	GetErr             error
	ListErr            error
	UpdateHeartbeatErr error
	GetByAPIKeyErr     error
	// I-004 preflight count seeds (read by CountActiveTargets etc.).
	ActiveTargetCounts map[string]int
	ActiveCertCounts   map[string]int
	PendingJobCounts   map[string]int
	// I-004 retirement write-path error seams. Let tests force a SoftRetire
	// or RetireAgentWithCascade failure after preflight passed, so the
	// service's error surfacing (wrap+return, skip audit, etc.) can be
	// exercised without having to stand up a real PG connection.
	SoftRetireErr    error
	RetireCascadeErr error
	CountErr         error
	ListRetiredErr   error
}

// List mirrors the production repo contract post-I-004: it returns only
// ACTIVE agents (RetiredAt == nil). Tests that seed a retired agent via
// AddAgent and then call a List-driven service method (e.g. ListAgents,
// MarkStaleAgentsOffline, stats dashboards) must not see the retired row
// here — otherwise the mock would pass while the real planner filters it
// out at the WHERE clause level. ListRetired is the companion method for
// explicit retired-only listing.
func (m *mockAgentRepo) List(ctx context.Context) ([]*domain.Agent, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	var agents []*domain.Agent
	for _, a := range m.Agents {
		if a.RetiredAt != nil {
			continue
		}
		agents = append(agents, a)
	}
	return agents, nil
}

func (m *mockAgentRepo) Get(ctx context.Context, id string) (*domain.Agent, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.GetErr != nil {
		return nil, m.GetErr
	}
	agent, ok := m.Agents[id]
	if !ok {
		return nil, errNotFound
	}
	return agent, nil
}

func (m *mockAgentRepo) Create(ctx context.Context, agent *domain.Agent) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.CreateErr != nil {
		return m.CreateErr
	}
	m.Agents[agent.ID] = agent
	return nil
}

func (m *mockAgentRepo) CreateIfNotExists(ctx context.Context, agent *domain.Agent) (bool, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.CreateErr != nil {
		return false, m.CreateErr
	}
	if _, exists := m.Agents[agent.ID]; exists {
		return false, nil
	}
	m.Agents[agent.ID] = agent
	return true, nil
}

func (m *mockAgentRepo) Update(ctx context.Context, agent *domain.Agent) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.UpdateErr != nil {
		return m.UpdateErr
	}
	m.Agents[agent.ID] = agent
	return nil
}

func (m *mockAgentRepo) Delete(ctx context.Context, id string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.DeleteErr != nil {
		return m.DeleteErr
	}
	delete(m.Agents, id)
	return nil
}

func (m *mockAgentRepo) UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.UpdateHeartbeatErr != nil {
		return m.UpdateHeartbeatErr
	}
	agent, ok := m.Agents[id]
	if !ok {
		return errNotFound
	}
	now := time.Now()
	agent.LastHeartbeatAt = &now
	m.HeartbeatUpdates[id] = now
	return nil
}

func (m *mockAgentRepo) GetByAPIKey(ctx context.Context, keyHash string) (*domain.Agent, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.GetByAPIKeyErr != nil {
		return nil, m.GetByAPIKeyErr
	}
	for _, a := range m.Agents {
		if a.APIKeyHash == keyHash {
			return a, nil
		}
	}
	return nil, errNotFound
}

func (m *mockAgentRepo) AddAgent(agent *domain.Agent) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Agents[agent.ID] = agent
}

// ListRetired returns the paginated retired-agents slice + total count.
// Matches the production repo contract: RetiredAt != nil, sorted by
// RetiredAt DESC, page<1 → 1, perPage<1 → 50. Sort is done in-memory over
// the keyed map so the mock stays dependency-free. I-004.
func (m *mockAgentRepo) ListRetired(ctx context.Context, page, perPage int) ([]*domain.Agent, int, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListRetiredErr != nil {
		return nil, 0, m.ListRetiredErr
	}
	if page < 1 {
		page = 1
	}
	if perPage < 1 {
		perPage = 50
	}
	var retired []*domain.Agent
	for _, a := range m.Agents {
		if a.RetiredAt != nil {
			retired = append(retired, a)
		}
	}
	total := len(retired)
	// Sort by RetiredAt DESC — most recent first. The real query uses the
	// partial idx_agents_retired_at index; here we sort in Go.
	sort.SliceStable(retired, func(i, j int) bool {
		return retired[i].RetiredAt.After(*retired[j].RetiredAt)
	})
	// Apply page/perPage window.
	offset := (page - 1) * perPage
	if offset >= total {
		return nil, total, nil
	}
	end := offset + perPage
	if end > total {
		end = total
	}
	return retired[offset:end], total, nil
}

// SoftRetire stamps RetiredAt + RetiredReason on the agent row. Mirrors
// the real repo's idempotent semantics: a row already retired is left
// untouched (zero-rows-affected is not an error). I-004 preserves
// retirement metadata across re-retire attempts — whoever retired it
// first owns the audit trail.
func (m *mockAgentRepo) SoftRetire(ctx context.Context, id string, retiredAt time.Time, reason string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.SoftRetireErr != nil {
		return m.SoftRetireErr
	}
	agent, ok := m.Agents[id]
	if !ok {
		return errNotFound
	}
	if agent.RetiredAt != nil {
		return nil // already retired — no-op
	}
	stamped := retiredAt
	agent.RetiredAt = &stamped
	stampedReason := reason
	agent.RetiredReason = &stampedReason
	return nil
}

// RetireAgentWithCascade stamps the agent row the same way SoftRetire
// does. The real repo also stamps every active deployment_targets row
// in the same transaction; the mock can't do that because targets live
// in mockTargetRepo, which the retirement service doesn't write to
// through this repo interface. Tests that need to assert cascade
// semantics on targets should seed mockTargetRepo directly and verify
// the service-layer audit event captured the cascade count. I-004.
func (m *mockAgentRepo) RetireAgentWithCascade(ctx context.Context, id string, retiredAt time.Time, reason string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.RetireCascadeErr != nil {
		return m.RetireCascadeErr
	}
	agent, ok := m.Agents[id]
	if !ok {
		return errNotFound
	}
	if agent.RetiredAt != nil {
		return nil // already retired — no-op (same as production transaction)
	}
	stamped := retiredAt
	agent.RetiredAt = &stamped
	stampedReason := reason
	agent.RetiredReason = &stampedReason
	return nil
}

// CountActiveTargets returns the seeded ActiveTargetCounts value (0 if
// unset). Matches the real repo signature: COUNT of non-retired
// deployment_targets with agent_id=$1. I-004 preflight.
func (m *mockAgentRepo) CountActiveTargets(ctx context.Context, agentID string) (int, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.CountErr != nil {
		return 0, m.CountErr
	}
	return m.ActiveTargetCounts[agentID], nil
}

// CountActiveCertificates returns the seeded ActiveCertCounts value.
// Real query: COUNT(DISTINCT certificate_id) across
// certificate_target_mappings ↔ deployment_targets on agent_id. I-004.
func (m *mockAgentRepo) CountActiveCertificates(ctx context.Context, agentID string) (int, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.CountErr != nil {
		return 0, m.CountErr
	}
	return m.ActiveCertCounts[agentID], nil
}

// CountPendingJobs returns the seeded PendingJobCounts value. Real
// query: COUNT of jobs with agent_id=$1 AND status IN (Pending,
// AwaitingCSR, AwaitingApproval, Running). I-004.
func (m *mockAgentRepo) CountPendingJobs(ctx context.Context, agentID string) (int, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.CountErr != nil {
		return 0, m.CountErr
	}
	return m.PendingJobCounts[agentID], nil
}

// mockTargetRepo is a test implementation of TargetRepository
type mockTargetRepo struct {
	mu            sync.Mutex
	Targets       map[string]*domain.DeploymentTarget
	CreateErr     error
	UpdateErr     error
	DeleteErr     error
	GetErr        error
	ListErr       error
	ListByCertErr error
}

func (m *mockTargetRepo) List(ctx context.Context) ([]*domain.DeploymentTarget, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	var targets []*domain.DeploymentTarget
	for _, t := range m.Targets {
		targets = append(targets, t)
	}
	return targets, nil
}

func (m *mockTargetRepo) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.GetErr != nil {
		return nil, m.GetErr
	}
	target, ok := m.Targets[id]
	if !ok {
		return nil, errNotFound
	}
	return target, nil
}

func (m *mockTargetRepo) Create(ctx context.Context, target *domain.DeploymentTarget) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.CreateErr != nil {
		return m.CreateErr
	}
	m.Targets[target.ID] = target
	return nil
}

func (m *mockTargetRepo) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.CreateErr != nil {
		return false, m.CreateErr
	}
	if _, exists := m.Targets[target.ID]; exists {
		return false, nil
	}
	m.Targets[target.ID] = target
	return true, nil
}

func (m *mockTargetRepo) Update(ctx context.Context, target *domain.DeploymentTarget) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.UpdateErr != nil {
		return m.UpdateErr
	}
	m.Targets[target.ID] = target
	return nil
}

func (m *mockTargetRepo) Delete(ctx context.Context, id string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.DeleteErr != nil {
		return m.DeleteErr
	}
	delete(m.Targets, id)
	return nil
}

func (m *mockTargetRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.ListByCertErr != nil {
		return nil, m.ListByCertErr
	}
	// Don't call List again to avoid double-locking
	var targets []*domain.DeploymentTarget
	for _, t := range m.Targets {
		targets = append(targets, t)
	}
	return targets, nil
}

func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Targets[target.ID] = target
}

func newMockTargetRepository() *mockTargetRepo {
	return &mockTargetRepo{
		Targets: make(map[string]*domain.DeploymentTarget),
	}
}

// mockIssuerConnector is a test implementation of IssuerConnector
type mockIssuerConnector struct {
	Result               *IssuanceResult
	Err                  error
	getRenewalInfoResult *RenewalInfoResult
	getRenewalInfoErr    error
	// LastOCSPSignRequest captures the last request passed to SignOCSPResponse.
	// Tests use this to assert CertStatus (0=good, 1=revoked, 2=unknown).
	LastOCSPSignRequest *OCSPSignRequest
}

func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) {
	if m.Err != nil {
		return nil, m.Err
	}
	if m.Result != nil {
		return m.Result, nil
	}
	now := time.Now()
	return &IssuanceResult{
		Serial:    "test-serial-123",
		CertPEM:   "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
		ChainPEM:  "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----",
		NotBefore: now,
		NotAfter:  now.AddDate(1, 0, 0),
	}, nil
}

func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string, maxTTLSeconds int) (*IssuanceResult, error) {
	if m.Err != nil {
		return nil, m.Err
	}
	return m.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds)
}

func (m *mockIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error {
	if m.Err != nil {
		return m.Err
	}
	return nil
}

func (m *mockIssuerConnector) GenerateCRL(ctx context.Context, entries []CRLEntry) ([]byte, error) {
	if m.Err != nil {
		return nil, m.Err
	}
	return []byte("-----BEGIN X509 CRL-----\nmock-crl-data\n-----END X509 CRL-----"), nil
}

func (m *mockIssuerConnector) SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error) {
	// Capture the request for test assertions (e.g., CertStatus verification)
	reqCopy := req
	m.LastOCSPSignRequest = &reqCopy
	if m.Err != nil {
		return nil, m.Err
	}
	return []byte("mock-ocsp-response"), nil
}

func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error) {
	if m.Err != nil {
		return "", m.Err
	}
	return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil
}

func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
	if m.getRenewalInfoErr != nil {
		return nil, m.getRenewalInfoErr
	}
	if m.getRenewalInfoResult != nil {
		return m.getRenewalInfoResult, nil
	}
	// Default: return nil, nil (issuer does not support ARI)
	return nil, nil
}

// Constructor functions for mocks

func newMockCertificateRepository() *mockCertRepo {
	return &mockCertRepo{
		Certs:    make(map[string]*domain.ManagedCertificate),
		Versions: make(map[string][]*domain.CertificateVersion),
	}
}

func newMockJobRepository() *mockJobRepo {
	return &mockJobRepo{
		Jobs:          make(map[string]*domain.Job),
		StatusUpdates: make(map[string]domain.JobStatus),
	}
}

func newMockNotificationRepository() *mockNotifRepo {
	return &mockNotifRepo{
		Notifications: make([]*domain.NotificationEvent, 0),
	}
}

func newMockAuditRepository() *mockAuditRepo {
	return &mockAuditRepo{
		Events: make([]*domain.AuditEvent, 0),
	}
}

func newMockPolicyRepository() *mockPolicyRepo {
	return &mockPolicyRepo{
		Rules:      make(map[string]*domain.PolicyRule),
		Violations: make([]*domain.PolicyViolation, 0),
	}
}

func newMockRenewalPolicyRepository() *mockRenewalPolicyRepo {
	return &mockRenewalPolicyRepo{
		Policies: make(map[string]*domain.RenewalPolicy),
	}
}

func newMockAgentRepository() *mockAgentRepo {
	return &mockAgentRepo{
		Agents:           make(map[string]*domain.Agent),
		HeartbeatUpdates: make(map[string]time.Time),
		// I-004 preflight count maps. Tests seed these directly via
		// agentRepo.ActiveTargetCounts["agent-id"] = N — unset keys
		// read back as zero from CountActiveTargets etc., matching
		// the production repo behavior for agents with no deps.
		ActiveTargetCounts: make(map[string]int),
		ActiveCertCounts:   make(map[string]int),
		PendingJobCounts:   make(map[string]int),
	}
}

var _ = func() *mockTargetRepo {
	return &mockTargetRepo{
		Targets: make(map[string]*domain.DeploymentTarget),
	}
}

func newMockIssuerRepository() *mockIssuerRepository {
	return &mockIssuerRepository{
		issuers: make(map[string]*domain.Issuer),
	}
}

// mockIssuerRepository is a test implementation of IssuerRepository
type mockIssuerRepository struct {
	issuers   map[string]*domain.Issuer
	GetErr    error
	ListErr   error
	CreateErr error
	UpdateErr error
	DeleteErr error
}

func (m *mockIssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) {
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	var issuers []*domain.Issuer
	for _, i := range m.issuers {
		issuers = append(issuers, i)
	}
	return issuers, nil
}

func (m *mockIssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) {
	if m.GetErr != nil {
		return nil, m.GetErr
	}
	issuer, ok := m.issuers[id]
	if !ok {
		return nil, errNotFound
	}
	return issuer, nil
}

func (m *mockIssuerRepository) Create(ctx context.Context, issuer *domain.Issuer) error {
	if m.CreateErr != nil {
		return m.CreateErr
	}
	m.issuers[issuer.ID] = issuer
	return nil
}

func (m *mockIssuerRepository) Update(ctx context.Context, issuer *domain.Issuer) error {
	if m.UpdateErr != nil {
		return m.UpdateErr
	}
	m.issuers[issuer.ID] = issuer
	return nil
}

func (m *mockIssuerRepository) CreateIfNotExists(ctx context.Context, issuer *domain.Issuer) (bool, error) {
	if m.CreateErr != nil {
		return false, m.CreateErr
	}
	if _, exists := m.issuers[issuer.ID]; exists {
		return false, nil
	}
	m.issuers[issuer.ID] = issuer
	return true, nil
}

func (m *mockIssuerRepository) Delete(ctx context.Context, id string) error {
	if m.DeleteErr != nil {
		return m.DeleteErr
	}
	delete(m.issuers, id)
	return nil
}

func (m *mockIssuerRepository) AddIssuer(issuer *domain.Issuer) {
	m.issuers[issuer.ID] = issuer
}

// mockRevocationRepo is a test implementation of RevocationRepository
type mockRevocationRepo struct {
	Revocations []*domain.CertificateRevocation
	CreateErr   error
	ListErr     error
	// F-001 regression instrumentation: track which list method was invoked
	// so tests can assert that the CRL generation hot path uses the scoped
	// ListByIssuer query (migration 000012 composite index) rather than
	// ListAll followed by in-Go filtering.
	ListAllCalls      int
	ListByIssuerCalls int
	LastListIssuerID  string
}

func (m *mockRevocationRepo) Create(ctx context.Context, revocation *domain.CertificateRevocation) error {
	if m.CreateErr != nil {
		return m.CreateErr
	}
	m.Revocations = append(m.Revocations, revocation)
	return nil
}

func (m *mockRevocationRepo) GetByIssuerAndSerial(ctx context.Context, issuerID, serial string) (*domain.CertificateRevocation, error) {
	for _, r := range m.Revocations {
		if r.IssuerID == issuerID && r.SerialNumber == serial {
			return r, nil
		}
	}
	return nil, errNotFound
}

func (m *mockRevocationRepo) ListAll(ctx context.Context) ([]*domain.CertificateRevocation, error) {
	m.ListAllCalls++
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	return m.Revocations, nil
}

func (m *mockRevocationRepo) ListByIssuer(ctx context.Context, issuerID string) ([]*domain.CertificateRevocation, error) {
	m.ListByIssuerCalls++
	m.LastListIssuerID = issuerID
	if m.ListErr != nil {
		return nil, m.ListErr
	}
	var result []*domain.CertificateRevocation
	for _, r := range m.Revocations {
		if r.IssuerID == issuerID {
			result = append(result, r)
		}
	}
	return result, nil
}

func (m *mockRevocationRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.CertificateRevocation, error) {
	var result []*domain.CertificateRevocation
	for _, r := range m.Revocations {
		if r.CertificateID == certID {
			result = append(result, r)
		}
	}
	return result, nil
}

func (m *mockRevocationRepo) MarkIssuerNotified(ctx context.Context, id string) error {
	for _, r := range m.Revocations {
		if r.ID == id {
			r.IssuerNotified = true
			return nil
		}
	}
	return errNotFound
}

func newMockRevocationRepository() *mockRevocationRepo {
	return &mockRevocationRepo{
		Revocations: make([]*domain.CertificateRevocation, 0),
	}
}

// mockNotifier is a simple notifier for testing
type mockNotifier struct {
	mu       sync.Mutex
	messages []*mockNotifierMessage
	SendErr  error
}

type mockNotifierMessage struct {
	Recipient string
	Subject   string
	Body      string
}

func newMockNotifier() *mockNotifier {
	return &mockNotifier{
		messages: make([]*mockNotifierMessage, 0),
	}
}

func (m *mockNotifier) Send(ctx context.Context, recipient string, subject string, body string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.SendErr != nil {
		return m.SendErr
	}
	m.messages = append(m.messages, &mockNotifierMessage{
		Recipient: recipient,
		Subject:   subject,
		Body:      body,
	})
	return nil
}

func (m *mockNotifier) Channel() string {
	return "Email"
}

func (m *mockNotifier) getSentCount() int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return len(m.messages)
}

var _ = func(m *mockNotifier) *mockNotifierMessage {
	if len(m.messages) == 0 {
		return nil
	}
	return m.messages[len(m.messages)-1]
}
