package services

// Pure-function tests for ProjectionService Slice 2 (t-paliad-173) —
// no DB required. Validates lookahead cap behaviour, anchor-kind
// dispatch, and extractMetadataString. The live integration test in
// projection_service_test.go covers SQL paths; this file covers the
// pure helpers a future refactor most likely to break.

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

	"github.com/google/uuid"

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

func TestApplyLookaheadDefault(t *testing.T) {
	cases := []struct {
		in, want int
	}{
		{0, DefaultLookaheadCap},
		{-5, DefaultLookaheadCap},
		{1, 1},
		{7, 7},
		{50, 50},
		{51, MaxLookaheadCap},
		{1000, MaxLookaheadCap},
	}
	for _, c := range cases {
		if got := applyLookaheadDefault(c.in); got != c.want {
			t.Errorf("applyLookaheadDefault(%d) = %d, want %d", c.in, got, c.want)
		}
	}
}

func TestApplyLookaheadCap_DropsBeyondCap_ExemptsOverdueAndCourtSet(t *testing.T) {
	mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
	apr1 := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
	may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
	jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)

	rows := []TimelineEvent{
		// Two predicted_overdue (past) — must survive uncapped.
		{Kind: "projected", Status: "predicted_overdue", Date: &mar1, RuleCode: "rule.past1", Title: "Past 1"},
		{Kind: "projected", Status: "predicted_overdue", Date: &apr1, RuleCode: "rule.past2", Title: "Past 2"},
		// One court_set future — exempt from cap.
		{Kind: "projected", Status: "court_set", Date: &jul1, RuleCode: "rule.hearing", Title: "Hearing"},
		// Three predicted future — cap=2 means the third drops.
		{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "rule.fut1", Title: "Fut 1"},
		{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "rule.fut2", Title: "Fut 2"},
		{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "rule.fut3", Title: "Fut 3"},
	}

	kept, total, shown, overdue := applyLookaheadCap(rows, 2)
	if total != 3 {
		t.Errorf("ProjectedTotal = %d, want 3", total)
	}
	if shown != 2 {
		t.Errorf("ProjectedShown = %d, want 2", shown)
	}
	if overdue != 2 {
		t.Errorf("PredictedOverdue = %d, want 2", overdue)
	}
	// kept must include both overdue + court_set + first 2 predicted = 5 rows.
	if len(kept) != 5 {
		t.Errorf("kept rows = %d, want 5", len(kept))
	}
	// Past + court_set must remain.
	pastTitles := map[string]bool{}
	for _, r := range kept {
		pastTitles[r.Title] = true
	}
	for _, want := range []string{"Past 1", "Past 2", "Hearing", "Fut 1", "Fut 2"} {
		if !pastTitles[want] {
			t.Errorf("expected kept row %q missing", want)
		}
	}
	if pastTitles["Fut 3"] {
		t.Errorf("Fut 3 should have been dropped (cap=2)")
	}
}

func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
	may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
	rows := []TimelineEvent{
		{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "r1", Title: "1"},
	}
	kept, total, shown, _ := applyLookaheadCap(rows, 7)
	if total != 1 || shown != 1 {
		t.Errorf("counts = (%d, %d), want (1, 1)", total, shown)
	}
	if len(kept) != 1 {
		t.Errorf("kept = %d, want 1", len(kept))
	}
}

func TestRuleAnchorKind(t *testing.T) {
	hearing := "hearing"
	decision := "decision"
	order := "order"
	filing := "filing"

	cases := []struct {
		name string
		rule *models.DeadlineRule
		want string
	}{
		{"hearing → appointment", &models.DeadlineRule{EventType: &hearing}, "appointment"},
		{"decision → appointment", &models.DeadlineRule{EventType: &decision}, "appointment"},
		{"order → appointment", &models.DeadlineRule{EventType: &order}, "appointment"},
		{"filing → deadline", &models.DeadlineRule{EventType: &filing}, "deadline"},
		{"nil event_type → deadline", &models.DeadlineRule{}, "deadline"},
		{"nil rule → deadline", nil, "deadline"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := ruleAnchorKind(c.rule); got != c.want {
				t.Errorf("ruleAnchorKind = %q, want %q", got, c.want)
			}
		})
	}
}

func TestExtractMetadataString(t *testing.T) {
	cases := []struct {
		name string
		raw  string
		key  string
		want string
	}{
		{"present", `{"rule_code":"inf.sod","reason":"foo"}`, "rule_code", "inf.sod"},
		{"missing key", `{"foo":"bar"}`, "rule_code", ""},
		{"empty json", `{}`, "rule_code", ""},
		{"empty raw", ``, "rule_code", ""},
		{"non-string value", `{"rule_code":123}`, "rule_code", ""},
		{"malformed", `{`, "rule_code", ""},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := extractMetadataString(json.RawMessage(c.raw), c.key)
			if got != c.want {
				t.Errorf("extractMetadataString = %q, want %q", got, c.want)
			}
		})
	}
}

