package services

import (
	"sync"
	"testing"
	"time"
)

func TestCalculateEasterSunday(t *testing.T) {
	cases := []struct {
		year       int
		wantMonth  int
		wantDay    int
	}{
		{2024, 3, 31},
		{2025, 4, 20},
		{2026, 4, 5},
		{2027, 3, 28},
		{2030, 4, 21},
	}
	for _, c := range cases {
		gotMonth, gotDay := CalculateEasterSunday(c.year)
		if gotMonth != c.wantMonth || gotDay != c.wantDay {
			t.Errorf("Easter %d: got %d-%d, want %d-%d",
				c.year, gotMonth, gotDay, c.wantMonth, c.wantDay)
		}
	}
}

func TestGermanFederalHolidays(t *testing.T) {
	holidays := germanFederalHolidays(2026)
	if len(holidays) != 11 {
		t.Fatalf("expected 11 federal holidays for 2026, got %d", len(holidays))
	}
	// Spot-check Easter-relative entries against the algorithm output.
	easter := time.Date(2026, 4, 5, 0, 0, 0, 0, time.UTC)
	wantDates := map[string]bool{
		"2026-01-01": true,                                         // Neujahr
		easter.AddDate(0, 0, -2).Format("2006-01-02"): true,        // Karfreitag
		easter.AddDate(0, 0, 50).Format("2006-01-02"): true,        // Pfingstmontag
		"2026-12-26": true,                                         // 2. Weihnachtstag
	}
	got := map[string]bool{}
	for _, h := range holidays {
		got[h.Date.Format("2006-01-02")] = true
	}
	for d := range wantDates {
		if !got[d] {
			t.Errorf("expected %s in federal holidays for 2026, missing", d)
		}
	}
}

func TestIsNonWorkingDay_NoDB(t *testing.T) {
	// Without a DB, only the German federal hardcoded set + weekends apply.
	s := NewHolidayService(nil)

	// 2026-01-01 is Neujahr (Thursday)
	if !s.IsNonWorkingDay(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), "DE", "UPC") {
		t.Error("Neujahr 2026 should be non-working")
	}
	// Saturday: 2026-01-03
	if !s.IsNonWorkingDay(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), "DE", "UPC") {
		t.Error("Saturday should be non-working")
	}
	// Regular Tuesday: 2026-01-13
	if s.IsNonWorkingDay(time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC), "DE", "UPC") {
		t.Error("regular Tuesday should be working")
	}
}

func TestAdjustForNonWorkingDays_NoDB(t *testing.T) {
	s := NewHolidayService(nil)

	// 2026-01-01 (Neujahr Thu) → 2026-01-02 (Fri, working)
	got, _, adjusted := s.AdjustForNonWorkingDays(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), "DE", "UPC")
	want := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
	if !got.Equal(want) || !adjusted {
		t.Errorf("Neujahr: got %s adjusted=%v, want %s adjusted=true", got, adjusted, want)
	}

	// Saturday 2026-01-03 → Monday 2026-01-05
	got, _, adjusted = s.AdjustForNonWorkingDays(time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), "DE", "UPC")
	want = time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
	if !got.Equal(want) || !adjusted {
		t.Errorf("Saturday: got %s adjusted=%v, want %s adjusted=true", got, adjusted, want)
	}

	// Regular Tuesday 2026-01-13 → unchanged
	in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
	got, _, adjusted = s.AdjustForNonWorkingDays(in, "DE", "UPC")
	if !got.Equal(in) || adjusted {
		t.Errorf("Tuesday: got %s adjusted=%v, want %s adjusted=false", got, adjusted, in)
	}
}

// AdjustForNonWorkingDaysWithReason classifies the dominant cause and (for
// the vacation case) reports the contiguous block boundaries — feeds
// /tools/fristenrechner's "Verschoben wegen UPC-Sommerferien (27.7.–28.8.)"
// label, replacing the generic "Wochenende/Feiertag" string. See t-paliad-119.
func TestAdjustForNonWorkingDaysWithReason_Weekend(t *testing.T) {
	s := NewHolidayService(nil) // German federal holidays only — sufficient for weekend / public-holiday cases.

	// Saturday 2026-01-03 → Monday 2026-01-05, Kind = "weekend".
	in := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)
	adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in, "DE", "UPC")
	if !wasAdj || reason == nil {
		t.Fatalf("expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
	}
	if reason.Kind != "weekend" {
		t.Errorf("Kind: got %q, want weekend", reason.Kind)
	}
	if reason.OriginalWeekday != "Saturday" {
		t.Errorf("OriginalWeekday: got %q, want Saturday", reason.OriginalWeekday)
	}
	if len(reason.Holidays) != 0 {
		t.Errorf("Holidays should be empty for weekend-only, got %d", len(reason.Holidays))
	}
	want := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
	if !adj.Equal(want) {
		t.Errorf("adjusted: got %s, want %s", adj, want)
	}
}

