// Package desktop implements `dev --desktop`: it builds the pure Go sidecar
// and Electron main/preload, runs the root Web desktop entry through Vite, and
// launches Electron as the sole owner and supervisor of the sidecar.
package desktop

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"slices"
	"strings"
	"sync"
	"time"

	"github.com/fsnotify/fsnotify"

	"github.com/tidbcloud/AgentTeam/dev/internal/watch"
	"github.com/tidbcloud/AgentTeam/x/proc"
)

const (
	desktopDaemonPathEnv  = "AGENTTEAM_DESKTOP_DAEMON_PATH"
	desktopRendererURLEnv = "AGENTTEAM_DESKTOP_RENDERER_URL"
)

// Runner owns the Vite and Electron processes for `dev --desktop`. Electron,
// in turn, owns the daemon sidecar; Runner never starts `agentteam daemon` itself.
type Runner struct {
	repoRoot    string
	desktopDir  string
	electronDir string
	webDir      string
	configDir   string

	sidecarPath          string
	sidecarCandidatePath string

	processes processController
	deps      runnerDependencies

	vitePort int

	processMu sync.Mutex
	vite      *childProcess
	electron  *childProcess
	exits     chan procExit

	restartMu    sync.Mutex
	shutdownOnce sync.Once
}

type runnerDependencies struct {
	runCommand         func(context.Context, commandSpec) error
	cleanupOwnedDaemon func(context.Context, string) error
	ensureDependencies func(context.Context, ensureDepsRequest) error
}

type ensureDepsRequest struct {
	electronDir string
	webDir      string
}

type commandSpec struct {
	Name string
	Args []string
	Dir  string
	Env  []string
}

type procExit struct {
	process *childProcess
	err     error
}

type childProcess struct {
	name      string
	done      <-chan struct{}
	exitErr   func() error
	managed   *proc.Process
	testClose func()
}

type processController interface {
	Start(context.Context, proc.Spec) (*childProcess, error)
	Stop(*childProcess)
	StopAll()
	ForceKillProcess(*childProcess)
	ForceKillAll()
	Output() io.Writer
}

type managerProcessController struct {
	manager *proc.Manager
}

func (c managerProcessController) Start(ctx context.Context, spec proc.Spec) (*childProcess, error) {
	process, err := c.manager.Start(ctx, spec)
	if err != nil {
		return nil, err
	}
	return &childProcess{
		name:    process.Name,
		done:    process.Done,
		exitErr: process.ExitErr,
		managed: process,
	}, nil
}

func (c managerProcessController) Stop(process *childProcess) {
	if process == nil {
		return
	}
	c.manager.Stop(process.managed)
}

func (c managerProcessController) StopAll() {
	c.manager.StopAll()
}

func (c managerProcessController) ForceKillProcess(process *childProcess) {
	if process == nil {
		return
	}
	c.manager.ForceKillProcess(process.managed)
}

func (c managerProcessController) ForceKillAll() {
	c.manager.ForceKillAll()
}

func (c managerProcessController) Output() io.Writer {
	return c.manager.Output()
}

// New constructs a Runner.
func New(repoRoot string, manager *proc.Manager) *Runner {
	return newRunner(repoRoot, managerProcessController{manager: manager}, runnerDependencies{})
}

func newRunner(repoRoot string, processes processController, deps runnerDependencies) *Runner {
	devRoot := filepath.Join(repoRoot, ".data", "dev", "desktop")
	sidecarName := "agentteam"
	if runtime.GOOS == "windows" {
		sidecarName += ".exe"
	}
	runner := &Runner{
		repoRoot:             repoRoot,
		desktopDir:           filepath.Join(repoRoot, "desktop"),
		electronDir:          filepath.Join(repoRoot, "desktop", "electron"),
		webDir:               filepath.Join(repoRoot, "web"),
		configDir:            resolveAgentTeamConfigDir(),
		sidecarPath:          filepath.Join(devRoot, "bin", sidecarName),
		sidecarCandidatePath: filepath.Join(devRoot, "bin", "."+sidecarName+".next"),
		processes:            processes,
		deps:                 deps,
		exits:                make(chan procExit, 8),
	}
	if runner.deps.runCommand == nil {
		runner.deps.runCommand = runner.runCommand
	}
	if runner.deps.cleanupOwnedDaemon == nil {
		cleaner := newOwnedDaemonCleaner()
		runner.deps.cleanupOwnedDaemon = cleaner.cleanup
	}
	if runner.deps.ensureDependencies == nil {
		runner.deps.ensureDependencies = defaultEnsureDependencies
	}
	return runner
}

