package services

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

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"

	"mgit.msbls.de/m/paliad/internal/models"
)

// EventTriggerService backs POST /api/tools/event-trigger — Phase 3
// Slice 6 (t-paliad-187, design §5). Given an event-type or a concept
// (or both), it discovers the deadline rules triggered by the input
// and computes their dates via the unified Phase-3 helpers
// (applyDuration + evalConditionExpr).
//
// Distinct from the legacy /api/tools/event-deadlines surface (which
// is keyed exclusively on paliad.trigger_events bigints): this
// endpoint accepts either UUID paliad.event_types.id (Pipeline-C
// rules, via the trigger_event_id bridge on event_types) OR UUID
// paliad.deadline_concepts.id (Pipeline-A rules linked via the
// concept_id FK on deadline_rules). When both are passed the
// resulting rule set is UNIONed and deduped by rule.id.
//
// Distinct from FristenrechnerService.Calculate (proceeding-tree):
// no parent_id chain walk, no IsRootEvent / IsCourtSet
// classification, no AnchorOverrides — rules fire flat off the
// trigger date. The math, gate evaluation, and party-perspective
// filter all reuse Slice-4's unified helpers so the response shape
// stays calibrated against the proceeding-tree calculator.
type EventTriggerService struct {
	db       *sqlx.DB
	rules    *DeadlineRuleService
	holidays *HolidayService
	courts   *CourtService
}

// NewEventTriggerService wires the service to its dependencies.
func NewEventTriggerService(db *sqlx.DB, rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *EventTriggerService {
	return &EventTriggerService{db: db, rules: rules, holidays: holidays, courts: courts}
}

// EventTriggerInput is the parsed request body. At least one of
// EventTypeID / ConceptID must be set (validated in Trigger).
type EventTriggerInput struct {
	// EventTypeID resolves through paliad.event_types.id →
	// trigger_event_id (bigint) → SELECT deadline_rules WHERE
	// trigger_event_id matches. Nil = no event-type leg.
	EventTypeID *uuid.UUID
	// ConceptID matches deadline_rules.concept_id directly (the
	// Pipeline-A cascade leaf semantic that the result-card click
	// flow uses). Nil = no concept leg.
	ConceptID *uuid.UUID
	// TriggerDate is the anchor for the calculator. Required.
	// Format: YYYY-MM-DD.
	TriggerDate string
	// Flags is the caller's flag set used by evalConditionExpr to
	// gate / swap rules (e.g. with_ccr → alt-swap on flag-met).
	Flags []string
	// CourtID picks the (country, regime) tuple for non-working-day
	// arithmetic. Empty falls back to DE / UPC (UPC München default).
	CourtID string
	// Perspective filters opposing-side rules out of the response.
	// Empty = no filter (return rules for every party).
	Perspective string
}

// Trigger discovers rules and computes their deadlines, returning
// the same UIResponse shape as FristenrechnerService.Calculate so
// the frontend can render with one renderer. Mutates no state.
func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInput) (*UIResponse, error) {
	if input.EventTypeID == nil && input.ConceptID == nil {
		return nil, fmt.Errorf("%w: event_type_id or concept_id required", ErrInvalidInput)
	}

	triggerDate, err := time.Parse("2006-01-02", input.TriggerDate)
	if err != nil {
		return nil, fmt.Errorf("%w: trigger_date must be YYYY-MM-DD (got %q)", ErrInvalidInput, input.TriggerDate)
	}

	// Pipeline-C rules originate from the UPC-flavoured corpus —
	// default DE / UPC for the holiday calendar so this surface
	// matches EventDeadlineService.Calculate's behaviour when the
	// caller doesn't pick a specific court.
	country, regime, err := s.courts.CountryRegime(input.CourtID, CountryDE, RegimeUPC)
	if err != nil {
		return nil, fmt.Errorf("resolve court %q: %w", input.CourtID, err)
	}

	rules, err := s.discoverRules(ctx, input.EventTypeID, input.ConceptID)
	if err != nil {
		return nil, err
	}

	flagSet := make(map[string]struct{}, len(input.Flags))
	for _, f := range input.Flags {
		flagSet[f] = struct{}{}
	}

	deadlines := make([]UIDeadline, 0, len(rules))
	for _, r := range rules {
		if !matchesPerspective(r.PrimaryParty, input.Perspective) {
			continue
		}

		gateMet := evalConditionExpr([]byte(r.ConditionExpr), flagSet)
		if !gateMet && r.AltDurationValue == nil {
			continue
		}

		timing := ""
		if r.Timing != nil {
			timing = *r.Timing
		}

		// Legacy alt-swap (flag-keyed) is mutually exclusive with
		// combine_op composite in the live corpus; the same guard
		// FristenrechnerService.Calculate uses applies here.
		durationValue := r.DurationValue
		durationUnit := r.DurationUnit
		if r.CombineOp == nil && gateMet && hasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil {
			durationValue = *r.AltDurationValue
			if r.AltDurationUnit != nil {
				durationUnit = *r.AltDurationUnit
			}
		}

		origDate, adjusted, wasAdj, reason := applyDuration(
			triggerDate, durationValue, durationUnit, timing, country, regime, s.holidays,
		)

		if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
			altOrig, altAdj, altWasAdj, altReason := applyDuration(
				triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays,
			)
			switch *r.CombineOp {
			case "max":
				if altAdj.After(adjusted) {
					origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
				}
			case "min":
				if altAdj.Before(adjusted) {
					origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
				}
			}
		}

		// Slice 9 (t-paliad-195): Priority is the canonical wire signal.
		// Legacy IsMandatory/IsOptional fields dropped from UIDeadline
		// along with the underlying column drop.
		d := UIDeadline{
			RuleID:           r.ID.String(),
			Name:             r.Name,
			NameEN:           r.NameEN,
			Priority:         r.Priority,
			ConditionExpr:    json.RawMessage(r.ConditionExpr),
			IsCourtSet:       r.IsCourtSet,
			DueDate:          adjusted.Format("2006-01-02"),
			OriginalDate:     origDate.Format("2006-01-02"),
			WasAdjusted:      wasAdj,
			AdjustmentReason: reason,
		}
		if r.SubmissionCode != nil {
			d.Code = *r.SubmissionCode
		}
		if r.PrimaryParty != nil {
			d.Party = *r.PrimaryParty
		}
		if r.RuleCode != nil {
			d.RuleRef = *r.RuleCode
		}
		if r.LegalSource != nil {
			d.LegalSource = *r.LegalSource
		}
		if r.DeadlineNotes != nil {
			d.Notes = *r.DeadlineNotes
		}
		if r.DeadlineNotesEn != nil {
			d.NotesEN = *r.DeadlineNotesEn
		}
		// Court-set rules surface IsCourtSet=true and clear the
		// computed date — matches the proceeding-tree calculator's
		// "wird vom Gericht bestimmt" rendering.
		if r.IsCourtSet {
			d.DueDate = ""
			d.OriginalDate = ""
			d.WasAdjusted = false
			d.AdjustmentReason = nil
		}
		deadlines = append(deadlines, d)
	}

	return &UIResponse{
		// Event-trigger responses don't carry proceeding metadata —
		// the caller already has the event_type / concept context
		// (they're in the request). Leaving these empty is the
		// stable contract; FristenrechnerService.calculateByTriggerEvent
		// (the Pipeline-C delegate) does the same.
		ProceedingType: "",
		ProceedingName: "",
		TriggerDate:    input.TriggerDate,
		Deadlines:      deadlines,
	}, nil
}