func TestLang(t *testing.T) {
	cases := []struct {
		in, want string
	}{
		{"", "de"},
		{"de", "de"},
		{"DE", "de"},
		{"en", "en"},
		{"EN", "en"},
		{"  en ", "en"},
		{"fr", "de"},
	}
	for _, c := range cases {
		if got := lang(c.in); got != c.want {
			t.Errorf("lang(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

func TestRuleNameInLang(t *testing.T) {
	r := models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of Defence"}
	if got := ruleNameInLang(r, "de"); got != "Klageerwiderung" {
		t.Errorf("de = %q", got)
	}
	if got := ruleNameInLang(r, "en"); got != "Statement of Defence" {
		t.Errorf("en = %q", got)
	}
	rNoEN := models.DeadlineRule{Name: "Klageerwiderung"}
	if got := ruleNameInLang(rNoEN, "en"); got != "Klageerwiderung" {
		t.Errorf("missing EN should fall back to DE, got %q", got)
	}
}

func TestPredecessorMissingError(t *testing.T) {
	pme := &PredecessorMissingError{
		MissingRuleCode:    "upc.inf.cfi.soc",
		MissingRuleNameDE:  "Klageschrift",
		MissingRuleNameEN:  "Statement of Claim",
		RequestedRuleCode:  "upc.inf.cfi.sod",
		RequestedRuleNameDE: "Klageerwiderung",
		RequestedRuleNameEN: "Statement of Defence",
	}
	got, ok := IsPredecessorMissing(pme)
	if !ok {
		t.Fatal("IsPredecessorMissing on direct error should return ok")
	}
	if got != pme {
		t.Errorf("unwrapped pointer mismatch")
	}
	// Wrapping with errors.Errorf-style fmt should still unwrap.
	wrapped := wrap(pme, "context")
	got2, ok2 := IsPredecessorMissing(wrapped)
	if !ok2 {
		t.Fatal("IsPredecessorMissing on wrapped error should return ok")
	}
	if got2 != pme {
		t.Errorf("unwrapped wrapped pointer mismatch")
	}
	// Random other error must not unwrap.
	if _, ok := IsPredecessorMissing(errOther{}); ok {
		t.Error("non-PME should not unwrap as PME")
	}
}

// wrap is a tiny test helper that mimics fmt.Errorf("%w") wrapping.
func wrap(err error, msg string) error {
	return wrappedErr{msg: msg, inner: err}
}

type wrappedErr struct {
	msg   string
	inner error
}

func (w wrappedErr) Error() string { return w.msg + ": " + w.inner.Error() }
func (w wrappedErr) Unwrap() error { return w.inner }

type errOther struct{}

func (errOther) Error() string { return "other" }

func TestAnnotateDependsOn(t *testing.T) {
	socID := uuid.New()
	sodID := uuid.New()
	replyID := uuid.New()
	socCode := "upc.inf.cfi.soc"
	sodCode := "upc.inf.cfi.sod"
	replyCode := "upc.inf.cfi.reply"

	rules := []models.DeadlineRule{
		{ID: socID, SubmissionCode: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
		{ID: sodID, ParentID: &socID, SubmissionCode: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
		{ID: replyID, ParentID: &sodID, SubmissionCode: &replyCode, Name: "Replik", NameEN: "Reply"},
	}

	socDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
	sodDate := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)

	rows := []TimelineEvent{
		// SoC actual.
		{Kind: "deadline", Status: "done", Date: &socDate, RuleCode: socCode,
			DeadlineRuleID: ptrUUID(socID)},
		// SoD projected.
		{Kind: "projected", Status: "predicted", Date: &sodDate, RuleCode: sodCode,
			DeadlineRuleID: ptrUUID(sodID)},
		// Reply projected — depends on SoD.
		{Kind: "projected", Status: "predicted", RuleCode: replyCode,
			DeadlineRuleID: ptrUUID(replyID)},
	}

	svc := &ProjectionService{}
	svc.annotateDependsOn(rows, rules, "de")

	// SoC has no parent — depends_on stays empty.
	if rows[0].DependsOnRuleCode != "" {
		t.Errorf("SoC should have no depends_on, got %q", rows[0].DependsOnRuleCode)
	}
	// SoD's depends_on is SoC, dated.
	if rows[1].DependsOnRuleCode != socCode {
		t.Errorf("SoD depends_on = %q, want %q", rows[1].DependsOnRuleCode, socCode)
	}
	if rows[1].DependsOnRuleName != "Klageschrift" {
		t.Errorf("SoD depends_on name = %q (de)", rows[1].DependsOnRuleName)
	}
	if rows[1].DependsOnDate == nil || !rows[1].DependsOnDate.Equal(socDate) {
		t.Errorf("SoD depends_on_date = %v, want %v", rows[1].DependsOnDate, socDate)
	}
	// Reply's depends_on is SoD, dated (from SoD's projected date).
	if rows[2].DependsOnRuleCode != sodCode {
		t.Errorf("Reply depends_on = %q", rows[2].DependsOnRuleCode)
	}
	if rows[2].DependsOnDate == nil || !rows[2].DependsOnDate.Equal(sodDate) {
		t.Errorf("Reply depends_on_date = %v, want %v (SoD's projected date)",
			rows[2].DependsOnDate, sodDate)
	}

	// English mode flips the name.
	svc.annotateDependsOn(rows, rules, "en")
	if rows[1].DependsOnRuleName != "Statement of Claim" {
		t.Errorf("SoD depends_on name (en) = %q", rows[1].DependsOnRuleName)
	}
}

func ptrUUID(u uuid.UUID) *uuid.UUID { return &u }
