package store

import (
	"database/sql"
	"fmt"
	"sort"
	"time"
)

// migration is one ordered, atomic schema step. id must be unique and
// strictly increasing across the slice. name is a human-readable label
// stored alongside the id for debugging, and up receives a *sql.Tx so
// DDL + data backfills land atomically — either the migration fully
// applies and a schema_migrations row is written, or the whole thing
// rolls back and gets retried on next Open().
type migration struct {
	id   int
	name string
	up   func(*sql.Tx) error
}

// migrations is the canonical ordered history. Append new migrations
// at the bottom with the next id. Never edit or reorder existing
// entries — installed DBs key off the id column.
var migrations = []migration{
	{id: 1, name: "baseline", up: migrateBaseline},
	{id: 2, name: "drop_images_docker", up: migrateDropImagesDocker},
	{id: 3, name: "add_vm_workspace", up: migrateAddVMWorkspace},
}

// runMigrations ensures schema_migrations exists, then applies every
// migration whose id hasn't been recorded yet, in id order.
func runMigrations(db *sql.DB) error {
	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 {
		return fmt.Errorf("create schema_migrations: %w", err)
	}

	applied, err := loadAppliedMigrations(db)
	if err != nil {
		return err
	}

	sorted := make([]migration, len(migrations))
	copy(sorted, migrations)
	sort.Slice(sorted, func(i, j int) bool { return sorted[i].id < sorted[j].id })
	seen := map[int]bool{}
	for _, m := range sorted {
		if seen[m.id] {
			return fmt.Errorf("duplicate migration id %d (%q)", m.id, m.name)
		}
		seen[m.id] = true
	}

	for _, m := range sorted {
		if _, ok := applied[m.id]; ok {
			continue
		}
		if err := applyMigration(db, m); err != nil {
			return fmt.Errorf("migration %d (%s): %w", m.id, m.name, err)
		}
	}
	return nil
}

// SchemaCompatibility classifies the relationship between this
// binary's known migrations and a (possibly stale) DB's applied set.
type SchemaCompatibility int

const (
	// SchemaCompatible: every applied id is known to this binary AND
	// every known id has been applied. Binary and DB are in lockstep.
	SchemaCompatible SchemaCompatibility = iota
	// SchemaMigrationsNeeded: binary knows ids the DB hasn't applied
	// yet. Open() would auto-migrate; safe.
	SchemaMigrationsNeeded
	// SchemaIncompatible: DB has applied ids this binary doesn't
	// know about. Binary is older than the running install. Refuse
	// the swap.
	SchemaIncompatible
)

// SchemaState describes the migration status of a DB relative to
// this binary's compiled-in `migrations` slice. Used by
// `bangerd --check-migrations` to gate `banger update`'s binary swap
// before service restart — a staged binary must not be allowed to
// take over a DB whose schema it doesn't know how to read.
type SchemaState struct {
	Compatibility SchemaCompatibility
	AppliedIDs    []int
	KnownMaxID    int
	Pending       []int // known IDs not yet applied
	Unknown       []int // applied IDs the binary doesn't recognise
}

// InspectSchemaState opens path read-only and reports how the DB's
// applied-migration set compares to the binary's known set. Returns
// an error only on real I/O failures (file missing, permission
// denied, corrupt SQLite); a "DB ahead of binary" state is reported
// via Compatibility, not as an error.
func InspectSchemaState(path string) (SchemaState, error) {
	dsn, err := sqliteReadOnlyDSN(path)
	if err != nil {
		return SchemaState{}, err
	}
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		return SchemaState{}, err
	}
	defer db.Close()
	if err := db.Ping(); err != nil {
		return SchemaState{}, err
	}
	// schema_migrations may not exist on a fresh install. Treat that
	// as "applied = ∅" rather than an error — the equivalent of
	// "the new binary will create the table on first Open".
	rows, err := db.Query("SELECT id FROM schema_migrations")
	if err != nil {
		// modernc.org/sqlite doesn't expose a typed "no such table"
		// error; sniff the message. Anything else bubbles.
		if errMissingTable(err) {
			return classifySchemaState(nil), nil
		}
		return SchemaState{}, err
	}
	defer rows.Close()
	var applied []int
	for rows.Next() {
		var id int
		if err := rows.Scan(&id); err != nil {
			return SchemaState{}, err
		}
		applied = append(applied, id)
	}
	if err := rows.Err(); err != nil {
		return SchemaState{}, err
	}
	return classifySchemaState(applied), nil
}

