package services

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"

	"github.com/google/uuid"

	lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)

// ErrUnknownProceduralEvent is returned by LookupFollowUps when the
// requested procedural_event cannot be resolved (unknown id / unknown
// code / not active+published). Distinct from ErrUnknownTriggerEvent
// (which lives on the legacy Pipeline C / paliad.trigger_events path).
var ErrUnknownProceduralEvent = errors.New("unknown procedural event")

// FollowUpsResponse is the wire shape for GET
// /api/tools/fristenrechner/follow-ups (Fristenrechner overhaul S1,
// design §6.2). Captures the locked trigger event + every immediate
// follow-up rule with its computed due date.
type FollowUpsResponse struct {
	Trigger     FollowUpTrigger    `json:"trigger"`
	TriggerDate string             `json:"trigger_date"`
	Party       *string            `json:"party,omitempty"`
	FollowUps   []FollowUpRule     `json:"follow_ups"`
}

// FollowUpTrigger is the locked trigger event identity returned by
// LookupFollowUps.
type FollowUpTrigger struct {
	ID             uuid.UUID            `json:"id"`
	Code           string               `json:"code"`
	NameDE         string               `json:"name_de"`
	NameEN         string               `json:"name_en"`
	EventKind      *string              `json:"event_kind,omitempty"`
	ProceedingType EventSearchPT        `json:"proceeding_type"`
	AnchorRuleID   uuid.UUID            `json:"anchor_rule_id"`
}

// FollowUpRule is one follow-up deadline returned by LookupFollowUps.
// Carries the rule metadata + the computed due date (or the
// "wird vom Gericht bestimmt" / "abhängig von …" marker for rules whose
// date is undefined).
type FollowUpRule struct {
	RuleID              uuid.UUID `json:"rule_id"`
	EventCode           string    `json:"event_code"`
	TitleDE             string    `json:"title_de"`
	TitleEN             string    `json:"title_en"`
	Priority            string    `json:"priority"`
	PrimaryParty        *string   `json:"primary_party,omitempty"`
	// IsCrossParty is true when the requesting party is "claimant" or
	// "defendant" AND the rule's primary_party is the opposite side
	// (m/paliad#149 Phase 2 S1, design §2.4). Cross-party rows are
	// displayed with a `Gegenseitig` badge + muted style + unchecked
	// default, and are unconditionally excluded from the Akte
	// "Save as project deadlines" write-back. NULL/both/court rules
	// are never cross-party regardless of perspective.
	IsCrossParty        bool      `json:"is_cross_party"`
	DurationValue       *int      `json:"duration_value,omitempty"`
	DurationUnit        *string   `json:"duration_unit,omitempty"`
	Timing              *string   `json:"timing,omitempty"`
	DueDate             string    `json:"due_date,omitempty"`
	OriginalDueDate     string    `json:"original_due_date,omitempty"`
	WasAdjusted         bool      `json:"was_adjusted,omitempty"`
	IsCourtSet          bool      `json:"is_court_set"`
	IsSpawn             bool      `json:"is_spawn"`
	IsBilateral         bool      `json:"is_bilateral"`
	HasCondition        bool      `json:"has_condition"`
	RuleCode            *string   `json:"rule_code,omitempty"`
	LegalSource         *string   `json:"legal_source,omitempty"`
	LegalSourceDisplay  *string   `json:"legal_source_display,omitempty"`
	LegalSourceURL      *string   `json:"legal_source_url,omitempty"`
	NotesDE             *string   `json:"notes_de,omitempty"`
	NotesEN             *string   `json:"notes_en,omitempty"`
	SpawnLabel          *string   `json:"spawn_label,omitempty"`
	SpawnProceedingCode *string   `json:"spawn_proceeding_code,omitempty"`
	ConceptID           *uuid.UUID `json:"concept_id,omitempty"`
}

