package database

import (
	"database/sql"
	"errors"
	"strings"
	"time"

	"github.com/google/uuid"
	"gorm.io/gorm"
)

const (
	CAMPAIGN_RECIPIENT_TABLE_NAME = "campaign_recipients"
)

// CampaigReciever is gorm data model
// this model/table is primarily used to keep track of who and when should recieve a campaign
type CampaignRecipient struct {
	ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`

	CreatedAt *time.Time `gorm:"not null;index;"`
	UpdatedAt *time.Time `gorm:"not null;index;"`

	Campaign   *Campaign
	CampaignID *uuid.UUID `gorm:"not null;type:uuid;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"`

	// CancelledAt *time.Time `gorm:"index;"`
	CancelledAt *time.Time `gorm:"index;"`

	// when it should be send
	SendAt *time.Time `gorm:"index;"`

	// when it was last attempted send
	LastAttemptAt *time.Time `gorm:"index;"`

	// when it was sent
	SentAt *time.Time `gorm:"index;"`

	// self-managed
	SelfManaged bool `gorm:"not null;default:false;"`

	// AnonymizedID is set when the recipient has been anonymized
	AnonymizedID *uuid.UUID `gorm:"type:uuid;"`
	Recipient    *Recipient
	// A null recipientID means that the data has been anonymized
	RecipientID *uuid.UUID `gorm:"type:uuid;index;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"`

	// NotableEventID is the most notable event for this recipient
	NotableEvent   *Event     `gorm:"foreignKey:NotableEventID;references:ID"`
	NotableEventID *uuid.UUID `gorm:"type:uuid;index"`

	// LureCode replaces the campaign recipient UUID in a lure URL, for example
	// https://example.com/4H7K9QM2XR3T. Stored and matched byte for byte, so an
	// operator picking Special-42 gets that link verbatim.
	//
	// Releasing sets this to null rather than flagging it, which keeps a
	// reclaimed code from showing against its former owner and lets the unique
	// index below need nothing but IS NOT NULL.
	LureCode *string `gorm:"type:varchar(64)"`
	// LureCodeCustom marks a code the operator set by hand, which cannot be told
	// apart from a generated one by shape.
	LureCodeCustom bool `gorm:"not null;default:false"`
}

func (CampaignRecipient) TableName() string {
	return CAMPAIGN_RECIPIENT_TABLE_NAME
}

// lureCodeIndexName is the partial unique index backing lure code allocation.
const lureCodeIndexName = "idx_campaign_recipients_lure_code"

// lureCodeCustomIndexName is the index answering whether a campaign carries any
// operator set code.
const lureCodeCustomIndexName = "idx_campaign_recipients_lure_code_custom"

// Migrate creates the indexes the lure code feature reads.
//
// The unique predicate must exclude rows carrying no code, or every recipient in
// a query mode campaign enters the index under the same value and the second
// insert fails. It covers reuse too, since releasing a code nulls it and the row
// leaves the index.
//
// Raw SQL rather than gorm index tags, because the generic migrator drops the
// WHERE clause and would silently build a full unique index.
func (CampaignRecipient) Migrate(db *gorm.DB) error {
	createUnique := func() error {
		return ensureIndex(db, lureCodeIndexName, `CREATE UNIQUE INDEX `+lureCodeIndexName+`
			ON campaign_recipients (lure_code)
			WHERE lure_code IS NOT NULL`)
	}
	if err := createUnique(); err != nil {
		// creation fails when two rows already hold the same code. keep the first
		// row for each and release the rest. only after a failure, because the
		// scan and grouping are too costly to pay on every startup.
		if dedupErr := db.Exec(`UPDATE campaign_recipients SET lure_code = NULL
			WHERE lure_code IS NOT NULL
			  AND rowid NOT IN (
			    SELECT MIN(rowid) FROM campaign_recipients
			    WHERE lure_code IS NOT NULL
			    GROUP BY lure_code
			  )`).Error; dedupErr != nil {
			return dedupErr
		}
		if err := createUnique(); err != nil {
			return err
		}
	}

	// only rows with an operator set code enter the index, rather than every
	// recipient ever created. sqlite uses a partial index only where the query
	// repeats the predicate, so HasCustomLureCodesByCampaignID writes the same
	// lure_code_custom = 1 literal. a bare column predicate would not match.
	return ensureIndex(db, lureCodeCustomIndexName, `CREATE INDEX `+lureCodeCustomIndexName+`
		ON campaign_recipients (campaign_id)
		WHERE lure_code_custom = 1`)
}

// ensureIndex creates an index, replacing whatever carries the name when its
// definition differs.
//
// The whole definition is compared, not just existence: an index left by an
// earlier release keeps working and goes unnoticed, while a predicate that no
// longer matches the query stops the planner using it at all. The ddl must omit
// IF NOT EXISTS, because sqlite drops those words before storing the statement
// and the two would never compare equal.
func ensureIndex(db *gorm.DB, name string, ddl string) error {
	// through the underlying pool rather than gorm, whose Row returns nil when it
	// could not build the statement and would panic here during startup
	sqlDB, err := db.DB()
	if err != nil {
		return err
	}
	var definition sql.NullString
	err = sqlDB.QueryRow(
		`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`,
		name,
	).Scan(&definition)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return err
	}
	if err == nil {
		// an index sqlite created for itself carries no definition and is not ours
		if !definition.Valid {
			return nil
		}
		if normalizeSQL(definition.String) == normalizeSQL(ddl) {
			return nil
		}
		if err := db.Exec(`DROP INDEX IF EXISTS ` + name).Error; err != nil {
			return err
		}
	}
	return db.Exec(ddl).Error
}

// normalizeSQL collapses whitespace so a statement read back from sqlite_master
// compares equal to the indented literal it was created from.
func normalizeSQL(s string) string {
	return strings.Join(strings.Fields(s), " ")
}
