package alert

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"strings"
	"time"

	"github.com/tidbcloud/alertstore/pkg/audit"
	"github.com/tidbcloud/alertstore/pkg/event"
	"github.com/tidbcloud/alertstore/pkg/instance"
)

var (
	ErrInvalidTransition = errors.New("invalid alert state transition")
	ErrInvalidResolution = errors.New("invalid alert resolution")
)

// Service handles alert lifecycle operations.
// All state transitions go through here to ensure audit logging.
type Service struct {
	store      *Store
	eventStore *event.Store
	instances  *instance.Store
	audit      *audit.Store
	logger     *slog.Logger

	// OnStateChanged fires after any transition that changes what a posted card
	// shows. Every transition goes through this type, so hooking it here is the
	// only way to be sure none is missed — the alternative is remembering at
	// six call sites. Optional; nil in tests and in any deployment without IM.
	//
	// source is where the transition came from (web / im / issue_tracker), which
	// mirrors of the alert use to avoid writing a change back to the surface that
	// originated it.
	OnStateChanged func(ctx context.Context, a *Alert, source string)
}

func NewService(store *Store, eventStore *event.Store, instanceStore *instance.Store, auditStore *audit.Store, logger *slog.Logger) *Service {
	return &Service{
		store:      store,
		eventStore: eventStore,
		instances:  instanceStore,
		audit:      auditStore,
		logger:     logger,
	}
}

// StalenessWindow bounds occurrence accretion: an open alert older than this (from
// creation) stops absorbing new firings. A firing that lands on a stale alert
// detaches it (freeing the dedup slot, alert stays open) and starts a fresh
// alert. Mirrors the old system's "append within 24h of the ticket, else new".
const StalenessWindow = 24 * time.Hour

const ExpiryWindow = StalenessWindow + 30*time.Minute

// FiringOutcome reports what a firing event did to the aggregate. The two are
// mutually exclusive, and both are false when the occurrence was already bound
// — a repeat firing of something the group has already been told about.
type FiringOutcome struct {
	// AlertCreated is set when this event opened a new human alert.
	AlertCreated bool
	// InstanceAppended is set when the occurrence joined an alert that already
	// existed, which is the case the group is never otherwise told about.
	InstanceAppended bool
}

// HandleFiringEvent routes a firing alert to an aggregated alert, creating one
// if needed. Identity resolution, in order:
//  1. Sticky: if the occurrence is already bound to an open alert
//     within the window, route there — classification is frozen per alert, so a
//     mid-life classification flip does not split it into a second alert.
//  2. Aggregate: else find the active alert for this agg key (classification +
//     alertmanager group_by labels) within the window and append.
//  3. New: else create a new alert and bind this occurrence.
//
// A stale alert found in step 1 or 2 is detached first, so the firing falls
// through to a fresh alert instead of reviving it.
func (s *Service) HandleFiringEvent(ctx context.Context, attrs *AlertAttrs, instanceID int64, assignedTo string, responseDeadline, recoveryDeadline *time.Time) (*Alert, FiringOutcome, error) {
	// 1. Sticky binding: keep an occurrence on its current open human alert.
	stickyID, err := s.instances.FindOpenBoundAlertID(ctx, instanceID)
	if err != nil {
		return nil, FiringOutcome{}, fmt.Errorf("find bound alert: %w", err)
	}
	if stickyID != "" {
		sticky, err := s.store.GetByID(ctx, stickyID)
		if err != nil {
			return nil, FiringOutcome{}, err
		}
		if !isStale(sticky) {
			return s.bindInstance(ctx, sticky, instanceID, attrs)
		}
		if err := s.detach(ctx, sticky); err != nil {
			return nil, FiringOutcome{}, err
		}
	}

	// 2. Aggregate by key.
	aggDesc := AggKeyDesc(attrs.Classification, attrs.Labels)
	aggKey := AggKeyHash(aggDesc)
	attrs.AggKey = aggDesc

	active, err := s.store.FindActiveByAggKey(ctx, aggKey)
	if err != nil {
		return nil, FiringOutcome{}, fmt.Errorf("find active alert: %w", err)
	}
	if active != nil {
		if !isStale(active) {
			return s.bindInstance(ctx, active, instanceID, attrs)
		}
		if err := s.detach(ctx, active); err != nil {
			return nil, FiringOutcome{}, err
		}
	}

	// 3. Create a new alert.
	return s.createAlert(ctx, attrs, instanceID, assignedTo, aggKey, responseDeadline, recoveryDeadline)
}

