package handlers

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"net/http"
	"testing"

	"armature/database"
	"armature/models"
	"armature/store"
)

// ═══════════════════════════════════════════════
// Upgrade Tests
//
// Schema edge cases, settings migration, and
// package compatibility across kernel upgrades.
// ═══════════════════════════════════════════════

// ── Schema Edge Cases ─────────────────────────

func TestUpgrade_SchemaAddIndex(t *testing.T) {
	stores := newTestStores(t)
	ctx := context.Background()

	// Install v1 with a table, no indexes
	seedPackage(t, stores, map[string]any{
		"id": "idx-pkg", "title": "Index Test", "type": "surface", "version": "1.0.0",
		"db_tables": map[string]any{
			"events": map[string]any{
				"columns": map[string]any{"name": "text", "category": "text"},
			},
		},
	})
	tables, _ := ParseDBTables(map[string]any{
		"db_tables": map[string]any{
			"events": map[string]any{
				"columns": map[string]any{"name": "text", "category": "text"},
			},
		},
	})
	CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "idx-pkg", tables)

	// Insert a row
	physical := extPhysicalTable("idx-pkg", "events")
	_, err := database.TestDB.ExecContext(ctx,
		fmt.Sprintf("INSERT INTO %s (id, name, category) VALUES ('row1', 'hello', 'test')", physical))
	if err != nil {
		t.Fatal(err)
	}

	// Update to v2 with an index on category
	router := setupUpdateRouter(t, stores)
	pkg := buildPkgBytes(t, map[string]any{
		"id": "idx-pkg", "title": "Index Test", "type": "surface", "version": "2.0.0",
		"db_tables": map[string]any{
			"events": map[string]any{
				"columns": map[string]any{"name": "text", "category": "text"},
				"indexes": []any{[]any{"category"}},
			},
		},
	})

	w := doUpdate(router, "idx-pkg", pkg)
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify data still intact
	var name string
	err = database.TestDB.QueryRowContext(ctx,
		fmt.Sprintf("SELECT name FROM %s WHERE id = 'row1'", physical)).Scan(&name)
	if err != nil {
		t.Fatal("data should survive index addition:", err)
	}
	if name != "hello" {
		t.Errorf("name = %q, want %q", name, "hello")
	}

	// Verify index exists by re-running the same update (idempotent)
	pkg2 := buildPkgBytes(t, map[string]any{
		"id": "idx-pkg", "title": "Index Test", "type": "surface", "version": "3.0.0",
		"db_tables": map[string]any{
			"events": map[string]any{
				"columns": map[string]any{"name": "text", "category": "text"},
				"indexes": []any{[]any{"category"}},
			},
		},
	})
	w2 := doUpdate(router, "idx-pkg", pkg2)
	if w2.Code != http.StatusOK {
		t.Fatalf("idempotent index re-apply failed: %d: %s", w2.Code, w2.Body.String())
	}
}

func TestUpgrade_SchemaAddColumnIdempotent(t *testing.T) {
	stores := newTestStores(t)
	ctx := context.Background()

	// Install v1 with columns name + priority
	seedPackage(t, stores, map[string]any{
		"id": "idem-pkg", "title": "Idempotent", "type": "surface", "version": "1.0.0",
		"db_tables": map[string]any{
			"items": map[string]any{
				"columns": map[string]any{"name": "text", "priority": "int"},
			},
		},
	})
	tables, _ := ParseDBTables(map[string]any{
		"db_tables": map[string]any{
			"items": map[string]any{
				"columns": map[string]any{"name": "text", "priority": "int"},
			},
		},
	})
	CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "idem-pkg", tables)

	// Insert a row
	physical := extPhysicalTable("idem-pkg", "items")
	_, err := database.TestDB.ExecContext(ctx,
		fmt.Sprintf("INSERT INTO %s (id, name, priority) VALUES ('r1', 'test', 5)", physical))
	if err != nil {
		t.Fatal(err)
	}

	// Update to v2 with same columns (no-op migration)
	router := setupUpdateRouter(t, stores)
	pkg := buildPkgBytes(t, map[string]any{
		"id": "idem-pkg", "title": "Idempotent", "type": "surface", "version": "2.0.0",
		"db_tables": map[string]any{
			"items": map[string]any{
				"columns": map[string]any{"name": "text", "priority": "int"},
			},
		},
	})

	w := doUpdate(router, "idem-pkg", pkg)
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify data intact
	var name string
	var priority int
	err = database.TestDB.QueryRowContext(ctx,
		fmt.Sprintf("SELECT name, priority FROM %s WHERE id = 'r1'", physical)).Scan(&name, &priority)
	if err != nil {
		t.Fatal(err)
	}
	if name != "test" || priority != 5 {
		t.Errorf("data changed: name=%q priority=%d", name, priority)
	}

	// Check response has no schema changes
	var resp map[string]any
	json.Unmarshal(w.Body.Bytes(), &resp)
	changes, _ := resp["changes"].([]any)
	if len(changes) != 0 {
		t.Errorf("expected no schema changes for identical columns, got %v", changes)
	}
}

