// Package db tests — migration dry-run gate.
//
// This is the test that catches mig-N crash-loops before they reach prod.
// The new runner tracks applied state as a set in paliad.applied_migrations
// (one row per migration; see migrate.go). A migration that compiles cleanly
// but fails on apply (typo, missing column, wrong CHECK shape) crashes the
// Dokploy container loop before paliad.de finishes binding :8080, and the
// only way to learn about it today is to watch the deploy log.
//
// TestMigrations_DryRun closes that gap: for every *.up.sql in this
// directory whose version is NOT present in paliad.applied_migrations on
// the scratch DB, it opens a transaction, runs the SQL, and ROLLBACKs.
// Any error fails the test with the file name + Postgres error. Always
// non-destructive — the ROLLBACK runs even on success, so the scratch DB
// stays at its starting set.
//
// "Pending" means: a version that's on disk but not in applied_migrations.
// In CI against a fresh scratch DB (where applied_migrations either
// doesn't exist or is empty), every migration is pending and gets
// verified. On a developer laptop whose scratch DB is already at HEAD,
// no migrations are pending and the test logs and passes — the protection
// only kicks in the moment a new *.up.sql lands in the tree before the
// developer runs `db.ApplyMigrations` against the same scratch DB.
//
// Requires TEST_DATABASE_URL (same pattern as the rest of the live-DB
// tests). Skipped without it.
//
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
// docs/design-migration-runner-applied-set-2026-05-20.md §6.

package db

import (
	"database/sql"
	"fmt"
	"os"
	"strings"
	"testing"

	_ "github.com/lib/pq"
)

// TestMigrations_DryRun walks every pending *.up.sql in numeric order,
// applies each inside its own BEGIN/ROLLBACK against the scratch DB, and
// fails the test on the first SQL error. Reports per-file as a sub-test so
// `go test -v` shows which migration failed.
func TestMigrations_DryRun(t *testing.T) {
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL not set — skipping migration dry-run")
	}

	conn, err := sql.Open("postgres", url)
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	defer conn.Close()
	if err := conn.Ping(); err != nil {
		t.Fatalf("ping: %v", err)
	}

	// The paliad schema must exist before migration 001 runs against it,
	// mirroring the bootstrap step in ApplyMigrations. Without this, a
	// fresh scratch DB would fail migration 001's CREATE TABLE paliad.*
	// statements inside the BEGIN/ROLLBACK probe with "schema paliad does
	// not exist" — a false negative that distracts from real errors.
	if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil {
		t.Fatalf("ensure paliad schema: %v", err)
	}

	applied, err := readAppliedVersions(conn)
	if err != nil {
		t.Fatalf("read applied_migrations: %v", err)
	}

	onDisk, err := scanEmbeddedMigrations()
	if err != nil {
		t.Fatalf("scan embedded migrations: %v", err)
	}

	var pending []migration
	for _, m := range onDisk {
		if !applied[m.version] {
			pending = append(pending, m)
		}
	}

	if len(pending) == 0 {
		t.Logf("no pending migrations — scratch DB applied set covers every on-disk version (%d total)",
			len(onDisk))
		return
	}
	t.Logf("scratch DB has %d/%d on-disk migrations applied; walking %d pending",
		len(applied), len(onDisk), len(pending))

	for _, m := range pending {
		t.Run(fmt.Sprintf("%03d_%s", m.version, m.name), func(t *testing.T) {
			body, err := migrationFS.ReadFile("migrations/" + m.filename)
			if err != nil {
				t.Fatalf("read %s: %v", m.filename, err)
			}
			tx, err := conn.Begin()
			if err != nil {
				t.Fatalf("begin: %v", err)
			}
			// Always rollback; the dry-run must not leave the scratch
			// DB at a different applied set than where it started.
			// Rollback is safe after a failed Exec — Postgres aborts
			// the transaction internally on the first error.
			defer func() { _ = tx.Rollback() }()

			if _, err := tx.Exec(string(body)); err != nil {
				t.Fatalf("migration %s failed dry-run: %v", m.filename, err)
			}
		})
	}
}

