package services

import (
	"context"
	"os"
	"testing"
	"time"

	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"

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

// Phase 3 Slice 4 (t-paliad-185) dropped isCourtDeterminedRule: the
// is_court_set column (mig 078) backfilled in Slice 2 (mig 082) is now
// the source-of-truth. Calculate reads r.IsCourtSet directly. The
// runtime equivalence of the old heuristic vs the column was verified
// by the Slice 2 backfill integrity test (priority + is_court_set +
// condition_expr). The seven-case discrimination matrix the old test
// exercised lives now as the migration 082 WHERE predicate.

// TestAllFlagsSet covers the t-paliad-131 condition_flag text→text[]
// migration semantic. A rule's flags array gates rendering: every
// element of the array must be present in the caller's flag set, OR
// the rule is suppressed (when alt_duration_value is NULL) or rendered
// without alt swap (when alt_duration_value is non-NULL — legacy
// with_ccr pattern).
func TestAllFlagsSet(t *testing.T) {
	mkSet := func(fs ...string) map[string]struct{} {
		s := make(map[string]struct{}, len(fs))
		for _, f := range fs {
			s[f] = struct{}{}
		}
		return s
	}

	cases := []struct {
		name     string
		required []string
		set      map[string]struct{}
		want     bool
	}{
		{"empty required → true (unconditional rule)", nil, mkSet(), true},
		{"empty required → true even with flags set", nil, mkSet("with_ccr"), true},
		{"single flag, present → true (legacy with_ccr pattern)", []string{"with_ccr"}, mkSet("with_ccr"), true},
		{"single flag, absent → false", []string{"with_ccr"}, mkSet(), false},
		{"single flag, other present → false", []string{"with_ccr"}, mkSet("with_amend"), false},
		{"two flags, both present → true (upc.inf.cfi nested)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend"), true},
		{"two flags, only one present → false", []string{"with_ccr", "with_amend"}, mkSet("with_ccr"), false},
		{"two flags, both present + extra → true (extra flags don't matter)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend", "with_cci"), true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := allFlagsSet(tc.required, tc.set); got != tc.want {
				t.Errorf("allFlagsSet(%v, %v) = %v, want %v", tc.required, tc.set, got, tc.want)
			}
		})
	}
}