func TestUpgrade_SchemaMultiColumnAdd(t *testing.T) {
	stores := newTestStores(t)
	ctx := context.Background()

	// Install v1 with one column
	seedPackage(t, stores, map[string]any{
		"id": "multi-pkg", "title": "Multi", "type": "surface", "version": "1.0.0",
		"db_tables": map[string]any{
			"records": map[string]any{
				"columns": map[string]any{"name": "text"},
			},
		},
	})
	tables, _ := ParseDBTables(map[string]any{
		"db_tables": map[string]any{
			"records": map[string]any{
				"columns": map[string]any{"name": "text"},
			},
		},
	})
	CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "multi-pkg", tables)

	// Insert a row
	physical := extPhysicalTable("multi-pkg", "records")
	_, err := database.TestDB.ExecContext(ctx,
		fmt.Sprintf("INSERT INTO %s (id, name) VALUES ('r1', 'hello')", physical))
	if err != nil {
		t.Fatal(err)
	}

	// Update to v2 with three new columns
	router := setupUpdateRouter(t, stores)
	pkg := buildPkgBytes(t, map[string]any{
		"id": "multi-pkg", "title": "Multi", "type": "surface", "version": "2.0.0",
		"db_tables": map[string]any{
			"records": map[string]any{
				"columns": map[string]any{
					"name":     "text",
					"priority": "int",
					"score":    "real",
					"active":   "bool",
				},
			},
		},
	})

	w := doUpdate(router, "multi-pkg", pkg)
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify old row preserved with NULLs for new columns
	var name string
	var priority sql.NullInt64
	var score sql.NullFloat64
	var active sql.NullInt64 // bool is INTEGER on SQLite
	err = database.TestDB.QueryRowContext(ctx,
		fmt.Sprintf("SELECT name, priority, score, active FROM %s WHERE id = 'r1'", physical)).
		Scan(&name, &priority, &score, &active)
	if err != nil {
		t.Fatal("query failed after multi-column add:", err)
	}
	if name != "hello" {
		t.Errorf("name = %q, want %q", name, "hello")
	}
	if priority.Valid {
		t.Error("priority should be NULL for existing row")
	}
	if score.Valid {
		t.Error("score should be NULL for existing row")
	}

	// Verify new row can use all columns
	// Use 'true' for bool — works on both Postgres (BOOLEAN) and SQLite (INTEGER, coerced to 1)
	_, err = database.TestDB.ExecContext(ctx,
		fmt.Sprintf("INSERT INTO %s (id, name, priority, score, active) VALUES ('r2', 'new', 3, 4.5, true)", physical))
	if err != nil {
		t.Fatal("insert with new columns failed:", err)
	}

	// Check response lists schema changes
	var resp map[string]any
	json.Unmarshal(w.Body.Bytes(), &resp)
	changes, _ := resp["changes"].([]any)
	if len(changes) < 3 {
		t.Errorf("expected at least 3 schema changes, got %d: %v", len(changes), changes)
	}
}

// ── Settings Migration ────────────────────────

