// 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)
			}
		})
	}
}

// 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()
}
