package launch

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

	_ "github.com/mattn/go-sqlite3"
	"github.com/ollama/ollama/cmd/internal/fileutil"
)

func TestVSCodeIntegration(t *testing.T) {
	v := &VSCode{}

	t.Run("String", func(t *testing.T) {
		if got := v.String(); got != "Visual Studio Code" {
			t.Errorf("String() = %q, want %q", got, "Visual Studio Code")
		}
	})

	t.Run("implements Runner", func(t *testing.T) {
		var _ Runner = v
	})

	t.Run("implements Editor", func(t *testing.T) {
		var _ Editor = v
	})
}

func TestVSCodeEdit(t *testing.T) {
	v := &VSCode{}
	tmpDir := t.TempDir()
	setTestHome(t, tmpDir)
	t.Setenv("XDG_CONFIG_HOME", "")
	clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json")

	tests := []struct {
		name     string
		setup    string // initial chatLanguageModels.json content, empty means no file
		models   []string
		validate func(t *testing.T, data []byte)
	}{
		{
			name:   "fresh install",
			models: []string{"llama3.2"},
			validate: func(t *testing.T, data []byte) {
				assertOllamaVendorConfigured(t, data)
			},
		},
		{
			name:   "preserve other vendor entries",
			setup:  `[{"vendor": "azure", "name": "Azure", "url": "https://example.com"}]`,
			models: []string{"llama3.2"},
			validate: func(t *testing.T, data []byte) {
				var entries []map[string]any
				json.Unmarshal(data, &entries)
				if len(entries) != 2 {
					t.Errorf("expected 2 entries, got %d", len(entries))
				}
				// Check Azure entry preserved
				found := false
				for _, e := range entries {
					if v, _ := e["vendor"].(string); v == "azure" {
						found = true
					}
				}
				if !found {
					t.Error("azure vendor entry was not preserved")
				}
				assertOllamaVendorConfigured(t, data)
			},
		},
		{
			name:   "update existing ollama entry",
			setup:  `[{"vendor": "ollama", "name": "Ollama", "url": "http://old:11434"}]`,
			models: []string{"llama3.2"},
			validate: func(t *testing.T, data []byte) {
				assertOllamaVendorConfigured(t, data)
			},
		},
		{
			name:   "empty models is no-op",
			setup:  `[{"vendor": "azure", "name": "Azure"}]`,
			models: []string{},
			validate: func(t *testing.T, data []byte) {
				if string(data) != `[{"vendor": "azure", "name": "Azure"}]` {
					t.Error("empty models should not modify file")
				}
			},
		},
		{
			name:   "corrupted JSON treated as empty",
			setup:  `{corrupted json`,
			models: []string{"llama3.2"},
			validate: func(t *testing.T, data []byte) {
				var entries []map[string]any
				if err := json.Unmarshal(data, &entries); err != nil {
					t.Errorf("result is not valid JSON: %v", err)
				}
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			os.RemoveAll(filepath.Dir(clmPath))

			if tt.setup != "" {
				os.MkdirAll(filepath.Dir(clmPath), 0o755)
				os.WriteFile(clmPath, []byte(tt.setup), 0o644)
			}

			if err := v.Edit(launchModelsFromNames(tt.models)); err != nil {
				t.Fatal(err)
			}

			data, _ := os.ReadFile(clmPath)
			tt.validate(t, data)
		})
	}
}

func TestVSCodeEditCleansUpOldSettings(t *testing.T) {
	v := &VSCode{}
	tmpDir := t.TempDir()
	setTestHome(t, tmpDir)
	t.Setenv("XDG_CONFIG_HOME", "")
	settingsPath := testVSCodePath(t, tmpDir, "settings.json")

	// Create settings.json with old byok setting
	os.MkdirAll(filepath.Dir(settingsPath), 0o755)
	os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint": "http://old:11434", "ollama.launch.configured": true, "editor.fontSize": 14}`), 0o644)

	if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
		t.Fatal(err)
	}

	// Verify old settings were removed
	data, err := os.ReadFile(settingsPath)
	if err != nil {
		t.Fatal(err)
	}

	var settings map[string]any
	json.Unmarshal(data, &settings)
	if _, ok := settings["github.copilot.chat.byok.ollamaEndpoint"]; ok {
		t.Error("github.copilot.chat.byok.ollamaEndpoint should have been removed")
	}
	if _, ok := settings["ollama.launch.configured"]; ok {
		t.Error("ollama.launch.configured should have been removed")
	}
	if settings["editor.fontSize"] != float64(14) {
		t.Error("editor.fontSize should have been preserved")
	}
}

