package agent

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"os/exec"
	"strings"
	"sync"
	"time"

	"github.com/tidbcloud/AgentTeam/desktop/internal/agent/drivercontract"
	"github.com/tidbcloud/AgentTeam/desktop/internal/agent/runtimeconfig"
)

// External OpenCode uses a shared user data directory by default, while
// AgentTeam-managed OpenCode processes for the same agent share one persistent
// AgentTeam-owned store. SQLite migrations are not safe when multiple serve
// processes initialize either store concurrently, so every OpenCode launch path
// shares one startup gate until the new server passes /global/health or fails.
type openCodeServerStartupGate chan struct{}

var processOpenCodeServerStartupGate = make(openCodeServerStartupGate, 1)

func (g openCodeServerStartupGate) acquire(ctx context.Context) (func(), error) {
	select {
	case g <- struct{}{}:
		return func() { <-g }, nil
	case <-ctx.Done():
		return nil, ctx.Err()
	}
}

func (g openCodeServerStartupGate) run(ctx context.Context, startup func() error) error {
	release, err := g.acquire(ctx)
	if err != nil {
		return err
	}
	defer release()
	return startup()
}

// startTemporaryServer starts an opencode serve process and waits for it to
// become healthy. It returns a cleanup function that terminates the process.
func (d *OpenCodeDriver) startTemporaryServer(ctx context.Context, cfg drivercontract.LaunchConfig) (cleanup func(), err error) {
	password := generateRandomPassword()
	launchID := newOpenCodeLaunchID()

	d.mu.Lock()
	d.httpClient = &http.Client{Timeout: 120 * time.Second}
	d.serverURL = ""
	d.password = ""
	d.serverCancel = nil
	d.workDir = cfg.WorkDir
	d.runtimeDebugEnabled = cfg.RuntimeDebugEnabled
	d.serveLogPath = ""
	d.runtimeDebugLog = nil
	d.runtimeDebugAgentID = strings.TrimSpace(cfg.AgentID)
	d.runtimeDebugLaunchID = launchID
	d.mu.Unlock()

	binary, err := resolveOpenCodeLaunchBinary(cfg)
	if err != nil {
		return nil, fmt.Errorf("resolve binary: %w", err)
	}
	cfg.Binary = binary

	// Runtime diagnostics are written as AgentTeam-owned, scrubbed JSONL. Keep the
	// raw OpenCode process log level bounded because upstream DEBUG logs may
	// include request or prompt bodies that AgentTeam must not persist.
	logLevel := "WARN"

	env, err := buildOpenCodeServeEnv(cfg, password)
	if err != nil {
		return nil, fmt.Errorf("build serve env: %w", err)
	}
	stderr := newCapturingPrefixWriter("[opencode-detached] ", openCodeCapturedOutputMaxBytes)
	stdout := newCapturingPrefixWriter("[opencode-detached-stdout] ", openCodeCapturedOutputMaxBytes)
	diagnosticLogs, err := d.attachOpenCodeDiagnosticLogs(cfg, stderr, stdout)
	if err != nil {
		return nil, fmt.Errorf("prepare serve log: %w", err)
	}
	serveLogPath := diagnosticLogs.serve.Path()
	diagnostics := openCodeLaunchDiagnostics(cfg, env)

	d.emitRuntimeDebug("opencode detached serve starting", map[string]any{
		"binary":                 binary,
		"workdir":                cfg.WorkDir,
		"opencode_log_level":     logLevel,
		"serve_log_path":         serveLogPath,
		"runtime_debug_log_path": diagnosticLogs.runtimeDebug.Path(),
		"storage_scope":          openCodeStorageScope(diagnostics.isolationFlags),
		"isolation_flags":        diagnostics.isolationFlags,
	})
	startedServer, startupErr := d.startOpenCodeServe(ctx, openCodeServeStart{
		cfg:         cfg,
		env:         env,
		password:    password,
		logLevel:    logLevel,
		stderr:      stderr,
		stdout:      stdout,
		diagnostics: diagnostics,
		onProcessStarted: func(server openCodeStartedServer) {
			d.emitRuntimeDebug("opencode detached serve process started", map[string]any{
				"pid": server.process.cmd.Process.Pid,
				"url": server.url,
			})
		},
	})
	if startupErr != nil {
		d.stopOpenCodeServer(startedServer, password)
		logIgnoredCleanupError("failed to close detached opencode diagnostics logs after startup failure", diagnosticLogs.Close(), "agent_id", cfg.AgentID, "path", serveLogPath)
		return nil, startupErr
	}

	return sync.OnceFunc(func() {
		d.stopOpenCodeServer(startedServer, password)
		logIgnoredCleanupError("failed to close detached opencode diagnostics logs during cleanup", diagnosticLogs.Close(), "agent_id", cfg.AgentID, "path", serveLogPath)
	}), nil
}