// resolveAgentTeamConfigDir mirrors the daemon's profile.ConfigDir() resolution so
// the dev tool reads/writes receipts at the same path the daemon will use.
// Sharing the user's ~/.agentteam (rather than a dev-only sandbox) is what lets
// `make dev:desktop` see the same authenticated servers as production.
func resolveAgentTeamConfigDir() string {
	if env := strings.TrimSpace(os.Getenv("AGENTTEAM_CONFIG_DIR")); env != "" {
		return env
	}
	home, err := os.UserHomeDir()
	if err != nil || home == "" {
		home = "."
	}
	return filepath.Join(home, ".agentteam")
}

// Run builds the development app, starts Vite and Electron, and blocks until
// cancellation or an owned child exits unexpectedly.
func (r *Runner) Run(ctx context.Context) (runErr error) {
	defer r.Stop()

	if err := r.cleanupOwnedDaemon(ctx); err != nil {
		return fmt.Errorf("clean previous desktop development daemon: %w", err)
	}

	var err error
	r.vitePort, err = allocatePort()
	if err != nil {
		return fmt.Errorf("allocate Vite port: %w", err)
	}
	slog.Info("desktop renderer", "url", r.rendererURL())

	slog.Info("ensuring desktop dependencies...")
	if err := r.deps.ensureDependencies(ctx, ensureDepsRequest{
		electronDir: r.electronDir,
		webDir:      r.webDir,
	}); err != nil {
		return fmt.Errorf("ensure desktop dependencies: %w", err)
	}

	slog.Info("building pure Go desktop sidecar...")
	if err := r.buildAndInstallSidecar(ctx); err != nil {
		return fmt.Errorf("initial sidecar build failed: %w", err)
	}
	slog.Info("building Electron main and preload...")
	if err := r.buildElectron(ctx); err != nil {
		return fmt.Errorf("initial Electron build failed: %w", err)
	}

	runCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	vite, err := r.startVite(runCtx)
	if err != nil {
		return err
	}
	r.setVite(vite)
	if err := r.waitForVite(runCtx, vite); err != nil {
		if errors.Is(err, context.Canceled) {
			return nil
		}
		return err
	}
	if runCtx.Err() != nil {
		return nil
	}

	electron, err := r.startElectron(runCtx)
	if err != nil {
		return err
	}
	r.setElectron(electron)

	watcherDone := make(chan struct{})
	go func() {
		defer close(watcherDone)
		r.watchAndRebuild(runCtx)
	}()

	runErr = r.supervise(runCtx)
	cancel()
	<-watcherDone
	return runErr
}

// Stop terminates Electron, removes any detached daemon still owned by that
// Electron lifetime, then stops the remaining development children.
func (r *Runner) Stop() {
	r.shutdownOnce.Do(func() {
		r.restartMu.Lock()
		defer r.restartMu.Unlock()

		electron := r.takeElectron()
		if electron != nil {
			r.processes.Stop(electron)
		}
		cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		if err := r.cleanupOwnedDaemon(cleanupCtx); err != nil {
			slog.Error("cleanup Electron-owned development daemon", "error", err)
		}
		cancel()

		vite := r.takeVite()
		if vite != nil {
			r.processes.Stop(vite)
		}
		r.processes.StopAll()
	})
}

// ForceKill is the "second Ctrl+C" path. It SIGKILLs every managed child
// (Vite, Electron) and the detached daemon sidecar, then removes the receipt.
// Children live in their own process groups and would otherwise be orphaned
// when the parent process calls os.Exit. The graceful Stop() may still be
// draining when this arrives — the worst case is "Vite still alive, Electron
// still draining, daemon still alive, receipt still on disk", and this
// collapses it to a clean slate before the dev tool exits.
func (r *Runner) ForceKill() {
	r.restartMu.Lock()
	electron := r.takeElectron()
	vite := r.takeVite()
	r.restartMu.Unlock()

	if electron != nil {
		r.processes.ForceKillProcess(electron)
	}
	if vite != nil {
		r.processes.ForceKillProcess(vite)
	}
	r.processes.StopAll()

	forceCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := r.cleanupOwnedDaemon(forceCtx); err != nil {
		slog.Error("force cleanup Electron-owned development daemon", "error", err)
	}
}