func TestVSCodeEdit_CreatesDistinctBackupsForManagedFiles(t *testing.T) {
	v := &VSCode{}
	tmpDir := t.TempDir()
	setTestHome(t, tmpDir)
	t.Setenv("XDG_CONFIG_HOME", "")

	clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json")
	settingsPath := testVSCodePath(t, tmpDir, "settings.json")
	backupDir := fileutil.BackupDir()

	if err := os.MkdirAll(filepath.Dir(clmPath), 0o755); err != nil {
		t.Fatal(err)
	}

	clmOriginal := `[{"vendor":"ollama","name":"Ollama","url":"http://old:11434"}]`
	settingsOriginal := `{"github.copilot.chat.byok.ollamaEndpoint":"http://old:11434","ollama.launch.configured":true,"editor.fontSize":14}`
	if err := os.WriteFile(clmPath, []byte(clmOriginal), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(settingsPath, []byte(settingsOriginal), 0o644); err != nil {
		t.Fatal(err)
	}

	if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
		t.Fatal(err)
	}

	assertBackupMatches := func(pattern, want string) {
		t.Helper()
		backups, err := filepath.Glob(filepath.Join(backupDir, pattern))
		if err != nil {
			t.Fatalf("glob %q failed: %v", pattern, err)
		}
		for _, backup := range backups {
			data, err := os.ReadFile(backup)
			if err == nil && string(data) == want {
				return
			}
		}
		t.Fatalf("backup matching %q with expected content not found", pattern)
	}

	assertBackupMatches(filepath.Join("vscode", "chatLanguageModels.json.*"), clmOriginal)
	assertBackupMatches(filepath.Join("vscode", "settings.json.*"), settingsOriginal)
}

func TestVSCodePaths(t *testing.T) {
	v := &VSCode{}
	tmpDir := t.TempDir()
	setTestHome(t, tmpDir)
	t.Setenv("XDG_CONFIG_HOME", "")
	clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json")

	t.Run("no file returns nil", func(t *testing.T) {
		os.Remove(clmPath)
		if paths := v.Paths(); paths != nil {
			t.Errorf("expected nil, got %v", paths)
		}
	})

	t.Run("existing file returns path", func(t *testing.T) {
		os.MkdirAll(filepath.Dir(clmPath), 0o755)
		os.WriteFile(clmPath, []byte(`[]`), 0o644)

		if paths := v.Paths(); len(paths) != 1 {
			t.Errorf("expected 1 path, got %d", len(paths))
		}
	})
}

// testVSCodePath returns the expected VS Code config path for the given file in tests.
func testVSCodePath(t *testing.T, tmpDir, filename string) string {
	t.Helper()
	switch runtime.GOOS {
	case "darwin":
		return filepath.Join(tmpDir, "Library", "Application Support", "Code", "User", filename)
	case "windows":
		t.Setenv("APPDATA", tmpDir)
		return filepath.Join(tmpDir, "Code", "User", filename)
	default:
		return filepath.Join(tmpDir, ".config", "Code", "User", filename)
	}
}

func assertOllamaVendorConfigured(t *testing.T, data []byte) {
	t.Helper()
	var entries []map[string]any
	if err := json.Unmarshal(data, &entries); err != nil {
		t.Fatalf("invalid JSON: %v", err)
	}

	for _, entry := range entries {
		if vendor, _ := entry["vendor"].(string); vendor == "ollama" {
			if name, _ := entry["name"].(string); name != "Ollama" {
				t.Errorf("expected name \"Ollama\", got %q", name)
			}
			if url, _ := entry["url"].(string); url == "" {
				t.Error("url not set")
			}
			return
		}
	}
	t.Error("no ollama vendor entry found")
}