func resolveOpenCodeLaunchBinary(cfg drivercontract.LaunchConfig) (string, error) {
	binary := strings.TrimSpace(cfg.Binary)
	if runtimeconfig.IsBuiltinRuntime(cfg.RuntimeType) {
		return runtimeconfig.ResolveRuntimeBinary(runtimeconfig.TypeBuiltin, binary)
	}
	if binary == "" {
		return "opencode", nil
	}
	return binary, nil
}

type openCodeServeProcess struct {
	cmd     *exec.Cmd
	cancel  context.CancelFunc
	exited  chan struct{}
	waitErr error
	stop    sync.Once
}

type openCodeStartedServer struct {
	process *openCodeServeProcess
	url     string
}

type openCodeServeStart struct {
	cfg              drivercontract.LaunchConfig
	env              []string
	password         string
	logLevel         string
	stderr           *capturingPrefixWriter
	stdout           *capturingPrefixWriter
	diagnostics      openCodeLaunchDiagnostic
	onProcessStarted func(openCodeStartedServer)
	allocatePort     func() (int, error)
}

func (d *OpenCodeDriver) startOpenCodeServe(ctx context.Context, start openCodeServeStart) (openCodeStartedServer, error) {
	var started openCodeStartedServer
	err := processOpenCodeServerStartupGate.run(ctx, func() error {
		allocatePort := start.allocatePort
		if allocatePort == nil {
			allocatePort = allocateFreePort
		}
		port, err := allocatePort()
		if err != nil {
			return fmt.Errorf("allocate port: %w", err)
		}
		serverURL := fmt.Sprintf("http://127.0.0.1:%d", port)
		args := []string{
			"--print-logs", "--log-level", start.logLevel,
			"serve",
			"--hostname", "127.0.0.1",
			"--port", fmt.Sprintf("%d", port),
		}
		serverCtx, serverCancel := context.WithCancel(ctx)
		cmd := exec.CommandContext(serverCtx, start.cfg.Binary, args...)
		cmd.Dir = start.cfg.WorkDir
		cmd.Env = start.env
		cmd.Stderr = start.stderr
		cmd.Stdout = start.stdout
		configureRuntimeCommand(cmd)
		if err := cmd.Start(); err != nil {
			serverCancel()
			return fmt.Errorf("start serve: %w", err)
		}

		started = openCodeStartedServer{
			process: watchOpenCodeServeProcess(cmd, serverCancel),
			url:     serverURL,
		}
		d.mu.Lock()
		d.serverURL = started.url
		d.password = start.password
		d.mu.Unlock()

		if start.onProcessStarted != nil {
			start.onProcessStarted(started)
		}

		// Keep the process-wide startup gate until OpenCode has finished any
		// shared-store migrations and reports healthy.
		if err := d.waitForHealthy(ctx, start.cfg.AgentID, started.process, start.stderr.Tail, start.stdout.Tail, start.diagnostics); err != nil {
			stopOpenCodeServerProcess(started.process)
			return fmt.Errorf("health check: %w", err)
		}
		return nil
	})
	return started, err
}

