package cards

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/jackc/pgx/v5"

	"github.com/niels/banking-app/backend/internal/domain"
	"github.com/niels/banking-app/backend/internal/httpapi/middleware"
	"github.com/niels/banking-app/backend/internal/ledger"
	"github.com/niels/banking-app/backend/internal/respond"
)

type ProcessorConfig struct {
	ID                         string     `json:"id"`
	Provider                   string     `json:"provider"`
	ProcessorName              string     `json:"processor_name"`
	OperatingModel             string     `json:"operating_model"`
	Status                     string     `json:"status"`
	ContractReference          string     `json:"contract_reference,omitempty"`
	ContractSignedAt           *time.Time `json:"contract_signed_at,omitempty"`
	APIBaseURL                 string     `json:"api_base_url,omitempty"`
	WebhookSigningKeyReference string     `json:"webhook_signing_key_reference,omitempty"`
	CertificationReference     string     `json:"certification_reference,omitempty"`
	RealTrafficEnabled         bool       `json:"real_traffic_enabled"`
	CreatedAt                  time.Time  `json:"created_at"`
	UpdatedAt                  time.Time  `json:"updated_at"`
}

type PCIAssessment struct {
	ID                    string     `json:"id"`
	AssessmentName        string     `json:"assessment_name"`
	ScopeStatus           string     `json:"scope_status"`
	Assessor              string     `json:"assessor,omitempty"`
	QSAReference          string     `json:"qsa_reference,omitempty"`
	ScopeModel            string     `json:"scope_model"`
	StoresPAN             bool       `json:"stores_pan"`
	StoresCVV             bool       `json:"stores_cvv"`
	PANDisclosureAllowed  bool       `json:"pan_disclosure_allowed"`
	EvidenceReference     string     `json:"evidence_reference,omitempty"`
	ApprovedByAdminUserID string     `json:"approved_by_admin_user_id,omitempty"`
	ApprovedAt            *time.Time `json:"approved_at,omitempty"`
	CreatedAt             time.Time  `json:"created_at"`
	UpdatedAt             time.Time  `json:"updated_at"`
}

type TokenizationPolicy struct {
	ID                   string    `json:"id"`
	PolicyName           string    `json:"policy_name"`
	TokenVaultProvider   string    `json:"token_vault_provider"`
	TokenType            string    `json:"token_type"`
	KeyReference         string    `json:"key_reference"`
	RotationIntervalDays int       `json:"rotation_interval_days"`
	Status               string    `json:"status"`
	PCIScopeAssessmentID string    `json:"pci_scope_assessment_id,omitempty"`
	Notes                string    `json:"notes,omitempty"`
	CreatedAt            time.Time `json:"created_at"`
	UpdatedAt            time.Time `json:"updated_at"`
}

type processorConfigRequest struct {
	Provider                   string `json:"provider"`
	ProcessorName              string `json:"processor_name"`
	OperatingModel             string `json:"operating_model"`
	Status                     string `json:"status"`
	ContractReference          string `json:"contract_reference"`
	ContractSignedAt           string `json:"contract_signed_at"`
	APIBaseURL                 string `json:"api_base_url"`
	WebhookSigningKeyReference string `json:"webhook_signing_key_reference"`
	CertificationReference     string `json:"certification_reference"`
	RealTrafficEnabled         bool   `json:"real_traffic_enabled"`
}

type pciAssessmentRequest struct {
	AssessmentName       string `json:"assessment_name"`
	ScopeStatus          string `json:"scope_status"`
	Assessor             string `json:"assessor"`
	QSAReference         string `json:"qsa_reference"`
	ScopeModel           string `json:"scope_model"`
	StoresPAN            bool   `json:"stores_pan"`
	StoresCVV            bool   `json:"stores_cvv"`
	PANDisclosureAllowed bool   `json:"pan_disclosure_allowed"`
	EvidenceReference    string `json:"evidence_reference"`
}

type tokenizationPolicyRequest struct {
	PolicyName           string `json:"policy_name"`
	TokenVaultProvider   string `json:"token_vault_provider"`
	TokenType            string `json:"token_type"`
	KeyReference         string `json:"key_reference"`
	RotationIntervalDays int    `json:"rotation_interval_days"`
	Status               string `json:"status"`
	PCIScopeAssessmentID string `json:"pci_scope_assessment_id"`
	Notes                string `json:"notes"`
}

type createDisputeRequest struct {
	ReasonCode        string `json:"reason_code"`
	AmountCents       int64  `json:"amount_cents"`
	Description       string `json:"description"`
	ProvisionalCredit bool   `json:"provisional_credit"`
}

type disputeDecisionRequest struct {
	Status       string `json:"status"`
	DecisionNote string `json:"decision_note"`
}

