package db

import (
	"context"
	"database/sql"
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"ymhut-box/server/unified-management/internal/config"
)

func TestOpenImportsJSONPrototypeIntoSQLite(t *testing.T) {
	root := t.TempDir()
	path := filepath.Join(root, "unified.sqlite")
	prototype := state{
		Admins: []adminRow{{
			ID:              1,
			Username:        "admin",
			PasswordHash:    passwordHash("admin"),
			PasswordChanged: false,
			CreatedAt:       "2026-01-01T00:00:00Z",
			UpdatedAt:       "2026-01-01T00:00:00Z",
		}},
		Feedbacks: []Feedback{{Code: "FB-20260101-ABCDEF", Title: "Imported", Type: "issue", Severity: "normal", Body: "hello"}},
		Sources:   []Source{{CategoryID: "ip", CategoryName: "IP", SourceID: "ip-demo", Name: "IP Demo", APIURL: "https://example.com/ip", Enabled: true, ClientVisible: true}},
	}
	data, err := json.Marshal(prototype)
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, data, 0o640); err != nil {
		t.Fatal(err)
	}
	store, err := Open(&config.Config{
		StorageDir: root,
		Database: config.DatabaseConfig{
			Provider:               "sqlite",
			SQLitePath:             path,
			FailoverEnabled:        true,
			HealthIntervalSec:      3600,
			MaxOpenConns:           1,
			MaxIdleConns:           1,
			ConnMaxLifetimeSeconds: 60,
		},
		UploadGuard: config.UploadGuardConfig{MaxZipFiles: 80, MaxDecompressedBytes: 30 << 20, MaxSingleFileBytes: 8 << 20, MaxCompressionRatio: 120, MaxReadableTextBytes: 256 << 10, AllowUnexpectedZipFiles: true},
	})
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if _, _, err := store.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil {
		t.Fatal(err)
	}
	if _, err := store.GetFeedback("FB-20260101-ABCDEF"); err != nil {
		t.Fatal(err)
	}
	if count, err := store.CountSources(); err != nil || count != 1 {
		t.Fatalf("CountSources = %d, %v", count, err)
	}
	matches, _ := filepath.Glob(path + ".json-prototype-*.bak")
	if len(matches) != 1 {
		t.Fatalf("expected prototype backup, got %v", matches)
	}
}

