package externaldelivery

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/nexuscrm/kernel/pkg/utils"
	"github.com/nexuscrm/shared/pkg/constants"
)

type Channel string

const (
	ChannelEmail   Channel = "email"
	ChannelWebhook Channel = "webhook"
)

type Status string

const (
	StatusSuccess Status = "success"
	StatusFailed  Status = "failed"
)

const maxErrorMessageLen = 1024
const maxTargetSummaryLen = 512

var (
	ErrAttemptNotFound        = errors.New("externaldelivery: attempt not found")
	ErrDeliveryFailed         = errors.New("externaldelivery: delivery failed")
	ErrMalformedStoredPayload = errors.New("externaldelivery: malformed stored payload")
	ErrAttemptRecordFailed    = errors.New("externaldelivery: attempt record failed after successful delivery")
)

type EmailPayload struct {
	To      []string `json:"to,omitempty"`
	Cc      []string `json:"cc,omitempty"`
	Bcc     []string `json:"bcc,omitempty"`
	From    string   `json:"from,omitempty"`
	Subject string   `json:"subject,omitempty"`
	Body    string   `json:"body,omitempty"`
	HTML    string   `json:"html,omitempty"`
	// StepID addresses the sendEmail step inside a composite action's config for
	// operator resend (content-class addressing metadata, not a credential).
	// Empty for top-level sendEmail actions and flow-emitted email.
	StepID string `json:"stepId,omitempty"`
	// StepFingerprint is the storage-safe fingerprint (sha256 hex) of the
	// authored step's config, frozen at send time for step-addressed operator
	// resend. Secret positions are scrubbed before hashing (same rules as
	// storage), so it is content-class addressing metadata, not a credential
	// digest. Empty for top-level actions and flow-emitted sends.
	StepFingerprint string `json:"stepFingerprint,omitempty"`
}

type WebhookPayload struct {
	URL     string            `json:"url,omitempty"`
	Method  string            `json:"method,omitempty"`
	Headers map[string]string `json:"headers,omitempty"`
	Body    string            `json:"body,omitempty"`
	// StepID addresses the webhook step inside a composite action's config for
	// operator resend (content-class addressing metadata, not a credential).
	// Empty for top-level webhook actions.
	StepID string `json:"stepId,omitempty"`
	// StepFingerprint is the storage-safe fingerprint (sha256 hex) of the
	// authored step's config, frozen at send time for step-addressed operator
	// resend. Secret positions are scrubbed before hashing (same rules as
	// storage), so it is content-class addressing metadata, not a credential
	// digest. Empty for top-level actions and flow-emitted sends.
	StepFingerprint string `json:"stepFingerprint,omitempty"`
}

// ResendTarget is the re-resolved webhook send target for an operator resend:
// URL and Headers are the live values from the action's CURRENT config
// (send-only, never persisted); RedactedURL is the storage-safe host-only form
// recorded as the new attempt's TargetSummary and payload url.
type ResendTarget struct {
	URL         string
	RedactedURL string
	Headers     map[string]string
}

type AttemptWrite struct {
	Channel         Channel
	Status          Status
	SourceObjectAPI string
	SourceRecordID  string
	ActionID        string
	TargetSummary   string
	EmailPayload    *EmailPayload
	WebhookPayload  *WebhookPayload
	ResponseStatus  *int
	ErrorMessage    string
	ReplayOfID      string
	CreatedByID     string
}

type Attempt struct {
	ID               string
	Channel          Channel
	Status           Status
	SourceObjectAPI  string
	SourceRecordID   string
	ActionID         string
	TargetSummary    string
	RequestPayload   json.RawMessage
	ResponseStatus   *int
	ErrorMessage     string
	ReplayOfID       string
	CreatedByID      string
	CreatedDate      time.Time
	LastModifiedDate time.Time
}

type ListFilter struct {
	Channel         string
	Status          string
	SourceObjectAPI string
	SourceRecordID  string
	Limit           int
	Offset          int
}

// Store is the thin append-only evidence store for external delivery attempts.
// It holds a raw *sql.DB — deliberately outside any caller transaction — so
// evidence rows survive a rolled-back business transaction.
type Store struct {
	db    *sql.DB
	now   func() time.Time
	idgen func() string
}

func NewStore(db *sql.DB, now func() time.Time, idgen func() string) *Store {
	if now == nil {
		now = time.Now
	}
	if idgen == nil {
		idgen = utils.GenerateID
	}
	return &Store{
		db:    db,
		now:   now,
		idgen: idgen,
	}
}