// TestCalculateRule covers the v4 (t-paliad-136 Phase B) single-rule
// calculator that backs POST /api/tools/fristenrechner/calculate-rule.
// Distinct from Calculate: no parent-chain walk; the trigger date is
// the immediate parent event's effective date, and the calc applies
// the rule's duration + holiday adjustment directly.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring TestDeadlineSearch.
func TestCalculateRule(t *testing.T) {
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
	}
	if err := db.ApplyMigrations(url); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}
	pool, err := sqlx.Connect("postgres", url)
	if err != nil {
		t.Fatalf("connect: %v", err)
	}
	defer pool.Close()

	ctx := context.Background()
	holidays := NewHolidayService(pool)
	rules := NewDeadlineRuleService(pool)
	courts := NewCourtService(pool)
	svc := NewFristenrechnerService(rules, holidays, courts)

	t.Run("plain rule calc — upc.inf.cfi.sod, R.23(1), 3 months", func(t *testing.T) {
		// 2026-01-15 + 3 months = 2026-04-15. No vacation overlap.
		got, err := svc.CalculateRule(ctx, CalcRuleParams{
			ProceedingCode: CodeUPCInfringement,
			RuleLocalCode:  "upc.inf.cfi.sod",
			TriggerDate:    "2026-01-15",
		})
		if err != nil {
			t.Fatalf("CalculateRule: %v", err)
		}
		if got.IsCourtSet {
			t.Errorf("upc.inf.cfi.sod is not court-set; got IsCourtSet=true")
		}
		if got.DueDate != "2026-04-15" {
			t.Errorf("dueDate = %q, want 2026-04-15", got.DueDate)
		}
		if got.Rule.LegalSourceDisplay != "UPC RoP R.23(1)" {
			t.Errorf("legalSourceDisplay = %q, want UPC RoP R.23(1)", got.Rule.LegalSourceDisplay)
		}
		if got.Proceeding.Code != CodeUPCInfringement {
			t.Errorf("proceeding code = %q, want upc.inf.cfi", got.Proceeding.Code)
		}
	})

	t.Run("court-determined rule → IsCourtSet=true, no dueDate", func(t *testing.T) {
		got, err := svc.CalculateRule(ctx, CalcRuleParams{
			ProceedingCode: CodeUPCInfringement,
			RuleLocalCode:  "upc.inf.cfi.decision",
			TriggerDate:    "2026-01-15",
		})
		if err != nil {
			t.Fatalf("CalculateRule: %v", err)
		}
		if !got.IsCourtSet {
			t.Errorf("upc.inf.cfi.decision should be court-set; got IsCourtSet=false")
		}
		if got.DueDate != "" {
			t.Errorf("court-set dueDate = %q, want empty", got.DueDate)
		}
	})

	t.Run("flag-conditional rule surfaces FlagsRequired even when not satisfied", func(t *testing.T) {
		// upc.inf.cfi.def_to_ccr requires with_ccr. Without the flag,
		// FlagsRequired is still surfaced so the UI can render the
		// checkbox.
		got, err := svc.CalculateRule(ctx, CalcRuleParams{
			ProceedingCode: CodeUPCInfringement,
			RuleLocalCode:  "upc.inf.cfi.def_to_ccr",
			TriggerDate:    "2026-01-15",
		})
		if err != nil {
			t.Fatalf("CalculateRule: %v", err)
		}
		if len(got.FlagsRequired) == 0 || got.FlagsRequired[0] != "with_ccr" {
			t.Errorf("FlagsRequired = %v, want [with_ccr]", got.FlagsRequired)
		}
		if len(got.FlagsApplied) != 0 {
			t.Errorf("FlagsApplied = %v, want empty (flag not supplied)", got.FlagsApplied)
		}
	})

	t.Run("flag-conditional rule with flag → FlagsApplied populated", func(t *testing.T) {
		got, err := svc.CalculateRule(ctx, CalcRuleParams{
			ProceedingCode: CodeUPCInfringement,
			RuleLocalCode:  "upc.inf.cfi.def_to_ccr",
			TriggerDate:    "2026-01-15",
			Flags:          []string{"with_ccr"},
		})
		if err != nil {
			t.Fatalf("CalculateRule: %v", err)
		}
		if len(got.FlagsApplied) == 0 || got.FlagsApplied[0] != "with_ccr" {
			t.Errorf("FlagsApplied = %v, want [with_ccr]", got.FlagsApplied)
		}
	})

	t.Run("missing TriggerDate → error", func(t *testing.T) {
		_, err := svc.CalculateRule(ctx, CalcRuleParams{
			ProceedingCode: CodeUPCInfringement,
			RuleLocalCode:  "upc.inf.cfi.sod",
			TriggerDate:    "",
		})
		if err == nil {
			t.Errorf("expected error for empty triggerDate")
		}
	})

	t.Run("unknown rule → ErrUnknownRule", func(t *testing.T) {
		_, err := svc.CalculateRule(ctx, CalcRuleParams{
			ProceedingCode: CodeUPCInfringement,
			RuleLocalCode:  "totally.fake",
			TriggerDate:    "2026-01-15",
		})
		if err == nil {
			t.Errorf("expected ErrUnknownRule, got nil")
		}
	})
}

