package services

// Pure-function tests for ProjectionService — no DB required, runs by
// default. Validates the deterministic sort order and status-mapping
// behaviour; the live integration test in projection_service_test.go
// covers the SQL paths.

import (
	"encoding/json"
	"testing"
	"time"

	"github.com/google/uuid"
)

func TestSortTimeline_DateAscUndatedLast(t *testing.T) {
	d1 := uuid.New()
	d2 := uuid.New()
	a1 := uuid.New()
	pe1 := uuid.New()

	mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
	mar5 := time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC)
	mar10 := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)

	rows := []TimelineEvent{
		{Kind: "milestone", Title: "Undated milestone", ProjectEventID: &pe1}, // Date nil
		{Kind: "deadline", Date: &mar10, Title: "Mar10 deadline", DeadlineID: &d2},
		{Kind: "deadline", Date: &mar1, Title: "Mar1 deadline", DeadlineID: &d1},
		{Kind: "appointment", Date: &mar5, Title: "Mar5 appointment", AppointmentID: &a1},
	}

	sortTimeline(rows)

	// Date ASC (Mar1, Mar5, Mar10), undated last.
	if rows[0].Title != "Mar1 deadline" {
		t.Errorf("rows[0] = %q, want Mar1 deadline", rows[0].Title)
	}
	if rows[1].Title != "Mar5 appointment" {
		t.Errorf("rows[1] = %q, want Mar5 appointment", rows[1].Title)
	}
	if rows[2].Title != "Mar10 deadline" {
		t.Errorf("rows[2] = %q, want Mar10 deadline", rows[2].Title)
	}
	if rows[3].Title != "Undated milestone" {
		t.Errorf("rows[3] = %q, want Undated milestone", rows[3].Title)
	}
}

func TestSortTimeline_SameDateTiebreak(t *testing.T) {
	mar5 := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
	d1 := uuid.New()
	a1 := uuid.New()
	pe1 := uuid.New()

	rows := []TimelineEvent{
		{Kind: "milestone", Date: &mar5, Title: "C", ProjectEventID: &pe1},
		{Kind: "appointment", Date: &mar5, Title: "B", AppointmentID: &a1},
		{Kind: "deadline", Date: &mar5, Title: "A", DeadlineID: &d1},
	}

	sortTimeline(rows)

	// Tiebreak: deadline > appointment > milestone (kindOrder).
	if rows[0].Kind != "deadline" {
		t.Errorf("rows[0].Kind = %q, want deadline", rows[0].Kind)
	}
	if rows[1].Kind != "appointment" {
		t.Errorf("rows[1].Kind = %q, want appointment", rows[1].Kind)
	}
	if rows[2].Kind != "milestone" {
		t.Errorf("rows[2].Kind = %q, want milestone", rows[2].Kind)
	}
}

func TestDeadlineStatus(t *testing.T) {
	today := time.Now().UTC()
	yesterday := today.AddDate(0, 0, -1)
	tomorrow := today.AddDate(0, 0, 1)

	cases := []struct {
		name   string
		status string
		due    time.Time
		want   string
	}{
		{"completed regardless of date", "completed", yesterday, "done"},
		{"completed even if future", "completed", tomorrow, "done"},
		{"pending past = overdue", "pending", yesterday, "overdue"},
		{"pending today = open", "pending", today, "open"},
		{"pending future = open", "pending", tomorrow, "open"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := deadlineStatus(c.status, c.due)
			if got != c.want {
				t.Errorf("deadlineStatus(%q, %v) = %q, want %q",
					c.status, c.due, got, c.want)
			}
		})
	}
}

func TestAppointmentStatus(t *testing.T) {
	now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
	past := now.Add(-1 * time.Hour)
	future := now.Add(1 * time.Hour)

	if got := appointmentStatus(past, now); got != "done" {
		t.Errorf("past appointment status = %q, want done", got)
	}
	if got := appointmentStatus(future, now); got != "open" {
		t.Errorf("future appointment status = %q, want open", got)
	}
}

