package desktop

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io"
	"os"
	"path/filepath"
	"runtime"
	"slices"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/tidbcloud/AgentTeam/x/proc"
)

func TestDevelopmentProcessSpecsGiveElectronExplicitSidecarAndRenderer(t *testing.T) {
	repoRoot := t.TempDir()
	runner := newRunner(repoRoot, newFakeProcessController(), runnerDependencies{})
	runner.vitePort = 43127

	vite := runner.viteSpec()
	if got, want := vite.Dir, filepath.Join(repoRoot, "web"); got != want {
		t.Fatalf("Vite Dir = %q, want %q", got, want)
	}
	if got := strings.Join(vite.Args, " "); !strings.Contains(got, "npm run dev -- --mode desktop") {
		t.Fatalf("Vite Args = %q, want root Web desktop mode", got)
	}

	electron := runner.electronSpec()
	if got, want := electron.Dir, filepath.Join(repoRoot, "desktop", "electron"); got != want {
		t.Fatalf("Electron Dir = %q, want %q", got, want)
	}
	if got, want := envValue(electron.Env, desktopDaemonPathEnv), runner.sidecarPath; got != want {
		t.Fatalf("%s = %q, want %q", desktopDaemonPathEnv, got, want)
	}
	if got, want := envValue(electron.Env, desktopRendererURLEnv), "http://127.0.0.1:43127"; got != want {
		t.Fatalf("%s = %q, want %q", desktopRendererURLEnv, got, want)
	}
	if got := envValue(electron.Env, "AGENTTEAM_CONFIG_DIR"); got != "" {
		t.Fatalf("AGENTTEAM_CONFIG_DIR = %q, want unset so dev shares ~/.agentteam with production", got)
	}
	if got, want := envValue(electron.Env, "AGENTTEAM_DEV"), "127.0.0.1:43127"; got != want {
		t.Fatalf("AGENTTEAM_DEV = %q, want %q", got, want)
	}
	if strings.Contains(strings.Join(electron.Args, " "), " daemon ") {
		t.Fatalf("Electron Args unexpectedly start the daemon directly: %q", electron.Args)
	}
}

func TestSidecarBuildSpecIsPureGoAndUsesCandidateOutput(t *testing.T) {
	repoRoot := t.TempDir()
	runner := newRunner(repoRoot, newFakeProcessController(), runnerDependencies{})
	spec, err := runner.sidecarBuildSpec()
	if err != nil {
		t.Fatalf("sidecarBuildSpec() error = %v", err)
	}

	if got, want := spec.Dir, filepath.Join(repoRoot, "desktop"); got != want {
		t.Fatalf("Dir = %q, want %q", got, want)
	}
	if got := envValue(spec.Env, "CGO_ENABLED"); got != "0" {
		t.Fatalf("CGO_ENABLED = %q, want 0", got)
	}
	if got := envValue(spec.Env, "GOOS"); got != runtime.GOOS {
		t.Fatalf("GOOS = %q, want %q", got, runtime.GOOS)
	}
	if got := envValue(spec.Env, "GOARCH"); got != runtime.GOARCH {
		t.Fatalf("GOARCH = %q, want %q", got, runtime.GOARCH)
	}
	if !slices.Contains(spec.Args, "-trimpath") {
		t.Fatalf("go build Args = %q, want -trimpath", spec.Args)
	}
	outputIndex := slices.Index(spec.Args, "-o")
	if outputIndex < 0 || outputIndex+1 >= len(spec.Args) {
		t.Fatalf("go build Args = %q, want -o candidate", spec.Args)
	}
	if got, want := spec.Args[outputIndex+1], runner.sidecarCandidatePath; got != want {
		t.Fatalf("go build output = %q, want %q", got, want)
	}
	if got := strings.Join(spec.Args, " "); !strings.Contains(got, "./cmd/agentteam") {
		t.Fatalf("go build Args = %q, want ./cmd/agentteam", spec.Args)
	}
}

