package services

import (
	"context"
	"os"
	"strings"
	"testing"

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

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

// TestFormatLegalSourceDisplay covers the structured-form → display-form
// conversion the search API exposes alongside legal_source.
func TestFormatLegalSourceDisplay(t *testing.T) {
	cases := []struct {
		in, want string
	}{
		{"UPC.RoP.23.1", "UPC RoP R.23(1)"},
		{"UPC.RoP.139", "UPC RoP R.139"},
		{"UPC.RoP.220.1", "UPC RoP R.220(1)"},
		{"DE.PatG.82.1", "PatG §82(1)"},
		{"DE.PatG.111.1", "PatG §111(1)"},
		{"DE.PatG.59.3", "PatG §59(3)"},
		{"DE.ZPO.276.1", "ZPO §276(1)"},
		{"DE.ZPO.517", "ZPO §517"},
		{"EU.EPÜ.108", "EPÜ Art.108"},
		{"EU.EPÜ.112a", "EPÜ Art.112a"},
		{"EU.EPC-R.79.1", "EPC R.79(1)"},
		{"EU.RPBA.12.1.c", "RPBA Art.12(1)(c)"},
		{"", ""},
	}
	for _, c := range cases {
		got := FormatLegalSourceDisplay(c.in)
		if got != c.want {
			t.Errorf("FormatLegalSourceDisplay(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

// TestBuildLegalSourceURL covers the structured-form → youpc.org/laws
// permalink mapping. Only the UPC corpus has a youpc home today;
// DE/EPA/EU bodies fall through to the empty string and the renderer
// shows display text without a link.
func TestBuildLegalSourceURL(t *testing.T) {
	cases := []struct {
		in, want string
	}{
		{"UPC.RoP.23.1", "https://youpc.org/laws#UPCRoP.023"},
		{"UPC.RoP.139", "https://youpc.org/laws#UPCRoP.139"},
		{"UPC.RoP.220.1", "https://youpc.org/laws#UPCRoP.220"},
		{"UPC.RoP.29.a", "https://youpc.org/laws#UPCRoP.029"},
		{"UPC.RoP.49.2.a", "https://youpc.org/laws#UPCRoP.049"},
		{"UPC.RoP.19.1", "https://youpc.org/laws#UPCRoP.019"},
		{"UPC.UPCA.83", "https://youpc.org/laws#UPCA.083"},
		{"UPC.UPCS.40.1", "https://youpc.org/laws#UPCS.040"},
		{"DE.PatG.82.1", ""},
		{"DE.ZPO.276.1", ""},
		{"EU.EPÜ.108", ""},
		{"EU.EPC-R.79.1", ""},
		{"EU.RPBA.12.1.c", ""},
		{"UPC.RoP", ""},
		{"", ""},
	}
	for _, c := range cases {
		got := BuildLegalSourceURL(c.in)
		if got != c.want {
			t.Errorf("BuildLegalSourceURL(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

// TestNormalizeQuery covers the input-side legal-prefix stripping that
// keeps "§ 82" / "Art. 108" findable against structured legal_source
// values that don't carry the prefix.
func TestNormalizeQuery(t *testing.T) {
	cases := []struct {
		in, want string
	}{
		{"§ 82", "82"},
		{"§82", "82"},
		{"Art. 108", "108"},
		{"art.108", "108"},
		{"Section 276", "276"},
		{"  Wiedereinsetzung  ", "Wiedereinsetzung"},
		{"RoP 23", "RoP 23"}, // RoP is meaningful, kept verbatim
		{"", ""},
	}
	for _, c := range cases {
		got := normalizeQuery(c.in)
		if got != c.want {
			t.Errorf("normalizeQuery(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

// TestDeadlineSearch is the Phase C golden table — the binding spec for
// what the search backend must answer for a fixed set of HLC vocabulary
// queries. Skipped when TEST_DATABASE_URL is unset, mirroring the other
// live-DB tests in this package.
func TestDeadlineSearch(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()
	if err := RefreshSearchView(ctx, pool); err != nil {
		t.Fatalf("refresh search view: %v", err)
	}

	svc := NewDeadlineSearchService(pool)

	t.Run("Klageerwiderung returns the statement-of-defence concept with multi-jurisdiction pills", func(t *testing.T) {
		resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 12})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		card := findCardBySlug(t, resp, "statement-of-defence")
		// Expected at minimum: UPC R.23, ZPO §276, PatG §82, EPC R.79, PatG §59.
		// The actual data has 9 rule rows (upc.inf.cfi, upc.rev.cfi,
		// upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, de.inf.lg,
		// de.null.bpatg, epa.opp.opd, dpma.opp.dpma).
		mustHaveLegalSource(t, card, "UPC.RoP.23.1")
		mustHaveLegalSource(t, card, "DE.ZPO.276.1")
		mustHaveLegalSource(t, card, "DE.PatG.82.1")
		mustHaveLegalSource(t, card, "EU.EPC-R.79.1")
		mustHaveLegalSource(t, card, "DE.PatG.59.3")
		mustHaveProceedingCodes(t, card, CodeUPCInfringement, CodeDEInfringementLG, CodeDENullityBPatG, CodeEPAOpposition, CodeDPMAOpposition)
	})

	t.Run("RoP 23 returns the UPC R.23 hit", func(t *testing.T) {
		resp, err := svc.Search(ctx, "RoP 23", SearchOptions{Limit: 12})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		if len(resp.Cards) == 0 {
			t.Fatalf("expected at least one card for 'RoP 23', got 0")
		}
		// Statement-of-defence is the canonical R.23 concept; it must be
		// among the cards (not necessarily rank 1 because rule_code-based
		// trigram only weighs 0.9, and several rules with adjacent codes
		// also hit).
		card := findCardBySlug(t, resp, "statement-of-defence")
		mustHaveLegalSource(t, card, "UPC.RoP.23.1")
	})

	t.Run("§ 82 returns the BPatG nullity-defence card", func(t *testing.T) {
		resp, err := svc.Search(ctx, "§ 82", SearchOptions{Limit: 12})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		// PatG §82(1) is the BPatG Klageerwiderung anchor — should fall
		// under the statement-of-defence concept.
		card := findCardBySlug(t, resp, "statement-of-defence")
		mustHaveLegalSource(t, card, "DE.PatG.82.1")
	})

	t.Run("Wiedereinsetzung returns the cross-cutting concept with 5 trigger pills", func(t *testing.T) {
		resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		card := findCardBySlug(t, resp, "wiedereinsetzung")
		// Exactly 5 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
		// Art.122 (EU), DPMA §123, and UPC R.320 — trigger_event ids
		// 200..203 from mig 046 plus 207 from mig 063.
		triggerIDs := []int64{}
		for _, p := range card.Pills {
			if p.Kind != "trigger" {
				t.Errorf("Wiedereinsetzung card has non-trigger pill: kind=%q proc=%v", p.Kind, p.Proceeding)
			}
			if p.TriggerEventID != nil {
				triggerIDs = append(triggerIDs, *p.TriggerEventID)
			}
		}
		want := map[int64]bool{200: true, 201: true, 202: true, 203: true, 207: true}
		if len(triggerIDs) != 5 {
			t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 5 (ids 200..203, 207)", len(triggerIDs))
		}
		for _, id := range triggerIDs {
			if !want[id] {
				t.Errorf("Wiedereinsetzung card has unexpected trigger id %d", id)
			}
		}
	})

	// t-paliad-266 / m/paliad#97 — court-system filter narrows
	// cross-cutting trigger pills via legal_source inference.
	t.Run("forum filter narrows Wiedereinsetzung trigger pills by court system", func(t *testing.T) {
		// Each pair is (forum slug, expected trigger_event_ids).
		cases := []struct {
			name        string
			forum       string
			wantTrigIDs []int64
		}{
			{"upc_cfi shows only UPC R.320", "upc_cfi", []int64{207}},
			{"upc_coa shows only UPC R.320", "upc_coa", []int64{207}},
			{"de_lg shows only ZPO §233", "de_lg", []int64{201}},
			{"de_olg shows only ZPO §233", "de_olg", []int64{201}},
			{"de_bgh shows only ZPO §233", "de_bgh", []int64{201}},
			{"de_bpatg shows only PatG §123 (DE national)", "de_bpatg", []int64{200, 203}},
			{"dpma shows only PatG §123 (DPMA)", "dpma", []int64{200, 203}},
			{"epa_grant shows only EPC Art.122", "epa_grant", []int64{202}},
			{"epa_opp shows only EPC Art.122", "epa_opp", []int64{202}},
			{"epa_appeal shows only EPC Art.122", "epa_appeal", []int64{202}},
		}
		for _, tc := range cases {
			t.Run(tc.name, func(t *testing.T) {
				resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
					Forums: []string{tc.forum},
					Limit:  12,
				})
				if err != nil {
					t.Fatalf("search: %v", err)
				}
				card := findCardBySlug(t, resp, "wiedereinsetzung")
				got := map[int64]bool{}
				for _, p := range card.Pills {
					if p.TriggerEventID != nil {
						got[*p.TriggerEventID] = true
					}
				}
				want := map[int64]bool{}
				for _, id := range tc.wantTrigIDs {
					want[id] = true
				}
				for id := range got {
					if !want[id] {
						t.Errorf("forum=%s leaked trigger id %d (got pills: %v)", tc.forum, id, got)
					}
				}
				for id := range want {
					if !got[id] {
						t.Errorf("forum=%s missing expected trigger id %d (got pills: %v)", tc.forum, id, got)
					}
				}
			})
		}
	})

	t.Run("multiple forum chips union the legal_source allow-list for triggers", func(t *testing.T) {
		// upc_cfi + de_lg → UPC.* OR DE.ZPO.* → trigger ids 201 + 207.
		resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
			Forums: []string{"upc_cfi", "de_lg"},
			Limit:  12,
		})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		card := findCardBySlug(t, resp, "wiedereinsetzung")
		got := map[int64]bool{}
		for _, p := range card.Pills {
			if p.TriggerEventID != nil {
				got[*p.TriggerEventID] = true
			}
		}
		want := map[int64]bool{201: true, 207: true}
		for id := range got {
			if !want[id] {
				t.Errorf("union forum upc_cfi+de_lg leaked trigger id %d", id)
			}
		}
		for id := range want {
			if !got[id] {
				t.Errorf("union forum upc_cfi+de_lg missing trigger id %d", id)
			}
		}
	})

	t.Run("empty forum filter leaves cross-cutting pills untouched", func(t *testing.T) {
		// No forum chips = all 5 triggers stay visible.
		resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		card := findCardBySlug(t, resp, "wiedereinsetzung")
		count := 0
		for _, p := range card.Pills {
			if p.Kind == "trigger" {
				count++
			}
		}
		if count != 5 {
			t.Errorf("empty forum filter dropped a trigger pill: got %d, want 5", count)
		}
	})

	t.Run("party filter narrows to defendant-only", func(t *testing.T) {
		resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		// Statement-of-defence is filed by the defendant. Filtering
		// party=claimant should NOT drop the concept entirely — the
		// effective_party can vary per pill (e.g. epa.opp.opd Erwiderung
		// is owed by the patentee/claimant). At least it must not
		// return any card with EVERY pill on defendant side.
		for _, c := range resp.Cards {
			allDefendant := true
			for _, p := range c.Pills {
				if p.Party != "defendant" {
					allDefendant = false
					break
				}
			}
			if allDefendant {
				t.Errorf("party=claimant filter returned a card whose every pill is defendant: %q", c.Concept.Slug)
			}
		}
	})

	t.Run("source filter narrows to UPC.RoP-prefixed pills only", func(t *testing.T) {
		resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Source: "UPC.RoP", Limit: 12})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		for _, c := range resp.Cards {
			for _, p := range c.Pills {
				if p.LegalSource != nil && !startsWith(*p.LegalSource, "UPC.RoP") {
					t.Errorf("source=UPC.RoP filter leaked a non-UPC.RoP pill: %q", *p.LegalSource)
				}
			}
		}
	})

	t.Run("empty query returns empty cards (browse surface is Phase D)", func(t *testing.T) {
		resp, err := svc.Search(ctx, "", SearchOptions{Limit: 12})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		if len(resp.Cards) != 0 {
			t.Errorf("empty q should return 0 cards, got %d", len(resp.Cards))
		}
	})

	t.Run("limit is capped at MaxLimit", func(t *testing.T) {
		resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 1000, MaxLimit: 5})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		if len(resp.Cards) > 5 {
			t.Errorf("limit cap not enforced: got %d cards, want ≤ 5", len(resp.Cards))
		}
	})

	t.Run("legal_source_display is rendered for rule pills with a legal_source", func(t *testing.T) {
		resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 12})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		card := findCardBySlug(t, resp, "statement-of-defence")
		for _, p := range card.Pills {
			if p.LegalSource == nil {
				continue
			}
			if p.LegalSourceDisplay == nil || *p.LegalSourceDisplay == "" {
				t.Errorf("rule pill with legal_source=%q is missing legal_source_display", *p.LegalSource)
			}
		}
	})

	// v4 (t-paliad-136): event-category narrowing must apply per-leaf
	// (concept_id, proceeding_type_code) tuples, not just concept_id. The
	// v3 implementation collapsed tuples to flat concept_ids and surfaced
	// pills for every proceeding the matview had a row for. m's repro:
	// picking "CMS-Eingang → Gegenseite → UPC Verletzung" leaked DE/EPA/
	// DPMA pills.
	ec := NewEventCategoryService(pool)
	svc.SetEventCategoryService(ec)

	t.Run("v4 event_category_slug narrows pills to per-leaf proceeding (UPC infringement subtree)", func(t *testing.T) {
		resp, err := svc.Search(ctx, "", SearchOptions{
			EventCategorySlug: "cms-eingang.gegenseite.upc-inf",
			Limit:             200,
		})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		// Every rule pill must be a UPC proceeding. The seed maps every
		// concept under this subtree to upc.inf.cfi or upc.apl.merits — no
		// DE/EPA/DPMA codes should leak.
		allowedRulePrefix := []string{"upc."}
		for _, c := range resp.Cards {
			for _, p := range c.Pills {
				if p.Kind != "rule" {
					continue
				}
				if p.Proceeding == nil {
					t.Errorf("rule pill on %q has no proceeding", c.Concept.Slug)
					continue
				}
				ok := false
				for _, prefix := range allowedRulePrefix {
					if strings.HasPrefix(p.Proceeding.Code, prefix) {
						ok = true
						break
					}
				}
				if !ok {
					t.Errorf("subtree narrowing leaked non-UPC pill on %q: proc=%s rule=%s",
						c.Concept.Slug, p.Proceeding.Code, p.RuleLocalCode)
				}
			}
		}
	})

	t.Run("v4 event_category_slug honours per-tuple narrowing (klageerwiderung-mit-ccr leaf)", func(t *testing.T) {
		resp, err := svc.Search(ctx, "", SearchOptions{
			EventCategorySlug: "cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr",
			Limit:             200,
		})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		// Junction maps three concepts × upc.inf.cfi for this leaf:
		// defence-to-counterclaim-for-revocation, application-to-amend,
		// reply-to-defence. Every pill must be upc.inf.cfi.
		for _, c := range resp.Cards {
			for _, p := range c.Pills {
				if p.Kind != "rule" {
					continue
				}
				if p.Proceeding == nil || p.Proceeding.Code != CodeUPCInfringement {
					code := "(nil)"
					if p.Proceeding != nil {
						code = p.Proceeding.Code
					}
					t.Errorf("klageerwiderung-mit-ccr leaf leaked non-%s pill on %q: proc=%s",
						CodeUPCInfringement, c.Concept.Slug, code)
				}
			}
		}
	})

	t.Run("v4 trigger-only concept under leaf with NULL proc surfaces", func(t *testing.T) {
		// frist-verpasst.epa maps wiedereinsetzung and weiterbehandlung
		// with NULL proceeding_type_code (cross-cutting). Both must
		// appear and both must surface their trigger pills.
		resp, err := svc.Search(ctx, "", SearchOptions{
			EventCategorySlug: "frist-verpasst.epa",
			Limit:             50,
		})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		mustHaveCard := func(slug string) ConceptCard {
			for _, c := range resp.Cards {
				if c.Concept.Slug == slug {
					return c
				}
			}
			t.Fatalf("missing card %q under frist-verpasst.epa; got %v", slug, conceptSlugs(resp.Cards))
			return ConceptCard{}
		}
		wcard := mustHaveCard("wiedereinsetzung")
		// Trigger pills (no proceeding) must be present.
		hasTrigger := false
		for _, p := range wcard.Pills {
			if p.Kind == "trigger" {
				hasTrigger = true
				break
			}
		}
		if !hasTrigger {
			t.Errorf("wiedereinsetzung card under frist-verpasst.epa has no trigger pills")
		}
	})

	t.Run("v4 forum filter ANDs against subtree narrowing", func(t *testing.T) {
		// Pick the upc.inf.cfi subtree and add a forum chip that excludes
		// upc.inf.cfi — the result must be empty (the user contradicted
		// themselves; empty is the correct UX).
		resp, err := svc.Search(ctx, "", SearchOptions{
			EventCategorySlug: "cms-eingang.gegenseite.upc-inf",
			Forums:            []string{"epa_opp"},
			Limit:             200,
		})
		if err != nil {
			t.Fatalf("search: %v", err)
		}
		for _, c := range resp.Cards {
			for _, p := range c.Pills {
				if p.Kind == "rule" {
					t.Errorf("AND-narrowing produced a rule pill where forum + subtree contradict: %q proc=%v",
						c.Concept.Slug, p.Proceeding)
				}
			}
		}
	})

	// Invariant test: walk every leaf with at least one non-NULL
	// proceeding_type_code in the junction and assert that the search
	// result for that leaf only surfaces pills whose (concept, proc)
	// is authorised by an outcome row. This is the regression gate
	// that would have caught the v3 bug at PR time.
	t.Run("v4 invariant: per-leaf pills are authorised by junction tuples", func(t *testing.T) {
		leafRows, err := pool.QueryxContext(ctx, `
			SELECT DISTINCT ec.slug
			  FROM paliad.event_categories ec
			  JOIN paliad.event_category_concepts ecc ON ecc.event_category_id = ec.id
			 WHERE ec.is_active AND ec.is_leaf
			   AND ecc.proceeding_type_code IS NOT NULL
		`)
		if err != nil {
			t.Fatalf("list leaves: %v", err)
		}
		var slugs []string
		for leafRows.Next() {
			var s string
			if err := leafRows.Scan(&s); err != nil {
				leafRows.Close()
				t.Fatalf("scan leaf: %v", err)
			}
			slugs = append(slugs, s)
		}
		leafRows.Close()
		if len(slugs) == 0 {
			t.Fatalf("expected at least one leaf with non-NULL proc; got 0")
		}

		for _, slug := range slugs {
			outcomes, err := ec.ConceptsForSlug(ctx, slug)
			if err != nil {
				t.Fatalf("ConceptsForSlug(%q): %v", slug, err)
			}
			// Build tuple validator.
			isAllowed := func(conceptID, proc string) bool {
				for _, o := range outcomes {
					if o.ConceptID != conceptID {
						continue
					}
					if o.ProceedingTypeCode == nil {
						return true
					}
					if *o.ProceedingTypeCode == proc {
						return true
					}
				}
				return false
			}
			resp, err := svc.Search(ctx, "", SearchOptions{
				EventCategorySlug: slug,
				Limit:             200,
			})
			if err != nil {
				t.Fatalf("search %q: %v", slug, err)
			}
			for _, c := range resp.Cards {
				for _, p := range c.Pills {
					if p.Kind != "rule" {
						continue
					}
					proc := ""
					if p.Proceeding != nil {
						proc = p.Proceeding.Code
					}
					if !isAllowed(c.Concept.ID, proc) {
						t.Errorf("leaf %s leaked unauthorised pill: concept=%s proc=%s rule=%s",
							slug, c.Concept.Slug, proc, p.RuleLocalCode)
					}
				}
			}
		}
	})
}

func findCardBySlug(t *testing.T, resp *SearchResponse, slug string) ConceptCard {
	t.Helper()
	for _, c := range resp.Cards {
		if c.Concept.Slug == slug {
			return c
		}
	}
	t.Fatalf("expected concept slug %q in cards, got: %v", slug, conceptSlugs(resp.Cards))
	return ConceptCard{}
}

func conceptSlugs(cards []ConceptCard) []string {
	out := make([]string, len(cards))
	for i, c := range cards {
		out[i] = c.Concept.Slug
	}
	return out
}

func mustHaveLegalSource(t *testing.T, card ConceptCard, want string) {
	t.Helper()
	for _, p := range card.Pills {
		if p.LegalSource != nil && *p.LegalSource == want {
			return
		}
	}
	t.Errorf("concept %q card missing pill with legal_source=%q. Got: %v", card.Concept.Slug, want, pillSources(card.Pills))
}

func mustHaveProceedingCodes(t *testing.T, card ConceptCard, codes ...string) {
	t.Helper()
	have := map[string]bool{}
	for _, p := range card.Pills {
		if p.Proceeding != nil {
			have[p.Proceeding.Code] = true
		}
	}
	for _, c := range codes {
		if !have[c] {
			t.Errorf("concept %q card missing proceeding pill %q. Got: %v", card.Concept.Slug, c, mapKeys(have))
		}
	}
}

func pillSources(pills []Pill) []string {
	out := []string{}
	for _, p := range pills {
		if p.LegalSource != nil {
			out = append(out, *p.LegalSource)
		} else {
			out = append(out, "(null)")
		}
	}
	return out
}

func mapKeys(m map[string]bool) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	return out
}

func startsWith(s, prefix string) bool {
	if len(s) < len(prefix) {
		return false
	}
	return s[:len(prefix)] == prefix
}