// LookupFollowUps returns the follow-up rules anchored on a single
// procedural_event, with computed dates run through the holiday-aware
// litigationplanner.CalculateRule. Identifies the anchor by either the
// procedural_event.id (uuid) or its code; resolves the anchor rule
// (the sequencing_rule with procedural_event_id matching), then walks
// one hop down via parent_id to collect immediate follow-ups.
//
// Cross-party display (m/paliad#149 Phase 2 S1, design §2.4): the server
// returns ALL follow-ups regardless of party — including the opposing
// side's filings — and annotates each row with `is_cross_party` so the
// UI can render the Gegenseitig badge + muted style. The party param
// is kept as a perspective qualifier (it drives is_cross_party computation
// and remains in the response context), but no longer filters which
// rows are returned. Honest UX: the workflow continues on the other
// side and the lawyer can see what they file vs what they receive.
func (s *FristenrechnerService) LookupFollowUps(
	ctx context.Context,
	eventRef string,
	triggerDateStr string,
	party string,
	courtID string,
) (*FollowUpsResponse, error) {
	if eventRef == "" {
		return nil, fmt.Errorf("eventRef required")
	}
	if triggerDateStr == "" {
		return nil, fmt.Errorf("triggerDate required")
	}

	anchor, err := s.resolveTriggerEvent(ctx, eventRef)
	if err != nil {
		return nil, err
	}

	resp := &FollowUpsResponse{
		Trigger:     anchor.Trigger,
		TriggerDate: triggerDateStr,
		FollowUps:   []FollowUpRule{},
	}
	if party != "" {
		p := party
		resp.Party = &p
	}

	// Pull the proceeding_type metadata once so we can pass it
	// downstream to populate the trigger card and to seed the
	// CalculateRule lookup (which uses RuleID anyway).
	rows, err := s.queryFollowUpRows(ctx, anchor.AnchorRuleID)
	if err != nil {
		return nil, err
	}

	for _, r := range rows {
		fr := FollowUpRule{
			RuleID:       r.RuleID,
			EventCode:    r.EventCode,
			TitleDE:      r.NameDE,
			TitleEN:      r.NameEN,
			Priority:     r.Priority,
			IsCourtSet:   r.IsCourtSet,
			IsSpawn:      r.IsSpawn,
			IsBilateral:  r.IsBilateral,
			HasCondition: r.HasCondition,
		}
		if r.PrimaryParty.Valid {
			v := r.PrimaryParty.String
			fr.PrimaryParty = &v
		}
		fr.IsCrossParty = isCrossPartyRow(party, r.PrimaryParty)
		if r.DurationValue.Valid {
			v := int(r.DurationValue.Int32)
			fr.DurationValue = &v
		}
		if r.DurationUnit.Valid {
			v := r.DurationUnit.String
			fr.DurationUnit = &v
		}
		if r.Timing.Valid {
			v := r.Timing.String
			fr.Timing = &v
		}
		if r.RuleCode.Valid {
			v := r.RuleCode.String
			fr.RuleCode = &v
		}
		if r.LegalSource.Valid {
			v := r.LegalSource.String
			fr.LegalSource = &v
			display := lp.FormatLegalSourceDisplay(v)
			if display != "" {
				fr.LegalSourceDisplay = &display
			}
			url := lp.BuildLegalSourceURL(v)
			if url != "" {
				fr.LegalSourceURL = &url
			}
		}
		if r.NotesDE.Valid {
			v := r.NotesDE.String
			fr.NotesDE = &v
		}
		if r.NotesEN.Valid {
			v := r.NotesEN.String
			fr.NotesEN = &v
		}
		if r.SpawnLabel.Valid {
			v := r.SpawnLabel.String
			fr.SpawnLabel = &v
		}
		if r.SpawnProceedingCode.Valid {
			v := r.SpawnProceedingCode.String
			fr.SpawnProceedingCode = &v
		}
		if r.ConceptID != nil {
			fr.ConceptID = r.ConceptID
		}

		// Skip date computation for court-set / spawn rules — they don't
		// project a calendar date here.
		if !r.IsCourtSet && !r.IsSpawn {
			calc, err := s.CalculateRule(ctx, lp.CalcRuleParams{
				RuleID:      r.RuleID.String(),
				TriggerDate: triggerDateStr,
				CourtID:     courtID,
			})
			if err == nil {
				fr.DueDate = calc.DueDate
				fr.OriginalDueDate = calc.OriginalDate
				fr.WasAdjusted = calc.WasAdjusted
			}
			// On error: leave the date fields empty — the frontend
			// already handles missing dates as "abhängig von ..." style
			// markers and a single bad rule shouldn't 500 the whole
			// follow-up list.
		}

		resp.FollowUps = append(resp.FollowUps, fr)
	}

	return resp, nil
}