func TestRebuildStopsElectronCleansItsDaemonThenStartsElectron(t *testing.T) {
	repoRoot := t.TempDir()
	processes := newFakeProcessController()
	var eventsMu sync.Mutex
	var events []string
	record := func(event string) {
		eventsMu.Lock()
		defer eventsMu.Unlock()
		events = append(events, event)
	}
	processes.onEvent = record

	runner := newRunner(repoRoot, processes, runnerDependencies{
		runCommand: func(_ context.Context, spec commandSpec) error {
			record("command:" + spec.Name)
			if spec.Name == "sidecar-build" {
				if err := os.MkdirAll(filepath.Dir(runnerSidecarOutput(spec)), 0o755); err != nil {
					return err
				}
				return os.WriteFile(runnerSidecarOutput(spec), []byte("new-sidecar"), 0o755)
			}
			return nil
		},
		cleanupOwnedDaemon: func(context.Context, string) error {
			record("cleanup:owned-daemon")
			return nil
		},
	})
	runner.vitePort = 43127
	if err := os.MkdirAll(filepath.Dir(runner.sidecarPath), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(runner.sidecarPath, []byte("old-sidecar"), 0o755); err != nil {
		t.Fatal(err)
	}
	oldElectron, err := processes.Start(context.Background(), runner.electronSpec())
	if err != nil {
		t.Fatal(err)
	}
	runner.electron = oldElectron
	events = nil

	if err := runner.rebuildAndRestart(context.Background()); err != nil {
		t.Fatalf("rebuildAndRestart() error = %v", err)
	}
	gotSidecar, err := os.ReadFile(runner.sidecarPath)
	if err != nil {
		t.Fatal(err)
	}
	if string(gotSidecar) != "new-sidecar" {
		t.Fatalf("sidecar = %q, want rebuilt candidate", gotSidecar)
	}

	eventsMu.Lock()
	gotEvents := append([]string(nil), events...)
	eventsMu.Unlock()
	wantEvents := []string{
		"command:sidecar-build",
		"stop:electron",
		"cleanup:owned-daemon",
		"start:electron",
	}
	if !slices.Equal(gotEvents, wantEvents) {
		t.Fatalf("events = %q, want %q", gotEvents, wantEvents)
	}
	for _, spec := range processes.startedSpecs() {
		if spec.Name == "daemon" {
			t.Fatalf("dev runner independently started daemon: %+v", spec)
		}
	}
	processes.StopAll()
}

func TestRebuildFailureLeavesCurrentElectronRunning(t *testing.T) {
	processes := newFakeProcessController()
	buildErr := errors.New("compile failed")
	runner := newRunner(t.TempDir(), processes, runnerDependencies{
		runCommand: func(context.Context, commandSpec) error {
			return buildErr
		},
		cleanupOwnedDaemon: func(context.Context, string) error {
			t.Fatal("cleanup called after failed build")
			return nil
		},
	})
	oldElectron, err := processes.Start(context.Background(), runner.electronSpec())
	if err != nil {
		t.Fatal(err)
	}
	runner.electron = oldElectron

	err = runner.rebuildAndRestart(context.Background())
	if !errors.Is(err, buildErr) {
		t.Fatalf("rebuildAndRestart() error = %v, want %v", err, buildErr)
	}
	if processes.wasStopped(oldElectron) {
		t.Fatal("current Electron was stopped after a failed sidecar build")
	}
	processes.StopAll()
}

func TestForceKillSigKillsChildrenAndCleansUpDaemon(t *testing.T) {
	processes := newFakeProcessController()
	var eventsMu sync.Mutex
	var events []string
	record := func(event string) {
		eventsMu.Lock()
		defer eventsMu.Unlock()
		events = append(events, event)
	}
	processes.onEvent = record

	runner := newRunner(t.TempDir(), processes, runnerDependencies{
		cleanupOwnedDaemon: func(context.Context, string) error {
			record("cleanup:owned-daemon")
			return nil
		},
	})
	electron, err := processes.Start(context.Background(), runner.electronSpec())
	if err != nil {
		t.Fatal(err)
	}
	vite, err := processes.Start(context.Background(), runner.viteSpec())
	if err != nil {
		t.Fatal(err)
	}
	runner.electron = electron
	runner.vite = vite

	runner.ForceKill()

	if !processes.wasStopped(electron) {
		t.Fatal("ForceKill did not kill Electron")
	}
	if !processes.wasStopped(vite) {
		t.Fatal("ForceKill did not kill Vite")
	}
	eventsMu.Lock()
	gotEvents := append([]string(nil), events...)
	eventsMu.Unlock()
	wantEvents := []string{
		"start:electron",
		"start:vite",
		"force-kill:electron",
		"force-kill:vite",
		"cleanup:owned-daemon",
	}
	if !slices.Equal(gotEvents, wantEvents) {
		t.Fatalf("events = %q, want %q", gotEvents, wantEvents)
	}
}

func TestOwnedDaemonCleanerTerminatesOnlyOrphanFromDevelopmentProfile(t *testing.T) {
	configDir := t.TempDir()
	startedAt := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
	ownerStartedAt := startedAt.Add(-time.Minute)
	receipt := developmentDaemonReceipt{
		SchemaVersion:     1,
		DesktopAPIVersion: developmentDesktopAPIVersion,
		InstanceID:        "dev-instance",
		PID:               8123,
		StartedAt:         startedAt,
		StartID:           "linux:boot-a:1234",
		BaseURL:           "http://127.0.0.1:43128",
		Owner: developmentDaemonOwner{
			Kind:      "electron",
			ID:        "dev-owner",
			PID:       8001,
			StartedAt: ownerStartedAt,
			StartID:   "linux:boot-a:1200",
		},
		ControlToken: "private-control-token",
	}
	writeDevelopmentReceipt(t, configDir, receipt)

	daemonAlive := true
	var terminated []int
	cleaner := ownedDaemonCleaner{
		lookupProcess: func(pid int) (proc.ProcessInfo, bool) {
			switch pid {
			case receipt.Owner.PID:
				return proc.ProcessInfo{}, false
			case receipt.PID:
				if daemonAlive {
					return proc.ProcessInfo{
						PID:       pid,
						StartedAt: startedAt.Add(-7 * time.Second),
						StartID:   receipt.StartID,
					}, true
				}
			}
			return proc.ProcessInfo{}, false
		},
		terminateProcess: func(pid int, _ time.Duration) {
			terminated = append(terminated, pid)
			daemonAlive = false
		},
	}
	if err := cleaner.cleanup(context.Background(), configDir); err != nil {
		t.Fatalf("cleanup() error = %v", err)
	}
	if !slices.Equal(terminated, []int{receipt.PID}) {
		t.Fatalf("terminated = %v, want daemon PID %d", terminated, receipt.PID)
	}
	if _, err := os.Stat(filepath.Join(configDir, "run", daemonReceiptFilename)); !os.IsNotExist(err) {
		t.Fatalf("receipt remains after orphan cleanup: %v", err)
	}
}

func TestOwnedDaemonCleanerRefusesActiveElectronOwner(t *testing.T) {
	configDir := t.TempDir()
	startedAt := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
	receipt := developmentDaemonReceipt{
		SchemaVersion:     1,
		DesktopAPIVersion: developmentDesktopAPIVersion,
		InstanceID:        "dev-instance",
		PID:               8123,
		StartedAt:         startedAt,
		BaseURL:           "http://127.0.0.1:43128",
		Owner: developmentDaemonOwner{
			Kind:      "electron",
			ID:        "active-owner",
			PID:       8001,
			StartedAt: startedAt.Add(-time.Minute),
		},
		ControlToken: "private-control-token",
	}
	writeDevelopmentReceipt(t, configDir, receipt)

	cleaner := ownedDaemonCleaner{
		lookupProcess: func(pid int) (proc.ProcessInfo, bool) {
			if pid == receipt.Owner.PID {
				return proc.ProcessInfo{
					PID:       pid,
					StartedAt: receipt.Owner.StartedAt,
				}, true
			}
			return proc.ProcessInfo{}, false
		},
		terminateProcess: func(int, time.Duration) {
			t.Fatal("active Electron-owned daemon was terminated")
		},
	}
	err := cleaner.cleanup(context.Background(), configDir)
	if err == nil || !strings.Contains(err.Error(), "active Electron owner") {
		t.Fatalf("cleanup() error = %v, want active owner refusal", err)
	}
}

func envValue(environment []string, name string) string {
	prefix := name + "="
	for i := len(environment) - 1; i >= 0; i-- {
		if strings.HasPrefix(environment[i], prefix) {
			return strings.TrimPrefix(environment[i], prefix)
		}
	}
	return ""
}

func runnerSidecarOutput(spec commandSpec) string {
	index := slices.Index(spec.Args, "-o")
	if index < 0 || index+1 >= len(spec.Args) {
		return ""
	}
	return spec.Args[index+1]
}

func writeDevelopmentReceipt(t *testing.T, configDir string, receipt developmentDaemonReceipt) {
	t.Helper()
	data, err := json.Marshal(receipt)
	if err != nil {
		t.Fatal(err)
	}
	runtimeDir := filepath.Join(configDir, "run")
	if err := os.MkdirAll(runtimeDir, 0o700); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(runtimeDir, daemonReceiptFilename), data, 0o600); err != nil {
		t.Fatal(err)
	}
}