func TestMilestoneStatus(t *testing.T) {
	custom := "custom_milestone"
	other := "counterclaim_filed"

	cases := []struct {
		name         string
		timelineKind *string
		eventType    *string
		want         string
	}{
		{"custom_milestone via timeline_kind", &custom, nil, "off_script"},
		{"custom_milestone via event_type fallback", nil, &custom, "off_script"},
		{"structural milestone = done", nil, &other, "done"},
		{"both nil = done", nil, nil, "done"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := milestoneStatus(c.timelineKind, c.eventType)
			if got != c.want {
				t.Errorf("milestoneStatus = %q, want %q", got, c.want)
			}
		})
	}
}

func TestKindOrder(t *testing.T) {
	// Lock the exact ordering — frontend assumes deadline before
	// appointment before milestone before projected on same-date ties.
	if kindOrder("deadline") >= kindOrder("appointment") {
		t.Error("deadline should sort before appointment")
	}
	if kindOrder("appointment") >= kindOrder("milestone") {
		t.Error("appointment should sort before milestone")
	}
	if kindOrder("milestone") >= kindOrder("projected") {
		t.Error("milestone should sort before projected")
	}
}

// TestLevelPolicy pins the (kinds, statuses, lane_axis) triple per
// project type per design §5.1 (t-paliad-175 SmartTimeline Slice 4).
// These are user-visible policy decisions — locked here to catch
// accidental shifts during refactors.
func TestLevelPolicy(t *testing.T) {
	cases := []struct {
		projectType string
		kinds       []string
		statuses    []string
		laneAxis    string
	}{
		{"case", nil, nil, "self_plus_ccr"},
		{"", nil, nil, "self_plus_ccr"},          // unknown falls back to case behaviour
		{"unknown", nil, nil, "self_plus_ccr"},
		{
			"patent",
			[]string{"deadline", "milestone"},
			[]string{"done", "open", "overdue"},
			"child_case",
		},
		{
			"litigation",
			[]string{"milestone"},
			[]string{"done"},
			"child_patent",
		},
		{
			"client",
			[]string{"milestone"},
			[]string{"done"},
			"child_litigation",
		},
	}
	for _, c := range cases {
		t.Run(c.projectType, func(t *testing.T) {
			got := levelPolicy(c.projectType)
			if got.LaneAxis != c.laneAxis {
				t.Errorf("LaneAxis = %q, want %q", got.LaneAxis, c.laneAxis)
			}
			if !sliceEqual(got.Kinds, c.kinds) {
				t.Errorf("Kinds = %v, want %v", got.Kinds, c.kinds)
			}
			if !sliceEqual(got.Statuses, c.statuses) {
				t.Errorf("Statuses = %v, want %v", got.Statuses, c.statuses)
			}
		})
	}
}

