// Package services holds the Paliad domain services backed by paliad.* tables.
package services

import (
	"context"
	"fmt"
	"sync"
	"time"

	"github.com/jmoiron/sqlx"
)

// Country and regime constants — keep in sync with the paliad.countries
// seed list and the holidays_regime_chk / courts_regime_chk constraints.
const (
	CountryDE = "DE"
	RegimeUPC = "UPC"
	RegimeEPO = "EPO"
)

// Holiday is a non-working day. Mirrors paliad.holidays + the German federal
// hardcoded set used as a fallback when the DB lookup misses.
//
// Country is the ISO-3166 alpha-2 country whose national calendar this entry
// belongs to (e.g. "DE", "FR"). Empty string when the entry is regime-only.
// Regime is the supranational layer ("UPC" / "EPO") for entries that apply
// across UPC LDs / EPO branches regardless of country (e.g. UPC summer
// vacation). Empty string when the entry is country-only. Every Holiday
// carries at least one of the two.
type Holiday struct {
	Date       time.Time
	Name       string
	Country    string
	Regime     string
	IsVacation bool // part of court vacation period
	IsClosure  bool // single-day closure (public holiday)
}

// AppliesTo returns true if this holiday should be considered when computing
// deadlines for a court with the given country + regime.
//
// A row matches when its Country equals the court's country, OR when its
// Regime equals the court's regime. UPC LD München (DE, UPC) therefore picks
// up both DE national rows and UPC regime rows; LG München (DE, "") picks up
// only DE national rows.
func (h Holiday) AppliesTo(country, regime string) bool {
	if h.Country != "" && h.Country == country {
		return true
	}
	if h.Regime != "" && regime != "" && h.Regime == regime {
		return true
	}
	return false
}

// HolidayService loads and caches per-year holidays from paliad.holidays,
// merging with German federal holidays as a safety net.
//
// Concurrency: cache is guarded by a sync.Map of *sync.Once per year, which
// fixes audit §1.6 (the original KanzlAI version had an unprotected map).
// Each year is loaded at most once across all concurrent callers.
//
// The cache stores every row for a year regardless of country/regime. Lookup
// methods filter post-cache via Holiday.AppliesTo so multiple courts touching
// the same year share one DB hit.
type HolidayService struct {
	db    *sqlx.DB
	cache sync.Map // year (int) → *yearEntry
}

type yearEntry struct {
	once     sync.Once
	holidays []Holiday
	err      error
}

// NewHolidayService creates a new holiday service.
func NewHolidayService(db *sqlx.DB) *HolidayService {
	return &HolidayService{db: db}
}

type dbHoliday struct {
	ID          int       `db:"id"`
	Date        time.Time `db:"date"`
	Name        string    `db:"name"`
	Country     *string   `db:"country"`
	Regime      *string   `db:"regime"`
	State       *string   `db:"state"`
	HolidayType string    `db:"holiday_type"`
}

// LoadHolidaysForYear loads holidays for a year (cached, race-safe). Returns
// every row stored for that year — caller filters by country/regime.
func (s *HolidayService) LoadHolidaysForYear(year int) ([]Holiday, error) {
	v, _ := s.cache.LoadOrStore(year, &yearEntry{})
	entry := v.(*yearEntry)
	entry.once.Do(func() {
		entry.holidays, entry.err = s.loadYear(year)
	})
	return entry.holidays, entry.err
}