type fakeProcessController struct {
	mu      sync.Mutex
	process []*childProcess
	started []proc.Spec
	stopped map[*childProcess]bool
	onEvent func(string)
	output  bytes.Buffer
}

func newFakeProcessController() *fakeProcessController {
	return &fakeProcessController{stopped: make(map[*childProcess]bool)}
}

func (f *fakeProcessController) Start(_ context.Context, spec proc.Spec) (*childProcess, error) {
	done := make(chan struct{})
	process := &childProcess{
		name: spec.Name,
		done: done,
		exitErr: func() error {
			return nil
		},
		testClose: func() {
			select {
			case <-done:
			default:
				close(done)
			}
		},
	}
	f.mu.Lock()
	f.process = append(f.process, process)
	f.started = append(f.started, spec)
	f.mu.Unlock()
	f.event("start:" + spec.Name)
	return process, nil
}

func (f *fakeProcessController) Stop(process *childProcess) {
	if process == nil {
		return
	}
	f.mu.Lock()
	if f.stopped[process] {
		f.mu.Unlock()
		return
	}
	f.stopped[process] = true
	f.mu.Unlock()
	f.event("stop:" + process.name)
	process.testClose()
}

func (f *fakeProcessController) StopAll() {
	f.mu.Lock()
	processes := append([]*childProcess(nil), f.process...)
	f.mu.Unlock()
	for _, process := range processes {
		f.Stop(process)
	}
}

func (f *fakeProcessController) ForceKillProcess(process *childProcess) {
	if process == nil {
		return
	}
	f.mu.Lock()
	if f.stopped[process] {
		f.mu.Unlock()
		return
	}
	f.stopped[process] = true
	f.mu.Unlock()
	f.event("force-kill:" + process.name)
	process.testClose()
}

func (f *fakeProcessController) ForceKillAll() {
	f.mu.Lock()
	processes := append([]*childProcess(nil), f.process...)
	f.mu.Unlock()
	for _, process := range processes {
		f.ForceKillProcess(process)
	}
}

func (f *fakeProcessController) Output() io.Writer {
	return &f.output
}

func (f *fakeProcessController) startedSpecs() []proc.Spec {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]proc.Spec(nil), f.started...)
}

func (f *fakeProcessController) wasStopped(process *childProcess) bool {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.stopped[process]
}

func (f *fakeProcessController) event(event string) {
	if f.onEvent != nil {
		f.onEvent(event)
	}
}