func TestAdjustForNonWorkingDaysWithReason_PublicHoliday(t *testing.T) {
	s := NewHolidayService(nil)

	// Karfreitag 2025 (Fri 2025-04-18) → Tue 2025-04-22 (skips Sa/Su +
	// Ostersonntag + Ostermontag). All three are public_holidays in the
	// hardcoded set; Holidays should contain at least Karfreitag and
	// Ostermontag (Ostersonntag too — it is a federal holiday entry).
	in := time.Date(2025, 4, 18, 0, 0, 0, 0, time.UTC)
	adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in, "DE", "UPC")
	if !wasAdj || reason == nil {
		t.Fatalf("expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
	}
	if reason.Kind != "public_holiday" {
		t.Errorf("Kind: got %q, want public_holiday", reason.Kind)
	}
	want := time.Date(2025, 4, 22, 0, 0, 0, 0, time.UTC)
	if !adj.Equal(want) {
		t.Errorf("adjusted: got %s, want %s", adj, want)
	}
	names := map[string]bool{}
	for _, h := range reason.Holidays {
		names[h.Name] = true
	}
	if !names["Karfreitag"] {
		t.Errorf("Holidays should contain Karfreitag, got %v", names)
	}
	if !names["Ostermontag"] {
		t.Errorf("Holidays should contain Ostermontag, got %v", names)
	}
	if reason.VacationName != "" || reason.VacationStart != "" {
		t.Errorf("public_holiday reason should not set vacation fields, got %+v", reason)
	}
}

