package tilt

import (
	"context"
	"fmt"
	"net"
	"os"
	"os/exec"
	"strconv"
	"strings"
	"time"

	"github.com/tidbcloud/controlplane9/internal/verbose"
)

const (
	defaultReadyTimeout      = 20 * time.Second
	defaultReadyPollInterval = 200 * time.Millisecond
	defaultReadySettle       = 1 * time.Second
)

// Request describes one generated Tilt session invocation.
type Request struct {
	Tiltfile    string
	KubeContext string
	Kubeconfig  string
	Env         []string
	Host        string
	Port        int
	LogPath     string
}

// StartedProcess captures the launched long-running Tilt process metadata.
type StartedProcess struct {
	PID          int
	Command      []string
	LogPath      string
	ChildPIDPath string
	StartedAt    time.Time
	Exit         <-chan error
}

// LogsRequest captures the log query routed through a running Tilt session.
type LogsRequest struct {
	Host      string
	Port      int
	Resources []string
	Follow    bool
	Since     string
	Level     string
	Tail      int
}

// CLI executes the Tilt CLI and waits for the API server when necessary.
type CLI struct {
	CommandContext      func(ctx context.Context, name string, args ...string) *exec.Cmd
	Command             func(name string, args ...string) *exec.Cmd
	LookPath            func(file string) (string, error)
	RunCommand          func(cmd *exec.Cmd) ([]byte, error)
	DialContext         func(ctx context.Context, network, address string) (net.Conn, error)
	ReadyTimeout        time.Duration
	ReadyPollInterval   time.Duration
	ReadySettleDuration time.Duration
	Now                 func() time.Time
}

// NewCLI returns the default Tilt CLI runner used in production code.
func NewCLI() *CLI {
	dialer := &net.Dialer{Timeout: 500 * time.Millisecond}
	return &CLI{
		CommandContext:      exec.CommandContext,
		Command:             exec.Command,
		LookPath:            exec.LookPath,
		RunCommand:          verbose.RunCommand,
		DialContext:         dialer.DialContext,
		ReadyTimeout:        defaultReadyTimeout,
		ReadyPollInterval:   defaultReadyPollInterval,
		ReadySettleDuration: defaultReadySettle,
		Now:                 time.Now,
	}
}

// VerifyBinary confirms that the Tilt CLI is available on PATH.
func (c *CLI) VerifyBinary() error {
	if c == nil {
		return fmt.Errorf("tilt CLI is not configured")
	}
	if c.LookPath == nil {
		return fmt.Errorf("tilt CLI lookPath is not configured")
	}
	if _, err := c.LookPath("tilt"); err != nil {
		return fmt.Errorf("tilt binary not found: %w", err)
	}
	return nil
}

// RunCI executes `tilt ci` against the generated Tiltfile.
func (c *CLI) RunCI(ctx context.Context, req *Request) error {
	if err := c.VerifyBinary(); err != nil {
		return err
	}
	req = normalizeRequest(req)
	if c.CommandContext == nil || c.RunCommand == nil {
		return fmt.Errorf("tilt CLI command runner is not configured")
	}
	cmd := c.CommandContext(ctx, "tilt", ciArgs(req)...)
	applyRequestEnv(cmd, req)
	output, err := c.RunCommand(cmd)
	if err != nil {
		return fmt.Errorf("tilt ci failed: %w\n%s", err, strings.TrimSpace(string(output)))
	}
	return nil
}

// Down executes `tilt down` against the generated Tiltfile.
func (c *CLI) Down(ctx context.Context, req *Request) error {
	if err := c.VerifyBinary(); err != nil {
		return err
	}
	req = normalizeRequest(req)
	if c.CommandContext == nil || c.RunCommand == nil {
		return fmt.Errorf("tilt CLI command runner is not configured")
	}
	cmd := c.CommandContext(ctx, "tilt", downArgs(req)...)
	applyRequestEnv(cmd, req)
	output, err := c.RunCommand(cmd)
	if err != nil {
		return fmt.Errorf("tilt down failed: %w\n%s", err, strings.TrimSpace(string(output)))
	}
	return nil
}