// TestEvalConditionExpr covers the Phase 3 Slice 4 (t-paliad-185)
// jsonb gate evaluator. Long-form grammar per design §2.4: leaf
// {"flag":"X"}, AND / OR / NOT compositions. Single-flag values pass
// through unwrapped. NULL / empty expression falls back to
// condition_flag AND-semantics.
func TestEvalConditionExpr(t *testing.T) {
	mkSet := func(fs ...string) map[string]struct{} {
		m := make(map[string]struct{}, len(fs))
		for _, f := range fs {
			m[f] = struct{}{}
		}
		return m
	}

	cases := []struct {
		name  string
		expr  string
		flags map[string]struct{}
		want  bool
	}{
		// NULL / empty / "null" expr → unconditional. Slice 9 removed
		// the legacy condition_flag fallback that used to make this
		// branch return false on flags-not-met — the column is gone.
		{"empty expr → unconditional", "", mkSet(), true},
		{"empty expr with flags set → unconditional", "", mkSet("with_ccr"), true},
		{"literal null → unconditional", "null", mkSet(), true},

		// Single-flag leaf (mig 084 unwrapped form for [single]).
		{"single-flag leaf present → true", `{"flag":"with_ccr"}`, mkSet("with_ccr"), true},
		{"single-flag leaf absent → false", `{"flag":"with_ccr"}`, mkSet("with_amend"), false},

		// AND.
		{"and(a, b) both present → true",
			`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
			mkSet("with_ccr", "with_amend"), true},
		{"and(a, b) one absent → false",
			`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
			mkSet("with_ccr"), false},
		{"and() empty args → true (vacuously)", `{"op":"and","args":[]}`, mkSet(), true},

		// OR.
		{"or(a, b) any present → true",
			`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
			mkSet("with_amend"), true},
		{"or(a, b) none present → false",
			`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
			mkSet("with_cci"), false},
		{"or() empty args → false (vacuously)", `{"op":"or","args":[]}`, mkSet(), false},

		// NOT.
		{"not(flag) absent → true",
			`{"op":"not","args":[{"flag":"with_ccr"}]}`, mkSet(), true},
		{"not(flag) present → false",
			`{"op":"not","args":[{"flag":"with_ccr"}]}`, mkSet("with_ccr"), false},

		// Nested.
		{"and(or(a, b), not(c)) all conditions met → true",
			`{"op":"and","args":[
				{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]},
				{"op":"not","args":[{"flag":"expedited"}]}
			]}`,
			mkSet("with_amend"), true},
		{"and(or(a, b), not(c)) NOT condition fails → false",
			`{"op":"and","args":[
				{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]},
				{"op":"not","args":[{"flag":"expedited"}]}
			]}`,
			mkSet("with_amend", "expedited"), false},

		// Malformed → defensive true (rule still renders).
		{"malformed JSON → true (defensive)", `{"op":"bro`, mkSet(), true},
		{"unknown op → true (forward-compat)", `{"op":"xor","args":[{"flag":"with_ccr"}]}`, mkSet(), true},
		{"not with two args → true (malformed NOT)", `{"op":"not","args":[{"flag":"a"},{"flag":"b"}]}`, mkSet(), true},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := evalConditionExpr([]byte(tc.expr), tc.flags)
			if got != tc.want {
				t.Errorf("evalConditionExpr(%q, flags) = %v, want %v",
					tc.expr, got, tc.want)
			}
		})
	}
}

// TestWireFlagsFromPriority verifies the priority → (IsMandatory,
// IsOptional) reverse-mapping (Slice 4) matches the Slice 2 backfill so
// the wire shape stays byte-identical through the cutover. The four
// mappings + the safe default for unknown values are exhaustive.
func TestWireFlagsFromPriority(t *testing.T) {
	cases := []struct {
		priority         string
		wantMandatory    bool
		wantOptional     bool
	}{
		{"mandatory", true, false},
		{"optional", true, true},
		{"recommended", false, false},
		{"informational", false, false},
		{"", true, false}, // safe default — never drop a rule
		{"future_value", true, false},
	}
	for _, tc := range cases {
		t.Run(tc.priority, func(t *testing.T) {
			gotM, gotO := wireFlagsFromPriority(tc.priority)
			if gotM != tc.wantMandatory || gotO != tc.wantOptional {
				t.Errorf("wireFlagsFromPriority(%q) = (%v, %v), want (%v, %v)",
					tc.priority, gotM, gotO, tc.wantMandatory, tc.wantOptional)
			}
		})
	}
}

