package emailverify

import (
	"fmt"
	"math"
	"sort"
	"strings"
	"time"
)

// Evidence kinds. Positive kinds prove the mailbox is live; the recipient
// bounce proves it is not. Nothing here describes interest: a contact who
// never opens or replies has told us nothing about their address.
const (
	EvidenceDelivered        = "delivered"
	EvidenceOpened           = "opened"
	EvidenceClicked          = "clicked"
	EvidenceReplied          = "replied"
	EvidenceAutoReplied      = "auto_replied"
	EvidenceBouncedRecipient = "bounced_recipient"
	EvidenceBouncedOther     = "bounced_other"
)

// Evidence is one observed fact about a mailbox.
type Evidence struct {
	Kind       string
	Detail     string
	ObservedAt time.Time
}

// Verdict is the last probe, provider, import or manual verdict on the
// contact row.
type Verdict struct {
	Status Status
	// Source is the contacts.verification_source value.
	Source    string
	CheckedAt time.Time
}

// Scored is the derived state of an address.
type Scored struct {
	Status Status
	// Confidence is how sure the platform is of Status, 0 to 100.
	Confidence int
	// Reasons are the sentences a member reads, strongest first.
	Reasons []string
	// Decisive reports whether real mail (not a check) decided the status.
	Decisive bool
	// LastPositiveAt is the most recent positive observation.
	LastPositiveAt time.Time
}

// Evidence weights. Weights decay with a half-life so last week's reply
// outranks last year's, but nothing ever reaches zero on its own.
var evidenceWeights = map[string]struct {
	weight   float64
	halfLife time.Duration
	positive bool
	// cap bounds how many observations of this kind count.
	cap int
}{
	EvidenceReplied:          {45, 365 * 24 * time.Hour, true, 3},
	EvidenceAutoReplied:      {30, 180 * 24 * time.Hour, true, 2},
	EvidenceClicked:          {35, 270 * 24 * time.Hour, true, 3},
	EvidenceOpened:           {25, 180 * 24 * time.Hour, true, 3},
	EvidenceDelivered:        {14, 180 * 24 * time.Hour, true, 4},
	EvidenceBouncedRecipient: {70, 365 * 24 * time.Hour, false, 2},
}

// verdictBase is how much a check is trusted on its own, by source.
func verdictBase(v Verdict) (int, string) {
	switch v.Source {
	case "manual":
		return 95, "marked by a teammate"
	case "provider":
		switch v.Status {
		case StatusValid, StatusInvalid:
			return 88, "checked by the verification service"
		case StatusRisky:
			return 55, "flagged by the verification service"
		}
		return 30, "the verification service could not decide"
	case "imported":
		switch v.Status {
		case StatusValid, StatusInvalid:
			return 80, "verified before it was imported"
		case StatusRisky:
			return 50, "flagged before it was imported"
		}
		return 25, "imported without a decisive result"
	case "probe":
		switch v.Status {
		case StatusValid:
			return 75, "the mail server accepted the address"
		case StatusInvalid:
			return 80, "the mail server rejected the address"
		case StatusRisky:
			return 45, "the domain accepts any address, so the check proves nothing"
		}
		return 20, "the check was inconclusive"
	}
	return 0, "never checked"
}

// PositiveDecisiveScore is the evidence score above which real mail decides
// the address is deliverable regardless of what a check said.
const PositiveDecisiveScore = 20.0