func (r *Repository) ListProcessorConfigs(ctx context.Context, limit int) ([]ProcessorConfig, error) {
	limit = normalizeLimit(limit)
	rows, err := r.db.Query(ctx, `
		SELECT id::text, provider, processor_name, operating_model, status, contract_reference,
			contract_signed_at, api_base_url, webhook_signing_key_reference, certification_reference,
			real_traffic_enabled, created_at, updated_at
		FROM card_processor_configs
		ORDER BY created_at DESC
		LIMIT $1
	`, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	configs := []ProcessorConfig{}
	for rows.Next() {
		config, err := scanProcessorConfig(rows)
		if err != nil {
			return nil, err
		}
		configs = append(configs, config)
	}
	return configs, rows.Err()
}

func (r *Repository) UpsertProcessorConfig(ctx context.Context, req processorConfigRequest) (ProcessorConfig, error) {
	signedAt, err := parseOptionalRFC3339("contract_signed_at", req.ContractSignedAt)
	if err != nil {
		return ProcessorConfig{}, err
	}
	req.Provider = strings.ToLower(strings.TrimSpace(req.Provider))
	req.ProcessorName = strings.TrimSpace(req.ProcessorName)
	req.OperatingModel = strings.ToLower(strings.TrimSpace(req.OperatingModel))
	req.Status = strings.ToLower(strings.TrimSpace(req.Status))
	if req.Status == "" {
		req.Status = "draft"
	}
	if req.Provider == "" || req.ProcessorName == "" {
		return ProcessorConfig{}, fmt.Errorf("%w: provider and processor_name are required", domain.ErrValidation)
	}
	if req.OperatingModel != "simulator" && req.OperatingModel != "issuer_processor" && req.OperatingModel != "processor_only" && req.OperatingModel != "issuer_sponsor" {
		return ProcessorConfig{}, fmt.Errorf("%w: invalid operating_model", domain.ErrValidation)
	}
	if req.Status != "draft" && req.Status != "contracted" && req.Status != "certified" && req.Status != "disabled" {
		return ProcessorConfig{}, fmt.Errorf("%w: invalid processor status", domain.ErrValidation)
	}
	if req.RealTrafficEnabled && (req.Status != "certified" || strings.TrimSpace(req.ContractReference) == "" || strings.TrimSpace(req.CertificationReference) == "") {
		return ProcessorConfig{}, fmt.Errorf("%w: real_traffic_enabled requires certified status, contract_reference and certification_reference", domain.ErrValidation)
	}
	row := r.db.QueryRow(ctx, `
		INSERT INTO card_processor_configs (
			provider, processor_name, operating_model, status, contract_reference, contract_signed_at,
			api_base_url, webhook_signing_key_reference, certification_reference, real_traffic_enabled
		)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
		ON CONFLICT (provider) DO UPDATE
		SET processor_name = EXCLUDED.processor_name,
			operating_model = EXCLUDED.operating_model,
			status = EXCLUDED.status,
			contract_reference = EXCLUDED.contract_reference,
			contract_signed_at = EXCLUDED.contract_signed_at,
			api_base_url = EXCLUDED.api_base_url,
			webhook_signing_key_reference = EXCLUDED.webhook_signing_key_reference,
			certification_reference = EXCLUDED.certification_reference,
			real_traffic_enabled = EXCLUDED.real_traffic_enabled
		RETURNING id::text, provider, processor_name, operating_model, status, contract_reference,
			contract_signed_at, api_base_url, webhook_signing_key_reference, certification_reference,
			real_traffic_enabled, created_at, updated_at
	`, req.Provider, req.ProcessorName, req.OperatingModel, req.Status, strings.TrimSpace(req.ContractReference), signedAt,
		strings.TrimSpace(req.APIBaseURL), strings.TrimSpace(req.WebhookSigningKeyReference),
		strings.TrimSpace(req.CertificationReference), req.RealTrafficEnabled)
	return scanProcessorConfig(row)
}

func (r *Repository) ListPCIAssessments(ctx context.Context, limit int) ([]PCIAssessment, error) {
	limit = normalizeLimit(limit)
	rows, err := r.db.Query(ctx, `
		SELECT id::text, assessment_name, scope_status, assessor, qsa_reference, scope_model,
			stores_pan, stores_cvv, pan_disclosure_allowed, evidence_reference,
			COALESCE(approved_by_admin_user_id::text, ''), approved_at, created_at, updated_at
		FROM card_pci_scope_assessments
		ORDER BY created_at DESC
		LIMIT $1
	`, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	assessments := []PCIAssessment{}
	for rows.Next() {
		assessment, err := scanPCIAssessment(rows)
		if err != nil {
			return nil, err
		}
		assessments = append(assessments, assessment)
	}
	return assessments, rows.Err()
}

func (r *Repository) UpsertPCIAssessment(ctx context.Context, adminUserID string, req pciAssessmentRequest) (PCIAssessment, error) {
	req.AssessmentName = strings.TrimSpace(req.AssessmentName)
	req.ScopeStatus = strings.ToLower(strings.TrimSpace(req.ScopeStatus))
	req.ScopeModel = strings.ToLower(strings.TrimSpace(req.ScopeModel))
	if req.AssessmentName == "" {
		return PCIAssessment{}, fmt.Errorf("%w: assessment_name is required", domain.ErrValidation)
	}
	if req.ScopeStatus != "draft" && req.ScopeStatus != "in_review" && req.ScopeStatus != "approved" && req.ScopeStatus != "rejected" {
		return PCIAssessment{}, fmt.Errorf("%w: invalid scope_status", domain.ErrValidation)
	}
	if req.ScopeModel != "provider_hosted" && req.ScopeModel != "token_only" && req.ScopeModel != "cardholder_data_environment" {
		return PCIAssessment{}, fmt.Errorf("%w: invalid scope_model", domain.ErrValidation)
	}
	if req.StoresCVV {
		return PCIAssessment{}, fmt.Errorf("%w: storing CVV is not allowed", domain.ErrValidation)
	}
	if req.ScopeStatus == "approved" && (strings.TrimSpace(req.QSAReference) == "" || strings.TrimSpace(req.EvidenceReference) == "") {
		return PCIAssessment{}, fmt.Errorf("%w: approved PCI scope requires qsa_reference and evidence_reference", domain.ErrValidation)
	}
	var approvedBy any
	var approvedAt any
	if req.ScopeStatus == "approved" {
		approvedBy = adminUserID
		approvedAt = time.Now().UTC()
	}
	row := r.db.QueryRow(ctx, `
		INSERT INTO card_pci_scope_assessments (
			assessment_name, scope_status, assessor, qsa_reference, scope_model, stores_pan, stores_cvv,
			pan_disclosure_allowed, evidence_reference, approved_by_admin_user_id, approved_at
		)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
		RETURNING id::text, assessment_name, scope_status, assessor, qsa_reference, scope_model,
			stores_pan, stores_cvv, pan_disclosure_allowed, evidence_reference,
			COALESCE(approved_by_admin_user_id::text, ''), approved_at, created_at, updated_at
	`, req.AssessmentName, req.ScopeStatus, strings.TrimSpace(req.Assessor), strings.TrimSpace(req.QSAReference),
		req.ScopeModel, req.StoresPAN, req.StoresCVV, req.PANDisclosureAllowed,
		strings.TrimSpace(req.EvidenceReference), approvedBy, approvedAt)
	return scanPCIAssessment(row)
}

func (r *Repository) ListTokenizationPolicies(ctx context.Context, limit int) ([]TokenizationPolicy, error) {
	limit = normalizeLimit(limit)
	rows, err := r.db.Query(ctx, `
		SELECT id::text, policy_name, token_vault_provider, token_type, key_reference,
			rotation_interval_days, status, COALESCE(pci_scope_assessment_id::text, ''), notes,
			created_at, updated_at
		FROM card_tokenization_policies
		ORDER BY created_at DESC
		LIMIT $1
	`, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	policies := []TokenizationPolicy{}
	for rows.Next() {
		policy, err := scanTokenizationPolicy(rows)
		if err != nil {
			return nil, err
		}
		policies = append(policies, policy)
	}
	return policies, rows.Err()
}

func (r *Repository) UpsertTokenizationPolicy(ctx context.Context, req tokenizationPolicyRequest) (TokenizationPolicy, error) {
	req.PolicyName = strings.TrimSpace(req.PolicyName)
	req.TokenVaultProvider = strings.TrimSpace(req.TokenVaultProvider)
	req.TokenType = strings.ToLower(strings.TrimSpace(req.TokenType))
	req.KeyReference = strings.TrimSpace(req.KeyReference)
	req.Status = strings.ToLower(strings.TrimSpace(req.Status))
	if req.Status == "" {
		req.Status = "draft"
	}
	if req.PolicyName == "" || req.TokenVaultProvider == "" || req.KeyReference == "" {
		return TokenizationPolicy{}, fmt.Errorf("%w: policy_name, token_vault_provider and key_reference are required", domain.ErrValidation)
	}
	if req.TokenType != "issuer_token" && req.TokenType != "network_token" && req.TokenType != "processor_token" {
		return TokenizationPolicy{}, fmt.Errorf("%w: invalid token_type", domain.ErrValidation)
	}
	if req.Status != "draft" && req.Status != "active" && req.Status != "retired" {
		return TokenizationPolicy{}, fmt.Errorf("%w: invalid tokenization policy status", domain.ErrValidation)
	}
	if req.RotationIntervalDays < 1 || req.RotationIntervalDays > 1095 {
		return TokenizationPolicy{}, fmt.Errorf("%w: rotation_interval_days must be between 1 and 1095", domain.ErrValidation)
	}
	var assessmentID any
	if strings.TrimSpace(req.PCIScopeAssessmentID) != "" {
		if err := domain.ValidateUUID("pci_scope_assessment_id", req.PCIScopeAssessmentID); err != nil {
			return TokenizationPolicy{}, err
		}
		assessmentID = strings.TrimSpace(req.PCIScopeAssessmentID)
	}
	row := r.db.QueryRow(ctx, `
		INSERT INTO card_tokenization_policies (
			policy_name, token_vault_provider, token_type, key_reference, rotation_interval_days,
			status, pci_scope_assessment_id, notes
		)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
		ON CONFLICT (policy_name) DO UPDATE
		SET token_vault_provider = EXCLUDED.token_vault_provider,
			token_type = EXCLUDED.token_type,
			key_reference = EXCLUDED.key_reference,
			rotation_interval_days = EXCLUDED.rotation_interval_days,
			status = EXCLUDED.status,
			pci_scope_assessment_id = EXCLUDED.pci_scope_assessment_id,
			notes = EXCLUDED.notes
		RETURNING id::text, policy_name, token_vault_provider, token_type, key_reference,
			rotation_interval_days, status, COALESCE(pci_scope_assessment_id::text, ''), notes,
			created_at, updated_at
	`, req.PolicyName, req.TokenVaultProvider, req.TokenType, req.KeyReference, req.RotationIntervalDays,
		req.Status, assessmentID, strings.TrimSpace(req.Notes))
	return scanTokenizationPolicy(row)
}

func (r *Repository) CreateDispute(ctx context.Context, userID, cardID, authorizationID string, req createDisputeRequest) (domain.CardDispute, error) {
	if err := normalizeDisputeInput(userID, cardID, authorizationID, &req); err != nil {
		return domain.CardDispute{}, err
	}
	tx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
	if err != nil {
		return domain.CardDispute{}, err
	}
	defer tx.Rollback(ctx)

	auth, card, account, err := lockAuthorizationForDispute(ctx, tx, userID, cardID, authorizationID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.CardDispute{}, domain.ErrNotFound
		}
		return domain.CardDispute{}, err
	}
	if auth.Status != "cleared" {
		return domain.CardDispute{}, fmt.Errorf("%w: only cleared authorizations can be disputed", domain.ErrValidation)
	}
	amount := req.AmountCents
	if amount == 0 {
		amount = auth.AmountCents
	}
	if amount > auth.AmountCents {
		return domain.CardDispute{}, fmt.Errorf("%w: dispute amount cannot exceed cleared amount", domain.ErrValidation)
	}

	dispute, err := scanDispute(tx.QueryRow(ctx, `
		INSERT INTO card_disputes (
			user_id, virtual_card_id, card_authorization_id, reason_code, amount_cents,
			currency, status, description, provisional_credit
		)
		VALUES ($1, $2, $3, $4, $5, $6, 'open', $7, $8)
		RETURNING id::text, user_id::text, virtual_card_id::text, card_authorization_id::text,
			reason_code, amount_cents, currency, status, description, provisional_credit,
			COALESCE(provisional_credit_ledger_event_id::text, ''), decision_note,
			COALESCE(decided_by_admin_user_id::text, ''), decided_at, created_at, updated_at
	`, userID, cardID, authorizationID, req.ReasonCode, amount, auth.Currency, req.Description, req.ProvisionalCredit))
	if err != nil {
		return domain.CardDispute{}, err
	}
	if req.ProvisionalCredit {
		eventID, err := r.postDisputeCredit(ctx, tx, auth, card, account, dispute, "dispute_provisional_credit")
		if err != nil {
			return domain.CardDispute{}, err
		}
		dispute, err = updateDisputeLedgerEvent(ctx, tx, dispute.ID, eventID)
		if err != nil {
			return domain.CardDispute{}, err
		}
	}
	if _, err := tx.Exec(ctx, `
		UPDATE card_authorizations
		SET status = 'disputed', disputed_at = now()
		WHERE id = $1
	`, authorizationID); err != nil {
		return domain.CardDispute{}, err
	}
	if err := insertCardNotification(ctx, tx, userID, cardID, authorizationID, dispute.ID, "dispute_opened", "Card dispute opened", "Your card dispute has been opened for review."); err != nil {
		return domain.CardDispute{}, err
	}
	return dispute, tx.Commit(ctx)
}

func (r *Repository) ListDisputes(ctx context.Context, userID, status string, limit int) ([]domain.CardDispute, error) {
	limit = normalizeLimit(limit)
	status = strings.ToLower(strings.TrimSpace(status))
	rows, err := r.db.Query(ctx, `
		SELECT id::text, user_id::text, virtual_card_id::text, card_authorization_id::text,
			reason_code, amount_cents, currency, status, description, provisional_credit,
			COALESCE(provisional_credit_ledger_event_id::text, ''), decision_note,
			COALESCE(decided_by_admin_user_id::text, ''), decided_at, created_at, updated_at
		FROM card_disputes
		WHERE (NULLIF($1, '') IS NULL OR user_id = NULLIF($1, '')::uuid)
			AND (NULLIF($2, '') IS NULL OR status = $2)
		ORDER BY created_at DESC
		LIMIT $3
	`, strings.TrimSpace(userID), status, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	disputes := []domain.CardDispute{}
	for rows.Next() {
		dispute, err := scanDispute(rows)
		if err != nil {
			return nil, err
		}
		disputes = append(disputes, dispute)
	}
	return disputes, rows.Err()
}

func (r *Repository) DecideDispute(ctx context.Context, adminUserID, disputeID string, req disputeDecisionRequest) (domain.CardDispute, error) {
	adminUserID = strings.TrimSpace(adminUserID)
	disputeID = strings.TrimSpace(disputeID)
	req.Status = strings.ToLower(strings.TrimSpace(req.Status))
	req.DecisionNote = strings.TrimSpace(req.DecisionNote)
	if err := domain.ValidateUUID("admin_user_id", adminUserID); err != nil {
		return domain.CardDispute{}, err
	}
	if err := domain.ValidateUUID("id", disputeID); err != nil {
		return domain.CardDispute{}, err
	}
	if req.Status != "reviewing" && req.Status != "submitted" && req.Status != "won" && req.Status != "lost" && req.Status != "canceled" {
		return domain.CardDispute{}, fmt.Errorf("%w: invalid dispute status", domain.ErrValidation)
	}
	if (req.Status == "won" || req.Status == "lost" || req.Status == "canceled") && len(req.DecisionNote) < 8 {
		return domain.CardDispute{}, fmt.Errorf("%w: decision_note must be at least 8 characters", domain.ErrValidation)
	}
	tx, err := r.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
	if err != nil {
		return domain.CardDispute{}, err
	}
	defer tx.Rollback(ctx)

	dispute, auth, card, account, err := lockDisputeForDecision(ctx, tx, disputeID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.CardDispute{}, domain.ErrNotFound
		}
		return domain.CardDispute{}, err
	}
	if dispute.Status == "won" || dispute.Status == "lost" || dispute.Status == "canceled" {
		return domain.CardDispute{}, fmt.Errorf("%w: dispute is already closed", domain.ErrValidation)
	}
	if req.Status == "won" && !dispute.ProvisionalCredit {
		if _, err := r.postDisputeCredit(ctx, tx, auth, card, account, dispute, "dispute_provisional_credit"); err != nil {
			return domain.CardDispute{}, err
		}
	}
	if req.Status == "lost" && dispute.ProvisionalCredit {
		if _, err := r.postDisputeReversal(ctx, tx, auth, card, account, dispute); err != nil {
			return domain.CardDispute{}, err
		}
	}
	updated, err := scanDispute(tx.QueryRow(ctx, `
		UPDATE card_disputes
		SET status = $2,
			decision_note = NULLIF($3, ''),
			decided_by_admin_user_id = CASE WHEN $2 IN ('won', 'lost', 'canceled') THEN $4 ELSE decided_by_admin_user_id END,
			decided_at = CASE WHEN $2 IN ('won', 'lost', 'canceled') THEN now() ELSE decided_at END
		WHERE id = $1
		RETURNING id::text, user_id::text, virtual_card_id::text, card_authorization_id::text,
			reason_code, amount_cents, currency, status, description, provisional_credit,
			COALESCE(provisional_credit_ledger_event_id::text, ''), decision_note,
			COALESCE(decided_by_admin_user_id::text, ''), decided_at, created_at, updated_at
	`, dispute.ID, req.Status, req.DecisionNote, adminUserID))
	if err != nil {
		return domain.CardDispute{}, err
	}
	if req.Status == "won" || req.Status == "lost" {
		notificationType := "dispute_won"
		title := "Card dispute won"
		body := "Your card dispute was resolved in your favor."
		if req.Status == "lost" {
			notificationType = "dispute_lost"
			title = "Card dispute lost"
			body = "Your card dispute was not accepted."
		}
		if err := insertCardNotification(ctx, tx, updated.UserID, updated.VirtualCardID, updated.CardAuthorizationID, updated.ID, notificationType, title, body); err != nil {
			return domain.CardDispute{}, err
		}
	}
	return updated, tx.Commit(ctx)
}

func (r *Repository) ListNotifications(ctx context.Context, userID string, limit int) ([]domain.CardholderNotification, error) {
	limit = normalizeLimit(limit)
	rows, err := r.db.Query(ctx, `
		SELECT id::text, user_id::text, COALESCE(virtual_card_id::text, ''),
			COALESCE(card_authorization_id::text, ''), COALESCE(card_dispute_id::text, ''),
			notification_type, channel, status, title, body, sent_at, read_at, created_at, updated_at
		FROM cardholder_notifications
		WHERE user_id = $1
		ORDER BY created_at DESC
		LIMIT $2
	`, userID, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	notifications := []domain.CardholderNotification{}
	for rows.Next() {
		notification, err := scanNotification(rows)
		if err != nil {
			return nil, err
		}
		notifications = append(notifications, notification)
	}
	return notifications, rows.Err()
}

func (r *Repository) MarkNotificationRead(ctx context.Context, userID, notificationID string) (domain.CardholderNotification, error) {
	row := r.db.QueryRow(ctx, `
		UPDATE cardholder_notifications
		SET status = 'read', read_at = now()
		WHERE id = $1 AND user_id = $2
		RETURNING id::text, user_id::text, COALESCE(virtual_card_id::text, ''),
			COALESCE(card_authorization_id::text, ''), COALESCE(card_dispute_id::text, ''),
			notification_type, channel, status, title, body, sent_at, read_at, created_at, updated_at
	`, notificationID, userID)
	notification, err := scanNotification(row)
	if errors.Is(err, pgx.ErrNoRows) {
		return domain.CardholderNotification{}, domain.ErrNotFound
	}
	return notification, err
}

func (r *Repository) postDisputeCredit(ctx context.Context, tx pgx.Tx, auth domain.CardAuthorization, card authCard, account authAccount, dispute domain.CardDispute, eventType string) (string, error) {
	accountLedger, err := r.ledger.EnsureAccount(ctx, tx, ledger.AccountParams{
		OwnerUserID:   card.UserID,
		ReferenceType: "account",
		ReferenceID:   account.ID,
		Currency:      account.Currency,
		NormalBalance: "credit",
	})
	if err != nil {
		return "", err
	}
	receivable, err := r.ledger.EnsureAccount(ctx, tx, ledger.AccountParams{
		OwnerUserID:   card.UserID,
		ReferenceType: "card_dispute_receivable",
		ReferenceID:   dispute.ID,
		Currency:      dispute.Currency,
		NormalBalance: "debit",
	})
	if err != nil {
		return "", err
	}
	entry, err := r.ledger.Post(ctx, tx, ledger.PostParams{
		EventType:      "card_dispute.provisional_credit",
		SourceType:     "card_dispute_credit",
		SourceID:       dispute.ID,
		IdempotencyKey: "card-dispute-credit-" + dispute.ID,
		Description:    "Card dispute provisional credit",
		Metadata: map[string]any{
			"card_authorization_id": auth.ID,
			"virtual_card_id":       card.ID,
			"reason_code":           dispute.ReasonCode,
		},
		Lines: []ledger.LineParams{
			{LedgerAccountID: receivable.ID, Direction: "debit", AmountCents: dispute.AmountCents, Currency: dispute.Currency},
			{LedgerAccountID: accountLedger.ID, Direction: "credit", AmountCents: dispute.AmountCents, Currency: dispute.Currency},
		},
	})
	if err != nil {
		return "", err
	}
	return recordCardLedgerEvent(ctx, tx, auth.ID, eventType, dispute.AmountCents, dispute.Currency, entry.ID, dispute.ID)
}

func (r *Repository) postDisputeReversal(ctx context.Context, tx pgx.Tx, auth domain.CardAuthorization, card authCard, account authAccount, dispute domain.CardDispute) (string, error) {
	accountLedger, err := r.ledger.EnsureAccount(ctx, tx, ledger.AccountParams{
		OwnerUserID:   card.UserID,
		ReferenceType: "account",
		ReferenceID:   account.ID,
		Currency:      account.Currency,
		NormalBalance: "credit",
	})
	if err != nil {
		return "", err
	}
	receivable, err := r.ledger.EnsureAccount(ctx, tx, ledger.AccountParams{
		OwnerUserID:   card.UserID,
		ReferenceType: "card_dispute_receivable",
		ReferenceID:   dispute.ID,
		Currency:      dispute.Currency,
		NormalBalance: "debit",
	})
	if err != nil {
		return "", err
	}
	entry, err := r.ledger.Post(ctx, tx, ledger.PostParams{
		EventType:      "card_dispute.provisional_credit_reversed",
		SourceType:     "card_dispute_reversal",
		SourceID:       dispute.ID,
		IdempotencyKey: "card-dispute-reversal-" + dispute.ID,
		Description:    "Card dispute provisional credit reversal",
		Metadata: map[string]any{
			"card_authorization_id": auth.ID,
			"virtual_card_id":       card.ID,
			"reason_code":           dispute.ReasonCode,
		},
		Lines: []ledger.LineParams{
			{LedgerAccountID: accountLedger.ID, Direction: "debit", AmountCents: dispute.AmountCents, Currency: dispute.Currency},
			{LedgerAccountID: receivable.ID, Direction: "credit", AmountCents: dispute.AmountCents, Currency: dispute.Currency},
		},
	})
	if err != nil {
		return "", err
	}
	return recordCardLedgerEvent(ctx, tx, auth.ID, "dispute_reversal", dispute.AmountCents, dispute.Currency, entry.ID, dispute.ID)
}

func (h *Handler) Notifications(w http.ResponseWriter, r *http.Request) {
	claims, ok := middleware.CurrentClaims(r)
	if !ok {
		respond.Error(w, domain.ErrUnauthorized)
		return
	}
	notifications, err := h.repo.ListNotifications(r.Context(), claims.Subject, queryLimit(r))
	if err != nil {
		respond.Error(w, err)
		return
	}
	respond.JSON(w, http.StatusOK, map[string]any{"cardholder_notifications": notifications})
}

func (h *Handler) MarkNotificationRead(w http.ResponseWriter, r *http.Request) {
	claims, ok := middleware.CurrentClaims(r)
	if !ok {
		respond.Error(w, domain.ErrUnauthorized)
		return
	}
	notificationID := strings.TrimSpace(r.PathValue("id"))
	if err := domain.ValidateUUID("id", notificationID); err != nil {
		respond.Error(w, err)
		return
	}
	notification, err := h.repo.MarkNotificationRead(r.Context(), claims.Subject, notificationID)
	if err != nil {
		respond.Error(w, err)
		return
	}
	respond.JSON(w, http.StatusOK, notification)
}

func (h *Handler) CreateDispute(w http.ResponseWriter, r *http.Request) {
	claims, ok := middleware.CurrentClaims(r)
	if !ok {
		respond.Error(w, domain.ErrUnauthorized)
		return
	}
	var req createDisputeRequest
	if err := respond.DecodeJSON(r, &req); err != nil {
		respond.Error(w, err)
		return
	}
	dispute, err := h.repo.CreateDispute(
		r.Context(),
		claims.Subject,
		strings.TrimSpace(r.PathValue("id")),
		strings.TrimSpace(r.PathValue("authorizationID")),
		req,
	)
	if err != nil {
		respond.Error(w, err)
		return
	}
	h.recordAudit(r, claims.Subject, "card_dispute.created", "card_dispute", dispute.ID, dispute)
	respond.Created(w, dispute)
}

func (h *Handler) MyDisputes(w http.ResponseWriter, r *http.Request) {
	claims, ok := middleware.CurrentClaims(r)
	if !ok {
		respond.Error(w, domain.ErrUnauthorized)
		return
	}
	disputes, err := h.repo.ListDisputes(r.Context(), claims.Subject, strings.TrimSpace(r.URL.Query().Get("status")), queryLimit(r))
	if err != nil {
		respond.Error(w, err)
		return
	}
	respond.JSON(w, http.StatusOK, map[string]any{"card_disputes": disputes})
}

func (h *Handler) AdminProcessorConfigs(w http.ResponseWriter, r *http.Request) {
	configs, err := h.repo.ListProcessorConfigs(r.Context(), queryLimit(r))
	if err != nil {
		respond.Error(w, err)
		return
	}
	respond.JSON(w, http.StatusOK, map[string]any{"card_processor_configs": configs})
}

func (h *Handler) AdminUpsertProcessorConfig(w http.ResponseWriter, r *http.Request) {
	var req processorConfigRequest
	if err := respond.DecodeJSON(r, &req); err != nil {
		respond.Error(w, err)
		return
	}
	config, err := h.repo.UpsertProcessorConfig(r.Context(), req)
	if err != nil {
		respond.Error(w, err)
		return
	}
	respond.JSON(w, http.StatusOK, config)
}

func (h *Handler) AdminPCIAssessments(w http.ResponseWriter, r *http.Request) {
	assessments, err := h.repo.ListPCIAssessments(r.Context(), queryLimit(r))
	if err != nil {
		respond.Error(w, err)
		return
	}
	respond.JSON(w, http.StatusOK, map[string]any{"card_pci_scope_assessments": assessments})
}

func (h *Handler) AdminUpsertPCIAssessment(w http.ResponseWriter, r *http.Request) {
	claims, ok := middleware.CurrentClaims(r)
	if !ok {
		respond.Error(w, domain.ErrUnauthorized)
		return
	}
	var req pciAssessmentRequest
	if err := respond.DecodeJSON(r, &req); err != nil {
		respond.Error(w, err)
		return
	}
	assessment, err := h.repo.UpsertPCIAssessment(r.Context(), claims.Subject, req)
	if err != nil {
		respond.Error(w, err)
		return
	}
	respond.JSON(w, http.StatusOK, assessment)
}

func (h *Handler) AdminTokenizationPolicies(w http.ResponseWriter, r *http.Request) {
	policies, err := h.repo.ListTokenizationPolicies(r.Context(), queryLimit(r))
	if err != nil {
		respond.Error(w, err)
		return
	}
	respond.JSON(w, http.StatusOK, map[string]any{"card_tokenization_policies": policies})
}

func (h *Handler) AdminUpsertTokenizationPolicy(w http.ResponseWriter, r *http.Request) {
	var req tokenizationPolicyRequest
	if err := respond.DecodeJSON(r, &req); err != nil {
		respond.Error(w, err)
		return
	}
	policy, err := h.repo.UpsertTokenizationPolicy(r.Context(), req)
	if err != nil {
		respond.Error(w, err)
		return
	}
	respond.JSON(w, http.StatusOK, policy)
}

func (h *Handler) AdminExpireAuthorizations(w http.ResponseWriter, r *http.Request) {
	expired, err := h.repo.ExpireAuthorizations(r.Context(), queryLimit(r))
	if err != nil {
		respond.Error(w, err)
		return
	}
	respond.JSON(w, http.StatusOK, map[string]any{"expired_authorizations": expired})
}

func (h *Handler) AdminDisputes(w http.ResponseWriter, r *http.Request) {
	disputes, err := h.repo.ListDisputes(r.Context(), "", strings.TrimSpace(r.URL.Query().Get("status")), queryLimit(r))
	if err != nil {
		respond.Error(w, err)
		return
	}
	respond.JSON(w, http.StatusOK, map[string]any{"card_disputes": disputes})
}

func (h *Handler) AdminDecideDispute(w http.ResponseWriter, r *http.Request) {
	claims, ok := middleware.CurrentClaims(r)
	if !ok {
		respond.Error(w, domain.ErrUnauthorized)
		return
	}
	var req disputeDecisionRequest
	if err := respond.DecodeJSON(r, &req); err != nil {
		respond.Error(w, err)
		return
	}
	dispute, err := h.repo.DecideDispute(r.Context(), claims.Subject, strings.TrimSpace(r.PathValue("id")), req)
	if err != nil {
		respond.Error(w, err)
		return
	}
	h.recordAudit(r, claims.Subject, "admin.card_dispute.updated", "card_dispute", dispute.ID, dispute)
	respond.JSON(w, http.StatusOK, dispute)
}

func lockAuthorizationForDispute(ctx context.Context, tx pgx.Tx, userID, cardID, authorizationID string) (domain.CardAuthorization, authCard, authAccount, error) {
	row := tx.QueryRow(ctx, `
		SELECT ca.external_authorization_id
		FROM card_authorizations ca
		JOIN virtual_cards vc ON vc.id = ca.virtual_card_id
		WHERE ca.id = $1 AND vc.id = $2 AND vc.user_id = $3
	`, authorizationID, cardID, userID)
	var externalID string
	if err := row.Scan(&externalID); err != nil {
		return domain.CardAuthorization{}, authCard{}, authAccount{}, err
	}
	return lockAuthorizationForLifecycle(ctx, tx, externalID)
}

func lockDisputeForDecision(ctx context.Context, tx pgx.Tx, disputeID string) (domain.CardDispute, domain.CardAuthorization, authCard, authAccount, error) {
	dispute, err := scanDispute(tx.QueryRow(ctx, `
		SELECT id::text, user_id::text, virtual_card_id::text, card_authorization_id::text,
			reason_code, amount_cents, currency, status, description, provisional_credit,
			COALESCE(provisional_credit_ledger_event_id::text, ''), decision_note,
			COALESCE(decided_by_admin_user_id::text, ''), decided_at, created_at, updated_at
		FROM card_disputes
		WHERE id = $1
		FOR UPDATE
	`, disputeID))
	if err != nil {
		return domain.CardDispute{}, domain.CardAuthorization{}, authCard{}, authAccount{}, err
	}
	auth, card, account, err := lockAuthorizationForDispute(ctx, tx, dispute.UserID, dispute.VirtualCardID, dispute.CardAuthorizationID)
	return dispute, auth, card, account, err
}

func updateDisputeLedgerEvent(ctx context.Context, tx pgx.Tx, disputeID, eventID string) (domain.CardDispute, error) {
	return scanDispute(tx.QueryRow(ctx, `
		UPDATE card_disputes
		SET provisional_credit_ledger_event_id = $2
		WHERE id = $1
		RETURNING id::text, user_id::text, virtual_card_id::text, card_authorization_id::text,
			reason_code, amount_cents, currency, status, description, provisional_credit,
			COALESCE(provisional_credit_ledger_event_id::text, ''), decision_note,
			COALESCE(decided_by_admin_user_id::text, ''), decided_at, created_at, updated_at
	`, disputeID, eventID))
}

func scanProcessorConfig(row scanner) (ProcessorConfig, error) {
	var config ProcessorConfig
	var signedAt sql.NullTime
	err := row.Scan(&config.ID, &config.Provider, &config.ProcessorName, &config.OperatingModel, &config.Status,
		&config.ContractReference, &signedAt, &config.APIBaseURL, &config.WebhookSigningKeyReference,
		&config.CertificationReference, &config.RealTrafficEnabled, &config.CreatedAt, &config.UpdatedAt)
	if signedAt.Valid {
		config.ContractSignedAt = &signedAt.Time
	}
	return config, err
}

func scanPCIAssessment(row scanner) (PCIAssessment, error) {
	var assessment PCIAssessment
	var approvedAt sql.NullTime
	err := row.Scan(&assessment.ID, &assessment.AssessmentName, &assessment.ScopeStatus, &assessment.Assessor,
		&assessment.QSAReference, &assessment.ScopeModel, &assessment.StoresPAN, &assessment.StoresCVV,
		&assessment.PANDisclosureAllowed, &assessment.EvidenceReference, &assessment.ApprovedByAdminUserID,
		&approvedAt, &assessment.CreatedAt, &assessment.UpdatedAt)
	if approvedAt.Valid {
		assessment.ApprovedAt = &approvedAt.Time
	}
	return assessment, err
}

func scanTokenizationPolicy(row scanner) (TokenizationPolicy, error) {
	var policy TokenizationPolicy
	err := row.Scan(&policy.ID, &policy.PolicyName, &policy.TokenVaultProvider, &policy.TokenType,
		&policy.KeyReference, &policy.RotationIntervalDays, &policy.Status, &policy.PCIScopeAssessmentID,
		&policy.Notes, &policy.CreatedAt, &policy.UpdatedAt)
	return policy, err
}

func scanDispute(row scanner) (domain.CardDispute, error) {
	var dispute domain.CardDispute
	var decidedAt sql.NullTime
	err := row.Scan(&dispute.ID, &dispute.UserID, &dispute.VirtualCardID, &dispute.CardAuthorizationID,
		&dispute.ReasonCode, &dispute.AmountCents, &dispute.Currency, &dispute.Status, &dispute.Description,
		&dispute.ProvisionalCredit, &dispute.ProvisionalCreditLedgerEventID, &dispute.DecisionNote,
		&dispute.DecidedByAdminUserID, &decidedAt, &dispute.CreatedAt, &dispute.UpdatedAt)
	if decidedAt.Valid {
		dispute.DecidedAt = &decidedAt.Time
	}
	return dispute, err
}

func scanNotification(row scanner) (domain.CardholderNotification, error) {
	var notification domain.CardholderNotification
	var sentAt sql.NullTime
	var readAt sql.NullTime
	err := row.Scan(&notification.ID, &notification.UserID, &notification.VirtualCardID,
		&notification.CardAuthorizationID, &notification.CardDisputeID, &notification.Type,
		&notification.Channel, &notification.Status, &notification.Title, &notification.Body,
		&sentAt, &readAt, &notification.CreatedAt, &notification.UpdatedAt)
	if sentAt.Valid {
		notification.SentAt = &sentAt.Time
	}
	if readAt.Valid {
		notification.ReadAt = &readAt.Time
	}
	return notification, err
}

func normalizeDisputeInput(userID, cardID, authorizationID string, req *createDisputeRequest) error {
	if err := domain.ValidateUUID("user_id", strings.TrimSpace(userID)); err != nil {
		return err
	}
	if err := domain.ValidateUUID("card_id", strings.TrimSpace(cardID)); err != nil {
		return err
	}
	if err := domain.ValidateUUID("authorization_id", strings.TrimSpace(authorizationID)); err != nil {
		return err
	}
	req.ReasonCode = strings.ToLower(strings.TrimSpace(req.ReasonCode))
	req.Description = strings.TrimSpace(req.Description)
	switch req.ReasonCode {
	case "fraud", "duplicate", "goods_not_received", "service_not_provided", "other":
	default:
		return fmt.Errorf("%w: invalid dispute reason_code", domain.ErrValidation)
	}
	if req.AmountCents < 0 {
		return fmt.Errorf("%w: amount_cents cannot be negative", domain.ErrValidation)
	}
	if len(req.Description) > 1000 {
		return fmt.Errorf("%w: description must be 1000 characters or fewer", domain.ErrValidation)
	}
	return nil
}

func parseOptionalRFC3339(field, raw string) (*time.Time, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil, nil
	}
	parsed, err := time.Parse(time.RFC3339, raw)
	if err != nil {
		return nil, fmt.Errorf("%w: %s must be RFC3339", domain.ErrValidation, field)
	}
	parsed = parsed.UTC()
	return &parsed, nil
}

func queryLimit(r *http.Request) int {
	limit := 50
	if raw := r.URL.Query().Get("limit"); raw != "" {
		if parsed, err := strconv.Atoi(raw); err == nil {
			limit = parsed
		}
	}
	return normalizeLimit(limit)
}

func normalizeLimit(limit int) int {
	if limit <= 0 || limit > 100 {
		return 50
	}
	return limit
}