func TestVerifyAdminPasswordUsesLocalSQLiteWhenRemoteIsUnavailable(t *testing.T) {
	root := t.TempDir()
	path := filepath.Join(root, "unified.sqlite")
	store, err := Open(&config.Config{
		StorageDir: root,
		Database: config.DatabaseConfig{
			Provider:               "sqlite",
			SQLitePath:             path,
			FailoverEnabled:        true,
			HealthIntervalSec:      3600,
			MaxOpenConns:           1,
			MaxIdleConns:           1,
			ConnMaxLifetimeSeconds: 60,
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
		t.Fatal(err)
	}

	remote, err := sql.Open("sqlite", filepath.Join(root, "closed-remote.sqlite"))
	if err != nil {
		t.Fatal(err)
	}
	_ = remote.Close()
	store.cfg.Database.Provider = "mysql"
	store.mu.Lock()
	store.remoteDB = remote
	store.remoteDialect = dialectFor("sqlite")
	store.db = remote
	store.dialect = store.remoteDialect
	store.status.ActiveProvider = "mysql"
	store.status.ConfigProvider = "mysql"
	store.mu.Unlock()

	if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil || !ok {
		t.Fatalf("VerifyAdminPassword local priority failed, ok=%v err=%v", ok, err)
	}
}

func TestOpenRecordsCurrentSchemaVersion(t *testing.T) {
	root := t.TempDir()
	path := filepath.Join(root, "unified.sqlite")
	store, err := Open(&config.Config{
		StorageDir: root,
		Database: config.DatabaseConfig{
			Provider:               "sqlite",
			SQLitePath:             path,
			FailoverEnabled:        true,
			HealthIntervalSec:      3600,
			MaxOpenConns:           1,
			MaxIdleConns:           1,
			ConnMaxLifetimeSeconds: 60,
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()

	var description string
	if err := store.localDB.QueryRow(`SELECT description FROM schema_migrations WHERE version = ?`, CurrentSchemaVersion).Scan(&description); err != nil {
		t.Fatal(err)
	}
	if description == "" {
		t.Fatal("schema version description is empty")
	}
}

func TestChangeAdminPasswordPersistsWhenRemoteSyncFails(t *testing.T) {
	root := t.TempDir()
	path := filepath.Join(root, "unified.sqlite")
	store, err := Open(&config.Config{
		StorageDir: root,
		Database: config.DatabaseConfig{
			Provider:               "sqlite",
			SQLitePath:             path,
			FailoverEnabled:        true,
			HealthIntervalSec:      3600,
			MaxOpenConns:           1,
			MaxIdleConns:           1,
			ConnMaxLifetimeSeconds: 60,
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
		t.Fatal(err)
	}

	remote, err := sql.Open("sqlite", filepath.Join(root, "closed-remote-password.sqlite"))
	if err != nil {
		t.Fatal(err)
	}
	_ = remote.Close()
	store.cfg.Database.Provider = "mysql"
	store.mu.Lock()
	store.remoteDB = remote
	store.remoteDialect = dialectFor("sqlite")
	store.status.ConfigProvider = "mysql"
	store.mu.Unlock()

	warning, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "admin", "new-local-password")
	if err != nil {
		t.Fatal(err)
	}
	if warning == "" {
		t.Fatal("expected remote sync warning")
	}
	if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "new-local-password"); err != nil || !ok {
		t.Fatalf("new password was not persisted locally, ok=%v err=%v", ok, err)
	}
	if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil || ok {
		t.Fatalf("old password still works, ok=%v err=%v", ok, err)
	}
}

func TestChangeAdminPasswordAcceptsRemoteCurrentPasswordAndPersistsLocal(t *testing.T) {
	root := t.TempDir()
	path := filepath.Join(root, "unified.sqlite")
	store, err := Open(&config.Config{
		StorageDir: root,
		Database: config.DatabaseConfig{
			Provider:               "sqlite",
			SQLitePath:             path,
			FailoverEnabled:        true,
			HealthIntervalSec:      3600,
			MaxOpenConns:           1,
			MaxIdleConns:           1,
			ConnMaxLifetimeSeconds: 60,
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
		t.Fatal(err)
	}

	remote, err := sql.Open("sqlite", filepath.Join(root, "remote-password.sqlite"))
	if err != nil {
		t.Fatal(err)
	}
	remoteDialect := dialectFor("sqlite")
	if err := store.migrate(remote, remoteDialect); err != nil {
		t.Fatal(err)
	}
	if err := store.ensureDefaultAdminOn(remote, remoteDialect); err != nil {
		t.Fatal(err)
	}
	if err := store.changeAdminPasswordOn(remote, remoteDialect, "admin", passwordHash("remote-current-password"), Now(), false); err != nil {
		t.Fatal(err)
	}
	defer remote.Close()

	store.cfg.Database.Provider = "mysql"
	store.mu.Lock()
	store.remoteDB = remote
	store.remoteDialect = remoteDialect
	store.status.ConfigProvider = "mysql"
	store.mu.Unlock()

	if _, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "remote-current-password", "merged-password"); err != nil {
		t.Fatal(err)
	}
	if _, ok, err := store.verifyAdminPasswordOn(store.localDB, store.localDialect, "admin", "merged-password"); err != nil || !ok {
		t.Fatalf("new password was not persisted to local sqlite, ok=%v err=%v", ok, err)
	}
	if _, ok, err := store.verifyAdminPasswordOn(remote, remoteDialect, "admin", "merged-password"); err != nil || !ok {
		t.Fatalf("new password was not synced to remote, ok=%v err=%v", ok, err)
	}
}

func TestChangeAdminPasswordRejectsWeakPasswords(t *testing.T) {
	root := t.TempDir()
	path := filepath.Join(root, "unified.sqlite")
	store, err := Open(&config.Config{
		StorageDir: root,
		Database: config.DatabaseConfig{
			Provider:               "sqlite",
			SQLitePath:             path,
			FailoverEnabled:        true,
			HealthIntervalSec:      3600,
			MaxOpenConns:           1,
			MaxIdleConns:           1,
			ConnMaxLifetimeSeconds: 60,
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()
	if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
		t.Fatal(err)
	}

	for _, next := range []string{"", "short", "admin"} {
		if _, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "admin", next); err == nil {
			t.Fatalf("expected password %q to be rejected", next)
		}
	}
}

func TestMySQLSchemaAvoidsTextKeys(t *testing.T) {
	statements := strings.Join(schemaStatements(dialectFor("mysql")), "\n")
	for _, forbidden := range []string{
		"TEXT NOT NULL UNIQUE",
		"TEXT PRIMARY KEY",
		"TEXT NOT NULL PRIMARY KEY",
		"key VARCHAR(191) NOT NULL PRIMARY KEY",
	} {
		if strings.Contains(statements, forbidden) {
			t.Fatalf("mysql schema contains forbidden fragment %q:\n%s", forbidden, statements)
		}
	}
	if !strings.Contains(statements, "`key` VARCHAR(191) NOT NULL PRIMARY KEY") {
		t.Fatalf("system_settings.key must be quoted for MySQL:\n%s", statements)
	}
}

func TestDashboardOverviewKeepsChecksForDeletedSources(t *testing.T) {
	root := t.TempDir()
	path := filepath.Join(root, "unified.sqlite")
	store, err := Open(&config.Config{
		StorageDir: root,
		Database: config.DatabaseConfig{
			Provider:               "sqlite",
			SQLitePath:             path,
			FailoverEnabled:        true,
			HealthIntervalSec:      3600,
			MaxOpenConns:           1,
			MaxIdleConns:           1,
			ConnMaxLifetimeSeconds: 60,
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	defer store.Close()

	source, err := store.UpsertSource(Source{
		CategoryID:    "video",
		CategoryName:  "视频",
		SourceID:      "video-demo",
		Name:          "演示接口",
		APIURL:        "https://example.com/video.json",
		Enabled:       true,
		ClientVisible: true,
	})
	if err != nil {
		t.Fatal(err)
	}
	if err := store.RecordSourceCheck(source.ID, "ok", 123, ""); err != nil {
		t.Fatal(err)
	}
	if err := store.DeleteSource(source.SourceID); err != nil {
		t.Fatal(err)
	}

	overview, err := store.DashboardOverview(10)
	if err != nil {
		t.Fatal(err)
	}
	checks, ok := overview["heartbeats"].([]map[string]any)
	if !ok {
		t.Fatalf("heartbeats has unexpected type %T", overview["heartbeats"])
	}
	if len(checks) != 1 {
		t.Fatalf("expected deleted source check to remain visible, got %d", len(checks))
	}
	if checks[0]["sourceId"] == "" || checks[0]["name"] == "" {
		t.Fatalf("deleted source check should have fallback sourceId/name: %#v", checks[0])
	}
}