// TestMigrations_NoDuplicateSlot is a free-standing pre-flight check that
// scanEmbeddedMigrations refuses to walk a tree where two *.up.sql files
// claim the same NNN slot. This is the brunel-slot-collision class of
// outage (m/paliad#114, 2026-05-25 ~13:20): a worker writes a migration
// at slot N while another shipped slot N from a separate branch, both
// merge, both end up in the embed.FS, and the runner refuses to start.
//
// Catching this at CI time (no DB needed) lets the second PR fail before
// it merges, instead of breaking prod at the next deploy. Pure unit test;
// runs even on developer laptops that don't set TEST_DATABASE_URL.
func TestMigrations_NoDuplicateSlot(t *testing.T) {
	if _, err := scanEmbeddedMigrations(); err != nil {
		t.Fatalf("scanEmbeddedMigrations: %v "+
			"(two migrations share the same NNN slot — coordinate with head "+
			"and rename one of them before merging)", err)
	}
}

// TestMigrations_EndToEndAsAppRole applies every embedded migration in
// numeric order against a scratch DB connected as a NON-SUPERUSER role.
// This is the prod-shape smoke that the per-mig BEGIN/ROLLBACK dry-run
// (TestMigrations_DryRun) cannot deliver: the dry-run runs each
// statement in isolation and rolls back, so it cannot reproduce the
// mig-129-class outage (m/paliad#114, 2026-05-25 ~14:56 — pq: must be
// owner of table project_event_choices, SQLSTATE 42501) where a
// migration assumes ownership the deploy role doesn't have.
//
// Requires TEST_APP_DATABASE_URL — a Postgres URL whose role is NOT a
// superuser and does NOT own the `paliad` schema (m's Q11.2 pick:
// generic two-role model, see docs/design-cicd-pre-deploy-gate-2026-05-25.md
// §6.2(a)). The CI workflow creates the role + schema split before
// invoking the test; a developer who wants to reproduce the gate locally
// runs the same SQL preamble (see Makefile target `verify-migrations`).
//
// Skipped without TEST_APP_DATABASE_URL — keeps `go test ./...` green
// on machines that haven't set up the role split.
func TestMigrations_EndToEndAsAppRole(t *testing.T) {
	url := os.Getenv("TEST_APP_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_APP_DATABASE_URL not set — skipping role-split end-to-end migration smoke")
	}
	if err := ApplyMigrations(url); err != nil {
		t.Fatalf("ApplyMigrations as app role failed: %v "+
			"(a migration assumes more privilege than the deploy role has — "+
			"common cases: ALTER TABLE on a schema-owner table, CREATE EXTENSION "+
			"without grants, SET ROLE without permission. Fix the migration to "+
			"work as the deploy role, or arrange for the schema to be owned by "+
			"the deploy role)", err)
	}
}

// readAppliedVersions returns the set of versions present in
// paliad.applied_migrations on the scratch DB. Missing table → empty set
// (fresh-DB path; the table only exists after the runner has been called).
//
// We don't pre-create the table here because the dry-run is supposed to be
// a passive observer — it must not mutate the scratch DB outside of its
// own per-mig BEGIN/ROLLBACK probes. A "table doesn't exist" outcome is
// the right read against a virgin scratch DB.
func readAppliedVersions(conn *sql.DB) (map[int]bool, error) {
	rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations`)
	if err != nil {
		if strings.Contains(err.Error(), "does not exist") {
			return map[int]bool{}, nil
		}
		return nil, err
	}
	defer rows.Close()
	out := map[int]bool{}
	for rows.Next() {
		var v int
		if err := rows.Scan(&v); err != nil {
			return nil, err
		}
		out[v] = true
	}
	return out, rows.Err()
}
