package store

import (
	"database/sql"
	"errors"
	"path/filepath"
	"testing"

	_ "modernc.org/sqlite"
)

// openRawDB opens a SQLite DB at a fresh tempfile without running any
// migrations, so tests can observe migration-runner behaviour directly.
func openRawDB(t *testing.T) *sql.DB {
	t.Helper()
	path := filepath.Join(t.TempDir(), "state.db")
	dsn, err := sqliteDSN(path)
	if err != nil {
		t.Fatalf("sqliteDSN: %v", err)
	}
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		t.Fatalf("sql.Open: %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	return db
}

func TestRunMigrationsAppliesBaselineOnFreshDB(t *testing.T) {
	db := openRawDB(t)
	if err := runMigrations(db); err != nil {
		t.Fatalf("runMigrations: %v", err)
	}
	// All declared migrations must be recorded.
	for _, m := range migrations {
		var got string
		if err := db.QueryRow("SELECT name FROM schema_migrations WHERE id = ?", m.id).Scan(&got); err != nil {
			t.Fatalf("migration %d not recorded: %v", m.id, err)
		}
		if got != m.name {
			t.Errorf("migration %d name = %q, want %q", m.id, got, m.name)
		}
	}
	// Baseline must have created the real tables.
	for _, table := range []string{"images", "vms"} {
		var name string
		if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name); err != nil {
			t.Fatalf("table %s missing: %v", table, err)
		}
	}
}

func TestRunMigrationsIsIdempotent(t *testing.T) {
	db := openRawDB(t)
	if err := runMigrations(db); err != nil {
		t.Fatalf("runMigrations first pass: %v", err)
	}
	if err := runMigrations(db); err != nil {
		t.Fatalf("runMigrations second pass: %v", err)
	}
	// One row per migration, no duplicates.
	var count int
	if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
		t.Fatalf("count: %v", err)
	}
	if count != len(migrations) {
		t.Errorf("schema_migrations rows = %d, want %d", count, len(migrations))
	}
}

func TestRunMigrationsSkipsAlreadyApplied(t *testing.T) {
	db := openRawDB(t)

	// Swap in a test-only migration whose body would error if invoked,
	// pre-insert its id into schema_migrations, and confirm the runner
	// recognises the marker and skips the body entirely.
	orig := migrations
	t.Cleanup(func() { migrations = orig })
	migrations = []migration{
		{id: 1, name: "baseline", up: migrateBaseline},
		{id: 99, name: "explodes-if-run", up: func(*sql.Tx) error {
			return errors.New("must not execute")
		}},
	}

	if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
		id INTEGER PRIMARY KEY,
		name TEXT NOT NULL,
		applied_at TEXT NOT NULL
	)`); err != nil {
		t.Fatalf("seed schema_migrations table: %v", err)
	}
	if _, err := db.Exec(
		"INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)",
		99, "explodes-if-run", "2026-04-20T00:00:00Z",
	); err != nil {
		t.Fatalf("seed applied row: %v", err)
	}

	if err := runMigrations(db); err != nil {
		t.Fatalf("runMigrations: %v", err)
	}
}

func TestApplyMigrationRollsBackOnBodyError(t *testing.T) {
	db := openRawDB(t)
	if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
		id INTEGER PRIMARY KEY,
		name TEXT NOT NULL,
		applied_at TEXT NOT NULL
	)`); err != nil {
		t.Fatalf("seed schema_migrations: %v", err)
	}

	err := applyMigration(db, migration{
		id:   7,
		name: "creates-then-fails",
		up: func(tx *sql.Tx) error {
			if _, err := tx.Exec("CREATE TABLE transient (x INTEGER)"); err != nil {
				return err
			}
			return errors.New("synthetic failure")
		},
	})
	if err == nil {
		t.Fatal("expected applyMigration to surface body error")
	}

	// The transient table must NOT survive the failed migration.
	var name string
	if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='transient'").Scan(&name); err == nil {
		t.Fatal("transient table survived rollback")
	}
	// And no schema_migrations row for id=7.
	var count int
	if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE id=7").Scan(&count); err != nil {
		t.Fatalf("count: %v", err)
	}
	if count != 0 {
		t.Fatalf("schema_migrations recorded failed migration: count=%d", count)
	}
}

