package models

import (
	"bytes"
	"time"

	"github.com/google/uuid"
)

// MiniCategory is the lightweight shape we attach to contact responses
// so the UI can render category chips without doing a second lookup. It
// is a denormalised slice of the row in `categories` plus nothing else.
type MiniCategory struct {
	ID    uuid.UUID `json:"id"`
	Title string    `json:"title"`
	Color string    `json:"color"`
}

type Contact struct {
	ID uuid.UUID `json:"id"`

	FirstName string `json:"first_name"`
	LastName  string `json:"last_name"`
	Email     string `json:"email"`
	Company   string `json:"company"`
	Phone     string `json:"phone"`

	CustomFields map[string]string `json:"custom_fields"`

	Subscribed bool           `json:"subscribed"`
	Campaigns  []MiniCampaign `json:"campaigns"`
	Categories []MiniCategory `json:"categories"`

	// Pre-send verification state (see internal/pkg/emailverify). Populated by
	// the verification scheduler / on-demand verify; the campaign send path uses
	// VerificationStatus == "invalid" to drop addresses before a worker sends.
	// VerificationStatus is one of: valid | risky | invalid | unknown.
	VerificationStatus    string     `json:"verification_status"`
	VerificationReason    string     `json:"verification_reason"`
	IsCatchAll            bool       `json:"is_catch_all"`
	VerificationCheckedAt *time.Time `json:"verification_checked_at,omitempty"`
	// VerificationSource says who produced the verdict (see the
	// VerificationSource* constants); VerificationProvider names the backend or
	// the external vocabulary; VerificationSubStatus refines the status
	// (catch_all, disposable, role, ...).
	VerificationSource    string `json:"verification_source"`
	VerificationProvider  string `json:"verification_provider"`
	VerificationSubStatus string `json:"verification_sub_status"`
	// VerificationConfidence is how sure the platform is of the status, 0 to
	// 100, scored from the last check plus what real mail to the address
	// showed (deliveries, opens, replies, bounces).
	VerificationConfidence int `json:"verification_confidence"`

	// Recipient ESP/provider, derived in the control plane from the recipient
	// domain (never an MX dial on the send hot path). '' | 'gmail' | 'outlook'
	// | 'other'. Used by the campaign ESP-matching feature.
	ESPProvider   string     `json:"esp_provider"`
	ESPResolvedAt *time.Time `json:"esp_resolved_at,omitempty"`

	// CampaignLead is this contact's processing state WITHIN a single campaign.
	// Populated by Search ONLY when the query filters by exactly one campaign
	// (the campaign Leads view); nil otherwise. Lets the Leads list show which
	// leads are queued, in progress, replied, bounced, or unsubscribed.
	CampaignLead *ContactCampaignProgress `json:"campaign_lead,omitempty"`

	UpdatedAt time.Time `json:"updated_at"`
	CreatedAt time.Time `json:"created_at"`
}

// ContactCampaignProgress is a contact's aggregate processing state inside one
// campaign, derived from campaign_contact_progress (across all of the campaign's
// steps) plus the contact's subscription flag.
type ContactCampaignProgress struct {
	// Status is the single derived state shown in the Leads list:
	//   pending      — a lead, but no email sent yet (queued)
	//   active       — some but not all steps sent, still progressing through the flow
	//   completed    — every sequence step was sent, no reply (done, nothing left to send)
	//   replied      — the contact has replied (terminal/positive)
	//   bounced      — a send hard-bounced (terminal/negative)
	//   failed       — the mailbox could not send a step after every retry (terminal/negative)
	//   unsubscribed — the contact is unsubscribed/suppressed (terminal)
	Status string `json:"status"`
	Sent   int    `json:"sent"`
	// Opened counts human opens only; automated fetches are in MachineOpened.
	Opened         int        `json:"opened"`
	MachineOpened  int        `json:"machine_opened"`
	Clicked        int        `json:"clicked"`
	Replied        int        `json:"replied"`
	Bounced        int        `json:"bounced"`
	LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
	// CurrentStep is the label of the step the contact is on now — the latest
	// step actually sent ("Email 2", a custom step name, or an action label).
	// Empty when nothing has been sent yet (status "pending").
	CurrentStep string `json:"current_step,omitempty"`
	// FailureReason is the worker's reason for the last failed send, set only
	// when Status is "failed".
	FailureReason string `json:"failure_reason,omitempty"`
}