// TestVacationDoesNotShiftDeadlines locks t-paliad-121: UPC judicial vacations
// (summer + winter, holiday_type='vacation') are informational metadata —
// the Court continues to operate during them per UPC AC decision 2023-05-26
// (decision-on-judicial-vacation), so they must NOT extend procedural
// deadlines. Only weekends + closure-type holidays (German federal public
// holidays) shift.
//
// Pre-fix this case adjusted Tue 2026-08-04 → Mon 2026-08-31 (a 27-day
// shift across the entire summer vacation), which was wrong on two axes
// (m's correction 2026-05-04): (a) the shift shouldn't happen at all, and
// (b) 4 Aug 2026 is a regular Tuesday so even if vacation did shift,
// landing 27 days later was a math artefact of walking the seeded run.
func TestVacationDoesNotShiftDeadlines(t *testing.T) {
	// Inject UPC summer + winter vacations 2026 directly into the cache (no
	// DB) so the test reflects the production seed without needing a live DB.
	s := NewHolidayService(nil)
	holidays := make([]Holiday, 0, 11+25+5)
	holidays = append(holidays, germanFederalHolidays(2026)...)
	addVacationRun := func(name string, start, end time.Time) {
		for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
			if wd := d.Weekday(); wd == time.Saturday || wd == time.Sunday {
				continue // matches migration 010 — weekdays only
			}
			holidays = append(holidays, Holiday{Date: d, Name: name, Regime: RegimeUPC, IsVacation: true})
		}
	}
	addVacationRun("UPC Summer Vacation",
		time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC),
		time.Date(2026, 8, 28, 0, 0, 0, 0, time.UTC))
	addVacationRun("UPC Winter Vacation",
		time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC),
		time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC))
	entry := &yearEntry{holidays: holidays}
	entry.once.Do(func() {}) // mark loaded so loadYear is not invoked.
	s.cache.Store(2026, entry)

	// (a) Tue 2026-08-04 — m's reproduction. Inside UPC summer vacation,
	//     but a regular working Tuesday for the Court → no shift.
	summerWeekday := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
	if s.IsNonWorkingDay(summerWeekday, "DE", "UPC") {
		t.Errorf("2026-08-04 (UPC summer vacation, Tue): IsNonWorkingDay=true, want false")
	}
	if h := s.IsHoliday(summerWeekday, "DE", "UPC"); h == nil || !h.IsVacation {
		t.Errorf("2026-08-04: IsHoliday should still surface the vacation entry, got %v", h)
	}
	adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(summerWeekday, "DE", "UPC")
	if wasAdj || reason != nil {
		t.Errorf("2026-08-04: wasAdj=%v reason=%v, want no adjustment", wasAdj, reason)
	}
	if !adj.Equal(summerWeekday) {
		t.Errorf("2026-08-04: adjusted=%s, want %s", adj, summerWeekday)
	}

	// (b) Mon 2026-12-28 — inside UPC winter vacation, but a working Monday
	//     between Christmas and Neujahr for the Court → no shift.
	winterWeekday := time.Date(2026, 12, 28, 0, 0, 0, 0, time.UTC)
	if s.IsNonWorkingDay(winterWeekday, "DE", "UPC") {
		t.Errorf("2026-12-28 (UPC winter vacation, Mon): IsNonWorkingDay=true, want false")
	}
	adj, _, wasAdj, _ = s.AdjustForNonWorkingDaysWithReason(winterWeekday, "DE", "UPC")
	if wasAdj || !adj.Equal(winterWeekday) {
		t.Errorf("2026-12-28: wasAdj=%v adjusted=%s, want no shift from %s", wasAdj, adj, winterWeekday)
	}

	// (c) Christmas Day 2026 (Fri, public_holiday) still shifts. Walks Fri
	//     (Christmas) → Sa 26 (2. Weihnachtstag) → Su 27 (weekend) → Mo
	//     2026-12-28. Mon 28 IS in UPC winter vacation, but that entry is
	//     informational only (IsClosure=false) so the walk stops there.
	christmas := time.Date(2026, 12, 25, 0, 0, 0, 0, time.UTC)
	adj, _, wasAdj, reason = s.AdjustForNonWorkingDaysWithReason(christmas, "DE", "UPC")
	if !wasAdj || reason == nil {
		t.Fatalf("Christmas 2026: expected adjustment + reason, got wasAdj=%v reason=%v", wasAdj, reason)
	}
	if reason.Kind != "public_holiday" {
		t.Errorf("Christmas 2026 Kind: got %q, want public_holiday", reason.Kind)
	}
	wantChristmas := time.Date(2026, 12, 28, 0, 0, 0, 0, time.UTC)
	if !adj.Equal(wantChristmas) {
		t.Errorf("Christmas 2026 adjusted: got %s, want %s (vacation entries skipped, not shifted to)", adj, wantChristmas)
	}

	// (d) Neujahr 2027 (Fri, public_holiday) → Mo 2027-01-04. Even though
	//     the UPC winter vacation continues through 6 Jan 2027, those days
	//     are informational only, so the shift stops at the next non-
	//     weekend / non-closure day.
	neujahr := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
	if !s.IsNonWorkingDay(neujahr, "DE", "UPC") {
		t.Errorf("Neujahr 2027: IsNonWorkingDay=false, want true")
	}
	adj, _, wasAdj, _ = s.AdjustForNonWorkingDaysWithReason(neujahr, "DE", "UPC")
	wantNeujahr := time.Date(2027, 1, 4, 0, 0, 0, 0, time.UTC)
	if !wasAdj || !adj.Equal(wantNeujahr) {
		t.Errorf("Neujahr 2027: adjusted=%s wasAdj=%v, want %s adjusted=true", adj, wasAdj, wantNeujahr)
	}

	// (e) Karfreitag 2026 (Fri 3 Apr) regression — DE public_holiday outside
	//     any UPC vacation, must still shift to Tue 2026-04-07 (Easter Mon).
	karfreitag := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC)
	adj, _, wasAdj, reason = s.AdjustForNonWorkingDaysWithReason(karfreitag, "DE", "UPC")
	if !wasAdj || reason == nil || reason.Kind != "public_holiday" {
		t.Errorf("Karfreitag 2026: wasAdj=%v reason=%v, want public_holiday", wasAdj, reason)
	}
	wantKar := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC)
	if !adj.Equal(wantKar) {
		t.Errorf("Karfreitag 2026 adjusted: got %s, want %s", adj, wantKar)
	}
}

func TestAdjustForNonWorkingDaysWithReason_NoShift(t *testing.T) {
	s := NewHolidayService(nil)
	in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC) // regular Tuesday
	adj, _, wasAdj, reason := s.AdjustForNonWorkingDaysWithReason(in, "DE", "UPC")
	if wasAdj || reason != nil {
		t.Errorf("regular Tuesday should not adjust; got wasAdj=%v reason=%v", wasAdj, reason)
	}
	if !adj.Equal(in) {
		t.Errorf("adjusted: got %s, want %s", adj, in)
	}
}

// Verifies the cache is concurrency-safe (audit §1.6 fix). Run with -race.
func TestLoadHolidaysForYear_ConcurrentReads(t *testing.T) {
	s := NewHolidayService(nil)
	var wg sync.WaitGroup
	for i := 0; i < 50; i++ {
		wg.Add(1)
		go func(year int) {
			defer wg.Done()
			h, err := s.LoadHolidaysForYear(year)
			if err != nil {
				t.Errorf("year %d: %v", year, err)
			}
			if len(h) != 11 {
				t.Errorf("year %d: got %d holidays, want 11", year, len(h))
			}
		}(2026 + (i % 4)) // 4 distinct years × 50 calls
	}
	wg.Wait()
}