// StartUp launches `tilt up` in the background, waits until the API server is
// reachable, and returns the persisted process metadata.
func (c *CLI) StartUp(ctx context.Context, req *Request) (*StartedProcess, error) {
	if err := c.VerifyBinary(); err != nil {
		return nil, err
	}
	req = normalizeRequest(req)
	if c.Command == nil || c.DialContext == nil {
		return nil, fmt.Errorf("tilt CLI background runner is not configured")
	}
	if strings.TrimSpace(req.LogPath) == "" {
		return nil, fmt.Errorf("tilt up log path is required")
	}
	if err := os.MkdirAll(filepathDir(req.LogPath), 0o755); err != nil {
		return nil, fmt.Errorf("create tilt log dir: %w", err)
	}
	logFile, err := os.Create(req.LogPath)
	if err != nil {
		return nil, fmt.Errorf("create tilt log file: %w", err)
	}
	args := upArgs(req)
	cmd := c.Command("tilt", args...)
	cmd.SysProcAttr = detachedSysProcAttr()
	applyRequestEnv(cmd, req)
	cmd.Stdout = logFile
	cmd.Stderr = logFile
	if err := cmd.Start(); err != nil {
		_ = logFile.Close()
		return nil, fmt.Errorf("start tilt up: %w", err)
	}
	_ = logFile.Close()

	waitCh := make(chan error, 1)
	go func() {
		waitCh <- cmd.Wait()
	}()

	host, port := normalizeHostPort(req.Host, req.Port)
	address := net.JoinHostPort(host, strconv.Itoa(port))
	timeout := c.ReadyTimeout
	if timeout <= 0 {
		timeout = defaultReadyTimeout
	}
	poll := c.ReadyPollInterval
	if poll <= 0 {
		poll = defaultReadyPollInterval
	}
	deadline := c.now().Add(timeout)
	for c.now().Before(deadline) {
		select {
		case <-ctx.Done():
			if cmd.Process != nil {
				_ = cmd.Process.Kill()
			}
			<-waitCh
			return nil, ctx.Err()
		case err := <-waitCh:
			logText, _ := os.ReadFile(req.LogPath)
			return nil, tiltUpExitError("tilt up exited before ready", err, logText)
		default:
		}

		probeCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
		conn, err := c.DialContext(probeCtx, "tcp", address)
		cancel()
		if err == nil {
			_ = conn.Close()
			if err := c.waitForStableReadyProcess(ctx, waitCh, req.LogPath); err != nil {
				if cmd.Process != nil {
					_ = cmd.Process.Kill()
				}
				return nil, err
			}
			if err := rejectTiltfileLoadError(req.LogPath); err != nil {
				if cmd.Process != nil {
					_ = cmd.Process.Kill()
				}
				return nil, err
			}
			return &StartedProcess{
				PID:       cmd.Process.Pid,
				Command:   append([]string{"tilt"}, args...),
				LogPath:   req.LogPath,
				StartedAt: c.now(),
			}, nil
		}
		time.Sleep(poll)
	}

	if cmd.Process != nil {
		_ = cmd.Process.Kill()
	}
	waitErr := <-waitCh
	logText, _ := os.ReadFile(req.LogPath)
	return nil, tiltUpExitError("tilt up did not become ready", waitErr, logText)
}

func (c *CLI) waitForStableReadyProcess(ctx context.Context, waitCh <-chan error, logPath string) error {
	settle := c.readySettleDuration()
	if settle <= 0 {
		return nil
	}
	timer := time.NewTimer(settle)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case err := <-waitCh:
		logText, _ := os.ReadFile(logPath)
		return tiltUpExitError("tilt up exited after API ready", err, logText)
	case <-timer.C:
		return nil
	}
}

func rejectTiltfileLoadError(logPath string) error {
	logText, err := os.ReadFile(logPath)
	if err != nil {
		return nil
	}
	log := strings.TrimSpace(string(logText))
	if log == "" || !strings.Contains(log, "Loading Tiltfile at:") {
		return nil
	}
	if strings.Contains(log, "ERROR: Traceback") || strings.Contains(log, "\nError in ") {
		return tiltUpExitError("tilt up failed to load Tiltfile", fmt.Errorf("tiltfile load error"), logText)
	}
	return nil
}

func tiltUpExitError(prefix string, err error, logText []byte) error {
	reason := "process exited"
	if err != nil {
		reason = err.Error()
	}
	log := strings.TrimSpace(string(logText))
	if log == "" {
		return fmt.Errorf("%s: %s", prefix, reason)
	}
	return fmt.Errorf("%s: %s\n%s", prefix, reason, log)
}

// Trigger executes `tilt trigger` against a running Tilt session.
func (c *CLI) Trigger(ctx context.Context, host string, port int, resource string) error {
	if err := c.VerifyBinary(); err != nil {
		return err
	}
	if c.CommandContext == nil || c.RunCommand == nil {
		return fmt.Errorf("tilt CLI command runner is not configured")
	}
	cmd := c.CommandContext(ctx, "tilt", triggerArgs(host, port, resource)...)
	output, err := c.RunCommand(cmd)
	if err != nil {
		return fmt.Errorf("tilt trigger %s failed: %w\n%s", resource, err, strings.TrimSpace(string(output)))
	}
	return nil
}

// Logs executes `tilt logs` against a running Tilt session.
func (c *CLI) Logs(ctx context.Context, req *LogsRequest) ([]byte, error) {
	if err := c.VerifyBinary(); err != nil {
		return nil, err
	}
	if c.CommandContext == nil || c.RunCommand == nil {
		return nil, fmt.Errorf("tilt CLI command runner is not configured")
	}
	cmd := c.CommandContext(ctx, "tilt", logsArgs(req)...)
	output, err := c.RunCommand(cmd)
	if err != nil {
		return nil, fmt.Errorf("tilt logs failed: %w\n%s", err, strings.TrimSpace(string(output)))
	}
	return output, nil
}