// Lead status constants for ContactCampaignProgress.Status.
const (
	LeadStatusPending      = "pending"
	LeadStatusActive       = "active"
	LeadStatusCompleted    = "completed"
	LeadStatusReplied      = "replied"
	LeadStatusBounced      = "bounced"
	LeadStatusFailed       = "failed"
	LeadStatusUnsubscribed = "unsubscribed"
	// LeadStatusUndeliverable is a lead routing will never send to because
	// address verification refused it (invalid, or risky with the campaign's
	// "send to risky emails" toggle off). Distinct from pending, which is a
	// lead still waiting its turn.
	LeadStatusUndeliverable = "undeliverable"
)

// ValidLeadStatus reports whether s is one of the derived lead-status values.
// Used to gate the single-campaign Leads-view `lead_status` search filter.
func ValidLeadStatus(s string) bool {
	switch s {
	case LeadStatusPending, LeadStatusActive, LeadStatusCompleted, LeadStatusReplied, LeadStatusBounced, LeadStatusFailed, LeadStatusUnsubscribed, LeadStatusUndeliverable:
		return true
	default:
		return false
	}
}

// Lead engagement filter values for SearchContacts.Engagement. Each is a
// predicate over the contact's progress rows in one campaign; the negative
// forms only match leads that were sent at least one step, so a lead never
// emailed is neither "opened" nor "not opened".
const (
	LeadEngagementOpened     = "opened"
	LeadEngagementNotOpened  = "not_opened"
	LeadEngagementClicked    = "clicked"
	LeadEngagementNotClicked = "not_clicked"
	LeadEngagementReplied    = "replied"
	LeadEngagementNotReplied = "not_replied"
	LeadEngagementBounced    = "bounced"
)

// ValidLeadEngagement reports whether s is one of the engagement filter values.
func ValidLeadEngagement(s string) bool {
	switch s {
	case LeadEngagementOpened, LeadEngagementNotOpened, LeadEngagementClicked, LeadEngagementNotClicked, LeadEngagementReplied, LeadEngagementNotReplied, LeadEngagementBounced:
		return true
	default:
		return false
	}
}

type ContactsResult struct {
	Data       []Contact  `json:"data"`
	Pagination Pagination `json:"pagination"`
	// Counts is populated only when the search request asks for it
	// (`?counts=true`, first page). It carries org-wide facet totals for the
	// browse sidebar (all/subscribed/unsubscribed/in-campaign/not-contacted +
	// per category) and is independent of the request's filters, like the
	// campaigns-overview drawer counts.
	Counts *ContactsCounts `json:"counts,omitempty"`
	// LeadCounts carries per-status lead totals for ONE campaign (the campaign
	// Leads view). Populated only on the first page when the search filters by
	// exactly one campaign_id; nil otherwise. Independent of the request's
	// lead_status filter, so the scope chips can show every bucket's total.
	LeadCounts *CampaignLeadCounts `json:"lead_counts,omitempty"`
}

// CampaignLeadCounts are per-status lead totals within a single campaign,
// derived the same way as ContactCampaignProgress.Status (unsubscribed >
// bounced > replied > failed > completed > processing > undeliverable >
// queued). Drives the Leads-view scope chips.
type CampaignLeadCounts struct {
	Total        int `json:"total"`
	Queued       int `json:"queued"`     // pending: a lead, no email sent yet
	Processing   int `json:"processing"` // active: some steps sent, more to send
	Completed    int `json:"completed"`  // done: every step sent, no reply
	Replied      int `json:"replied"`
	Bounced      int `json:"bounced"`
	Failed       int `json:"failed"` // a step could not be sent after every retry
	Unsubscribed int `json:"unsubscribed"`
	// Undeliverable: address verification refused it, so routing skips it.
	Undeliverable int `json:"undeliverable"`
	// Engagement totals, matching the `engagement` search filter: leads sent
	// at least one step, and of those the ones with a human open, a click, or
	// a reply on any step.
	Contacted  int `json:"contacted"`
	Opened     int `json:"opened"`
	Clicked    int `json:"clicked"`
	RepliedAny int `json:"replied_any"`
}