// TestOpenReadOnlyDoesNotRunMigrations pins the doctor contract:
// OpenReadOnly must not mutate the DB. Seed a DB whose baseline
// migration row has been forcibly removed (simulating a "behind"
// state), open it read-only, and confirm nothing was re-applied.
func TestOpenReadOnlyDoesNotRunMigrations(t *testing.T) {
	path := filepath.Join(t.TempDir(), "state.db")
	full, err := Open(path)
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	if _, err := full.db.Exec("DELETE FROM schema_migrations WHERE id = 1"); err != nil {
		t.Fatalf("remove baseline marker: %v", err)
	}
	_ = full.Close()

	ro, err := OpenReadOnly(path)
	if err != nil {
		t.Fatalf("OpenReadOnly: %v", err)
	}
	defer ro.Close()

	var migCount int
	if err := ro.db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE id = 1").Scan(&migCount); err != nil {
		t.Fatalf("query schema_migrations: %v", err)
	}
	if migCount != 0 {
		t.Fatal("OpenReadOnly re-recorded a migration row — the open path mutated the DB")
	}
}

// TestOpenReadOnlyRefusesWrites confirms SQLite's mode=ro is in effect
// — no matter what a caller tries, writes are rejected at the driver
// level. Belt-and-braces guard against a future refactor that might
// plumb a write method through.
func TestOpenReadOnlyRefusesWrites(t *testing.T) {
	path := filepath.Join(t.TempDir(), "state.db")
	if s, err := Open(path); err != nil {
		t.Fatalf("seed Open: %v", err)
	} else {
		_ = s.Close()
	}
	ro, err := OpenReadOnly(path)
	if err != nil {
		t.Fatalf("OpenReadOnly: %v", err)
	}
	defer ro.Close()
	if _, err := ro.db.Exec("INSERT INTO schema_migrations (id, name, applied_at) VALUES (999, 'x', 'x')"); err == nil {
		t.Fatal("write succeeded against a read-only store")
	}
}

// TestRunMigrationsIgnoresUnknownAppliedIDs simulates an older banger
// opening a DB that was written by a newer version: schema_migrations
// carries rows with ids the current binary's migrations slice doesn't
// know about. The runner must leave those rows alone and still apply
// any of its own known migrations that haven't been recorded yet.
//
// Without this behaviour, upgrading forward then downgrading back
// (or running two daemon versions against the same state dir) would
// either fail outright or start destructively reinterpreting rows.
func TestRunMigrationsIgnoresUnknownAppliedIDs(t *testing.T) {
	db := openRawDB(t)

	// Bootstrap schema_migrations and pre-seed a row for a migration
	// id the current binary doesn't know. Use a high id so it's
	// clearly outside our slice.
	if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
		id INTEGER PRIMARY KEY,
		name TEXT NOT NULL,
		applied_at TEXT NOT NULL
	)`); err != nil {
		t.Fatalf("seed schema_migrations: %v", err)
	}
	if _, err := db.Exec(
		"INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)",
		9001, "from-the-future", "2099-01-01T00:00:00Z",
	); err != nil {
		t.Fatalf("seed future migration row: %v", err)
	}

	if err := runMigrations(db); err != nil {
		t.Fatalf("runMigrations: %v", err)
	}

	// The alien row is untouched.
	var name string
	if err := db.QueryRow("SELECT name FROM schema_migrations WHERE id = 9001").Scan(&name); err != nil {
		t.Fatalf("alien migration row disappeared: %v", err)
	}
	if name != "from-the-future" {
		t.Fatalf("alien row name = %q, want 'from-the-future'", name)
	}

	// Every known migration in our slice was applied — their rows
	// should exist too.
	for _, m := range migrations {
		var got string
		if err := db.QueryRow("SELECT name FROM schema_migrations WHERE id = ?", m.id).Scan(&got); err != nil {
			t.Fatalf("migration %d not recorded despite unknown alien row: %v", m.id, err)
		}
	}
}

// TestInspectSchemaStateCompatible pins the happy path: a fully-
// migrated DB reports SchemaCompatible.
func TestInspectSchemaStateCompatible(t *testing.T) {
	path := filepath.Join(t.TempDir(), "state.db")
	dsn, _ := sqliteDSN(path)
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		t.Fatalf("sql.Open: %v", err)
	}
	if err := runMigrations(db); err != nil {
		t.Fatalf("runMigrations: %v", err)
	}
	_ = db.Close()

	state, err := InspectSchemaState(path)
	if err != nil {
		t.Fatalf("InspectSchemaState: %v", err)
	}
	if state.Compatibility != SchemaCompatible {
		t.Fatalf("Compatibility = %d, want SchemaCompatible (state=%+v)", state.Compatibility, state)
	}
	if len(state.Pending) != 0 || len(state.Unknown) != 0 {
		t.Fatalf("expected empty pending/unknown; got %+v", state)
	}
}

// TestInspectSchemaStateMigrationsNeeded covers the "binary newer
// than DB" case: the DB has only the baseline, so migrations 2 and 3
// show up in Pending and Compatibility = SchemaMigrationsNeeded.
func TestInspectSchemaStateMigrationsNeeded(t *testing.T) {
	path := filepath.Join(t.TempDir(), "state.db")
	dsn, _ := sqliteDSN(path)
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		t.Fatalf("sql.Open: %v", err)
	}
	// Create just schema_migrations + record only id=1.
	if _, err := db.Exec(`CREATE TABLE schema_migrations (id INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil {
		t.Fatalf("create schema_migrations: %v", err)
	}
	if _, err := db.Exec(`INSERT INTO schema_migrations VALUES (1, 'baseline', '2026-01-01T00:00:00Z')`); err != nil {
		t.Fatalf("insert: %v", err)
	}
	_ = db.Close()

	state, err := InspectSchemaState(path)
	if err != nil {
		t.Fatalf("InspectSchemaState: %v", err)
	}
	if state.Compatibility != SchemaMigrationsNeeded {
		t.Fatalf("Compatibility = %d, want SchemaMigrationsNeeded (state=%+v)", state.Compatibility, state)
	}
	if len(state.Pending) == 0 {
		t.Fatal("expected non-empty pending list")
	}
}