// RecordAttempt writes via the raw *sql.DB handle on purpose, outside any
// caller transaction propagated through ctx, so the delivery attempt is durable
// append-only evidence that does not vanish if the caller's business transaction
// rolls back. The trade-off: a rolled-back business transaction can leave a
// committed status=success attempt row — acceptable because the email/webhook
// was already physically sent and cannot be un-sent.
func (s *Store) RecordAttempt(ctx context.Context, in AttemptWrite) (string, error) {
	payload, err := marshalFrozenPayload(in)
	if err != nil {
		return "", err
	}

	id := s.idgen()
	now := s.now()

	// Detach the evidence INSERT from request/flow cancellation: a client
	// disconnect or deadline after a successful send must not cancel the write
	// and drop the row (durability). Keep only observability values needed for
	// correlation; do not carry request-scoped caches or security values into
	// the background write.
	writeCtx, cancel := externalDeliveryWriteContext(ctx)
	defer cancel()
	_, err = s.db.ExecContext(writeCtx, `
INSERT INTO _System_ExternalDeliveryAttempt
  (Id, Channel, Status, SourceObjectApiName, SourceRecordId, ActionId, TargetSummary,
   RequestPayload, ResponseStatus, ErrorMessage, ReplayOfId, CreatedById, CreatedDate, LastModifiedDate)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		id,
		string(in.Channel),
		string(in.Status),
		nullableString(in.SourceObjectAPI),
		nullableString(in.SourceRecordID),
		nullableString(in.ActionID),
		truncateRunes(in.TargetSummary, maxTargetSummaryLen),
		payload,
		in.ResponseStatus,
		nullableString(in.ErrorMessage),
		nullableString(in.ReplayOfID),
		nullableString(in.CreatedByID),
		now,
		now,
	)
	if err != nil {
		return "", err
	}
	return id, nil
}

const externalDeliveryWriteTimeout = 5 * time.Second

func externalDeliveryWriteContext(sourceCtx context.Context) (context.Context, context.CancelFunc) {
	ctx, cancel := context.WithTimeout(context.Background(), externalDeliveryWriteTimeout)
	if sourceCtx == nil {
		return ctx, cancel
	}
	if requestID, _ := sourceCtx.Value(constants.ContextKeyRequestID).(string); requestID != "" {
		ctx = context.WithValue(ctx, constants.ContextKeyRequestID, requestID)
	}
	if traceparent, _ := sourceCtx.Value(constants.ContextKeyTraceparent).(string); traceparent != "" {
		ctx = context.WithValue(ctx, constants.ContextKeyTraceparent, traceparent)
	}
	return ctx, cancel
}

func (s *Store) ListAttempts(ctx context.Context, f ListFilter) ([]Attempt, error) {
	limit := f.Limit
	if limit <= 0 {
		limit = 50
	}

	rows, err := s.db.QueryContext(ctx, `
SELECT Id, Channel, Status, SourceObjectApiName, SourceRecordId, ActionId, TargetSummary, ResponseStatus, ErrorMessage, ReplayOfId, CreatedById, CreatedDate, LastModifiedDate
  FROM _System_ExternalDeliveryAttempt
 WHERE (? = '' OR Channel = ?)
   AND (? = '' OR Status = ?)
   AND (? = '' OR SourceObjectApiName = ?)
   AND (? = '' OR SourceRecordId = ?)
 ORDER BY CreatedDate DESC
 LIMIT ? OFFSET ?`,
		f.Channel, f.Channel,
		f.Status, f.Status,
		f.SourceObjectAPI, f.SourceObjectAPI,
		f.SourceRecordID, f.SourceRecordID,
		limit, f.Offset,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var out []Attempt
	for rows.Next() {
		row, err := scanAttemptListRow(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, row)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return out, nil
}

func (s *Store) GetAttempt(ctx context.Context, id string) (*Attempt, error) {
	rows, err := s.db.QueryContext(ctx, `
SELECT Id, Channel, Status, SourceObjectApiName, SourceRecordId, ActionId, TargetSummary, RequestPayload, ResponseStatus, ErrorMessage, ReplayOfId, CreatedById, CreatedDate, LastModifiedDate
  FROM _System_ExternalDeliveryAttempt
 WHERE Id = ?`, id)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	if !rows.Next() {
		if err := rows.Err(); err != nil {
			return nil, err
		}
		return nil, ErrAttemptNotFound
	}
	row, err := scanAttempt(rows)
	if err != nil {
		return nil, err
	}
	return &row, nil
}

func marshalFrozenPayload(in AttemptWrite) ([]byte, error) {
	switch in.Channel {
	case ChannelEmail:
		if in.EmailPayload == nil {
			return nil, errors.New("externaldelivery: email payload is required")
		}
		return json.Marshal(in.EmailPayload)
	case ChannelWebhook:
		if in.WebhookPayload == nil {
			return nil, errors.New("externaldelivery: webhook payload is required")
		}
		return json.Marshal(in.WebhookPayload)
	default:
		return nil, fmt.Errorf("externaldelivery: unsupported channel %q", in.Channel)
	}
}

func scanAttempt(scanner interface {
	Scan(dest ...interface{}) error
}) (Attempt, error) {
	var row Attempt
	var sourceObject, sourceRecord, actionID, errorMessage, replayOfID, createdByID sql.NullString
	var responseStatus sql.NullInt64
	var payload []byte
	if err := scanner.Scan(
		&row.ID,
		&row.Channel,
		&row.Status,
		&sourceObject,
		&sourceRecord,
		&actionID,
		&row.TargetSummary,
		&payload,
		&responseStatus,
		&errorMessage,
		&replayOfID,
		&createdByID,
		&row.CreatedDate,
		&row.LastModifiedDate,
	); err != nil {
		return Attempt{}, err
	}
	row.SourceObjectAPI = sourceObject.String
	row.SourceRecordID = sourceRecord.String
	row.ActionID = actionID.String
	row.RequestPayload = append([]byte(nil), payload...)
	row.ErrorMessage = errorMessage.String
	row.ReplayOfID = replayOfID.String
	row.CreatedByID = createdByID.String
	if responseStatus.Valid {
		v := int(responseStatus.Int64)
		row.ResponseStatus = &v
	}
	return row, nil
}

// scanAttemptListRow scans the 13-column SELECT used by ListAttempts (no
// RequestPayload column). RequestPayload is left nil; callers that need the
// payload must use GetAttempt which selects the full 14-column row.
func scanAttemptListRow(scanner interface {
	Scan(dest ...interface{}) error
}) (Attempt, error) {
	var row Attempt
	var sourceObject, sourceRecord, actionID, errorMessage, replayOfID, createdByID sql.NullString
	var responseStatus sql.NullInt64
	if err := scanner.Scan(
		&row.ID,
		&row.Channel,
		&row.Status,
		&sourceObject,
		&sourceRecord,
		&actionID,
		&row.TargetSummary,
		&responseStatus,
		&errorMessage,
		&replayOfID,
		&createdByID,
		&row.CreatedDate,
		&row.LastModifiedDate,
	); err != nil {
		return Attempt{}, err
	}
	row.SourceObjectAPI = sourceObject.String
	row.SourceRecordID = sourceRecord.String
	row.ActionID = actionID.String
	row.ErrorMessage = errorMessage.String
	row.ReplayOfID = replayOfID.String
	row.CreatedByID = createdByID.String
	if responseStatus.Valid {
		v := int(responseStatus.Int64)
		row.ResponseStatus = &v
	}
	return row, nil
}

// truncateRunes truncates s to at most max runes. If len([]rune(s)) <= max the
// original string is returned unchanged (no allocation).
func truncateRunes(s string, max int) string {
	runes := []rune(s)
	if len(runes) <= max {
		return s
	}
	return string(runes[:max])
}

func nullableString(v string) interface{} {
	if v == "" {
		return nil
	}
	return v
}

func TruncateErrorMessage(err error) string {
	if err == nil {
		return ""
	}
	msg := err.Error()
	if len(msg) > maxErrorMessageLen {
		return msg[:maxErrorMessageLen]
	}
	return msg
}

func CopyStringSlice(in []string) []string {
	if len(in) == 0 {
		return nil
	}
	out := make([]string, len(in))
	copy(out, in)
	return out
}

func CopyHeaders(in map[string]string) map[string]string {
	if len(in) == 0 {
		return nil
	}
	out := make(map[string]string, len(in))
	for key, value := range in {
		out[key] = value
	}
	return out
}

// StoredHeaderScrubSentinel replaces every stored header value except
// Content-Type. Attempt rows are content evidence; credential-bearing header
// values must never be persisted (same at-rest posture as
// _System_ConnectorPushAttempt, whose snapshot carries no Authorization).
// Resend re-resolves headers from the action's current config instead.
const StoredHeaderScrubSentinel = "[scrubbed]"

// ScrubHeadersForStorage returns a copy of in where only Content-Type keeps
// its real value (allowlist — name-blacklists cannot cover custom header
// names); every other header keeps its NAME with the value replaced by
// StoredHeaderScrubSentinel. nil/empty input returns nil.
func ScrubHeadersForStorage(in map[string]string) map[string]string {
	if len(in) == 0 {
		return nil
	}
	out := make(map[string]string, len(in))
	for k, v := range in {
		if strings.EqualFold(k, "Content-Type") {
			out[k] = v
		} else {
			out[k] = StoredHeaderScrubSentinel
		}
	}
	return out
}

// TargetSummaryForEmail returns a display summary for an email attempt.
// Recipient-count shape only: TargetSummary is metadata-tier (the admin
// list/detail API exposes it), and raw addresses can derive from hidden
// fields, formulas, or related-record data — they belong only in the
// BLOCKED RequestPayload. Counts To+Cc, the same number the sanitized
// send-failure error reports.
func TargetSummaryForEmail(to, cc []string) string {
	return fmt.Sprintf("%d recipients", len(to)+len(cc))
}
