// Package migration implements the v0.8→v0.11 data migration (REQ-066,
// gate C-07). The v0.8 layout is flat: a single orca.db, ca.crt/ca.key
// (internal CA), config.hcl, and server.crt all at the root of
// ORCA_HOME. The v0.11 layout (R-002) is multi-namespace: a cluster/
// subdir for cluster-wide artifacts and per-namespace db/, jobs/,
// alloc/, ns.md directories, with the implicit root namespace
// _defaults holding the migrated v0.8 database.
//
// Migratev08tov11 is the entrypoint. It is idempotent: running it
// twice is safe (the second run detects the v0.11 layout and skips).
package migration

import (
	"context"
	"database/sql"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"

	_ "modernc.org/sqlite"

	"git.cloudinit.dev/coreci/orca/internal/paths"
)

// MigrateOptions configures a v0.8→v0.11 migration run.
type MigrateOptions struct {
	SourceDir string
	TargetDir string
	ImportCA  bool
	DryRun    bool
}

// CAImporter is the seam for importing a v0.8 internal CA into
// step-ca. The CLI's upgrade command injects a real step-ca client;
// tests inject a mock. nil means CA import is skipped (the files are
// still preserved on disk).
type CAImporter interface {
	ImportCA(ctx context.Context, caCertPath, caKeyPath string) error
}

// caImporterOverride is the package-level test seam for the CA
// importer. When non-nil it replaces the production importer.
var caImporterOverride CAImporter

// v0.8 layout markers (files that live at the flat root in v0.8).
const (
	v08DBName     = "orca.db"
	v08CACertName = "ca.crt"
	v08CAKeyName  = "ca.key"
	v08ConfigName = "config.hcl"
)

// Detectv08 returns true if dir has v0.8 layout markers: an orca.db
// at the root AND ca.crt/ca.key (the internal CA). A dir that already
// has the v0.11 cluster/ layout is NOT a v0.8 layout.
func Detectv08(dir string) bool {
	if dir == "" {
		return false
	}
	dbPath := filepath.Join(dir, v08DBName)
	if !fileExists(dbPath) {
		return false
	}
	caCrt := filepath.Join(dir, v08CACertName)
	caKey := filepath.Join(dir, v08CAKeyName)
	if !fileExists(caCrt) || !fileExists(caKey) {
		return false
	}
	clusterDir := filepath.Join(dir, "cluster")
	if fileExists(clusterDir) {
		return false
	}
	return true
}

// Migratev08tov11 migrates a v0.8 flat layout to the v0.11
// multi-namespace layout. See the package doc for the full step list.
// Idempotent: if the target already has the v0.11 layout (cluster/
// present and the DB already moved), the call is a no-op.
func Migratev08tov11(opts MigrateOptions) error {
	src := opts.SourceDir
	tgt := opts.TargetDir
	if src == "" {
		src = paths.Root()
	}
	if tgt == "" {
		tgt = src
	}

	if !Detectv08(src) {
		slog.Info("migration: not a v0.8 layout, skipping", "dir", src)
		return nil
	}

	if alreadyMigrated(tgt) {
		slog.Info("migration: target already at v0.11 layout, skipping", "dir", tgt)
		return nil
	}

	slog.Info("migration: v0.8 layout detected", "source", src, "target", tgt, "dry_run", opts.DryRun, "import_ca", opts.ImportCA)

	if opts.DryRun {
		return dryRunReport(opts)
	}

	clusterDir := filepath.Join(tgt, "cluster")
	defaultsDir := filepath.Join(tgt, paths.DefaultNamespace())
	defaultsDBDir := filepath.Join(defaultsDir, "db")
	defaultsJobsDir := filepath.Join(defaultsDir, "jobs")
	defaultsAllocDir := filepath.Join(defaultsDir, "alloc")
	defaultsMd := filepath.Join(defaultsDir, "ns.md")

	for _, d := range []string{clusterDir, defaultsDBDir, defaultsJobsDir, defaultsAllocDir} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			return fmt.Errorf("migration: mkdir %s: %w", d, err)
		}
	}

	if !fileExists(defaultsMd) {
		if err := os.WriteFile(defaultsMd, defaultNamespaceMd(), 0o644); err != nil {
			return fmt.Errorf("migration: write ns.md: %w", err)
		}
	}

	srcDB := filepath.Join(src, v08DBName)
	tgtDB := filepath.Join(defaultsDBDir, v08DBName)
	if src != tgt {
		if err := copyFile(srcDB, tgtDB); err != nil {
			return fmt.Errorf("migration: move db: %w", err)
		}
		_ = os.Remove(srcDB)
	} else {
		if !fileExists(tgtDB) {
			if err := os.Rename(srcDB, tgtDB); err != nil {
				return fmt.Errorf("migration: rename db: %w", err)
			}
		}
	}

	if err := migrateDBSchema(tgtDB); err != nil {
		return fmt.Errorf("migration: schema: %w", err)
	}

	srcConfig := filepath.Join(src, v08ConfigName)
	legacyConfig := filepath.Join(clusterDir, "config.legacy.hcl")
	if fileExists(srcConfig) {
		if src != tgt {
			if err := copyFile(srcConfig, legacyConfig); err != nil {
				return fmt.Errorf("migration: move config.hcl: %w", err)
			}
		} else {
			if !fileExists(legacyConfig) {
				if err := os.Rename(srcConfig, legacyConfig); err != nil {
					return fmt.Errorf("migration: rename config.hcl: %w", err)
				}
			}
		}
	}

	caCrt := filepath.Join(src, v08CACertName)
	caKey := filepath.Join(src, v08CAKeyName)
	if fileExists(caCrt) && fileExists(caKey) && opts.ImportCA {
		importer := caImporterOverride
		if importer != nil {
			slog.Info("migration: importing v0.8 CA into step-ca (C-07)", "cert", caCrt)
			if err := importer.ImportCA(context.Background(), caCrt, caKey); err != nil {
				return fmt.Errorf("migration: import CA: %w", err)
			}
		} else {
			slog.Warn("migration: --import-ca requested but no CA importer available; preserving old CA files")
		}
	}

	slog.Info("migration: v0.8→v0.11 complete", "target", tgt)
	return nil
}