// anchorResolution carries the resolver output: the trigger card metadata
// plus the anchor rule id (the sequencing_rule.id whose
// procedural_event_id equals the trigger event).
type anchorResolution struct {
	Trigger      FollowUpTrigger
	AnchorRuleID uuid.UUID
}

// resolveTriggerEvent looks up the trigger event by either uuid or code.
// Returns ErrUnknownTriggerEvent when no published+active anchor row
// matches.
func (s *FristenrechnerService) resolveTriggerEvent(ctx context.Context, ref string) (*anchorResolution, error) {
	// Try uuid first; fall back to code lookup.
	type row struct {
		EventID        uuid.UUID      `db:"event_id"`
		Code           string         `db:"code"`
		NameDE         string         `db:"name_de"`
		NameEN         string         `db:"name_en"`
		EventKind      sql.NullString `db:"event_kind"`
		AnchorRuleID   uuid.UUID      `db:"anchor_rule_id"`
		PTID           int            `db:"pt_id"`
		PTCode         string         `db:"pt_code"`
		PTNameDE       string         `db:"pt_name_de"`
		PTNameEN       string         `db:"pt_name_en"`
		PTJurisdiction sql.NullString `db:"pt_jurisdiction"`
	}

	var r row
	queryBase := `
		SELECT pe.id AS event_id,
		       pe.code,
		       pe.name AS name_de,
		       pe.name_en,
		       pe.event_kind,
		       sr.id AS anchor_rule_id,
		       pt.id AS pt_id,
		       pt.code AS pt_code,
		       pt.name AS pt_name_de,
		       pt.name_en AS pt_name_en,
		       pt.jurisdiction AS pt_jurisdiction
		  FROM paliad.sequencing_rules sr
		  JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
		  JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
		 WHERE sr.is_active = true
		   AND sr.lifecycle_state = 'published'
		   AND pe.is_active = true
		   AND pe.lifecycle_state = 'published'
		   AND pt.is_active = true
		   AND %s
		 ORDER BY pt.sort_order
		 LIMIT 1`

	if id, err := uuid.Parse(ref); err == nil {
		// Treat as a procedural_event id OR a sequencing_rule id (the
		// frontend may pass either — search returns event id but a
		// concept-card-derived flow may pass the rule id).
		err := s.rules.db.GetContext(ctx, &r, fmt.Sprintf(queryBase, "(pe.id = $1 OR sr.id = $1)"), id)
		if err == nil {
			goto found
		}
		if !errors.Is(err, sql.ErrNoRows) {
			return nil, fmt.Errorf("resolve trigger event by id: %w", err)
		}
		// fall through to code lookup
	}
	{
		err := s.rules.db.GetContext(ctx, &r, fmt.Sprintf(queryBase, "pe.code = $1"), ref)
		if err == nil {
			goto found
		}
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUnknownProceduralEvent
		}
		return nil, fmt.Errorf("resolve trigger event by code: %w", err)
	}

found:
	res := &anchorResolution{
		AnchorRuleID: r.AnchorRuleID,
		Trigger: FollowUpTrigger{
			ID:           r.EventID,
			Code:         r.Code,
			NameDE:       r.NameDE,
			NameEN:       r.NameEN,
			AnchorRuleID: r.AnchorRuleID,
			ProceedingType: EventSearchPT{
				ID:     r.PTID,
				Code:   r.PTCode,
				NameDE: r.PTNameDE,
				NameEN: r.PTNameEN,
			},
		},
	}
	if r.EventKind.Valid {
		v := r.EventKind.String
		res.Trigger.EventKind = &v
	}
	if r.PTJurisdiction.Valid {
		v := r.PTJurisdiction.String
		res.Trigger.ProceedingType.Jurisdiction = &v
	}
	return res, nil
}