func TestShowInModelPicker(t *testing.T) {
	v := &VSCode{}

	// helper to create a state DB with optional seed data
	setupDB := func(t *testing.T, tmpDir string, seedPrefs map[string]bool, seedCache []map[string]any) string {
		t.Helper()
		dbDir := filepath.Join(tmpDir, "globalStorage")
		os.MkdirAll(dbDir, 0o755)
		dbPath := filepath.Join(dbDir, "state.vscdb")

		db, err := sql.Open("sqlite3", dbPath)
		if err != nil {
			t.Fatal(err)
		}
		defer db.Close()

		if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil {
			t.Fatal(err)
		}
		if seedPrefs != nil {
			data, _ := json.Marshal(seedPrefs)
			db.Exec("INSERT INTO ItemTable (key, value) VALUES ('chatModelPickerPreferences', ?)", string(data))
		}
		if seedCache != nil {
			data, _ := json.Marshal(seedCache)
			db.Exec("INSERT INTO ItemTable (key, value) VALUES ('chat.cachedLanguageModels.v2', ?)", string(data))
		}
		return dbPath
	}

	// helper to read prefs back from DB
	readPrefs := func(t *testing.T, dbPath string) map[string]bool {
		t.Helper()
		db, err := sql.Open("sqlite3", dbPath)
		if err != nil {
			t.Fatal(err)
		}
		defer db.Close()

		var raw string
		if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chatModelPickerPreferences'").Scan(&raw); err != nil {
			t.Fatal(err)
		}
		prefs := make(map[string]bool)
		json.Unmarshal([]byte(raw), &prefs)
		return prefs
	}

	t.Run("fresh DB creates table and shows models", func(t *testing.T) {
		tmpDir := t.TempDir()
		setTestHome(t, tmpDir)
		t.Setenv("XDG_CONFIG_HOME", "")
		if runtime.GOOS == "windows" {
			t.Setenv("APPDATA", tmpDir)
		}

		err := v.ShowInModelPicker([]string{"llama3.2"})
		if err != nil {
			t.Fatal(err)
		}

		dbPath := testVSCodePath(t, tmpDir, filepath.Join("globalStorage", "state.vscdb"))
		prefs := readPrefs(t, dbPath)
		if !prefs["ollama/Ollama/llama3.2"] {
			t.Error("expected llama3.2 to be shown")
		}
		if !prefs["ollama/Ollama/llama3.2:latest"] {
			t.Error("expected llama3.2:latest to be shown")
		}
	})

	t.Run("configured models are shown", func(t *testing.T) {
		tmpDir := t.TempDir()
		setTestHome(t, tmpDir)
		t.Setenv("XDG_CONFIG_HOME", "")
		dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, nil)

		err := v.ShowInModelPicker([]string{"llama3.2", "qwen3:8b"})
		if err != nil {
			t.Fatal(err)
		}

		prefs := readPrefs(t, dbPath)
		if !prefs["ollama/Ollama/llama3.2"] {
			t.Error("expected llama3.2 to be shown")
		}
		if !prefs["ollama/Ollama/qwen3:8b"] {
			t.Error("expected qwen3:8b to be shown")
		}
	})

	t.Run("removed models are hidden", func(t *testing.T) {
		tmpDir := t.TempDir()
		setTestHome(t, tmpDir)
		t.Setenv("XDG_CONFIG_HOME", "")
		dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{
			"ollama/Ollama/llama3.2":        true,
			"ollama/Ollama/llama3.2:latest": true,
			"ollama/Ollama/mistral":         true,
			"ollama/Ollama/mistral:latest":  true,
		}, nil)

		// Only configure llama3.2 — mistral should get hidden
		err := v.ShowInModelPicker([]string{"llama3.2"})
		if err != nil {
			t.Fatal(err)
		}

		prefs := readPrefs(t, dbPath)
		if !prefs["ollama/Ollama/llama3.2"] {
			t.Error("expected llama3.2 to stay shown")
		}
		if prefs["ollama/Ollama/mistral"] {
			t.Error("expected mistral to be hidden")
		}
		if prefs["ollama/Ollama/mistral:latest"] {
			t.Error("expected mistral:latest to be hidden")
		}
	})

	t.Run("non-ollama prefs are preserved", func(t *testing.T) {
		tmpDir := t.TempDir()
		setTestHome(t, tmpDir)
		t.Setenv("XDG_CONFIG_HOME", "")
		dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{
			"copilot/gpt-4o": true,
		}, nil)

		err := v.ShowInModelPicker([]string{"llama3.2"})
		if err != nil {
			t.Fatal(err)
		}

		prefs := readPrefs(t, dbPath)
		if !prefs["copilot/gpt-4o"] {
			t.Error("expected copilot/gpt-4o to stay shown")
		}
	})

	t.Run("uses cached numeric IDs when available", func(t *testing.T) {
		tmpDir := t.TempDir()
		setTestHome(t, tmpDir)
		t.Setenv("XDG_CONFIG_HOME", "")
		cache := []map[string]any{
			{
				"identifier": "ollama/Ollama/4",
				"metadata":   map[string]any{"vendor": "ollama", "name": "llama3.2"},
			},
		}
		dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, cache)

		err := v.ShowInModelPicker([]string{"llama3.2"})
		if err != nil {
			t.Fatal(err)
		}

		prefs := readPrefs(t, dbPath)
		if !prefs["ollama/Ollama/4"] {
			t.Error("expected numeric ID ollama/Ollama/4 to be shown")
		}
		// Name-based fallback should also be set
		if !prefs["ollama/Ollama/llama3.2"] {
			t.Error("expected name-based ID to also be shown")
		}
	})

	t.Run("empty models is no-op", func(t *testing.T) {
		err := v.ShowInModelPicker([]string{})
		if err != nil {
			t.Fatal(err)
		}
	})

	t.Run("previously hidden model is re-shown when configured", func(t *testing.T) {
		tmpDir := t.TempDir()
		setTestHome(t, tmpDir)
		t.Setenv("XDG_CONFIG_HOME", "")
		dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{
			"ollama/Ollama/llama3.2":        false,
			"ollama/Ollama/llama3.2:latest": false,
		}, nil)

		// Ollama config is authoritative — should override the hidden state
		err := v.ShowInModelPicker([]string{"llama3.2"})
		if err != nil {
			t.Fatal(err)
		}

		prefs := readPrefs(t, dbPath)
		if !prefs["ollama/Ollama/llama3.2"] {
			t.Error("expected llama3.2 to be re-shown")
		}
	})
}