// ContactsCounts are org-wide contact facet totals for the browse sidebar.
type ContactsCounts struct {
	Total        int                       `json:"total"`
	Subscribed   int                       `json:"subscribed"`
	Unsubscribed int                       `json:"unsubscribed"`
	InCampaign   int                       `json:"in_campaign"`
	NotContacted int                       `json:"not_contacted"`
	Categories   []ContactCategoryCount    `json:"categories"`
	Verification ContactVerificationCounts `json:"verification"`
}

// ContactVerificationCounts is the org's contacts by verification status.
// Pending is the subset of Unknown nobody has checked yet.
type ContactVerificationCounts struct {
	Valid   int `json:"valid"`
	Risky   int `json:"risky"`
	Invalid int `json:"invalid"`
	Unknown int `json:"unknown"`
	Pending int `json:"pending"`
}

// ContactVerificationDetail is the "why" behind a contact's verdict.
type ContactVerificationDetail struct {
	Status     string   `json:"status"`
	Confidence int      `json:"confidence"`
	Reasons    []string `json:"reasons"`
	// Decisive is true when real mail, not a check, decided the status.
	Decisive bool                          `json:"decisive"`
	Evidence []ContactVerificationEvidence `json:"evidence"`
}

// ContactVerificationEvidence is one observed fact about the mailbox.
type ContactVerificationEvidence struct {
	Kind       string    `json:"kind"`
	Detail     string    `json:"detail,omitempty"`
	ObservedAt time.Time `json:"observed_at"`
}

// Verification provenance values for contacts.verification_source.
const (
	VerificationSourceNone     = ""
	VerificationSourceProbe    = "probe"
	VerificationSourceProvider = "provider"
	VerificationSourceImported = "imported"
	VerificationSourceManual   = "manual"
)

// ContactVerificationWrite is a verdict to store on a contact, already
// normalised into Warmbly's vocabulary.
type ContactVerificationWrite struct {
	Status    string
	SubStatus string
	Reason    string
	Provider  string
	Source    string
}

// Actions for POST /contacts/verification.
const (
	ContactVerificationActionVerify            = "verify"
	ContactVerificationActionMarkDeliverable   = "mark_deliverable"
	ContactVerificationActionMarkUndeliverable = "mark_undeliverable"
)

// ContactVerificationRequest is the body of POST /contacts/verification.
type ContactVerificationRequest struct {
	Contacts []string `json:"contacts"`
	// CampaignID selects every lead of one campaign that verification refused
	// (the "re-verify skipped leads" action), instead of listing ids.
	CampaignID string `json:"campaign_id,omitempty"`
	Action     string `json:"action"`
}

// ContactVerificationResponse reports how many contacts the action touched.
type ContactVerificationResponse struct {
	Affected int    `json:"affected"`
	Action   string `json:"action"`
	// Queued is true for the verify action: the check runs in the background
	// and each contact updates live as its verdict lands.
	Queued bool `json:"queued"`
}

// VerificationOverview is what Settings shows about address verification.
type VerificationOverview struct {
	// Provider is who checks this workspace's addresses: "builtin" or
	// "millionverifier".
	Provider string `json:"provider"`
	// ConnectionID is the integration connection behind a paid provider.
	ConnectionID *string `json:"connection_id,omitempty"`
	// Credits is the paid provider's remaining balance when it could be read.
	Credits *int `json:"credits,omitempty"`
	// ProviderError is set when the paid provider is connected but unusable
	// (bad key, no credits), in which case the built-in check is in use.
	ProviderError string `json:"provider_error,omitempty"`
	// BuiltinReady says whether the in-house probe can reach mail servers from
	// this instance (a HELO host is configured). Off, it still checks syntax,
	// MX, and disposable domains.
	BuiltinReady bool                      `json:"builtin_ready"`
	Counts       ContactVerificationCounts `json:"counts"`
}

// ContactCategoryCount is the number of org contacts carrying one category.
type ContactCategoryCount struct {
	CategoryID string `json:"category_id"`
	Count      int    `json:"count"`
}