func (r *Runner) supervise(ctx context.Context) error {
	for {
		select {
		case <-ctx.Done():
			return nil
		case exit := <-r.exits:
			if ctx.Err() != nil {
				return nil
			}
			r.processMu.Lock()
			isVite := exit.process == r.vite
			isElectron := exit.process == r.electron
			r.processMu.Unlock()
			if !isVite && !isElectron {
				continue
			}
			name := "process"
			if isVite {
				name = "vite"
			}
			if isElectron {
				name = "electron"
			}
			if exit.err == nil {
				return fmt.Errorf("%s exited unexpectedly", name)
			}
			return fmt.Errorf("%s exited unexpectedly: %w", name, exit.err)
		}
	}
}

func allocatePort() (int, error) {
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		return 0, err
	}
	defer listener.Close()
	return listener.Addr().(*net.TCPAddr).Port, nil
}

func (r *Runner) rendererURL() string {
	return fmt.Sprintf("http://127.0.0.1:%d", r.vitePort)
}

func (r *Runner) sidecarBuildSpec() (commandSpec, error) {
	version := runGit(r.repoRoot, "describe", "--tags", "--always", "--dirty")
	if version == "unknown" || version == "" {
		version = "dev"
	}
	commit := runGit(r.repoRoot, "rev-parse", "--short", "HEAD")
	buildAt := time.Now().UTC().Format(time.RFC3339)
	goVersion := runGoVersion()

	ldflags := fmt.Sprintf(
		"-s -w "+
			"-X 'github.com/tidbcloud/AgentTeam/desktop/internal/version.Version=%s' "+
			"-X 'github.com/tidbcloud/AgentTeam/desktop/internal/version.Commit=%s' "+
			"-X 'github.com/tidbcloud/AgentTeam/desktop/internal/version.BuildAt=%s' "+
			"-X 'github.com/tidbcloud/AgentTeam/desktop/internal/version.GoVersion=%s'",
		version, commit, buildAt, goVersion,
	)
	if managedVersion := strings.TrimSpace(os.Getenv("AGENTTEAM_MANAGED_OPENCODE_VERSION")); managedVersion != "" {
		ldflags += fmt.Sprintf(
			" -X 'github.com/tidbcloud/AgentTeam/desktop/internal/agent/runtimeconfig.ManagedOpenCodeVersion=%s'",
			managedVersion,
		)
	}

	environment := replaceEnv(os.Environ(), "CGO_ENABLED", "0")
	environment = replaceEnv(environment, "GOOS", runtime.GOOS)
	environment = replaceEnv(environment, "GOARCH", runtime.GOARCH)
	environment = replaceEnv(environment, "MACOSX_DEPLOYMENT_TARGET", "12")
	return commandSpec{
		Name: "sidecar-build",
		Args: []string{
			"go", "build",
			"-buildvcs=false",
			"-trimpath",
			"-tags", "devtools",
			"-ldflags", ldflags,
			"-o", r.sidecarCandidatePath,
			"./cmd/agentteam",
		},
		Dir: r.desktopDir,
		Env: environment,
	}, nil
}

func (r *Runner) buildAndInstallSidecar(ctx context.Context) error {
	spec, err := r.sidecarBuildSpec()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(r.sidecarCandidatePath), 0o755); err != nil {
		return fmt.Errorf("create sidecar output directory: %w", err)
	}
	_ = os.Remove(r.sidecarCandidatePath)
	if err := r.deps.runCommand(ctx, spec); err != nil {
		return err
	}
	if err := ctx.Err(); err != nil {
		return err
	}
	if err := replaceSidecar(r.sidecarCandidatePath, r.sidecarPath); err != nil {
		return fmt.Errorf("install rebuilt sidecar: %w", err)
	}
	return nil
}

func replaceSidecar(candidate, destination string) error {
	info, err := os.Stat(candidate)
	if err != nil {
		return fmt.Errorf("inspect candidate: %w", err)
	}
	if !info.Mode().IsRegular() {
		return fmt.Errorf("candidate %s is not a regular file", candidate)
	}
	if err := os.Chmod(candidate, 0o755); err != nil {
		return fmt.Errorf("make candidate executable: %w", err)
	}

	backup := destination + ".previous"
	_ = os.Remove(backup)
	hadDestination := false
	if _, err := os.Stat(destination); err == nil {
		if err := os.Rename(destination, backup); err != nil {
			return fmt.Errorf("preserve previous sidecar: %w", err)
		}
		hadDestination = true
	} else if !os.IsNotExist(err) {
		return fmt.Errorf("inspect previous sidecar: %w", err)
	}
	if err := os.Rename(candidate, destination); err != nil {
		if hadDestination {
			_ = os.Rename(backup, destination)
		}
		return err
	}
	if hadDestination {
		_ = os.Remove(backup)
	}
	return nil
}