// ResourceReadiness reads Tilt UIResource readiness through the running Tilt API.
func (c *CLI) ResourceReadiness(ctx context.Context, host string, port int) (*ResourceReadiness, error) {
	if err := c.VerifyBinary(); err != nil {
		return nil, err
	}
	if c.CommandContext == nil || c.RunCommand == nil {
		return nil, fmt.Errorf("tilt CLI command runner is not configured")
	}
	cmd := c.CommandContext(ctx, "tilt", resourceReadinessArgs(host, port)...)
	output, err := c.RunCommand(cmd)
	if err != nil {
		return nil, fmt.Errorf("tilt get uiresources failed: %w\n%s", err, strings.TrimSpace(string(output)))
	}
	return ParseUIResourceReadiness(output)
}

func ciArgs(req *Request) []string {
	req = normalizeRequest(req)
	host, port := normalizeHostPort(req.Host, req.Port)
	return []string{
		"ci",
		"--file", req.Tiltfile,
		"--context", req.KubeContext,
		"--host", host,
		"--port", strconv.Itoa(port),
	}
}

func upArgs(req *Request) []string {
	req = normalizeRequest(req)
	host, port := normalizeHostPort(req.Host, req.Port)
	return []string{
		"up",
		"--file", req.Tiltfile,
		"--context", req.KubeContext,
		"--host", host,
		"--port", strconv.Itoa(port),
		"--stream=true",
	}
}

func downArgs(req *Request) []string {
	req = normalizeRequest(req)
	return []string{
		"down",
		"--file", req.Tiltfile,
		"--context", req.KubeContext,
	}
}

func triggerArgs(host string, port int, resource string) []string {
	host, port = normalizeHostPort(host, port)
	return []string{
		"trigger",
		resource,
		"--host", host,
		"--port", strconv.Itoa(port),
	}
}

func logsArgs(req *LogsRequest) []string {
	if req == nil {
		return []string{"logs"}
	}
	host, port := normalizeHostPort(req.Host, req.Port)
	tail := req.Tail
	if tail <= 0 {
		tail = 200
	}
	args := []string{"logs"}
	args = append(args, req.Resources...)
	args = append(args, "--host", host, "--port", strconv.Itoa(port), "--source", "runtime", "--tail", strconv.Itoa(tail))
	if req.Follow {
		args = append(args, "--follow")
	}
	if value := strings.TrimSpace(req.Since); value != "" {
		args = append(args, "--since", value)
	}
	if value := strings.TrimSpace(req.Level); value != "" {
		args = append(args, "--level", value)
	}
	return args
}

func resourceReadinessArgs(host string, port int) []string {
	host, port = normalizeHostPort(host, port)
	return []string{
		"get", "uiresources",
		"-o", "json",
		"--host", host,
		"--port", strconv.Itoa(port),
	}
}

func applyKubeconfigEnv(cmd *exec.Cmd, kubeconfig string) {
	if cmd == nil {
		return
	}
	if len(cmd.Env) == 0 {
		cmd.Env = os.Environ()
	}
	if strings.TrimSpace(kubeconfig) != "" {
		cmd.Env = append(cmd.Env, "KUBECONFIG="+kubeconfig)
	}
}

func applyRequestEnv(cmd *exec.Cmd, req *Request) {
	if cmd == nil || req == nil {
		return
	}
	applyKubeconfigEnv(cmd, req.Kubeconfig)
	if len(req.Env) > 0 {
		if len(cmd.Env) == 0 {
			cmd.Env = os.Environ()
		}
		cmd.Env = append(cmd.Env, req.Env...)
	}
}

func normalizeRequest(req *Request) *Request {
	if req == nil {
		return &Request{}
	}
	return req
}

func normalizeHostPort(host string, port int) (string, int) {
	host = strings.TrimSpace(host)
	if host == "" {
		host = DefaultAPIHost
	}
	if port <= 0 {
		port = DefaultAPIPort
	}
	return host, port
}

func filepathDir(path string) string {
	dir := strings.TrimSpace(path)
	if dir == "" {
		return "."
	}
	if idx := strings.LastIndex(dir, string(os.PathSeparator)); idx >= 0 {
		if idx == 0 {
			return string(os.PathSeparator)
		}
		return dir[:idx]
	}
	return "."
}

func (c *CLI) now() time.Time {
	if c == nil || c.Now == nil {
		return time.Now()
	}
	return c.Now()
}

func (c *CLI) readySettleDuration() time.Duration {
	if c == nil || c.ReadySettleDuration <= 0 {
		return defaultReadySettle
	}
	return c.ReadySettleDuration
}