// discoverRules returns the UNION of rules triggered by the
// event-type and concept inputs, deduped by rule.id. Either input
// may be nil — the corresponding branch is skipped.
func (s *EventTriggerService) discoverRules(ctx context.Context, eventTypeID, conceptID *uuid.UUID) ([]models.DeadlineRule, error) {
	seen := make(map[uuid.UUID]struct{})
	out := make([]models.DeadlineRule, 0, 16)

	if eventTypeID != nil {
		// event_types.trigger_event_id is nullable on the column but
		// every active row in the corpus today carries a bigint here
		// (the row is the bridge to the Pipeline-C corpus). NULL is
		// possible for future hand-edited event_types; treat as "no
		// rules triggered" rather than an error.
		var triggerEventID sql.NullInt64
		err := s.db.GetContext(ctx, &triggerEventID,
			`SELECT trigger_event_id
			   FROM paliad.event_types
			  WHERE id = $1 AND archived_at IS NULL`, *eventTypeID)
		if err != nil {
			if errors.Is(err, sql.ErrNoRows) {
				return nil, fmt.Errorf("%w: event_type_id=%s not found", ErrInvalidInput, *eventTypeID)
			}
			return nil, fmt.Errorf("lookup event_type: %w", err)
		}
		if triggerEventID.Valid {
			byTrigger, err := s.rules.ListByTriggerEvent(ctx, triggerEventID.Int64)
			if err != nil {
				return nil, err
			}
			for _, r := range byTrigger {
				if _, ok := seen[r.ID]; ok {
					continue
				}
				seen[r.ID] = struct{}{}
				out = append(out, r)
			}
		}
	}

	if conceptID != nil {
		byConcept, err := s.rules.ListByConcept(ctx, *conceptID)
		if err != nil {
			return nil, err
		}
		for _, r := range byConcept {
			if _, ok := seen[r.ID]; ok {
				continue
			}
			seen[r.ID] = struct{}{}
			out = append(out, r)
		}
	}

	return out, nil
}

// matchesPerspective returns true iff a rule whose primary_party is
// `party` (may be nil/empty) should render under the given
// perspective filter. Empty perspective passes everything through.
// Rules without a party (NULL primary_party) always render — the
// caller didn't ask the system to take a side for these.
//
// The drop-only-on-explicit-mismatch policy keeps 'both' / 'court'
// / NULL rules visible and only filters claimant↔defendant pairs.
func matchesPerspective(party *string, perspective string) bool {
	if perspective == "" || party == nil {
		return true
	}
	switch perspective {
	case "claimant":
		return *party != "defendant"
	case "defendant":
		return *party != "claimant"
	default:
		// Unknown perspective: pass-through. Phase 3 Slice 8 will
		// surface the allowed set; until then the API is forgiving.
		return true
	}
}