func TestUpgrade_SettingsPreservedAcrossUpdate(t *testing.T) {
	stores := newTestStores(t)
	ctx := context.Background()

	// Seed a user and team (FK constraints require them)
	user := &models.User{
		Username:     "testuser",
		PasswordHash: "hash",
		IsActive:     true,
		AuthSource:   "builtin",
	}
	if err := stores.Users.Create(ctx, user); err != nil {
		t.Fatalf("seed user: %v", err)
	}
	userID := user.ID

	team := &models.Team{
		Name:      "Test Team",
		CreatedBy: userID,
		IsActive:  true,
	}
	if err := stores.Teams.Create(ctx, team); err != nil {
		t.Fatalf("seed team: %v", err)
	}
	teamID := team.ID

	// Install with settings schema
	seedPackage(t, stores, map[string]any{
		"id": "set-pkg", "title": "Settings", "type": "surface", "version": "1.0.0",
		"settings": []any{
			map[string]any{"key": "theme", "type": "text", "default": "light"},
			map[string]any{"key": "limit", "type": "int", "default": float64(10)},
		},
	})

	// Set global settings
	globalSettings := json.RawMessage(`{"theme":"dark","limit":25}`)
	stores.Packages.SetPackageSettings(ctx, "set-pkg", globalSettings)

	// Set team settings
	teamSettings := json.RawMessage(`{"theme":"ocean"}`)
	if err := stores.Packages.SetTeamSettings(ctx, "set-pkg", teamID, teamSettings); err != nil {
		t.Fatalf("SetTeamSettings failed: %v", err)
	}

	// Set user settings
	if err := stores.Packages.SetUserSettings(ctx, &store.PackageUserSettings{
		PackageID: "set-pkg",
		UserID:    userID,
		Settings:  json.RawMessage(`{"limit":50}`),
		IsEnabled: true,
	}); err != nil {
		t.Fatalf("SetUserSettings failed: %v", err)
	}

	// Update package to v2 with one new setting, one removed
	router := setupUpdateRouter(t, stores)
	pkg := buildPkgBytes(t, map[string]any{
		"id": "set-pkg", "title": "Settings", "type": "surface", "version": "2.0.0",
		"settings": []any{
			map[string]any{"key": "theme", "type": "text", "default": "light"},
			// "limit" removed from schema
			map[string]any{"key": "font_size", "type": "int", "default": float64(14)},
		},
	})

	w := doUpdate(router, "set-pkg", pkg)
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify global settings: theme preserved, limit preserved (not deleted), font_size added
	raw, _ := stores.Packages.GetPackageSettings(ctx, "set-pkg")
	var global map[string]any
	json.Unmarshal(raw, &global)

	if global["theme"] != "dark" {
		t.Errorf("global theme = %v, want %q", global["theme"], "dark")
	}
	if global["limit"] != float64(25) {
		t.Errorf("global limit = %v, want %v (removed key should be preserved)", global["limit"], 25)
	}
	if global["font_size"] != float64(14) {
		t.Errorf("global font_size = %v, want %v (new key should get default)", global["font_size"], 14)
	}

	// Verify team settings: untouched by package update
	teamRaw, _ := stores.Packages.GetTeamSettings(ctx, "set-pkg", teamID)
	var teamSet map[string]any
	json.Unmarshal(teamRaw, &teamSet)
	if teamSet["theme"] != "ocean" {
		t.Errorf("team theme = %v, want %q (should survive package update)", teamSet["theme"], "ocean")
	}

	// Verify user settings: untouched by package update
	pus, _ := stores.Packages.GetUserSettings(ctx, "set-pkg", userID)
	if pus == nil {
		t.Fatal("user settings should survive package update")
	}
	var userSet map[string]any
	json.Unmarshal(pus.Settings, &userSet)
	if userSet["limit"] != float64(50) {
		t.Errorf("user limit = %v, want %v (should survive package update)", userSet["limit"], 50)
	}
}