// TestApplyDuration_Matrix exercises the unified date-arithmetic helper
// across the 4 units × 3 timings × calendar/holiday matrix added in
// Slice 4. Mixes calendar units (days/weeks/months with weekend +
// holiday rollover) with working_days (skip-by-construction, no
// rollover).
func TestApplyDuration_Matrix(t *testing.T) {
	hs := NewHolidayService(nil)

	// Anchor: Thu 2026-04-30. Adjacent Fri (May 1) is Tag der Arbeit;
	// Sat-Sun follow. Sequence exercises the rollover path.
	thursday := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)

	cases := []struct {
		name        string
		base        time.Time
		value       int
		unit        string
		timing      string
		wantRaw     time.Time
		wantAdj     time.Time
		wantDidAdj  bool
	}{
		{
			name:       "days/after — Thu + 1 calendar day → Fri (holiday) → adjusted to Mon",
			base:       thursday, value: 1, unit: "days", timing: "after",
			wantRaw:    time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
			wantAdj:    time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
			wantDidAdj: true,
		},
		{
			name:       "days/before — Thu - 1 → Wed (working) → no adjust",
			base:       thursday, value: 1, unit: "days", timing: "before",
			wantRaw:    time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC),
			wantAdj:    time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC),
			wantDidAdj: false,
		},
		{
			name:       "weeks/after — Thu + 1 week → next Thu (working) → no adjust",
			base:       thursday, value: 1, unit: "weeks", timing: "after",
			wantRaw:    time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC),
			wantAdj:    time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC),
			wantDidAdj: false,
		},
		{
			name:       "months/after — Thu Apr 30 + 1 month → Sat May 30 → adjusted to Mon Jun 1",
			base:       thursday, value: 1, unit: "months", timing: "after",
			wantRaw:    time.Date(2026, 5, 30, 0, 0, 0, 0, time.UTC),
			wantAdj:    time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
			wantDidAdj: true,
		},
		{
			name:       "working_days/after — Thu + 1 wd → Mon (skip Fri holiday + weekend)",
			base:       thursday, value: 1, unit: "working_days", timing: "after",
			wantRaw:    time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
			wantAdj:    time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
			wantDidAdj: false,
		},
		{
			name:       "working_days/before — Mon May 4 - 1 wd → Thu Apr 30 (skip Fri holiday)",
			base:       time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
			value:      1, unit: "working_days", timing: "before",
			wantRaw:    time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC),
			wantAdj:    time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC),
			wantDidAdj: false,
		},
		{
			name:       "unknown unit → identity (defensive)",
			base:       thursday, value: 5, unit: "fortnights", timing: "after",
			wantRaw:    thursday,
			wantAdj:    thursday, // adjusted = AdjustForNonWorkingDays(raw); thursday is a working day
			wantDidAdj: false,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			raw, adj, didAdj, _ := applyDuration(tc.base, tc.value, tc.unit, tc.timing, "DE", "UPC", hs)
			if !raw.Equal(tc.wantRaw) {
				t.Errorf("raw: got %s, want %s", raw, tc.wantRaw)
			}
			if !adj.Equal(tc.wantAdj) {
				t.Errorf("adjusted: got %s, want %s", adj, tc.wantAdj)
			}
			if didAdj != tc.wantDidAdj {
				t.Errorf("didAdjust: got %v, want %v", didAdj, tc.wantDidAdj)
			}
		})
	}
}

// TestUIDeadline_WireShape_Slice8 asserts Phase 3 Slice 8 (t-paliad-189)
// wire-shape additivity: UIResponse.Deadlines MUST carry the new
// `priority` + `conditionExpr` fields AND the legacy `isMandatory` +
// `isOptional` pair (derived via wireFlagsFromPriority) for one release.
// Slice 9 will drop the legacy fields — until then the response
// shape is a superset.
//
// Live DB required so the rules.List returns real (not synthetic)
// rules with the priority column populated by the Slice 2 backfill.
func TestUIDeadline_WireShape_Slice8(t *testing.T) {
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
	}
	if err := db.ApplyMigrations(url); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}
	pool, err := sqlx.Connect("postgres", url)
	if err != nil {
		t.Fatalf("connect: %v", err)
	}
	defer pool.Close()

	ctx := context.Background()
	holidays := NewHolidayService(pool)
	rules := NewDeadlineRuleService(pool)
	courts := NewCourtService(pool)
	svc := NewFristenrechnerService(rules, holidays, courts)

	resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-01-15", CalcOptions{})
	if err != nil {
		t.Fatalf("Calculate upc.inf.cfi: %v", err)
	}
	if len(resp.Deadlines) == 0 {
		t.Fatal("Calculate upc.inf.cfi returned no deadlines — seed-data missing?")
	}

	allowed := map[string]bool{
		"mandatory": true, "recommended": true, "optional": true, "informational": true,
	}
	for _, d := range resp.Deadlines {
		if !allowed[d.Priority] {
			t.Errorf("rule %s: priority=%q not in unified enum", d.Code, d.Priority)
		}
	}

	// At least one rule should carry a populated conditionExpr (the
	// 17 with_ccr / with_amend / with_cci rules mig 084 translated).
	// Spot-check that the field actually serialises as jsonb (non-empty
	// bytes on at least one row).
	var sawConditionExpr bool
	for _, d := range resp.Deadlines {
		if len(d.ConditionExpr) > 0 && string(d.ConditionExpr) != "null" {
			sawConditionExpr = true
			break
		}
	}
	if !sawConditionExpr {
		t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
	}
}