// ContactEngagement summarises every email touchpoint we have for a
// single contact. It's denormalised on read so the contact 360 view
// can render counts and "last X" timestamps in a single round-trip.
type ContactEngagement struct {
	TotalSent       int `json:"total_sent"`
	TotalOpened     int `json:"total_opened"`
	TotalClicked    int `json:"total_clicked"`
	TotalReplied    int `json:"total_replied"`
	TotalBounced    int `json:"total_bounced"`
	TotalComplained int `json:"total_complained"`

	LastSentAt    *time.Time `json:"last_sent_at,omitempty"`
	LastOpenedAt  *time.Time `json:"last_opened_at,omitempty"`
	LastClickedAt *time.Time `json:"last_clicked_at,omitempty"`
	LastRepliedAt *time.Time `json:"last_replied_at,omitempty"`
	LastBouncedAt *time.Time `json:"last_bounced_at,omitempty"`
}

// ContactSuppression mirrors a row from suppressed_recipients for the
// contact's email. Null on the wire when the contact is not suppressed.
type ContactSuppression struct {
	ID uuid.UUID `json:"id"`
	// Kind is "email" for the contact's own address or "domain" when the
	// whole domain is suppressed; Value is the matching list entry.
	Kind      string     `json:"kind"`
	Value     string     `json:"value"`
	Reason    string     `json:"reason"`
	Source    string     `json:"source"` // bounce | complaint | unsubscribe | manual | import
	ExpiresAt *time.Time `json:"expires_at,omitempty"`
	CreatedAt time.Time  `json:"created_at"`
}

// ContactDetail is the hydrated read model returned by GET /contacts/:id.
// It bundles everything the slide-over needs in one payload so the UI
// doesn't have to fan out a half-dozen requests on open.
type ContactDetail struct {
	Contact
	Engagement  ContactEngagement   `json:"engagement"`
	Suppression *ContactSuppression `json:"suppression,omitempty"`
	// Verification explains the verdict: the reasons behind it and the
	// observations it was scored from.
	Verification *ContactVerificationDetail `json:"verification,omitempty"`

	// First-touch attribution. Source never changes after creation.
	Source       ContactSource `json:"source"`
	SourceDetail string        `json:"source_detail"`
	FirstSeenAt  time.Time     `json:"first_seen_at"`
}

// ContactSentEmail is one row in the "Emails sent to this contact"
// list. Each row corresponds to a single delivered (or attempted)
// task. Engagement timestamps come from campaign_contact_progress
// when present; some legacy rows may have nil progress.
type ContactSentEmail struct {
	TaskID    uuid.UUID `json:"task_id"`
	Status    string    `json:"status"`
	MessageID string    `json:"message_id"`
	Subject   string    `json:"subject"`
	SentAt    time.Time `json:"sent_at"`

	// Sender mailbox
	EmailAccountID    *uuid.UUID `json:"email_account_id,omitempty"`
	EmailAccountEmail *string    `json:"email_account_email,omitempty"`
	EmailAccountName  *string    `json:"email_account_name,omitempty"`

	// Campaign + sequence context
	CampaignID   *uuid.UUID `json:"campaign_id,omitempty"`
	CampaignName *string    `json:"campaign_name,omitempty"`
	SequenceID   *uuid.UUID `json:"step_id,omitempty"`
	SequenceName *string    `json:"step_name,omitempty"`

	// Engagement (from campaign_contact_progress, may be nil).
	OpenedAt  *time.Time `json:"opened_at,omitempty"`
	ClickedAt *time.Time `json:"clicked_at,omitempty"`
	RepliedAt *time.Time `json:"replied_at,omitempty"`
	BouncedAt *time.Time `json:"bounced_at,omitempty"`
}

type ContactSentEmailsResult struct {
	Data       []ContactSentEmail `json:"data"`
	Pagination Pagination         `json:"pagination"`
}

// ContactTimelineEventType is a closed enum so the frontend can pick
// the right icon / colour without parsing free text.
type ContactTimelineEventType string