func (r *Runner) buildElectron(ctx context.Context) error {
	return r.deps.runCommand(ctx, commandSpec{
		Name: "electron-build",
		Args: []string{"npm", "run", "build:electron"},
		Dir:  r.electronDir,
		Env:  os.Environ(),
	})
}

func (r *Runner) runCommand(ctx context.Context, spec commandSpec) error {
	if len(spec.Args) == 0 {
		return errors.New("desktop development command is empty")
	}
	command := exec.CommandContext(ctx, spec.Args[0], spec.Args[1:]...)
	command.Dir = spec.Dir
	command.Env = spec.Env
	command.Stdout = r.processes.Output()
	command.Stderr = r.processes.Output()
	return command.Run()
}

func runGit(dir string, args ...string) string {
	command := exec.Command("git", args...)
	command.Dir = dir
	output, err := command.Output()
	if err != nil {
		return "unknown"
	}
	return strings.TrimSpace(string(output))
}

func runGoVersion() string {
	output, err := exec.Command("go", "version").Output()
	if err != nil {
		return "unknown"
	}
	fields := strings.Fields(string(output))
	if len(fields) >= 3 {
		return fields[2]
	}
	return "unknown"
}

func (r *Runner) viteSpec() proc.Spec {
	environment := removeEnv(os.Environ(), "VITE_DEV_PROXY_TARGET")
	environment = replaceEnv(environment, "BROWSER", "none")
	return proc.Spec{
		Name: "vite",
		Args: []string{
			"npm", "run", "dev", "--",
			"--mode", "desktop",
			"--port", fmt.Sprintf("%d", r.vitePort),
			"--host", "127.0.0.1",
		},
		Dir: r.webDir,
		Env: environment,
	}
}

func (r *Runner) electronSpec() proc.Spec {
	environment := replaceEnv(os.Environ(), desktopDaemonPathEnv, r.sidecarPath)
	environment = replaceEnv(environment, desktopRendererURLEnv, r.rendererURL())
	environment = replaceEnv(
		environment,
		"AGENTTEAM_DEV",
		fmt.Sprintf("127.0.0.1:%d", r.vitePort),
	)
	return proc.Spec{
		Name: "electron",
		Args: []string{"npm", "exec", "--", "electron", "."},
		Dir:  r.electronDir,
		Env:  environment,
	}
}

func (r *Runner) startVite(ctx context.Context) (*childProcess, error) {
	process, err := r.processes.Start(ctx, r.viteSpec())
	if err != nil {
		return nil, fmt.Errorf("start Vite: %w", err)
	}
	go r.monitor(process)
	return process, nil
}

func (r *Runner) startElectron(ctx context.Context) (*childProcess, error) {
	process, err := r.processes.Start(ctx, r.electronSpec())
	if err != nil {
		return nil, fmt.Errorf("start Electron: %w", err)
	}
	go r.monitor(process)
	return process, nil
}

func (r *Runner) monitor(process *childProcess) {
	<-process.done
	r.exits <- procExit{process: process, err: process.exitErr()}
}

func (r *Runner) waitForVite(ctx context.Context, vite *childProcess) error {
	for attempt := 0; attempt < 30; attempt++ {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-vite.done:
			if err := vite.exitErr(); err != nil {
				return fmt.Errorf("Vite exited before ready: %w", err)
			}
			return errors.New("Vite exited before ready")
		default:
		}

		connection, err := net.DialTimeout(
			"tcp",
			fmt.Sprintf("127.0.0.1:%d", r.vitePort),
			200*time.Millisecond,
		)
		if err == nil {
			_ = connection.Close()
			return nil
		}
		timer := time.NewTimer(300 * time.Millisecond)
		select {
		case <-ctx.Done():
			timer.Stop()
			return ctx.Err()
		case <-vite.done:
			timer.Stop()
			if err := vite.exitErr(); err != nil {
				return fmt.Errorf("Vite exited before ready: %w", err)
			}
			return errors.New("Vite exited before ready")
		case <-timer.C:
		}
	}
	return fmt.Errorf("Vite not reachable at %s after 30 attempts", r.rendererURL())
}