func (s *HolidayService) loadYear(year int) ([]Holiday, error) {
	holidays := make([]Holiday, 0, 30)

	if s.db != nil {
		var rows []dbHoliday
		err := s.db.SelectContext(context.Background(), &rows,
			`SELECT id, date, name, country, regime, state, holiday_type
			   FROM paliad.holidays
			  WHERE EXTRACT(YEAR FROM date) = $1
			  ORDER BY date`, year)
		if err != nil {
			return nil, fmt.Errorf("load holidays for %d: %w", year, err)
		}
		for _, h := range rows {
			country := ""
			if h.Country != nil {
				country = *h.Country
			}
			regime := ""
			if h.Regime != nil {
				regime = *h.Regime
			}
			holidays = append(holidays, Holiday{
				Date:       h.Date,
				Name:       h.Name,
				Country:    country,
				Regime:     regime,
				IsClosure:  h.HolidayType == "public_holiday" || h.HolidayType == "closure",
				IsVacation: h.HolidayType == "vacation",
			})
		}
	}

	// Merge German federal holidays so a misconfigured DB never silently
	// returns a working day for, say, Christmas. Tagged country='DE' so the
	// per-country filter applies them only to DE-jurisdictional callers.
	seen := make(map[string]bool, len(holidays))
	for _, h := range holidays {
		if h.Country == CountryDE {
			seen[h.Date.Format("2006-01-02")] = true
		}
	}
	for _, h := range germanFederalHolidays(year) {
		key := h.Date.Format("2006-01-02")
		if !seen[key] {
			holidays = append(holidays, h)
		}
	}
	return holidays, nil
}

// IsHoliday returns the matching Holiday entry if the date is a holiday for
// the given (country, regime), else nil. country must not be empty; regime
// may be empty for non-UPC, non-EPO contexts.
func (s *HolidayService) IsHoliday(date time.Time, country, regime string) *Holiday {
	holidays, err := s.LoadHolidaysForYear(date.Year())
	if err != nil {
		return nil
	}
	key := date.Format("2006-01-02")
	for i := range holidays {
		if holidays[i].Date.Format("2006-01-02") != key {
			continue
		}
		if !holidays[i].AppliesTo(country, regime) {
			continue
		}
		return &holidays[i]
	}
	return nil
}

// IsNonWorkingDay returns true on weekends or closure-type holidays
// applicable to the given (country, regime).
//
// "Vacation" entries (today: UPC summer + winter judicial vacations per UPC
// AC decision 2023-05-26) are deliberately excluded — the Court continues to
// operate during them and they do not extend procedural deadlines (RoP /
// AC decision-on-judicial-vacation). They stay in paliad.holidays as
// informational metadata so callers of IsHoliday can still surface "this
// date overlaps with UPC vacation" if they want to. See t-paliad-121.
func (s *HolidayService) IsNonWorkingDay(date time.Time, country, regime string) bool {
	if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
		return true
	}
	h := s.IsHoliday(date, country, regime)
	return h != nil && h.IsClosure
}

// AdjustForNonWorkingDays moves the date forward to the next working day for
// the given (country, regime). Returns adjusted date, the original
// (unmodified) date, and whether any adjustment was made.
//
// Since t-paliad-121 vacations are no longer non-working, so the longest
// real-world run is Karfreitag → Ostermontag (~4 days) or Christmas-eve
// weekend → Neujahr (~6 days). The 60-iter cap is over-provisioned but
// kept as-is — it predates t-paliad-121 (the t-paliad-086 PR-3 history
// note explains the original 30 → 60 bump for full-vacation walks), and
// over-provisioning is harmless here.
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted time.Time, original time.Time, wasAdjusted bool) {
	adjusted, original, wasAdjusted, _ = s.AdjustForNonWorkingDaysWithReason(date, country, regime)
	return adjusted, original, wasAdjusted
}