const (
	TimelineEmailSent      ContactTimelineEventType = "email_sent"
	TimelineEmailOpened    ContactTimelineEventType = "email_opened"
	TimelineEmailClicked   ContactTimelineEventType = "email_clicked"
	TimelineEmailReplied   ContactTimelineEventType = "email_replied"
	TimelineEmailBounced   ContactTimelineEventType = "email_bounced"
	TimelineReplyReceived  ContactTimelineEventType = "reply_received"
	TimelineDeliverability ContactTimelineEventType = "deliverability"
	TimelineSuppressed     ContactTimelineEventType = "suppressed"
	TimelineNote           ContactTimelineEventType = "note"

	// Meetings booked through a connected scheduling provider (Calendly /
	// Cal.com). The event time is when the booking arrived; ScheduledFor holds
	// when the call itself is set for.
	TimelineMeetingBooked      ContactTimelineEventType = "meeting_booked"
	TimelineMeetingRescheduled ContactTimelineEventType = "meeting_rescheduled"
	TimelineMeetingCanceled    ContactTimelineEventType = "meeting_canceled"

	// Lifecycle events, read from contact_activities. contact_created carries
	// the first-touch Source (and SourceDetail), which is how "imported" and
	// "created via API" are told apart without a type per origin.
	TimelineContactCreated  ContactTimelineEventType = "contact_created"
	TimelineCampaignAdded   ContactTimelineEventType = "campaign_added"
	TimelineCampaignRemoved ContactTimelineEventType = "campaign_removed"
	TimelineCategoryAdded   ContactTimelineEventType = "category_added"
	TimelineCategoryRemoved ContactTimelineEventType = "category_removed"
	TimelineFormSubmitted   ContactTimelineEventType = "form_submitted"

	// A page view from the website tracking snippet, tied to the contact
	// through an email-link ticket. Detail rides in PageHit.
	TimelinePageHit ContactTimelineEventType = "page_hit"
)

// ContactTimelineSource ranks the tables the timeline is merged from. It is
// the middle key of the feed's order (at, source, id): two events at the same
// instant sort by source, then by that source's row id, so a page boundary
// can never split a tie. The values are part of the cursor; never renumber.
type ContactTimelineSource int

const (
	// campaign_contact_progress stamps, keyed by the step (sequence) id.
	TimelineSourceProgressSent    ContactTimelineSource = 1
	TimelineSourceProgressOpened  ContactTimelineSource = 2
	TimelineSourceProgressClicked ContactTimelineSource = 3
	TimelineSourceProgressReplied ContactTimelineSource = 4
	TimelineSourceProgressBounced ContactTimelineSource = 5

	TimelineSourceLinkClick      ContactTimelineSource = 6  // email_link_clicks
	TimelineSourceOpen           ContactTimelineSource = 7  // email_opens
	TimelineSourceReplyIntent    ContactTimelineSource = 8  // reply_intents
	TimelineSourceDeliverability ContactTimelineSource = 9  // deliverability_events
	TimelineSourceSuppression    ContactTimelineSource = 10 // suppressed_recipients
	TimelineSourceNote           ContactTimelineSource = 11 // contact_notes
	TimelineSourceMeeting        ContactTimelineSource = 12 // meeting_bookings
	TimelineSourceActivity       ContactTimelineSource = 13 // contact_activities
	TimelineSourcePageHit        ContactTimelineSource = 14 // website_page_hits
)

// Valid reports whether s names a source the timeline is merged from. A
// cursor carrying any other rank is malformed: zero is reserved for the
// legacy bare-timestamp bound and anything above the last source would
// re-admit the events at the cursor's instant.
func (s ContactTimelineSource) Valid() bool {
	return s >= TimelineSourceProgressSent && s <= TimelineSourcePageHit
}

// ContactTimelineKey is one event's position in the merged feed. A page
// resumes strictly after the key of the last event it returned, comparing
// (At, Source, ID) as a tuple, which is what the opaque cursor carries.
type ContactTimelineKey struct {
	At     time.Time
	Source ContactTimelineSource
	ID     uuid.UUID
}

// Before reports whether k sorts after o in the feed's newest-first order,
// which is to say it is the older position: a smaller time, or the same time
// and a lower source rank, or the same time and source and a lower id (uuid
// order is the byte order Postgres uses, so Go and SQL agree).
func (k ContactTimelineKey) Before(o ContactTimelineKey) bool {
	if !k.At.Equal(o.At) {
		return k.At.Before(o.At)
	}
	if k.Source != o.Source {
		return k.Source < o.Source
	}
	return bytes.Compare(k.ID[:], o.ID[:]) < 0
}