// TestInspectSchemaStateIncompatible covers the "DB ahead of binary"
// case: the DB records migration id=99 that this binary doesn't
// know about. Compatibility = SchemaIncompatible; Unknown contains 99.
func TestInspectSchemaStateIncompatible(t *testing.T) {
	path := filepath.Join(t.TempDir(), "state.db")
	dsn, _ := sqliteDSN(path)
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		t.Fatalf("sql.Open: %v", err)
	}
	if err := runMigrations(db); err != nil {
		t.Fatalf("runMigrations: %v", err)
	}
	if _, err := db.Exec(`INSERT INTO schema_migrations VALUES (99, 'from_the_future', '2030-01-01T00:00:00Z')`); err != nil {
		t.Fatalf("insert future: %v", err)
	}
	_ = db.Close()

	state, err := InspectSchemaState(path)
	if err != nil {
		t.Fatalf("InspectSchemaState: %v", err)
	}
	if state.Compatibility != SchemaIncompatible {
		t.Fatalf("Compatibility = %d, want SchemaIncompatible (state=%+v)", state.Compatibility, state)
	}
	if len(state.Unknown) != 1 || state.Unknown[0] != 99 {
		t.Fatalf("Unknown = %v, want [99]", state.Unknown)
	}
}

// TestInspectSchemaStateMissingTable handles the fresh-install case:
// a DB file exists but schema_migrations doesn't (the file was created
// by something other than banger, or banger was halted before its
// first migration). Treat this as "all migrations pending".
func TestInspectSchemaStateMissingTable(t *testing.T) {
	path := filepath.Join(t.TempDir(), "state.db")
	dsn, _ := sqliteDSN(path)
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		t.Fatalf("sql.Open: %v", err)
	}
	if err := db.Ping(); err != nil {
		t.Fatalf("ping: %v", err)
	}
	_ = db.Close()

	state, err := InspectSchemaState(path)
	if err != nil {
		t.Fatalf("InspectSchemaState: %v", err)
	}
	if state.Compatibility != SchemaMigrationsNeeded {
		t.Fatalf("Compatibility = %d, want SchemaMigrationsNeeded (no schema_migrations table)", state.Compatibility)
	}
	if len(state.Pending) != len(migrations) {
		t.Fatalf("Pending = %v, want all %d migrations", state.Pending, len(migrations))
	}
}

func TestRunMigrationsRejectsDuplicateID(t *testing.T) {
	db := openRawDB(t)
	orig := migrations
	t.Cleanup(func() { migrations = orig })
	migrations = []migration{
		{id: 1, name: "first", up: func(*sql.Tx) error { return nil }},
		{id: 1, name: "dupe", up: func(*sql.Tx) error { return nil }},
	}
	err := runMigrations(db)
	if err == nil {
		t.Fatal("expected error for duplicate migration id")
	}
}