func sliceEqual(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

// TestRowSurvivesPolicy_BubbleUpOverridesFilter pins the contract that
// a project_event milestone with bubble_up=true survives the level
// policy's kind/status filter at higher levels (design §5.3 + Q5).
func TestRowSurvivesPolicy_BubbleUpOverridesFilter(t *testing.T) {
	allowKind := stringSet([]string{"deadline"})        // milestones excluded
	allowStatus := stringSet([]string{"done"})           // off_script excluded
	bubbledMilestone := TimelineEvent{
		Kind:     "milestone",
		Status:   "off_script",
		BubbleUp: true,
	}
	if !rowSurvivesPolicy(bubbledMilestone, allowKind, allowStatus) {
		t.Error("bubble_up=true row should survive both kind and status filters")
	}

	regularMilestone := TimelineEvent{
		Kind:   "milestone",
		Status: "off_script",
	}
	if rowSurvivesPolicy(regularMilestone, allowKind, allowStatus) {
		t.Error("regular milestone should be filtered when kind/status both excluded")
	}

	// kind allowed, status excluded → drop.
	allowedKindBadStatus := TimelineEvent{
		Kind:   "deadline",
		Status: "open",
	}
	if rowSurvivesPolicy(allowedKindBadStatus, allowKind, allowStatus) {
		t.Error("excluded status should drop a row even when kind allowed")
	}

	// kind excluded, status allowed → drop.
	badKindGoodStatus := TimelineEvent{
		Kind:   "appointment",
		Status: "done",
	}
	if rowSurvivesPolicy(badKindGoodStatus, allowKind, allowStatus) {
		t.Error("excluded kind should drop a row even when status allowed")
	}

	// Empty filters = pass-through.
	if !rowSurvivesPolicy(badKindGoodStatus, nil, nil) {
		t.Error("empty filters should pass everything")
	}
}

// TestExtractBubbleUp pins the per-event-type defaults (Q5):
//   - counterclaim_created / third_party_intervention / scope_change
//     default to true.
//   - custom_milestone defaults to false.
//   - Explicit metadata.bubble_up always wins.
func TestExtractBubbleUp(t *testing.T) {
	str := func(s string) *string { return &s }

	cases := []struct {
		name         string
		raw          string
		eventType    *string
		timelineKind *string
		want         bool
	}{
		{"counterclaim_created defaults true", "{}", str("counterclaim_created"), str("milestone"), true},
		{"third_party_intervention defaults true", "", str("third_party_intervention"), nil, true},
		{"scope_change defaults true", "", str("scope_change"), nil, true},
		{"custom_milestone defaults false", "{}", str("custom_milestone"), str("custom_milestone"), false},
		{"unknown defaults false", "{}", str("note_created"), nil, false},
		{"explicit true overrides", `{"bubble_up":true}`, str("custom_milestone"), nil, true},
		{"explicit false overrides", `{"bubble_up":false}`, str("counterclaim_created"), nil, false},
		{"string \"true\" parses", `{"bubble_up":"true"}`, str("custom_milestone"), nil, true},
		{"string \"1\" parses", `{"bubble_up":"1"}`, str("custom_milestone"), nil, true},
		{"non-bool ignored", `{"bubble_up":42}`, str("custom_milestone"), nil, false},
		{"malformed metadata falls back to default", `{`, str("counterclaim_created"), nil, true},
		{"empty metadata + nil event_type = false", "", nil, nil, false},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := extractBubbleUp(json.RawMessage(c.raw), c.eventType, c.timelineKind)
			if got != c.want {
				t.Errorf("extractBubbleUp = %v, want %v", got, c.want)
			}
		})
	}
}

// TestChildTypeForAxis pins the axis → project type map.
func TestChildTypeForAxis(t *testing.T) {
	cases := map[string]string{
		"child_case":       "case",
		"child_patent":     "patent",
		"child_litigation": "litigation",
		"self_plus_ccr":    "",
		"":                 "",
		"bogus":            "",
	}
	for axis, want := range cases {
		if got := childTypeForAxis(axis); got != want {
			t.Errorf("childTypeForAxis(%q) = %q, want %q", axis, got, want)
		}
	}
}

// TestDerivedCounterclaimOurSide pins the our_side flip semantics
// (t-paliad-174 §11 Q2):
//   - Default (override nil): claimant ↔ defendant; court / both pass through.
//   - Override true: same default-flip semantics.
//   - Override false (R.49.2.b CCI edge case): keep parent's side.
//   - NULL parent_side yields empty string (no flip without a starting side).
func TestDerivedCounterclaimOurSide(t *testing.T) {
	tru := true
	fal := false
	str := func(s string) *string { return &s }

	cases := []struct {
		name     string
		parent   *string
		override *bool
		want     string
	}{
		{"nil parent → empty", nil, nil, ""},
		{"nil parent + override → empty", nil, &tru, ""},
		{"claimant → defendant (default)", str("claimant"), nil, "defendant"},
		{"defendant → claimant (default)", str("defendant"), nil, "claimant"},
		{"court passes through", str("court"), nil, "court"},
		{"both passes through", str("both"), nil, "both"},
		{"explicit flip=true", str("claimant"), &tru, "defendant"},
		{"explicit flip=false keeps parent's side", str("claimant"), &fal, "claimant"},
		{"flip=false on defendant keeps defendant", str("defendant"), &fal, "defendant"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := derivedCounterclaimOurSide(c.parent, c.override)
			if got != c.want {
				t.Errorf("derivedCounterclaimOurSide(%v, %v) = %q, want %q",
					c.parent, c.override, got, c.want)
			}
		})
	}
}