// alreadyMigrated returns true if the target dir already has the v0.11
// layout: cluster/ present AND _defaults/db/orca.db present.
func alreadyMigrated(dir string) bool {
	clusterDir := filepath.Join(dir, "cluster")
	defaultsDB := filepath.Join(dir, paths.DefaultNamespace(), "db", v08DBName)
	return fileExists(clusterDir) && fileExists(defaultsDB)
}

// migrateDBSchema opens the migrated DB and removes the `namespace`
// column from the nodes table if it exists (v0.8 dual-write window
// added it; v0.11 is single-namespace-per-DB). This mirrors the
// internal/store/migrate.go pattern but operates on a copied DB.
func migrateDBSchema(dbPath string) error {
	db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)")
	if err != nil {
		return fmt.Errorf("open %s: %w", dbPath, err)
	}
	defer db.Close()

	if err := db.Ping(); err != nil {
		return fmt.Errorf("ping %s: %w", dbPath, err)
	}

	hasCol, err := columnExists(db, "nodes", "namespace")
	if err != nil {
		return fmt.Errorf("check namespace column: %w", err)
	}
	if hasCol {
		slog.Info("migration: removing namespace column from nodes table")
		if _, err := db.Exec(`ALTER TABLE nodes DROP COLUMN namespace`); err != nil {
			return fmt.Errorf("drop namespace column: %w", err)
		}
	}

	if err := ensureSchemaMigrations(db); err != nil {
		return fmt.Errorf("ensure schema_migrations: %w", err)
	}

	return nil
}

// columnExists checks whether a column exists in a table using
// pragma_table_info.
func columnExists(db *sql.DB, table, column string) (bool, error) {
	q := fmt.Sprintf(`SELECT count(*) FROM pragma_table_info('%s') WHERE name = ?`, table)
	var n int
	if err := db.QueryRow(q, column).Scan(&n); err != nil {
		return false, err
	}
	return n > 0, nil
}

// ensureSchemaMigrations creates the schema_migrations table if it
// doesn't exist so the migrated DB is compatible with the v0.11
// store.Open migrate runner.
func ensureSchemaMigrations(db *sql.DB) error {
	_, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at DATETIME NOT NULL)`)
	return err
}

// dryRunReport logs what the migration would do without writing.
func dryRunReport(opts MigrateOptions) error {
	src := opts.SourceDir
	tgt := opts.TargetDir
	if src == "" {
		src = paths.Root()
	}
	if tgt == "" {
		tgt = src
	}
	slog.Info("migration: dry-run plan", "source", src, "target", tgt)
	slog.Info("  create cluster/ directory", "path", filepath.Join(tgt, "cluster"))
	slog.Info("  create _defaults/ namespace", "path", filepath.Join(tgt, paths.DefaultNamespace()))
	slog.Info("  move orca.db", "from", filepath.Join(src, v08DBName), "to", filepath.Join(tgt, paths.DefaultNamespace(), "db", v08DBName))
	if fileExists(filepath.Join(src, v08ConfigName)) {
		slog.Info("  move config.hcl", "from", filepath.Join(src, v08ConfigName), "to", filepath.Join(tgt, "cluster", "config.legacy.hcl"))
	}
	if fileExists(filepath.Join(src, v08CACertName)) && opts.ImportCA {
		slog.Info("  import CA into step-ca (C-07)", "cert", filepath.Join(src, v08CACertName), "key", filepath.Join(src, v08CAKeyName))
	}
	slog.Info("  migrate DB schema (remove namespace column if present)")
	return nil
}

// defaultNamespaceMd returns the ns.md content for the implicit root
// namespace _defaults.
func defaultNamespaceMd() []byte {
	return []byte("---\nname: _defaults\ndescription: \"Implicit root namespace (migrated from v0.8 flat layout)\"\n---\n\n# _defaults\n\nThe implicit root namespace. Created by the v0.8→v0.11 migration.\n")
}

// fileExists returns true if path exists (file or dir).
func fileExists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

// copyFile copies src to dst preserving the file mode.
// copyFile copies src to dst atomically (temp + rename). REQ-137/F19:
// a crash mid-copy must not leave a partial DB file.
func copyFile(src, dst string) error {
	data, err := os.ReadFile(src)
	if err != nil {
		return err
	}
	info, err := os.Stat(src)
	if err != nil {
		return err
	}
	tmp := dst + ".tmp"
	if err := os.WriteFile(tmp, data, info.Mode().Perm()); err != nil {
		return fmt.Errorf("copyFile: write tmp: %w", err)
	}
	return os.Rename(tmp, dst)
}

// GetCAImporter returns the package-level CA importer (set via
// SetCAImporter). Returns nil if none is configured. The CLI's
// upgrade command calls this to wire a real step-ca client.
func GetCAImporter() CAImporter {
	return caImporterOverride
}

// SetCAImporter configures the package-level CA importer. The CLI's
// upgrade command calls this before Migratev08tov11 when --import-ca is
// set; tests call it to inject a mock.
func SetCAImporter(i CAImporter) {
	caImporterOverride = i
}