func (s *Service) createAlert(ctx context.Context, attrs *AlertAttrs, instanceID int64, assignedTo, aggKey string, responseDeadline, recoveryDeadline *time.Time) (*Alert, FiringOutcome, error) {
	a, err := s.store.Create(ctx, attrs.Classification, attrs, assignedTo, aggKey, AggKeyVersion, responseDeadline, recoveryDeadline)
	if errors.Is(err, ErrDuplicateOpenAlert) {
		// Lost a create race with a concurrent webhook for the same agg key —
		// the unique slot funnels both into one alert; append to the winner.
		active, ferr := s.store.FindActiveByAggKey(ctx, aggKey)
		if ferr != nil || active == nil {
			return nil, FiringOutcome{}, fmt.Errorf("alert create raced for agg key %s: %w", aggKey, err)
		}
		return s.bindInstance(ctx, active, instanceID, attrs)
	}
	if err != nil {
		return nil, FiringOutcome{}, fmt.Errorf("create alert: %w", err)
	}

	if _, err := s.instances.BindAlert(ctx, a.ID, instanceID); err != nil {
		return nil, FiringOutcome{}, err
	}

	s.audit.Log(ctx, a.ID, "created", "", StateOpen, "system", "system", map[string]interface{}{
		"classification": attrs.Classification,
		"fingerprint":    attrs.Fingerprint,
		"assigned_to":    assignedTo,
	})

	return a, FiringOutcome{AlertCreated: true}, nil
}

// bindInstance folds an occurrence into an existing open alert.
func (s *Service) bindInstance(ctx context.Context, a *Alert, instanceID int64, attrs *AlertAttrs) (*Alert, FiringOutcome, error) {
	bound, err := s.instances.BindAlert(ctx, a.ID, instanceID)
	if err != nil {
		return nil, FiringOutcome{}, err
	}
	if bound {
		s.audit.Log(ctx, a.ID, "am_firing", a.State, a.State, "system", "system", map[string]interface{}{
			"fingerprint": attrs.Fingerprint,
			"note":        "firing, appended to aggregated alert",
		})
	}
	// bound is false for a repeat firing of an occurrence already on this alert.
	return a, FiringOutcome{InstanceAppended: bound}, nil
}

// detach releases a stale alert's dedup slot without resolving it. The alert
// and all occurrence history stay open and queryable for a human to close.
func (s *Service) detach(ctx context.Context, a *Alert) error {
	if err := s.store.Detach(ctx, a.ID); err != nil {
		return err
	}
	s.audit.Log(ctx, a.ID, "detached", a.State, a.State, "system", "system", map[string]interface{}{
		"reason":       "stale aggregate past routing window",
		"window_hours": int(StalenessWindow.Hours()),
	})
	return nil
}

// isStale reports whether an alert is older than the staleness window (measured
// from its creation), so it should stop absorbing new firings.
func isStale(a *Alert) bool {
	return time.Since(a.CreatedAt) > StalenessWindow
}

// FindOpenByInstanceID returns the most recent open human alert bound to an occurrence.
func (s *Service) FindOpenByInstanceID(ctx context.Context, instanceID int64) (*Alert, error) {
	alertID, err := s.instances.FindOpenBoundAlertID(ctx, instanceID)
	if err != nil || alertID == "" {
		return nil, err
	}
	return s.store.GetByID(ctx, alertID)
}

// HandleResolvedInstance records an AM resolution on the bound human alert.
// Instance state has already been updated by the ingestion state machine.
func (s *Service) HandleResolvedInstance(ctx context.Context, instanceID int64, fingerprint string) error {
	a, err := s.FindOpenByInstanceID(ctx, instanceID)
	if err != nil || a == nil {
		return err
	}
	firing, err := s.instances.CountFiringByAlertID(ctx, a.ID)
	if err != nil {
		return err
	}

	s.audit.Log(ctx, a.ID, "am_resolved", a.State, a.State, "system", "system", map[string]interface{}{
		"fingerprint":      fingerprint,
		"instances_firing": firing,
		"fully_recovered":  firing == 0,
	})

	return nil
}

func (s *Service) Ack(ctx context.Context, alertID, user, source string) error {
	a, err := s.store.GetByID(ctx, alertID)
	if err != nil {
		return err
	}

	if a.State == StateAcked {
		return nil // already acked, idempotent
	}
	if a.State != StateOpen {
		return fmt.Errorf("%w: cannot ack alert %s from %s", ErrInvalidTransition, alertID, a.State)
	}

	oldState := a.State
	if err := s.store.Ack(ctx, alertID, user, source); err != nil {
		return err
	}

	s.audit.Log(ctx, alertID, "ack", oldState, StateAcked, user, source, nil)
	s.takeOwnership(ctx, a, user, source)
	s.stateChanged(ctx, alertID, source)
	return nil
}