func (r *Runner) watchAndRebuild(ctx context.Context) {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		slog.Warn("cannot create desktop source watcher", "error", err)
		return
	}
	defer watcher.Close()

	if err := r.addWatchDirs(watcher); err != nil {
		slog.Warn("cannot set up desktop source watcher", "error", err)
		return
	}

	debouncer := watch.NewDebouncer(ctx, 300*time.Millisecond, watch.RealTimerFactory)
	defer debouncer.Close()

	for {
		select {
		case <-ctx.Done():
			return
		case event, ok := <-watcher.Events:
			if !ok {
				return
			}
			if !watch.IsRelevant(event.Name) {
				continue
			}
			if !watch.IsSourceMutation(event.Op) {
				continue
			}
			debouncer.Schedule(func(restartCtx context.Context) {
				if err := r.rebuildAndRestart(restartCtx); err != nil &&
					!errors.Is(err, context.Canceled) {
					slog.Error("desktop sidecar rebuild/restart failed", "error", err)
				}
			})
		case err, ok := <-watcher.Errors:
			if !ok {
				return
			}
			slog.Warn("desktop source watcher error", "error", err)
		}
	}
}

var goWatchExcludes = map[string]bool{
	"bin":          true,
	"node_modules": true,
	"dist":         true,
	"testdata":     true,
	"vendor":       true,
	".git":         true,
	".claude":      true,
}

func (r *Runner) addWatchDirs(watcher *fsnotify.Watcher) error {
	roots := []string{
		r.desktopDir,
		filepath.Join(r.repoRoot, "x", "proc"),
		filepath.Join(r.repoRoot, "x", "go-sql-driver-mysql"),
	}
	for _, root := range roots {
		if _, err := os.Stat(root); errors.Is(err, os.ErrNotExist) {
			continue
		} else if err != nil {
			return err
		}
		if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
			if err != nil {
				return nil
			}
			if !entry.IsDir() {
				return nil
			}
			if path != root {
				name := entry.Name()
				if goWatchExcludes[name] || strings.HasPrefix(name, ".") {
					return filepath.SkipDir
				}
			}
			if err := watcher.Add(path); err != nil {
				slog.Warn("cannot watch Go source directory", "path", path, "error", err)
			}
			return nil
		}); err != nil {
			return err
		}
	}
	return nil
}

func (r *Runner) rebuildAndRestart(ctx context.Context) error {
	r.restartMu.Lock()
	defer r.restartMu.Unlock()

	if err := ctx.Err(); err != nil {
		return err
	}
	slog.Info("Go change detected, rebuilding pure Go sidecar...")
	spec, err := r.sidecarBuildSpec()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(r.sidecarCandidatePath), 0o755); err != nil {
		return err
	}
	_ = os.Remove(r.sidecarCandidatePath)
	if err := r.deps.runCommand(ctx, spec); err != nil {
		return fmt.Errorf("sidecar build failed: %w", err)
	}
	if err := ctx.Err(); err != nil {
		return err
	}

	oldElectron := r.takeElectron()
	if oldElectron != nil {
		r.processes.Stop(oldElectron)
	}
	if err := r.cleanupOwnedDaemon(ctx); err != nil {
		return fmt.Errorf("cleanup previous Electron-owned daemon: %w", err)
	}
	if err := replaceSidecar(r.sidecarCandidatePath, r.sidecarPath); err != nil {
		return fmt.Errorf("install rebuilt sidecar: %w", err)
	}
	if err := ctx.Err(); err != nil {
		return err
	}

	electron, err := r.startElectron(ctx)
	if err != nil {
		return fmt.Errorf("restart Electron: %w", err)
	}
	r.setElectron(electron)
	slog.Info("sidecar rebuilt; Electron and its owned daemon restarted")
	return nil
}

func (r *Runner) cleanupOwnedDaemon(ctx context.Context) error {
	return r.deps.cleanupOwnedDaemon(ctx, r.configDir)
}

func (r *Runner) setVite(process *childProcess) {
	r.processMu.Lock()
	defer r.processMu.Unlock()
	r.vite = process
}

func (r *Runner) takeVite() *childProcess {
	r.processMu.Lock()
	defer r.processMu.Unlock()
	process := r.vite
	r.vite = nil
	return process
}

func (r *Runner) setElectron(process *childProcess) {
	r.processMu.Lock()
	defer r.processMu.Unlock()
	r.electron = process
}

func (r *Runner) takeElectron() *childProcess {
	r.processMu.Lock()
	defer r.processMu.Unlock()
	process := r.electron
	r.electron = nil
	return process
}

func replaceEnv(environment []string, name, value string) []string {
	result := removeEnv(environment, name)
	return append(result, name+"="+value)
}

func removeEnv(environment []string, names ...string) []string {
	result := make([]string, 0, len(environment))
	for _, entry := range environment {
		name, _, ok := strings.Cut(entry, "=")
		if ok && slices.Contains(names, name) {
			continue
		}
		result = append(result, entry)
	}
	return result
}