// t-paliad-122: per-country / per-regime filtering. Same Holiday cache, but
// (country, regime) selects which entries apply. Confirms the design:
//   - DE court (DE, "")          → German federal only.
//   - UPC LD München (DE, UPC)   → DE federal + UPC vacations.
//   - UPC LD Paris (FR, UPC)     → no DE, gets UPC vacations + (eventually) FR.
//   - LG München (DE, "")        → German federal only, no UPC vacations.
func TestAppliesTo_CountryRegimeFilter(t *testing.T) {
	s := NewHolidayService(nil)

	// Inject a curated cache: DE federal + UPC summer vacation + an FR row.
	rows := make([]Holiday, 0, 15)
	rows = append(rows, germanFederalHolidays(2026)...)
	rows = append(rows, Holiday{
		Date:       time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC),
		Name:       "UPC Summer Vacation",
		Regime:     RegimeUPC,
		IsVacation: true,
	})
	rows = append(rows, Holiday{
		Date:      time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC),
		Name:      "Fête nationale",
		Country:   "FR",
		IsClosure: true,
	})
	entry := &yearEntry{holidays: rows}
	entry.once.Do(func() {})
	s.cache.Store(2026, entry)

	// Christmas: applies to anyone with country=DE.
	christmas := time.Date(2026, 12, 25, 0, 0, 0, 0, time.UTC)
	if h := s.IsHoliday(christmas, "DE", ""); h == nil || !h.IsClosure {
		t.Errorf("DE court / Christmas: want closure, got %v", h)
	}
	if h := s.IsHoliday(christmas, "DE", "UPC"); h == nil || !h.IsClosure {
		t.Errorf("UPC LD München / Christmas: want closure, got %v", h)
	}
	if h := s.IsHoliday(christmas, "FR", "UPC"); h != nil {
		t.Errorf("UPC LD Paris / DE Christmas: want no match, got %v", h)
	}

	// UPC summer vacation row: only UPC-regime queries see it.
	summerVac := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
	if h := s.IsHoliday(summerVac, "DE", ""); h != nil {
		t.Errorf("LG München (DE only) / UPC vacation date: want no match, got %v", h)
	}
	if h := s.IsHoliday(summerVac, "DE", "UPC"); h == nil || !h.IsVacation {
		t.Errorf("UPC LD München / UPC vacation: want vacation, got %v", h)
	}
	if h := s.IsHoliday(summerVac, "FR", "UPC"); h == nil || !h.IsVacation {
		t.Errorf("UPC LD Paris / UPC vacation: want vacation, got %v", h)
	}

	// FR row: only FR queries see it.
	bastille := time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC)
	if h := s.IsHoliday(bastille, "DE", "UPC"); h != nil {
		t.Errorf("UPC LD München / Bastille Day: want no match, got %v", h)
	}
	if h := s.IsHoliday(bastille, "FR", "UPC"); h == nil || !h.IsClosure {
		t.Errorf("UPC LD Paris / Bastille Day: want closure, got %v", h)
	}
	if h := s.IsHoliday(bastille, "FR", ""); h == nil || !h.IsClosure {
		t.Errorf("FR national court / Bastille Day: want closure, got %v", h)
	}
}

// AppliesTo behaviour at the unit level — pinning the matching rule that
// drives every filter call above. Cheap regression net for future tweaks.
func TestAppliesTo_Rules(t *testing.T) {
	cases := []struct {
		name     string
		holiday  Holiday
		country  string
		regime   string
		want     bool
	}{
		{"DE row, DE court", Holiday{Country: "DE"}, "DE", "", true},
		{"DE row, FR court", Holiday{Country: "DE"}, "FR", "", false},
		{"UPC row, DE court (no regime)", Holiday{Regime: "UPC"}, "DE", "", false},
		{"UPC row, UPC LD München (DE, UPC)", Holiday{Regime: "UPC"}, "DE", "UPC", true},
		{"UPC row, UPC LD Paris (FR, UPC)", Holiday{Regime: "UPC"}, "FR", "UPC", true},
		{"DE+UPC overlap (UPC vacation seeded with country=DE)", Holiday{Country: "DE", Regime: "UPC"}, "FR", "UPC", true},
		{"empty row never matches", Holiday{}, "DE", "UPC", false},
	}
	for _, tc := range cases {
		got := tc.holiday.AppliesTo(tc.country, tc.regime)
		if got != tc.want {
			t.Errorf("%s: AppliesTo(%q, %q) = %v, want %v", tc.name, tc.country, tc.regime, got, tc.want)
		}
	}
}