func TestUpgrade_SettingsNewKeyGetsDefault(t *testing.T) {
	stores := newTestStores(t)
	ctx := context.Background()

	// Install with no settings
	seedPackage(t, stores, map[string]any{
		"id": "newkey-pkg", "title": "NewKey", "type": "surface", "version": "1.0.0",
	})

	// Update to v2 with settings
	router := setupUpdateRouter(t, stores)
	pkg := buildPkgBytes(t, map[string]any{
		"id": "newkey-pkg", "title": "NewKey", "type": "surface", "version": "2.0.0",
		"settings": []any{
			map[string]any{"key": "color", "type": "text", "default": "blue"},
			map[string]any{"key": "count", "type": "int", "default": float64(5)},
		},
	})

	w := doUpdate(router, "newkey-pkg", pkg)
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}

	raw, _ := stores.Packages.GetPackageSettings(ctx, "newkey-pkg")
	var settings map[string]any
	json.Unmarshal(raw, &settings)

	if settings["color"] != "blue" {
		t.Errorf("color = %v, want %q", settings["color"], "blue")
	}
	if settings["count"] != float64(5) {
		t.Errorf("count = %v, want %v", settings["count"], 5)
	}
}

// ── Package Compatibility ─────────────────────

func TestUpgrade_BundledSkipExistingOnRestart(t *testing.T) {
	stores := newTestStores(t)
	ctx := context.Background()

	bundledDir := t.TempDir()
	packagesDir := t.TempDir()

	// Build a bundled package at v1.0.0
	buildTestPkg(t, bundledDir, map[string]any{
		"id": "restart-pkg", "title": "Restart", "type": "surface", "version": "1.0.0",
	})

	// First install
	InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)

	pkg, _ := stores.Packages.Get(ctx, "restart-pkg")
	if pkg == nil {
		t.Fatal("package should be installed on first run")
	}
	if pkg.Version != "1.0.0" {
		t.Errorf("version = %q, want %q", pkg.Version, "1.0.0")
	}

	// Simulate restart: bundled dir now has v2.0.0
	buildTestPkg(t, bundledDir, map[string]any{
		"id": "restart-pkg", "title": "Restart Updated", "type": "surface", "version": "2.0.0",
	})

	// Second install — should skip because package exists
	InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)

	pkg, _ = stores.Packages.Get(ctx, "restart-pkg")
	if pkg.Version != "1.0.0" {
		t.Errorf("version = %q, want %q (should NOT be upgraded by re-run)", pkg.Version, "1.0.0")
	}
	if pkg.Title != "Restart" {
		t.Errorf("title = %q, want %q (should NOT be overwritten)", pkg.Title, "Restart")
	}
}

func TestUpgrade_PackageDormantOnUnmetRequires(t *testing.T) {
	stores := newTestStores(t)
	ctx := context.Background()

	bundledDir := t.TempDir()
	packagesDir := t.TempDir()

	// Package requires a capability that doesn't exist
	buildTestPkg(t, bundledDir, map[string]any{
		"id":       "future-pkg",
		"title":    "Future",
		"type":     "extension",
		"version":  "1.0.0",
		"hooks":    []string{"on_install"},
		"requires": []string{"kernel>=99.0.0"},
	})

	InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)

	pkg, _ := stores.Packages.Get(ctx, "future-pkg")
	if pkg == nil {
		t.Fatal("package should still be registered")
	}
	if pkg.Status != "dormant" {
		t.Errorf("status = %q, want %q", pkg.Status, "dormant")
	}
	if pkg.Enabled {
		t.Error("dormant package should not be enabled")
	}
}

func TestUpgrade_PackageRoutesAfterVersionBump(t *testing.T) {
	stores := newTestStores(t)
	ctx := context.Background()

	// Install a surface at v1
	seedPackage(t, stores, map[string]any{
		"id": "route-pkg", "title": "Route Test", "type": "surface", "version": "1.0.0",
	})

	// Update to v2
	router := setupUpdateRouter(t, stores)
	pkg := buildPkgBytes(t, map[string]any{
		"id": "route-pkg", "title": "Route Test", "type": "surface", "version": "2.0.0",
	})

	w := doUpdate(router, "route-pkg", pkg)
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}

	// Verify package still enabled and type unchanged
	updated, _ := stores.Packages.Get(ctx, "route-pkg")
	if updated == nil {
		t.Fatal("package should exist after update")
	}
	if !updated.Enabled {
		t.Error("package should remain enabled after update")
	}
	if updated.Type != "surface" {
		t.Errorf("type = %q, want %q", updated.Type, "surface")
	}
	if updated.Version != "2.0.0" {
		t.Errorf("version = %q, want %q", updated.Version, "2.0.0")
	}
}

// ── MigrateExtTables direct tests ─────────────