func classifySchemaState(applied []int) SchemaState {
	known := map[int]struct{}{}
	knownMax := 0
	for _, m := range migrations {
		known[m.id] = struct{}{}
		if m.id > knownMax {
			knownMax = m.id
		}
	}
	appliedSet := map[int]struct{}{}
	var unknown []int
	for _, id := range applied {
		appliedSet[id] = struct{}{}
		if _, ok := known[id]; !ok {
			unknown = append(unknown, id)
		}
	}
	var pending []int
	for _, m := range migrations {
		if _, ok := appliedSet[m.id]; !ok {
			pending = append(pending, m.id)
		}
	}
	state := SchemaState{
		AppliedIDs: append([]int(nil), applied...),
		KnownMaxID: knownMax,
		Pending:    pending,
		Unknown:    unknown,
	}
	switch {
	case len(unknown) > 0:
		state.Compatibility = SchemaIncompatible
	case len(pending) > 0:
		state.Compatibility = SchemaMigrationsNeeded
	default:
		state.Compatibility = SchemaCompatible
	}
	return state
}

func errMissingTable(err error) bool {
	if err == nil {
		return false
	}
	msg := err.Error()
	// modernc.org/sqlite wraps the underlying SQLITE_ERROR with this
	// canonical sub-string for missing-table errors.
	return contains(msg, "no such table: schema_migrations")
}

func contains(s, sub string) bool {
	if len(sub) > len(s) {
		return false
	}
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return true
		}
	}
	return false
}

func loadAppliedMigrations(db *sql.DB) (map[int]struct{}, error) {
	rows, err := db.Query("SELECT id FROM schema_migrations")
	if err != nil {
		return nil, fmt.Errorf("load schema_migrations: %w", err)
	}
	defer rows.Close()
	applied := map[int]struct{}{}
	for rows.Next() {
		var id int
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		applied[id] = struct{}{}
	}
	return applied, rows.Err()
}

func applyMigration(db *sql.DB, m migration) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	if err := m.up(tx); err != nil {
		_ = tx.Rollback()
		return err
	}
	if _, err := tx.Exec(
		"INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)",
		m.id, m.name, time.Now().UTC().Format(time.RFC3339),
	); err != nil {
		_ = tx.Rollback()
		return fmt.Errorf("record migration: %w", err)
	}
	return tx.Commit()
}

// migrateBaseline creates the full current schema.
func migrateBaseline(tx *sql.Tx) error {
	stmts := []string{
		`CREATE TABLE IF NOT EXISTS images (
			id TEXT PRIMARY KEY,
			name TEXT NOT NULL UNIQUE,
			managed INTEGER NOT NULL DEFAULT 0,
			artifact_dir TEXT,
			rootfs_path TEXT NOT NULL,
			work_seed_path TEXT,
			kernel_path TEXT NOT NULL,
			initrd_path TEXT,
			modules_dir TEXT,
			build_size TEXT,
			seeded_ssh_public_key_fingerprint TEXT,
			docker INTEGER NOT NULL DEFAULT 0,
			created_at TEXT NOT NULL,
			updated_at TEXT NOT NULL
		);`,
		`CREATE TABLE IF NOT EXISTS vms (
			id TEXT PRIMARY KEY,
			name TEXT NOT NULL UNIQUE,
			image_id TEXT NOT NULL,
			guest_ip TEXT NOT NULL UNIQUE,
			state TEXT NOT NULL,
			created_at TEXT NOT NULL,
			updated_at TEXT NOT NULL,
			last_touched_at TEXT NOT NULL,
			spec_json TEXT NOT NULL,
			runtime_json TEXT NOT NULL,
			stats_json TEXT NOT NULL DEFAULT '{}',
			FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT
		);`,
	}
	for _, stmt := range stmts {
		if _, err := tx.Exec(stmt); err != nil {
			return err
		}
	}
	return nil
}

// migrateDropImagesDocker removes the legacy images.docker column.
// SQLite supports ALTER TABLE ... DROP COLUMN since 3.35 (2021), and
// banger ships against modern SQLite, so a single statement is enough.
// Existing values are simply discarded — the field never affected
// runtime behaviour.
func migrateDropImagesDocker(tx *sql.Tx) error {
	_, err := tx.Exec(`ALTER TABLE images DROP COLUMN docker;`)
	return err
}

// migrateAddVMWorkspace adds the workspace_json column that records
// the last workspace.prepare result (guest path, host source path,
// HEAD commit, and timestamp) per VM. Default '{}' means no workspace
// has been prepared yet. The column is managed exclusively via
// Store.SetVMWorkspace; lifecycle UpsertVM calls never touch it so
// workspace state survives VM stop/start cycles.
func migrateAddVMWorkspace(tx *sql.Tx) error {
	_, err := tx.Exec(`ALTER TABLE vms ADD COLUMN workspace_json TEXT NOT NULL DEFAULT '{}'`)
	return err
}