// followUpRow is the joined SELECT shape for follow-up rules.
type followUpRow struct {
	RuleID              uuid.UUID      `db:"rule_id"`
	EventCode           string         `db:"event_code"`
	NameDE              string         `db:"name_de"`
	NameEN              string         `db:"name_en"`
	Priority            string         `db:"priority"`
	PrimaryParty        sql.NullString `db:"primary_party"`
	DurationValue       sql.NullInt32  `db:"duration_value"`
	DurationUnit        sql.NullString `db:"duration_unit"`
	Timing              sql.NullString `db:"timing"`
	IsCourtSet          bool           `db:"is_court_set"`
	IsSpawn             bool           `db:"is_spawn"`
	IsBilateral         bool           `db:"is_bilateral"`
	HasCondition        bool           `db:"has_condition"`
	RuleCode            sql.NullString `db:"rule_code"`
	LegalSource         sql.NullString `db:"legal_source"`
	NotesDE             sql.NullString `db:"notes_de"`
	NotesEN             sql.NullString `db:"notes_en"`
	SpawnLabel          sql.NullString `db:"spawn_label"`
	SpawnProceedingCode sql.NullString `db:"spawn_proceeding_code"`
	ConceptID           *uuid.UUID     `db:"concept_id"`
	SequenceOrder       int            `db:"sequence_order"`
}

// queryFollowUpRows pulls the immediate-children rules of an anchor.
//
// Cross-party display (m/paliad#149 Phase 2 S1, design §2.4): no longer
// filters by party. The server returns every published+active child;
// LookupFollowUps annotates each row with `is_cross_party` so the UI
// can render opposing-side rows with the Gegenseitig badge instead of
// silently dropping them. Hiding cross-party rows lied about what the
// workflow does next (cf. RoP.029.d falling off when perspective=claimant
// on def_to_ccr — the workflow continues, just on the defendant's
// docket, and the lawyer needs to see that move exists).
func (s *FristenrechnerService) queryFollowUpRows(
	ctx context.Context,
	anchorRuleID uuid.UUID,
) ([]followUpRow, error) {
	where := []string{
		"sr.parent_id = $1",
		"sr.is_active = true",
		"sr.lifecycle_state = 'published'",
		"pe.is_active = true",
		"pe.lifecycle_state = 'published'",
	}
	args := []any{anchorRuleID}

	query := `
		SELECT sr.id AS rule_id,
		       pe.code AS event_code,
		       pe.name AS name_de,
		       pe.name_en,
		       sr.priority,
		       sr.primary_party,
		       sr.duration_value,
		       sr.duration_unit,
		       sr.timing,
		       sr.is_court_set,
		       sr.is_spawn,
		       sr.is_bilateral,
		       (sr.condition_expr IS NOT NULL) AS has_condition,
		       sr.rule_code,
		       ls.citation AS legal_source,
		       sr.deadline_notes AS notes_de,
		       sr.deadline_notes_en AS notes_en,
		       sr.spawn_label,
		       spt.code AS spawn_proceeding_code,
		       pe.concept_id,
		       sr.sequence_order
		  FROM paliad.sequencing_rules sr
		  JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
		  LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
		  LEFT JOIN paliad.proceeding_types spt ON spt.id = sr.spawn_proceeding_type_id
		 WHERE ` + strings.Join(where, "\n   AND ") + `
		 ORDER BY sr.sequence_order, pe.code`

	var rows []followUpRow
	if err := s.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
		return nil, fmt.Errorf("load follow-up rows: %w", err)
	}
	return rows, nil
}

// isCrossPartyRow reports whether the row represents an opposing-side
// filing relative to the requesting perspective.
//
// Returns true only when:
//   - the perspective is "claimant" or "defendant" (the two
//     binary-opposed sides Paliad models today)
//   - the row's primary_party is the opposite side
//
// "both" / "court" / NULL primary_party are never cross-party — they
// apply to all sides or to the court itself. An empty perspective
// (kontextfrei / "I'm just browsing") also yields false: with no
// perspective there is no opposing side. The flag is purely display
// metadata; cross-party rows still appear in the result, just with the
// Gegenseitig badge + muted styling per design §2.4.
func isCrossPartyRow(perspective string, primaryParty sql.NullString) bool {
	if perspective != "claimant" && perspective != "defendant" {
		return false
	}
	if !primaryParty.Valid {
		return false
	}
	p := primaryParty.String
	if p == "" || p == "both" || p == "court" {
		return false
	}
	return p != perspective
}