// AdjustmentReason explains why a deadline was shifted off the originally
// computed date. The Fristenrechner UI uses it to render specific labels
// — "UPC-Sommerferien (27.7.–28.8.)" instead of the generic "Wochenende/
// Feiertag" — so a 27-day shift across UPC vacation no longer looks like a
// math bug. See t-paliad-119.
//
// Date fields are JSON-serialised as YYYY-MM-DD strings (the same convention
// as UIDeadline.DueDate / OriginalDate) so the frontend doesn't need a
// separate RFC3339 parser. Holidays carries the same string-date shape.
type AdjustmentReason struct {
	// Kind is the dominant cause; longest cause wins when several apply
	// (vacation > public_holiday > weekend).
	Kind string `json:"kind"`
	// Holidays collects every named holiday encountered while walking past
	// the non-working run, deduped by (date, name). May be empty when the
	// only cause is a weekend.
	Holidays []HolidayDTO `json:"holidays,omitempty"`
	// VacationName, VacationStart and VacationEnd describe the contiguous
	// vacation block the original date sits in. Populated only when Kind
	// == "vacation". Span boundaries are the first/last vacation day in
	// the block (excludes the weekends that pad it).
	VacationName  string `json:"vacationName,omitempty"`
	VacationStart string `json:"vacationStart,omitempty"`
	VacationEnd   string `json:"vacationEnd,omitempty"`
	// OriginalWeekday is the English weekday name of the original date —
	// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
	// can localise it.
	OriginalWeekday string `json:"originalWeekday,omitempty"`
}

// HolidayDTO is the JSON shape for a holiday emitted in AdjustmentReason —
// distinct from Holiday so dates serialise as YYYY-MM-DD strings.
type HolidayDTO struct {
	Date       string `json:"date"`
	Name       string `json:"name"`
	IsVacation bool   `json:"isVacation,omitempty"`
	IsClosure  bool   `json:"isClosure,omitempty"`
}

// AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an
// explanation. Reason is nil when wasAdjusted is false.
//
// The walk semantics (forward, day-by-day, 60-iter bound) are identical to
// AdjustForNonWorkingDays — only an additional bookkeeping pass collects the
// holidays hit and decides the dominant Kind. Do not shrink the 60-iter
// bound; see AdjustForNonWorkingDays for the t-paliad-086 PR-2 history.
func (s *HolidayService) AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted time.Time, original time.Time, wasAdjusted bool, reason *AdjustmentReason) {
	original = date
	adjusted = date

	var holidaysHit []HolidayDTO
	seen := map[string]bool{}
	var sawWeekend, sawVacation, sawPublicHoliday bool
	var vacationName string

	for i := 0; i < 60 && s.IsNonWorkingDay(adjusted, country, regime); i++ {
		if wd := adjusted.Weekday(); wd == time.Saturday || wd == time.Sunday {
			sawWeekend = true
		}
		if h := s.IsHoliday(adjusted, country, regime); h != nil {
			if h.IsVacation {
				sawVacation = true
				if vacationName == "" {
					vacationName = h.Name
				}
			} else {
				sawPublicHoliday = true
			}
			key := h.Date.Format("2006-01-02") + "|" + h.Name
			if !seen[key] {
				holidaysHit = append(holidaysHit, HolidayDTO{
					Date:       h.Date.Format("2006-01-02"),
					Name:       h.Name,
					IsVacation: h.IsVacation,
					IsClosure:  h.IsClosure,
				})
				seen[key] = true
			}
		}
		adjusted = adjusted.AddDate(0, 0, 1)
		wasAdjusted = true
	}

	if !wasAdjusted {
		return adjusted, original, false, nil
	}

	r := &AdjustmentReason{Holidays: holidaysHit}
	switch {
	case sawVacation:
		r.Kind = "vacation"
		r.VacationName = vacationName
		if vs, ve, ok := s.findVacationBlock(original, country, regime); ok {
			r.VacationStart = vs.Format("2006-01-02")
			r.VacationEnd = ve.Format("2006-01-02")
		}
	case sawPublicHoliday:
		r.Kind = "public_holiday"
	default:
		r.Kind = "weekend"
	}
	if sawWeekend && r.Kind == "weekend" {
		r.OriginalWeekday = original.Weekday().String()
	}
	return adjusted, original, true, r
}