func (d *OpenCodeDriver) clearOpenCodeServerState(server openCodeStartedServer, expectedPassword string) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.serverURL != server.url || d.password != expectedPassword {
		return
	}
	d.serverURL = ""
	d.password = ""
	d.serverCancel = nil
}

func (d *OpenCodeDriver) stopOpenCodeServer(server openCodeStartedServer, expectedPassword string) {
	stopOpenCodeServerProcess(server.process)
	d.clearOpenCodeServerState(server, expectedPassword)
}

func watchOpenCodeServeProcess(cmd *exec.Cmd, cancel context.CancelFunc) *openCodeServeProcess {
	process := &openCodeServeProcess{
		cmd:    cmd,
		cancel: cancel,
		exited: make(chan struct{}),
	}
	go func() {
		process.waitErr = cmd.Wait()
		close(process.exited)
	}()
	return process
}

func stopOpenCodeServerProcess(process *openCodeServeProcess) {
	if process == nil {
		return
	}
	process.stop.Do(func() {
		terminateRuntimeCommand(process.cmd)
		if process.cancel != nil {
			process.cancel()
		}
		<-process.exited
		logIgnoredCleanupError("failed waiting for opencode server cleanup", process.waitErr)
	})
}

// ---------------------------------------------------------------------------
// HTTP API helpers
// ---------------------------------------------------------------------------

// waitForHealthy polls the /global/health endpoint until it returns healthy.
func (d *OpenCodeDriver) waitForHealthy(ctx context.Context, agentID string, process *openCodeServeProcess, stderrTail func() string, stdoutTail func() string, diagnostics openCodeLaunchDiagnostic) error {
	startedAt := time.Now()
	deadline := startedAt.Add(openCodeHealthTimeout)
	nextLogAt := startedAt.Add(openCodeHealthLogInterval)
	lastCheck := ""
	attempt := 0
	d.mu.Lock()
	serverURL := d.serverURL
	d.mu.Unlock()
	processPID := 0
	if process != nil && process.cmd != nil && process.cmd.Process != nil {
		processPID = process.cmd.Process.Pid
	}
	d.emitRuntimeDebug("opencode health check started", map[string]any{
		"url":     serverURL,
		"pid":     processPID,
		"timeout": openCodeHealthTimeout.String(),
	})
	for {
		if process != nil {
			select {
			case <-process.exited:
				reason := "serve process exited before health check passed: process exited successfully"
				if process.waitErr != nil {
					reason = "serve process exited before health check passed: " + process.waitErr.Error()
				}
				d.emitRuntimeDebug("opencode health check process exited", map[string]any{
					"pid":        processPID,
					"attempts":   attempt,
					"elapsed":    time.Since(startedAt).Round(time.Millisecond).String(),
					"last_error": lastCheck,
					"reason":     reason,
				})
				return openCodeHealthCheckError(reason, lastCheck, stderrTail, stdoutTail)
			default:
			}
		}
		now := time.Now()
		remaining := time.Until(deadline)
		if remaining <= 0 {
			d.emitRuntimeDebug("opencode health check timed out", map[string]any{
				"url":        serverURL,
				"pid":        processPID,
				"attempts":   attempt,
				"elapsed":    time.Since(startedAt).Round(time.Millisecond).String(),
				"last_error": lastCheck,
			})
			slog.Warn("opencode health check timed out", append([]any{
				"agent_id", agentID,
				"url", serverURL,
				"pid", processPID,
				"attempts", attempt,
				"elapsed", time.Since(startedAt).Round(time.Millisecond).String(),
				"last_error", lastCheck,
			}, diagnostics.logAttrs()...)...)
			return openCodeHealthCheckError(fmt.Sprintf("timed out after %s", openCodeHealthTimeout), lastCheck, stderrTail, stdoutTail)
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}
		if !now.Before(nextLogAt) {
			slog.Info("opencode health check pending",
				"agent_id", agentID,
				"url", serverURL,
				"pid", processPID,
				"attempts", attempt,
				"elapsed", time.Since(startedAt).Round(time.Millisecond).String(),
				"last_error", lastCheck,
			)
			d.emitRuntimeDebug("opencode health check pending", map[string]any{
				"url":        serverURL,
				"pid":        processPID,
				"attempts":   attempt,
				"elapsed":    time.Since(startedAt).Round(time.Millisecond).String(),
				"last_error": lastCheck,
			})
			nextLogAt = now.Add(openCodeHealthLogInterval)
		}

		attempt++
		attemptTimeout := openCodeHealthAttemptTimeout
		if remaining < attemptTimeout {
			attemptTimeout = remaining
		}
		attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
		req, err := d.newRequest(attemptCtx, http.MethodGet, "/global/health", nil)
		if err != nil {
			cancel()
			lastCheck = "build request: " + err.Error()
			if err := waitOpenCodeRetry(ctx, openCodeHealthPollInterval, time.Until(deadline)); err != nil {
				return err
			}
			continue
		}

		resp, err := d.httpClient.Do(req)
		if err != nil {
			cancel()
			lastCheck = err.Error()
			if err := waitOpenCodeRetry(ctx, openCodeHealthPollInterval, time.Until(deadline)); err != nil {
				return err
			}
			continue
		}
		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		logIgnoredCleanupError("failed to close opencode health response body", resp.Body.Close())
		cancel()

		if resp.StatusCode == http.StatusOK {
			d.emitRuntimeDebug("opencode health check healthy", map[string]any{
				"url":      serverURL,
				"pid":      processPID,
				"attempts": attempt,
				"elapsed":  time.Since(startedAt).Round(time.Millisecond).String(),
			})
			return nil
		}
		lastCheck = fmt.Sprintf("status %d", resp.StatusCode)
		if body := strings.TrimSpace(string(respBody)); body != "" {
			lastCheck += ": " + body
		}
		if err := waitOpenCodeRetry(ctx, openCodeHealthPollInterval, time.Until(deadline)); err != nil {
			return err
		}
	}
}