// ContactTimelineEvent is one entry in the merged activity feed. The
// optional fields are tagged with omitempty so the JSON stays compact
// for event types that don't carry that data.
type ContactTimelineEvent struct {
	Type ContactTimelineEventType `json:"type"`
	At   time.Time                `json:"at"`

	// Position in the feed, used for the merged sort and the next-page
	// cursor. Not part of the wire shape: the row ids are not unique across
	// sources, so a client gets an opaque cursor instead.
	Key ContactTimelineKey `json:"-"`

	// Mailbox sender (email_sent / opened / clicked / replied / bounced).
	EmailAccountID    *uuid.UUID `json:"email_account_id,omitempty"`
	EmailAccountEmail *string    `json:"email_account_email,omitempty"`
	EmailAccountName  *string    `json:"email_account_name,omitempty"`

	// Campaign / sequence linkage. Optional because notes, suppression,
	// and out-of-campaign reply intents don't always have one.
	CampaignID   *uuid.UUID `json:"campaign_id,omitempty"`
	CampaignName *string    `json:"campaign_name,omitempty"`
	SequenceID   *uuid.UUID `json:"step_id,omitempty"`
	SequenceName *string    `json:"step_name,omitempty"`

	// Task linkage for engagement events.
	TaskID  *uuid.UUID `json:"task_id,omitempty"`
	Subject *string    `json:"subject,omitempty"`

	// Type-specific.
	Reason   *string `json:"reason,omitempty"`   // deliverability / suppression / meeting cancellation
	Source   *string `json:"source,omitempty"`   // suppression: bounce/complaint/unsubscribe; meeting: calendly/cal_com
	Provider *string `json:"provider,omitempty"` // deliverability provider
	Intent   *string `json:"intent,omitempty"`   // reply_intent classification
	Content  *string `json:"content,omitempty"`  // note body

	// Meeting events (meeting_booked / rescheduled / canceled).
	ScheduledFor *time.Time `json:"scheduled_for,omitempty"` // when the call is set for
	JoinURL      *string    `json:"join_url,omitempty"`      // video/conference link
	MeetingState *string    `json:"meeting_state,omitempty"` // booked / rescheduled / canceled

	// Lifecycle events: the category a contact joined or left, and the
	// free-form detail behind a contact_created source (file name, campaign,
	// API key).
	CategoryID    *uuid.UUID `json:"category_id,omitempty"`
	CategoryTitle *string    `json:"category_title,omitempty"`
	SourceDetail  *string    `json:"source_detail,omitempty"`

	// Form submission (form_submitted): which hosted form was filled in.
	FormID   *uuid.UUID `json:"form_id,omitempty"`
	FormName *string    `json:"form_name,omitempty"`

	// Website page view (page_hit): URL, referrer, device, UTM, location.
	PageHit *WebsitePageHit `json:"page_hit,omitempty"`

	// Engagement classification (email_opened / email_clicked). Machine is
	// true when the open or click came from an automated fetcher (a mail
	// privacy proxy, a security gateway walking the links) rather than a
	// person; MachineReason says which rule caught it.
	Machine       *bool   `json:"machine,omitempty"`
	MachineReason *string `json:"machine_reason,omitempty"`

	// The exact link behind an email_clicked event, when the click was
	// logged per link (every click since link attribution shipped).
	Link *ContactLinkClick `json:"link,omitempty"`

	// Where an email_opened / email_clicked event came from, when it was
	// logged per event: mail client, device and rough location.
	Origin *EngagementOrigin `json:"origin,omitempty"`

	// Author (notes, lifecycle events).
	UserID *uuid.UUID `json:"user_id,omitempty"`
}

// ContactLinkClick names the link a contact clicked: where it went, the
// anchor text it was minted from, and the UTM parameters the destination
// carried (automatic or hand-written).
type ContactLinkClick struct {
	ID          uuid.UUID `json:"id"`
	URL         string    `json:"url"`
	Label       string    `json:"label,omitempty"`
	UTMSource   string    `json:"utm_source,omitempty"`
	UTMMedium   string    `json:"utm_medium,omitempty"`
	UTMCampaign string    `json:"utm_campaign,omitempty"`
	UTMTerm     string    `json:"utm_term,omitempty"`
	UTMContent  string    `json:"utm_content,omitempty"`
	UserAgent   string    `json:"user_agent,omitempty"`
}