// stateChanged notifies the delivery layer that a posted card is now stale.
// Best-effort by construction: the database is already correct, and a card that
// lags is a worse outcome than a transition that fails.
func (s *Service) stateChanged(ctx context.Context, alertID, source string) {
	if s.OnStateChanged == nil {
		return
	}
	a, err := s.store.GetByID(ctx, alertID)
	if err != nil {
		s.logger.Error("reload alert after state change", "err", err, "alert_id", alertID)
		return
	}
	s.OnStateChanged(ctx, a, source)
}

// takeOwnership moves the alert to whoever acked it. Acknowledging is claiming:
// without this an alert reads as assigned to one person and acked by another,
// and nobody can tell which of them is actually working it.
//
// It deliberately does not gate who may ack. The case it would block — the
// on-caller is asleep and a colleague covers — is the one that matters most,
// and refusing there is worse than a slightly surprising handover. Claiming it
// by acking is also its own deterrent against an idle click.
//
// Failing here leaves a correct ack with stale ownership, which is the same
// state the system had before ownership followed the ack at all, so it is
// logged rather than surfaced.
func (s *Service) takeOwnership(ctx context.Context, a *Alert, user, source string) {
	// An actor that is not an address came from an IM identity the directory
	// could not resolve. Writing it here would make an owner that no later
	// lookup can turn back into a person.
	if a.AssignedTo == user || !strings.Contains(user, "@") {
		return
	}
	if err := s.store.Assign(ctx, a.ID, user); err != nil {
		s.logger.Error("take ownership on ack", "err", err, "alert_id", a.ID, "user", user)
		return
	}
	s.audit.Log(ctx, a.ID, "assigned", a.State, a.State, user, source, map[string]interface{}{
		"assignee": user,
		"previous": a.AssignedTo,
		"reason":   "acked",
	})
}

func (s *Service) Resolve(ctx context.Context, alertID, user, source, resolution, note string) error {
	if !isValidResolution(resolution) {
		return fmt.Errorf("%w: %s", ErrInvalidResolution, resolution)
	}

	a, err := s.store.GetByID(ctx, alertID)
	if err != nil {
		return err
	}

	if a.State == StateResolved {
		return nil // idempotent
	}
	if a.State != StateAcked {
		return fmt.Errorf("%w: ack alert %s before resolving it", ErrInvalidTransition, alertID)
	}

	oldState := a.State
	if err := s.store.Resolve(ctx, alertID, resolution, note); err != nil {
		return err
	}

	detail := map[string]interface{}{
		"resolution": resolution,
	}
	if note != "" {
		detail["note"] = note
	}
	s.audit.Log(ctx, alertID, "resolved", oldState, StateResolved, user, source, detail)
	s.stateChanged(ctx, alertID, source)
	return nil
}

func isValidResolution(resolution string) bool {
	switch resolution {
	case ResolutionHandled,
		ResolutionSelfRecovered,
		ResolutionNoise,
		ResolutionExpired:
		return true
	default:
		return false
	}
}

// Expire closes an unresolved alert that has lived past ExpiryWindow without
// human action. Unlike Resolve it accepts any pre-terminal state (open or
// acked), since the whole point is that nobody touched it.
func (s *Service) Expire(ctx context.Context, alertID string) error {
	a, err := s.store.GetByID(ctx, alertID)
	if err != nil {
		return err
	}
	if a.State == StateResolved {
		return nil
	}

	oldState := a.State
	if err := s.store.Resolve(ctx, alertID, ResolutionExpired, ""); err != nil {
		return err
	}

	s.audit.Log(ctx, alertID, "expired", oldState, StateResolved, "system", "expiry", map[string]interface{}{
		"window_hours": int(ExpiryWindow.Hours()),
	})
	s.stateChanged(ctx, alertID, "expiry")
	return nil
}

// ExpireStale finds all unresolved alerts older than ExpiryWindow and expires
// them. Returns the number of alerts expired.
func (s *Service) ExpireStale(ctx context.Context) (int, error) {
	cutoff := time.Now().UTC().Add(-ExpiryWindow)
	ids, err := s.store.ListExpirable(ctx, cutoff)
	if err != nil {
		return 0, err
	}
	expired := 0
	for _, id := range ids {
		if err := s.Expire(ctx, id); err != nil {
			s.logger.Error("expire alert", "err", err, "alert_id", id)
			continue
		}
		expired++
	}
	return expired, nil
}

