package services

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

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

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

// addWorkingDays + composite-rule semantics — pure-Go logic, no DB needed.
//
// Phase 3 Slice 4 (t-paliad-185) collapsed the prior method versions
// (s.addWorkingDays / s.applyDuration on *EventDeadlineService) into
// package-level helpers shared with FristenrechnerService. Tests now
// call them directly without a receiver.

func TestAddWorkingDays_SkipsWeekends(t *testing.T) {
	hs := NewHolidayService(nil)

	// 2026-04-30 = Thu. +3 wd: step → Fri May 1 (Tag der Arbeit, skip) → Sat
	// (skip) → Sun (skip) → Mon May 4 = WD 1; → Tue May 5 = WD 2; → Wed
	// May 6 = WD 3. So +3 wd = Wed 2026-05-06.
	in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
	got := addWorkingDays(in, 3, "DE", "UPC", hs)
	want := time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC)
	if !got.Equal(want) {
		t.Errorf("addWorkingDays(+3): got %s, want %s", got, want)
	}
}

func TestAddWorkingDays_SkipsHolidays(t *testing.T) {
	hs := NewHolidayService(nil)

	// 2026-04-30 = Thu. +1 wd = Fri 2026-05-01 = Tag der Arbeit (DE federal holiday).
	// → skip → Sat (weekend) → skip → Sun (weekend) → skip → Mon 2026-05-04.
	in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
	got := addWorkingDays(in, 1, "DE", "UPC", hs)
	want := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
	if !got.Equal(want) {
		t.Errorf("addWorkingDays(+1) over Tag der Arbeit: got %s, want %s", got, want)
	}
}

func TestAddWorkingDays_NegativeWalksBackward(t *testing.T) {
	hs := NewHolidayService(nil)

	// Mon 2026-05-04 - 2 wd = Thu 2026-04-30 (skipping Fri 2026-05-01 holiday).
	in := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
	got := addWorkingDays(in, -2, "DE", "UPC", hs)
	want := time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC)
	if !got.Equal(want) {
		t.Errorf("addWorkingDays(-2) over Tag der Arbeit: got %s, want %s", got, want)
	}
}

func TestAddWorkingDays_Zero(t *testing.T) {
	hs := NewHolidayService(nil)

	// Day-zero convention: returns input unchanged, even if it's a weekend.
	weekend := time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC) // Saturday
	got := addWorkingDays(weekend, 0, "DE", "UPC", hs)
	if !got.Equal(weekend) {
		t.Errorf("addWorkingDays(0) on weekend: got %s, want %s (unchanged)", got, weekend)
	}
}

func TestApplyDuration_WorkingDays_SkipsAdjustment(t *testing.T) {
	hs := NewHolidayService(nil)

	// working_days lands on a working day by construction → no further adjust.
	// Thu 2026-04-30 + 1 wd = Mon 2026-05-04 (skipped Fri holiday + weekend).
	in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
	raw, adjusted, didAdjust, _ := applyDuration(in, 1, "working_days", "after", "DE", "UPC", hs)
	want := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)

	if !raw.Equal(want) {
		t.Errorf("raw: got %s, want %s", raw, want)
	}
	if !adjusted.Equal(want) {
		t.Errorf("adjusted: got %s, want %s", adjusted, want)
	}
	if didAdjust {
		t.Error("working_days unit should report didAdjust=false")
	}
}

func TestApplyDuration_BeforeTiming(t *testing.T) {
	hs := NewHolidayService(nil)

	// Wed 2026-04-15 - 2 weeks = Wed 2026-04-01. Working day → no adjust.
	in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
	raw, adjusted, _, _ := applyDuration(in, 2, "weeks", "before", "DE", "UPC", hs)
	want := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
	if !raw.Equal(want) {
		t.Errorf("raw: got %s, want %s", raw, want)
	}
	if !adjusted.Equal(want) {
		t.Errorf("adjusted: got %s, want %s", adjusted, want)
	}
}