// EngagementOrigin is what an open or click said about where it came from.
// Client names the mail client or image proxy when the user agent does
// (Gmail, Apple Mail, Outlook); the browser fields describe the rest. The
// location is resolved from the source network and the address itself is
// never stored. Every field is empty when unknown.
type EngagementOrigin struct {
	Client         string `json:"client,omitempty"`
	DeviceType     string `json:"device_type,omitempty"`
	OS             string `json:"os,omitempty"`
	Browser        string `json:"browser,omitempty"`
	BrowserVersion string `json:"browser_version,omitempty"`
	CountryCode    string `json:"country_code,omitempty"`
	Region         string `json:"region,omitempty"`
	City           string `json:"city,omitempty"`
}

// Empty reports whether nothing about the origin is known.
func (o EngagementOrigin) Empty() bool {
	return o == EngagementOrigin{}
}

type ContactTimelineResult struct {
	Data []ContactTimelineEvent `json:"data"`
	// Deprecated: read pagination.has_more. Kept for clients written against
	// the bare-timestamp pagination that predated the cursor envelope.
	HasMore bool `json:"has_more"`
	// NextCursor is an opaque (at, source, id) position; pass it back as
	// `cursor` for the next page. Total is never counted across the sources.
	Pagination Pagination `json:"pagination"`
}

type UpdateContact struct {
	FirstName        *string            `json:"first_name"`
	LastName         *string            `json:"last_name"`
	Company          *string            `json:"company"`
	Phone            *string            `json:"phone"`
	CustomFields     *map[string]string `json:"custom_fields"`
	Subscribed       *bool              `json:"subscribed"`
	Campaigns        []string           `json:"campaigns"`         // List of campaign IDs to set (nil = leave as-is)
	Categories       []string           `json:"categories"`        // List of category IDs to set (nil = leave as-is)
	AddCategories    []string           `json:"add_categories"`    // Diff-style add (ignored when Categories is set)
	RemoveCategories []string           `json:"remove_categories"` // Diff-style remove (ignored when Categories is set)
}

type AddContact struct {
	FirstName  string   `json:"first_name"`
	LastName   string   `json:"last_name"`
	Email      string   `json:"email"`
	Company    string   `json:"company"`
	Phone      string   `json:"phone"`
	Campaigns  []string `json:"campaigns"`
	Categories []string `json:"categories"`

	// Segments the new contact joins as an include override, so a contact
	// created from inside a segment belongs to it whatever the conditions
	// say. Unknown ids are a 400 before anything is written.
	Segments []string `json:"segments,omitempty"`

	CustomFields map[string]string `json:"custom_fields"`

	// VerificationStatus is a verdict the caller already holds for this
	// address, in Warmbly's vocabulary or any provider's the platform knows
	// (ZeroBounce, MillionVerifier, NeverBounce, ...). VerificationProvider
	// optionally names that vocabulary; without it the value is recognised by
	// itself. An unrecognised value is a 400. Stored as an imported verdict,
	// which the background check leaves alone until it ages out.
	VerificationStatus   string `json:"verification_status,omitempty"`
	VerificationProvider string `json:"verification_provider,omitempty"`

	// Verification is the normalised verdict derived from VerificationStatus.
	// Filled by the repository, never read from the request.
	Verification *ContactVerificationWrite `json:"-"`

	// Subscribed is the marketing-consent flag to store. nil means "don't
	// decide": a new contact defaults to subscribed, an existing one keeps
	// whatever it already had. Set explicitly by the importer when the file
	// carries a subscribed column.
	Subscribed *bool `json:"subscribed,omitempty"`

	// Source is the first-touch origin stamped on a NEW contact (an existing
	// one keeps its original). Dashboard callers may say "manual" or
	// "campaign"; every other value is decided server-side by the creation
	// path, and an API-key request is always "api". SourceDetail is never
	// read from the request body.
	Source       ContactSource `json:"source,omitempty"`
	SourceDetail string        `json:"-"`
}

// ContactSource is where a contact first came from. Mirrors the CHECK on
// contacts.source; ContactSourceUnknown is the honest value for rows created
// before attribution existed.
type ContactSource string