// Score derives an address's status and confidence from its last verdict and
// the evidence ledger. Rules, in order:
//
//  1. A manual verdict wins outright.
//  2. A bounce naming the recipient, newer than every positive observation,
//     makes the address undeliverable.
//  3. Enough positive evidence (a reply, a click, a human open, or repeated
//     clean deliveries) makes it deliverable, whatever a probe said.
//  4. Otherwise the verdict stands, with the evidence nudging confidence.
//
// Absence of engagement never appears here: nothing in the ledger says "did
// not open", and Score has no input for it.
func Score(v Verdict, evidence []Evidence, now time.Time) Scored {
	base, baseReason := verdictBase(v)
	out := Scored{Status: v.Status, Confidence: base}
	if out.Status == "" {
		out.Status = StatusUnknown
	}

	sort.Slice(evidence, func(i, j int) bool { return evidence[i].ObservedAt.After(evidence[j].ObservedAt) })
	counts := map[string]int{}
	var positive, negative float64
	var lastPositive, lastNegative time.Time
	var reasons []string
	for _, e := range evidence {
		w, ok := evidenceWeights[e.Kind]
		if !ok {
			continue
		}
		if counts[e.Kind] >= w.cap {
			continue
		}
		counts[e.Kind]++
		age := now.Sub(e.ObservedAt)
		if age < 0 {
			age = 0
		}
		value := w.weight * math.Pow(0.5, age.Hours()/w.halfLife.Hours())
		if w.positive {
			positive += value
			if e.ObservedAt.After(lastPositive) {
				lastPositive = e.ObservedAt
			}
		} else {
			negative += value
			if e.ObservedAt.After(lastNegative) {
				lastNegative = e.ObservedAt
			}
		}
		if counts[e.Kind] == 1 {
			reasons = append(reasons, evidenceReason(e, counts, evidence, now))
		}
	}
	out.LastPositiveAt = lastPositive

	switch {
	case v.Source == "manual":
		out.Reasons = append([]string{baseReason}, reasons...)
		out.Confidence = clamp(base + int(positive/4))
		return out
	case !lastNegative.IsZero() && lastNegative.After(lastPositive):
		out.Status = StatusInvalid
		out.Decisive = true
		out.Confidence = clamp(60 + int(negative/2))
		out.Reasons = prepend(reasons, EvidenceBouncedRecipient)
		return out
	case positive >= PositiveDecisiveScore:
		out.Status = StatusValid
		out.Decisive = true
		out.Confidence = clamp(70 + int(positive/2))
		out.Reasons = reasons
		if v.Status == StatusRisky || v.Status == StatusInvalid {
			out.Reasons = append(out.Reasons, "real mail outranks the earlier check ("+baseReason+")")
		}
		return out
	}
	// The verdict stands; a little evidence either way moves confidence.
	switch out.Status {
	case StatusValid:
		out.Confidence = clamp(base + int(positive/2) - int(negative))
	case StatusInvalid:
		out.Confidence = clamp(base + int(negative/2) - int(positive))
	default:
		out.Confidence = clamp(base + int(positive/2))
	}
	out.Reasons = append([]string{baseReason}, reasons...)
	return out
}

func prepend(reasons []string, kind string) []string {
	out := make([]string, 0, len(reasons))
	for _, r := range reasons {
		if strings.HasPrefix(r, "bounced") {
			out = append([]string{r}, out...)
			continue
		}
		out = append(out, r)
	}
	return out
}

func evidenceReason(e Evidence, counts map[string]int, all []Evidence, now time.Time) string {
	n := 0
	for _, x := range all {
		if x.Kind == e.Kind {
			n++
		}
	}
	when := humanAge(now.Sub(e.ObservedAt))
	switch e.Kind {
	case EvidenceReplied:
		return "replied " + when
	case EvidenceAutoReplied:
		return "sent an automatic reply " + when + " (the mailbox is live)"
	case EvidenceClicked:
		return "clicked a link " + when
	case EvidenceOpened:
		return "opened an email " + when
	case EvidenceDelivered:
		if n > 1 {
			return fmt.Sprintf("delivered %d times without a bounce, last %s", n, when)
		}
		return "delivered without a bounce " + when
	case EvidenceBouncedRecipient:
		d := strings.TrimSpace(e.Detail)
		if d != "" {
			return "bounced " + when + ": " + d
		}
		return "bounced " + when + ": the server said the mailbox does not exist"
	}
	return e.Kind + " " + when
}

func humanAge(d time.Duration) string {
	days := int(d.Hours() / 24)
	switch {
	case days <= 0:
		return "today"
	case days == 1:
		return "yesterday"
	case days < 30:
		return fmt.Sprintf("%d days ago", days)
	case days < 365:
		return fmt.Sprintf("%d months ago", days/30)
	}
	return fmt.Sprintf("%d years ago", days/365)
}

func clamp(n int) int {
	if n < 0 {
		return 0
	}
	if n > 100 {
		return 100
	}
	return n
}

// NamesRecipient reports whether a bounce or rejection text says the
// RECIPIENT does not exist, as opposed to a full mailbox, a policy block, a
// reputation rejection or a greeting the server disliked. Only the former is
// evidence against the address.
func NamesRecipient(text string) bool {
	lower := strings.ToLower(text)
	if containsAny(lower, recipientMarkers) {
		return true
	}
	if containsAny(lower, probeMarkers) {
		return false
	}
	// Enhanced status anywhere in the text: 5.1.1 / 5.1.2 / 5.1.3 / 5.1.6 /
	// 5.1.10 address errors and 5.2.1 disabled mailbox.
	for _, code := range []string{"5.1.1", "5.1.2", "5.1.3", "5.1.6", "5.1.10", "5.2.1"} {
		if strings.Contains(lower, code+" ") || strings.HasSuffix(lower, code) || strings.Contains(lower, code+":") {
			return true
		}
	}
	return false
}
