package services

import (
	"context"
	"encoding/json"
	"os"
	"testing"

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

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

// TestDeadlineRuleService_UnifiedColumns_CompatRead exercises the Phase 3
// Slice 1 (mig 078–080, t-paliad-182) additive-schema landing.
//
// What it validates:
//
//  1. Every Phase 3 column (trigger_event_id, spawn_proceeding_type_id,
//     combine_op, condition_expr, priority, is_court_set,
//     lifecycle_state, draft_of, published_at) is present on
//     paliad.deadline_rules after migrations apply and scans cleanly
//     into models.DeadlineRule.
//
//  2. The default migration values land: priority='mandatory',
//     is_court_set=false, lifecycle_state='published' on every pre-
//     Slice-1 row. New rows default the same way.
//
//  3. The audit trigger fires on UPDATE — exactly one
//     paliad.deadline_rule_audit row is written for an UPDATE that
//     supplies a reason via SET LOCAL paliad.audit_reason.
//
//  4. The audit trigger raises when paliad.audit_reason is unset on
//     UPDATE — Slice 2 backfills MUST set the reason or they fail
//     loudly.
//
//  5. paliad.projects.instance_level (mig 080) accepts NULL and the
//     three CHECK-allowed values, and rejects anything else.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
func TestDeadlineRuleService_UnifiedColumns_CompatRead(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()
	svc := NewDeadlineRuleService(pool)

	// -------------------------------------------------------------------
	// 1. SELECT every column via the service's ruleColumns list. The list
	//    must end the test green even though it now includes the Phase 3
	//    columns; if a scan error pops up we know a column name or Go
	//    type slipped.
	// -------------------------------------------------------------------

	rules, err := svc.List(ctx, nil)
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(rules) == 0 {
		t.Fatal("no rules returned; seed-data missing?")
	}

	// 2. Every row scans cleanly. Priority + is_court_set values depend on
	//    whether Slice 2 (mig 082–084) has applied: pre-Slice-2 they carry
	//    the mig 078 defaults (priority='mandatory', is_court_set=false);
	//    post-Slice-2 they carry the backfilled values per design §2.3.
	//    LifecycleState is set by mig 078 to 'published' for every row and
	//    is unaffected by Slice 2.
	allowedPriorities := map[string]bool{
		"mandatory": true, "recommended": true, "optional": true, "informational": true,
	}
	for _, r := range rules {
		if !allowedPriorities[r.Priority] {
			t.Errorf("rule %s: priority=%q not in enum", r.ID, r.Priority)
		}
		if r.LifecycleState != "published" {
			t.Errorf("rule %s: lifecycle_state=%q, want default 'published'", r.ID, r.LifecycleState)
		}
	}

	// -------------------------------------------------------------------
	// 3 + 4. Audit trigger behaviour. Use a throwaway row in its own tx
	//        so SET LOCAL is scoped to this test.
	// -------------------------------------------------------------------

	// Pick any existing rule; we'll UPDATE its updated_at field with a
	// no-op-equivalent change (twice — once with reason, once without).
	target := rules[0]

	// Count the audit rows for this rule before we touch it.
	var beforeCount int
	if err := pool.GetContext(ctx, &beforeCount,
		`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
		t.Fatalf("count audit rows pre-update: %v", err)
	}

	// 3a. UPDATE WITH reason set — should succeed and write one audit row.
	tx, err := pool.BeginTxx(ctx, nil)
	if err != nil {
		t.Fatalf("begin tx: %v", err)
	}
	if _, err := tx.ExecContext(ctx,
		`SELECT set_config('paliad.audit_reason', 'test: compat-read audit smoke', true)`); err != nil {
		tx.Rollback()
		t.Fatalf("set audit reason: %v", err)
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID); err != nil {
		tx.Rollback()
		t.Fatalf("update with reason: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("commit update-with-reason tx: %v", err)
	}

	var afterCount int
	if err := pool.GetContext(ctx, &afterCount,
		`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
		t.Fatalf("count audit rows post-update: %v", err)
	}
	if afterCount != beforeCount+1 {
		t.Errorf("audit-row count: before=%d, after=%d, want before+1", beforeCount, afterCount)
	}

	// Look up the audit row we just wrote: latest by changed_at, action='update'.
	var (
		auditAction string
		auditReason string
		auditBefore json.RawMessage
		auditAfter  json.RawMessage
	)
	if err := pool.QueryRowxContext(ctx,
		`SELECT action, reason, before_json, after_json
		   FROM paliad.deadline_rule_audit
		  WHERE rule_id = $1
		  ORDER BY changed_at DESC
		  LIMIT 1`, target.ID).Scan(&auditAction, &auditReason, &auditBefore, &auditAfter); err != nil {
		t.Fatalf("read latest audit row: %v", err)
	}
	if auditAction != "update" {
		t.Errorf("audit action=%q, want 'update'", auditAction)
	}
	if auditReason != "test: compat-read audit smoke" {
		t.Errorf("audit reason=%q, want the set_config value", auditReason)
	}
	if len(auditBefore) == 0 || len(auditAfter) == 0 {
		t.Errorf("audit before/after json missing: before=%q after=%q", auditBefore, auditAfter)
	}

	// 4. UPDATE WITHOUT reason — trigger must raise.
	tx2, err := pool.BeginTxx(ctx, nil)
	if err != nil {
		t.Fatalf("begin tx2: %v", err)
	}
	_, err = tx2.ExecContext(ctx,
		`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID)
	tx2.Rollback()
	if err == nil {
		t.Error("UPDATE without paliad.audit_reason should have raised, but succeeded")
	}

	// -------------------------------------------------------------------
	// 5. paliad.projects.instance_level CHECK.
	// -------------------------------------------------------------------

	userID := uuid.New()
	projectID := uuid.New()
	cleanup := func() {
		pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
		pool.ExecContext(ctx, `DELETE FROM paliad.users    WHERE id = $1`, userID)
		pool.ExecContext(ctx, `DELETE FROM auth.users      WHERE id = $1`, userID)
	}
	cleanup()
	defer cleanup()

	if _, err := pool.ExecContext(ctx,
		`INSERT INTO auth.users (id, email) VALUES ($1, 'instance-level-test@hlc.com')`,
		userID); err != nil {
		t.Fatalf("seed auth.users: %v", err)
	}
	if _, err := pool.ExecContext(ctx,
		`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
		 VALUES ($1, 'instance-level-test@hlc.com', 'Instance Test', 'munich', 'associate', 'de')`,
		userID); err != nil {
		t.Fatalf("seed paliad.users: %v", err)
	}
	if _, err := pool.ExecContext(ctx,
		`INSERT INTO paliad.projects (id, type, path, title, status, created_by, instance_level)
		 VALUES ($1, 'project', $1::text, 'Instance Test', 'active', $2, 'appeal')`,
		projectID, userID); err != nil {
		t.Fatalf("seed paliad.projects with instance_level='appeal': %v", err)
	}

	// Update to each allowed value should succeed; bogus value must fail.
	for _, lvl := range []string{"first", "cassation", "appeal"} {
		if _, err := pool.ExecContext(ctx,
			`UPDATE paliad.projects SET instance_level = $1 WHERE id = $2`, lvl, projectID); err != nil {
			t.Errorf("update instance_level=%q: %v", lvl, err)
		}
	}
	if _, err := pool.ExecContext(ctx,
		`UPDATE paliad.projects SET instance_level = 'final' WHERE id = $1`, projectID); err == nil {
		t.Error("instance_level='final' should violate CHECK constraint, but succeeded")
	}
	if _, err := pool.ExecContext(ctx,
		`UPDATE paliad.projects SET instance_level = NULL WHERE id = $1`, projectID); err != nil {
		t.Errorf("NULL instance_level should be allowed: %v", err)
	}
}

// TestDeadlineRuleService_BackfillIntegrity exercises the Phase 3 Slice 2
// (mig 082–084, t-paliad-183) backfills against the live corpus.
//
// What it validates:
//
//  1. is_court_set (mig 082): every rule with primary_party='court' OR
//     event_type IN ('hearing','decision','order') is true; every other
//     rule is false. Replicates isCourtDeterminedRule() exactly.
//
//  2. priority (mig 083): zero rules with NULL priority (CHECK guards
//     the schema, this is belt-and-braces). The four mapping branches
//     hold per design §2.3 — T/F→'mandatory', T/T→'optional',
//     F/T→'recommended', F/F→'recommended'.
//
//  3. condition_expr (mig 084): every rule with a non-empty
//     condition_flag has a non-NULL condition_expr; every rule with
//     NULL/empty condition_flag has NULL condition_expr. Single-flag
//     rules carry {"flag":"<name>"} (unwrapped); multi-flag rules
//     carry {"op":"and","args":[{"flag":"<a>"},...]} long form.
//
// Skipped when TEST_DATABASE_URL is unset.
func TestDeadlineRuleService_BackfillIntegrity(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()

	// -------------------------------------------------------------------
	// 1. is_court_set matches the live heuristic exactly.
	// -------------------------------------------------------------------

	var mismatchCourt int
	if err := pool.GetContext(ctx, &mismatchCourt, `
		SELECT count(*)
		  FROM paliad.deadline_rules
		 WHERE is_court_set <> (
		         primary_party = 'court'
		         OR event_type IN ('hearing', 'decision', 'order')
		       )`); err != nil {
		t.Fatalf("count court-mismatch rows: %v", err)
	}
	if mismatchCourt != 0 {
		t.Errorf("is_court_set diverges from heuristic on %d rules (mig 082 incomplete)", mismatchCourt)
	}

	// -------------------------------------------------------------------
	// 2. priority backfill matches design §2.3.
	// -------------------------------------------------------------------

	var nullPriority int
	if err := pool.GetContext(ctx, &nullPriority,
		`SELECT count(*) FROM paliad.deadline_rules WHERE priority IS NULL`); err != nil {
		t.Fatalf("count NULL priority rows: %v", err)
	}
	if nullPriority != 0 {
		t.Errorf("found %d rules with NULL priority — mig 083 incomplete or CHECK bypassed", nullPriority)
	}

	// Slice 9 (t-paliad-195) dropped the legacy is_mandatory / is_optional
	// columns; pre-drop the test bucketed by the legacy pair to verify
	// Slice 2's backfill mapping. Post-Slice-9 the only remaining
	// invariant is "every row has a valid priority enum value", which
	// the nullPriority check above already asserts. The pre-drop
	// snapshot lives in paliad.deadline_rules_pre_091; a rollback
	// could rerun the full bucket check there.

	// -------------------------------------------------------------------
	// 3. condition_expr remains populated for the 17 originally-flagged
	//    rules. We can no longer cross-check against condition_flag (the
	//    column is gone in Slice 9) — instead, assert that the count of
	//    non-NULL condition_expr rows matches the pre-mig-091 snapshot's
	//    count of non-empty condition_flag rows (17 expected). If the
	//    snapshot table is gone (a follow-up cleanup slice drops it),
	//    skip this assertion gracefully.
	// -------------------------------------------------------------------

	// Cross-check via the pre-mig-091 snapshot (defensive — Slice 9
	// preserved it for rollback). If the snapshot is around, every
	// non-empty condition_flag row in the snapshot should map to a
	// non-NULL condition_expr in the live table.
	var snapshotExists bool
	_ = pool.GetContext(ctx, &snapshotExists, `
		SELECT EXISTS (SELECT 1 FROM pg_tables
		                WHERE schemaname='paliad' AND tablename='deadline_rules_pre_091')`)
	if snapshotExists {
		var orphans int
		if err := pool.GetContext(ctx, &orphans, `
			SELECT count(*)
			  FROM paliad.deadline_rules_pre_091 b
			  JOIN paliad.deadline_rules dr ON dr.id = b.id
			 WHERE b.condition_flag IS NOT NULL
			   AND array_length(b.condition_flag, 1) > 0
			   AND dr.condition_expr IS NULL`); err != nil {
			t.Fatalf("snapshot cross-check: %v", err)
		}
		if orphans != 0 {
			t.Errorf("%d rules had condition_flag in snapshot but no condition_expr live — mig 084 missed them", orphans)
		}
	}
}