func (s *Service) Assign(ctx context.Context, alertID, user, source, assignee, reason string) error {
	a, err := s.store.GetByID(ctx, alertID)
	if err != nil {
		return err
	}

	if a.State == StateResolved {
		return fmt.Errorf("alert %s is already resolved", alertID)
	}

	oldAssignee := a.AssignedTo
	if err := s.store.Assign(ctx, alertID, assignee); err != nil {
		return err
	}

	// An acknowledgement belongs to whoever gave it. Handing the alert on means
	// the new owner has not seen it yet, so the alert goes back to unacked and
	// they get their own chance to say they are on it. Without this an alert
	// reads as being handled while nobody has actually picked it up.
	newState := a.State
	if a.State == StateAcked && oldAssignee != assignee {
		if err := s.store.Unack(ctx, alertID); err != nil {
			return err
		}
		newState = StateOpen
		s.audit.Log(ctx, alertID, "unacked", StateAcked, StateOpen, user, source, map[string]interface{}{
			"reason":    "reassigned",
			"acked_by":  a.AckedBy,
			"new_owner": assignee,
		})
	}

	s.audit.Log(ctx, alertID, "reassigned", a.State, newState, user, source, map[string]interface{}{
		"old":    oldAssignee,
		"new":    assignee,
		"reason": reason,
	})
	if newState != a.State {
		s.stateChanged(ctx, alertID, source)
	}
	return nil
}

// GetDetail returns the alert with audit log and computed am_status.
func (s *Service) GetDetail(ctx context.Context, alertID string) (*AlertDetail, error) {
	a, err := s.store.GetByID(ctx, alertID)
	if err != nil {
		return nil, err
	}

	entries, err := s.audit.ListByAlertID(ctx, alertID)
	if err != nil {
		return nil, fmt.Errorf("list audit: %w", err)
	}

	instances, err := s.instances.ListByAlertID(ctx, alertID)
	if err != nil {
		return nil, fmt.Errorf("list instances: %w", err)
	}
	events, err := s.eventStore.ListByAlertID(ctx, alertID)
	if err != nil {
		return nil, fmt.Errorf("list instance events: %w", err)
	}
	receipts, err := s.eventStore.ListReceiptInfoByAlertID(ctx, alertID)
	if err != nil {
		return nil, fmt.Errorf("list receipt diagnostics: %w", err)
	}

	eventsByInstance := make(map[int64][]event.Event)
	var lastObserved *time.Time
	for _, ev := range events {
		if ev.InstanceID != nil {
			eventsByInstance[*ev.InstanceID] = append(eventsByInstance[*ev.InstanceID], ev)
		}
		if lastObserved == nil || ev.ReceivedAt.After(*lastObserved) {
			t := ev.ReceivedAt
			lastObserved = &t
		}
	}

	amStatus := "resolved"
	counts := InstanceCounts{}
	// The common labels are carried once on the alert, so each occurrence keeps
	// only what distinguishes it — without the subtraction every alert-level
	// label repeats on every occurrence, and on a single-occurrence alert the
	// whole common set is duplicated verbatim.
	common := CommonLabels(instances)
	instanceDetails := make([]InstanceDetail, 0, len(instances))
	if len(instances) == 0 {
		amStatus = "unknown"
	}
	for _, inst := range instances {
		switch inst.State {
		case instance.StateFiring:
			counts.Firing++
			amStatus = "firing"
		case instance.StateResolved:
			counts.Resolved++
		default:
			counts.Unknown++
			if amStatus != "firing" {
				amStatus = "unknown"
			}
		}
		if raw, err := json.Marshal(DistinctLabels(&inst, common)); err != nil {
			s.logger.Error("marshal distinct labels", "err", err, "alert_id", alertID, "instance_id", inst.ID)
		} else {
			inst.Labels = raw
		}
		instanceDetails = append(instanceDetails, InstanceDetail{
			Instance:     inst,
			Observations: eventsByInstance[inst.ID],
		})
	}

	auditEntries := make([]AuditEntry, len(entries))
	for i, e := range entries {
		auditEntries[i] = AuditEntry{
			ID:        e.ID,
			AlertID:   e.AlertID,
			Event:     e.Event,
			OldState:  e.OldState,
			NewState:  e.NewState,
			Actor:     e.Actor,
			Source:    e.Source,
			Detail:    e.Detail.Raw(),
			CreatedAt: e.CreatedAt,
		}
	}

	// Attrs is the alert's own record and stays in the response as stored; the
	// facts resolved across occurrences ride alongside it. A malformed blob costs
	// the extra fields, not the alert.
	var attrs AlertAttrs
	if err := json.Unmarshal(a.Attrs, &attrs); err != nil {
		s.logger.Error("unmarshal alert attrs for common facts", "err", err, "alert_id", alertID)
	}

	return &AlertDetail{
		Alert:            *a,
		AMStatus:         amStatus,
		InstanceCounts:   counts,
		LastAMObservedAt: lastObserved,
		Instances:        instanceDetails,
		Receipts:         receipts,
		Audit:            auditEntries,
		CommonFacts:      CommonFacts(instances, &attrs),
		CommonLabels:     common,
	}, nil
}