func TestParseCopilotChatVersion(t *testing.T) {
	tests := []struct {
		name          string
		output        string
		wantInstalled bool
		wantVersion   string
	}{
		{
			name:          "found among other extensions",
			output:        "ms-python.python@2024.1.1\ngithub.copilot-chat@0.40.1\ngithub.copilot@1.200.0\n",
			wantInstalled: true,
			wantVersion:   "0.40.1",
		},
		{
			name:          "only extension",
			output:        "GitHub.copilot-chat@0.41.0\n",
			wantInstalled: true,
			wantVersion:   "0.41.0",
		},
		{
			name:          "not installed",
			output:        "ms-python.python@2024.1.1\ngithub.copilot@1.200.0\n",
			wantInstalled: false,
		},
		{
			name:          "empty output",
			output:        "",
			wantInstalled: false,
		},
		{
			name:          "case insensitive match",
			output:        "GitHub.Copilot-Chat@0.39.0\n",
			wantInstalled: true,
			wantVersion:   "0.39.0",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			installed, version := parseCopilotChatVersion(tt.output)
			if installed != tt.wantInstalled {
				t.Errorf("installed = %v, want %v", installed, tt.wantInstalled)
			}
			if installed && version != tt.wantVersion {
				t.Errorf("version = %q, want %q", version, tt.wantVersion)
			}
		})
	}
}

func TestCompareVersions(t *testing.T) {
	tests := []struct {
		a, b string
		want int
	}{
		{"0.40.1", "0.40.1", 0},
		{"0.40.2", "0.40.1", 1},
		{"0.40.0", "0.40.1", -1},
		{"0.41.0", "0.40.1", 1},
		{"0.39.9", "0.40.1", -1},
		{"1.0.0", "0.40.1", 1},
		{"0.40", "0.40.1", -1},
		{"0.40.1.1", "0.40.1", 1},
	}

	for _, tt := range tests {
		t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) {
			got := compareVersions(tt.a, tt.b)
			if got != tt.want {
				t.Errorf("compareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
			}
		})
	}
}