// Composite-rule test: R.198/R.213 "31d OR 20 working_days, whichever is longer".
// We hand-compute the two legs and pick max via the same logic as Calculate.
func TestComposite_R198_LongerLegWins(t *testing.T) {
	hs := NewHolidayService(nil)
	in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)

	_, baseAdj, _, _ := applyDuration(in, 31, "days", "after", "DE", "UPC", hs)
	_, altAdj, _, _ := applyDuration(in, 20, "working_days", "after", "DE", "UPC", hs)

	// 31 calendar days from Thu 2026-04-30 = Sun 2026-05-31 → adjust to Mon 2026-06-01.
	// 20 working days from Thu 2026-04-30 ≈ early June (skipping May 1 holiday + weekends).
	// Hand-count: starts Apr 30 (Thu), wd1=May 4 (skip Fri holiday + weekend), wd2=May 5,
	// wd3=May 6, wd4=May 7, wd5=May 8 (Fri), wd6=May 11 (Mon), wd7=May 12,
	// wd8=May 13, wd9=May 14 (Christi Himmelfahrt skipped) → wd9=May 15, wd10=May 18,
	// wd11=May 19, wd12=May 20, wd13=May 21, wd14=May 22, wd15=May 25 (Pfingstmontag skipped) → wd15=May 26,
	// wd16=May 27, wd17=May 28, wd18=May 29, wd19=Jun 1, wd20=Jun 2.
	// So altAdj = 2026-06-02.
	wantAlt := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
	if !altAdj.Equal(wantAlt) {
		t.Fatalf("alt leg: got %s, want %s", altAdj, wantAlt)
	}
	wantBase := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
	if !baseAdj.Equal(wantBase) {
		t.Fatalf("base leg: got %s, want %s", baseAdj, wantBase)
	}

	// max(base, alt) = altAdj (Jun 2 > Jun 1) — working_days leg wins this case,
	// matching the R.198 "whichever is longer" intent.
	if !altAdj.After(baseAdj) {
		t.Error("expected altAdj > baseAdj (working_days leg longer than 31d leg)")
	}
}