const (
	ContactSourceUnknown     ContactSource = "unknown"
	ContactSourceManual      ContactSource = "manual"
	ContactSourceCampaign    ContactSource = "campaign"
	ContactSourceImport      ContactSource = "import"
	ContactSourceSheetSync   ContactSource = "sheet_sync"
	ContactSourceAPI         ContactSource = "api"
	ContactSourceAIAssistant ContactSource = "ai_assistant"
	ContactSourceForm        ContactSource = "form"
)

// Valid reports whether the value is one the database accepts.
func (s ContactSource) Valid() bool {
	switch s {
	case ContactSourceUnknown, ContactSourceManual, ContactSourceCampaign, ContactSourceImport,
		ContactSourceSheetSync, ContactSourceAPI, ContactSourceAIAssistant, ContactSourceForm:
		return true
	}
	return false
}

// RequestSettable reports whether a dashboard request body may claim the
// source. Only the two origins a dashboard user can actually be in.
func (s ContactSource) RequestSettable() bool {
	return s == ContactSourceManual || s == ContactSourceCampaign
}

type SearchContactsFilterType string

const (
	SearchContactsFilterTypeEqual      SearchContactsFilterType = "equal"
	SearchContactsFilterTypeStartsWith SearchContactsFilterType = "starts_with"
	SearchContactsFilterTypeEndsWith   SearchContactsFilterType = "ends_with"
	SearchContactsFilterTypeContains   SearchContactsFilterType = "contains"
)

type SearchContactsFilter struct {
	Name  string                   `json:"name"`
	Value string                   `json:"value"`
	Type  SearchContactsFilterType `json:"type"`
}

type SearchContacts struct {
	Query              string                 `json:"query"`                // Text search across core fields
	CustomFieldFilters []SearchContactsFilter `json:"custom_field_filters"` // Custom Field Filters
	CampaignIDs        []string               `json:"campaign_ids"`         // Contacts must be in ALL these campaigns
	LeadStatus         string                 `json:"lead_status"`          // Filter by derived lead status; requires exactly one campaign_id
	Engagement         string                 `json:"engagement"`           // Filter by lead engagement (opened, not_opened, ...); ANDed with lead_status; requires exactly one campaign_id
	CategoryIDs        []string               `json:"category_ids"`         // Contacts must have ALL these categories
	SegmentIDs         []string               `json:"segment_ids"`          // Contacts must be members of ALL these segments
	MinCampaigns       *int                   `json:"min_campaigns"`        // Minimum number of associated campaigns
	MaxCampaigns       *int                   `json:"max_campaigns"`        // Maximum number of associated campaigns
	Subscribed         *bool                  `json:"subscribed"`           // Filter by subscription status
	VerificationStatus string                 `json:"verification_status"`  // Filter by verification verdict: valid | risky | invalid | unknown
	CreatedAfter       *time.Time             `json:"created_after"`        // Contacts created after this date
	CreatedBefore      *time.Time             `json:"created_before"`       // Contacts created before this date
	UpdatedAfter       *time.Time             `json:"updated_after"`        // Contacts updated after this date
	UpdatedBefore      *time.Time             `json:"updated_before"`       // Contacts updated before this date
	SortBy             string                 `json:"sort_by"`              // e.g., "first_name ASC", "campaign_count DESC"
	Reverse            bool                   `json:"reverse"`              // ASC or DESC
}

type BulkEditContactsFieldType string

const (
	BulkAddField    BulkEditContactsFieldType = "ADD"
	BulkEditField   BulkEditContactsFieldType = "EDIT"
	BulkDeleteField BulkEditContactsFieldType = "DELETE"
	BulkRenameField BulkEditContactsFieldType = "RENAME"
)

type BulkEditContactsField struct {
	Type  BulkEditContactsFieldType `json:"type"`
	Key   string                    `json:"key"`
	Value string                    `json:"value"`
}

type BulkEditContactsData struct {
	Contacts []string `json:"contacts"`

	AddCampaigns     []string                `json:"add_campaigns"`
	RemoveCampaigns  []string                `json:"remove_campaigns"`
	AddCategories    []string                `json:"add_categories,omitempty"`
	RemoveCategories []string                `json:"remove_categories,omitempty"`
	Fields           []BulkEditContactsField `json:"fields"`
	Subscribe        *bool                   `json:"subscribe"`
}