// findVacationBlock locates the contiguous vacation block that `date` lies
// in by scanning outward through non-working days for the given (country,
// regime). Returns the earliest and latest IsVacation entries reached
// before hitting a working day. Weekends inside the run are traversed
// (non-working) but don't extend the reported span — start/end are always
// real vacation entries.
func (s *HolidayService) findVacationBlock(date time.Time, country, regime string) (start, end time.Time, ok bool) {
	var firstVac, lastVac time.Time

	cur := date
	for i := 0; i < 60; i++ {
		if !s.IsNonWorkingDay(cur, country, regime) {
			break
		}
		if h := s.IsHoliday(cur, country, regime); h != nil && h.IsVacation {
			firstVac = cur
		}
		cur = cur.AddDate(0, 0, -1)
	}

	cur = date.AddDate(0, 0, 1)
	for i := 0; i < 60; i++ {
		if !s.IsNonWorkingDay(cur, country, regime) {
			break
		}
		if h := s.IsHoliday(cur, country, regime); h != nil && h.IsVacation {
			lastVac = cur
		}
		cur = cur.AddDate(0, 0, 1)
	}

	if firstVac.IsZero() && lastVac.IsZero() {
		return time.Time{}, time.Time{}, false
	}
	if firstVac.IsZero() {
		firstVac = lastVac
	}
	if lastVac.IsZero() {
		lastVac = firstVac
	}
	return firstVac, lastVac, true
}

// germanFederalHolidays returns the 11 holidays observed in all 16 German Länder.
func germanFederalHolidays(year int) []Holiday {
	em, ed := CalculateEasterSunday(year)
	easter := time.Date(year, time.Month(em), ed, 0, 0, 0, 0, time.UTC)
	return []Holiday{
		{Date: time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), Name: "Neujahr", Country: CountryDE, IsClosure: true},
		{Date: easter.AddDate(0, 0, -2), Name: "Karfreitag", Country: CountryDE, IsClosure: true},
		{Date: easter, Name: "Ostersonntag", Country: CountryDE, IsClosure: true},
		{Date: easter.AddDate(0, 0, 1), Name: "Ostermontag", Country: CountryDE, IsClosure: true},
		{Date: time.Date(year, time.May, 1, 0, 0, 0, 0, time.UTC), Name: "Tag der Arbeit", Country: CountryDE, IsClosure: true},
		{Date: easter.AddDate(0, 0, 39), Name: "Christi Himmelfahrt", Country: CountryDE, IsClosure: true},
		{Date: easter.AddDate(0, 0, 49), Name: "Pfingstsonntag", Country: CountryDE, IsClosure: true},
		{Date: easter.AddDate(0, 0, 50), Name: "Pfingstmontag", Country: CountryDE, IsClosure: true},
		{Date: time.Date(year, time.October, 3, 0, 0, 0, 0, time.UTC), Name: "Tag der Deutschen Einheit", Country: CountryDE, IsClosure: true},
		{Date: time.Date(year, time.December, 25, 0, 0, 0, 0, time.UTC), Name: "1. Weihnachtstag", Country: CountryDE, IsClosure: true},
		{Date: time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC), Name: "2. Weihnachtstag", Country: CountryDE, IsClosure: true},
	}
}

// CalculateEasterSunday computes Easter Sunday for the given year using the
// Anonymous Gregorian algorithm. Returns (month 1-12, day 1-31).
func CalculateEasterSunday(year int) (int, int) {
	a := year % 19
	b := year / 100
	c := year % 100
	d := b / 4
	e := b % 4
	f := (b + 8) / 25
	g := (b - f + 1) / 3
	h := (19*a + b - d - g + 15) % 30
	i := c / 4
	k := c % 4
	l := (32 + 2*e + 2*i - h - k) % 7
	m := (a + 11*h + 22*l) / 451
	month := (h + l - 7*m + 114) / 31
	day := ((h + l - 7*m + 114) % 31) + 1
	return month, day
}