// TestEventDeadlineService_Calculate_Parity is the LOAD-BEARING assertion
// for Phase 3 Slice 3 (t-paliad-184). For every distinct trigger_event_id
// in the Pipeline-C corpus, it calls EventDeadlineService.Calculate (now
// fully delegating to FristenrechnerService.calculateByTriggerEvent) AND
// independently computes the same dates via the package-level
// applyDuration helper against the same deadline_rules source rows. Any
// divergence — date, composite-flag, rule_codes — signals a Pipeline-C
// regression that "Was kommt nach…" users would see in production.
//
// Phase 3 Slice 9 follow-up A (t-paliad-199): mig 092 dropped
// paliad.event_deadlines + paliad.event_deadline_rule_codes. The test
// source query now reads from paliad.deadline_rules WHERE
// trigger_event_id IS NOT NULL — the unified row set the service
// reads. The independent computation is still meaningful: it bypasses
// FristenrechnerService entirely and re-runs the package-level
// applyDuration math against the raw column values, so any future
// regression in the calculator's wrapping logic surfaces here.
//
// Field mapping (post-mig-092): name_en → Title, name → TitleDE,
// (sequence_order - 1000) → ID (legacy event_deadlines.id semantic via
// mig 085's sequence_order = 1000 + ed.id convention).
//
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL not set — skipping live DB parity 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)
	courts := NewCourtService(pool)
	rules := NewDeadlineRuleService(pool)
	fristen := NewFristenrechnerService(rules, holidays, courts)
	svc := NewEventDeadlineService(pool, NewDeadlineCalculator(holidays), holidays, courts, fristen)

	// Distinct trigger_event_id values for which we have at least one
	// active Pipeline-C rule. Mig 085 moved 77 active rows from
	// event_deadlines into deadline_rules with trigger_event_id IS NOT
	// NULL, so the set is stable across Slice 9 + follow-up A.
	var triggerIDs []int64
	if err := pool.SelectContext(ctx, &triggerIDs,
		`SELECT DISTINCT trigger_event_id
		   FROM paliad.deadline_rules
		  WHERE trigger_event_id IS NOT NULL AND is_active = true
		  ORDER BY trigger_event_id`); err != nil {
		t.Fatalf("list trigger ids: %v", err)
	}
	if len(triggerIDs) == 0 {
		t.Fatal("no Pipeline-C rules — corpus missing")
	}

	// Reference date — arbitrary working day so weekend rollover noise is
	// minimal. The parity test compares against an independently-computed
	// expected value, so any date that exercises the calculator is fine.
	triggerDateStr := "2026-01-15"
	triggerDate, _ := time.Parse("2006-01-02", triggerDateStr)
	country, regime, err := courts.CountryRegime("", CountryDE, RegimeUPC)
	if err != nil {
		t.Fatalf("default court regime: %v", err)
	}

	// Source-row shape mirrors EventDeadlineResult's columns so the
	// comparison is direct. ID derives from sequence_order via the
	// mig 085 convention; the post-mig-092 service does the same.
	type srcRow struct {
		ID               int64   `db:"id"`
		Title            string  `db:"title"`
		TitleDE          string  `db:"title_de"`
		DurationValue    int     `db:"duration_value"`
		DurationUnit     string  `db:"duration_unit"`
		Timing           string  `db:"timing"`
		AltDurationValue *int    `db:"alt_duration_value"`
		AltDurationUnit  *string `db:"alt_duration_unit"`
		CombineOp        *string `db:"combine_op"`
	}

	var totalChecked int
	for _, tid := range triggerIDs {
		resp, err := svc.Calculate(ctx, tid, triggerDateStr, "")
		if err != nil {
			t.Errorf("trigger=%d Calculate: %v", tid, err)
			continue
		}

		var src []srcRow
		if err := pool.SelectContext(ctx, &src,
			`SELECT (sequence_order - 1000)        AS id,
			        name_en                        AS title,
			        name                           AS title_de,
			        duration_value, duration_unit,
			        COALESCE(timing, 'after')      AS timing,
			        alt_duration_value, alt_duration_unit, combine_op
			   FROM paliad.deadline_rules
			  WHERE trigger_event_id = $1 AND is_active = true
			  ORDER BY sequence_order`, tid); err != nil {
			t.Fatalf("trigger=%d load source: %v", tid, err)
		}

		if len(resp.Deadlines) != len(src) {
			t.Errorf("trigger=%d: got %d deadlines, want %d", tid, len(resp.Deadlines), len(src))
			continue
		}

		// Sort both by ID — the source SELECT ORDER BYs sequence_order
		// and we derive ID = sequence_order - 1000, so positional
		// comparison after the sort is exact.
		sort.Slice(resp.Deadlines, func(i, j int) bool {
			return resp.Deadlines[i].ID < resp.Deadlines[j].ID
		})

		for i, r := range resp.Deadlines {
			s := src[i]
			totalChecked++

			if r.ID != s.ID {
				t.Errorf("trigger=%d idx=%d: id=%d, want %d", tid, i, r.ID, s.ID)
			}
			if r.Title != s.Title {
				t.Errorf("trigger=%d id=%d: title mismatch: %q vs %q", tid, s.ID, r.Title, s.Title)
			}
			if r.TitleDE != s.TitleDE {
				t.Errorf("trigger=%d id=%d: titleDE mismatch: %q vs %q", tid, s.ID, r.TitleDE, s.TitleDE)
			}
			if r.DurationValue != s.DurationValue {
				t.Errorf("trigger=%d id=%d: durationValue mismatch: %d vs %d",
					tid, s.ID, r.DurationValue, s.DurationValue)
			}
			if r.DurationUnit != s.DurationUnit {
				t.Errorf("trigger=%d id=%d: durationUnit mismatch: %q vs %q",
					tid, s.ID, r.DurationUnit, s.DurationUnit)
			}
			if r.Timing != s.Timing {
				t.Errorf("trigger=%d id=%d: timing mismatch: %q vs %q", tid, s.ID, r.Timing, s.Timing)
			}

			// Date parity: independently compute the expected DueDate
			// using the legacy applyDuration on the source row. If the
			// unified path diverges by even one day, this surfaces it.
			_, expectedAdj, _, _ := applyDuration(triggerDate, s.DurationValue, s.DurationUnit, s.Timing, country, regime, holidays)
			if s.CombineOp != nil && s.AltDurationValue != nil && s.AltDurationUnit != nil {
				_, altAdj, _, _ := applyDuration(triggerDate, *s.AltDurationValue, *s.AltDurationUnit, s.Timing, country, regime, holidays)
				switch *s.CombineOp {
				case "max":
					if altAdj.After(expectedAdj) {
						expectedAdj = altAdj
					}
				case "min":
					if altAdj.Before(expectedAdj) {
						expectedAdj = altAdj
					}
				}
			}
			gotAdj, perr := time.Parse("2006-01-02", r.DueDate)
			if perr != nil {
				t.Errorf("trigger=%d id=%d: parse dueDate %q: %v", tid, s.ID, r.DueDate, perr)
				continue
			}
			if !gotAdj.Equal(expectedAdj) {
				t.Errorf("trigger=%d id=%d (%q): dueDate=%s, want %s — Pipeline-C parity broken",
					tid, s.ID, s.Title, r.DueDate, expectedAdj.Format("2006-01-02"))
			}

			// Composite flag parity.
			wantComposite := s.CombineOp != nil && s.AltDurationValue != nil && s.AltDurationUnit != nil
			if r.IsComposite != wantComposite {
				t.Errorf("trigger=%d id=%d: isComposite=%v, want %v",
					tid, s.ID, r.IsComposite, wantComposite)
			}
		}
	}

	// Final tally — at least the 77 active rows must have been checked.
	if totalChecked < 77 {
		t.Errorf("checked only %d Pipeline-C rows (want >=77) — parity sweep incomplete", totalChecked)
	}
}