func openCodeHealthCheckError(reason, lastCheck string, stderrTail func() string, stdoutTail func() string) error {
	parts := []string{strings.TrimSpace(reason)}
	if lastCheck = strings.TrimSpace(lastCheck); lastCheck != "" {
		parts = append(parts, "last health check: "+lastCheck)
	}
	if stderrTail != nil {
		if stderr := scrubOpenCodeLogText(stderrTail()); stderr != "" {
			parts = append(parts, "stderr:\n"+stderr)
		}
	}
	if stdoutTail != nil {
		if stdout := scrubOpenCodeLogText(stdoutTail()); stdout != "" {
			parts = append(parts, "stdout:\n"+stdout)
		}
	}
	return fmt.Errorf("%s", strings.Join(parts, "\n"))
}

func waitOpenCodeRetry(ctx context.Context, delay time.Duration, remaining time.Duration) error {
	if delay <= 0 || remaining <= 0 {
		return nil
	}
	if remaining < delay {
		delay = remaining
	}
	timer := time.NewTimer(delay)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return nil
	}
}

// ---------------------------------------------------------------------------
// Utility functions
// ---------------------------------------------------------------------------

// allocateFreePort finds a free TCP port on 127.0.0.1.
func allocateFreePort() (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
}

// generateRandomPassword generates a random hex password string.
func generateRandomPassword() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return fmt.Sprintf("%x", time.Now().UnixNano())
	}
	return hex.EncodeToString(b)
}

func newOpenCodeLaunchID() string {
	b := make([]byte, 8)
	if _, err := rand.Read(b); err != nil {
		return fmt.Sprintf("ocl_%x", time.Now().UnixNano())
	}
	return "ocl_" + hex.EncodeToString(b)
}