func TestUpgrade_MigrateExtTables_NewTable(t *testing.T) {
	stores := newTestStores(t)
	ctx := context.Background()

	// Register package with no tables initially
	seedPackage(t, stores, map[string]any{
		"id": "migrate-new", "title": "Migrate", "type": "surface", "version": "1.0.0",
	})

	// Migrate with a brand-new table
	newTables := map[string]TableDef{
		"logs": {
			Columns: map[string]string{"message": "text", "level": "text"},
			Indexes: [][]string{{"level"}},
		},
	}

	changes, err := MigrateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-new", newTables)
	if err != nil {
		t.Fatal(err)
	}

	if len(changes) == 0 {
		t.Error("expected at least 1 change for new table creation")
	}

	// Verify table works
	physical := extPhysicalTable("migrate-new", "logs")
	_, err = database.TestDB.ExecContext(ctx,
		fmt.Sprintf("INSERT INTO %s (id, message, level) VALUES ('1', 'test', 'info')", physical))
	if err != nil {
		t.Fatal("new table should be usable:", err)
	}
}

func TestUpgrade_MigrateExtTables_AddColumnPreservesRows(t *testing.T) {
	stores := newTestStores(t)
	ctx := context.Background()

	// Create initial table with data
	seedPackage(t, stores, map[string]any{
		"id": "migrate-col", "title": "Migrate", "type": "surface", "version": "1.0.0",
		"db_tables": map[string]any{
			"records": map[string]any{
				"columns": map[string]any{"title": "text"},
			},
		},
	})
	tables, _ := ParseDBTables(map[string]any{
		"db_tables": map[string]any{
			"records": map[string]any{
				"columns": map[string]any{"title": "text"},
			},
		},
	})
	CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-col", tables)

	physical := extPhysicalTable("migrate-col", "records")
	_, err := database.TestDB.ExecContext(ctx,
		fmt.Sprintf("INSERT INTO %s (id, title) VALUES ('r1', 'original')", physical))
	if err != nil {
		t.Fatal(err)
	}

	// Migrate adding a column
	newTables := map[string]TableDef{
		"records": {
			Columns: map[string]string{"title": "text", "status": "text"},
		},
	}
	changes, err := MigrateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-col", newTables)
	if err != nil {
		t.Fatal(err)
	}

	found := false
	for _, c := range changes {
		if c == "added column 'status' to "+physical {
			found = true
		}
	}
	if !found {
		t.Errorf("expected change for 'status' column, got %v", changes)
	}

	// Verify old row intact
	var title string
	var status sql.NullString
	err = database.TestDB.QueryRowContext(ctx,
		fmt.Sprintf("SELECT title, status FROM %s WHERE id = 'r1'", physical)).Scan(&title, &status)
	if err != nil {
		t.Fatal(err)
	}
	if title != "original" {
		t.Errorf("title = %q, want %q", title, "original")
	}
	if status.Valid {
		t.Error("status should be NULL for existing row")
	}
}

func TestUpgrade_MigrateExtTables_IndexIdempotent(t *testing.T) {
	stores := newTestStores(t)
	ctx := context.Background()

	seedPackage(t, stores, map[string]any{
		"id": "migrate-idx", "title": "Migrate", "type": "surface", "version": "1.0.0",
		"db_tables": map[string]any{
			"items": map[string]any{
				"columns": map[string]any{"name": "text", "category": "text"},
				"indexes": []any{[]any{"category"}},
			},
		},
	})
	tables, _ := ParseDBTables(map[string]any{
		"db_tables": map[string]any{
			"items": map[string]any{
				"columns": map[string]any{"name": "text", "category": "text"},
				"indexes": []any{[]any{"category"}},
			},
		},
	})
	CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-idx", tables)

	// Migrate with same index — should not fail
	newTables := map[string]TableDef{
		"items": {
			Columns: map[string]string{"name": "text", "category": "text"},
			Indexes: [][]string{{"category"}},
		},
	}
	_, err := MigrateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-idx", newTables)
	if err != nil {
		t.Fatal("idempotent index migration should not fail:", err)
	}

	// Run again — still no error
	_, err = MigrateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-idx", newTables)
	if err != nil {
		t.Fatal("second idempotent index migration should not fail:", err)
	}
}
