package cmd

import (
	"bufio"
	"bytes"
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"strings"
	"syscall"
	"time"

	"github.com/fatih/color"
	"github.com/seznam/jailoc/internal/config"
	"github.com/seznam/jailoc/internal/docker"
	"github.com/seznam/jailoc/internal/logging"
	"github.com/seznam/jailoc/internal/password"
	"github.com/seznam/jailoc/internal/update"
	"github.com/seznam/jailoc/internal/workspace"
	"github.com/spf13/cobra"
	"golang.org/x/term"
)

var workspaceFlag string
var workspaceExplicit bool
var appVersion string
var remoteFlag, execFlag bool
var sessionFlag string
var continueFlag bool

var rootCmd = &cobra.Command{
	Use:          "jailoc [path]",
	Short:        "Manage sandboxed OpenCode Docker environments",
	SilenceUsage: true,
	Args:         cobra.MaximumNArgs(1),
	PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
		_ = logging.Init()
		slog.Info("jailoc started", "version", appVersion)
		if noColor, _ := cmd.Flags().GetBool("no-color"); noColor {
			color.NoColor = true
		}
		workspaceExplicit = cmd.Flags().Changed("workspace")
		return nil
	},
	RunE: func(cmd *cobra.Command, args []string) error {
		ctx := cmd.Context()

		targetPath, err := resolveTargetPath(args)
		if err != nil {
			return fmt.Errorf("resolve target path: %w", err)
		}

		cfg, err := config.Load()
		if err != nil {
			return fmt.Errorf("load config: %w", err)
		}

		if !workspaceExplicit {
			resolved, _, cwdErr := workspace.ResolveFromCWD(cfg, targetPath)
			switch {
			case cwdErr == nil:
				workspaceFlag = resolved.Name
			case errors.Is(cwdErr, workspace.ErrNoMatch):
				// no workspace matches target — keep default
			default:
				return fmt.Errorf("resolve workspace from target path: %w", cwdErr)
			}
		}

		ws, err := workspace.Resolve(cfg, workspaceFlag)
		if err != nil {
			return fmt.Errorf("resolve workspace %q: %w", workspaceFlag, err)
		}

		if !workspace.MatchesCWD(ws, targetPath) {
			_, _ = color.New(color.FgYellow, color.Bold).Printf("Path %s is not in workspace %s. Add it? [y/N]: ", targetPath, ws.Name)
			answer, err := readLineCtx(ctx)
			if err != nil {
				return fmt.Errorf("read add-to-workspace prompt response: %w", err)
			}

			trimmed := strings.ToLower(strings.TrimSpace(answer))
			if trimmed != "y" && trimmed != "yes" {
				return nil
			}

			ok, err := confirmBroadPath(ctx, targetPath)
			if err != nil {
				return err
			}
			if !ok {
				return nil
			}

			if err := config.AddPath(workspaceFlag, targetPath); err != nil {
				return fmt.Errorf("add path %q to workspace %q: %w", targetPath, workspaceFlag, err)
			}
			_, _ = color.New(color.FgGreen).Printf("Added %s to workspace %s\n", targetPath, workspaceFlag)

			cfg, err = config.Load()
			if err != nil {
				return fmt.Errorf("reload config: %w", err)
			}
			ws, err = workspace.Resolve(cfg, workspaceFlag)
			if err != nil {
				return fmt.Errorf("re-resolve workspace %q: %w", workspaceFlag, err)
			}
		}

		composePath := filepath.Join(ComposeCacheDir(ws.Name), "docker-compose.yml")
		client := docker.NewClient(composePath, "", ws.Name)
		running, err := client.IsRunning(ctx)
		if err != nil {
			if !isComposeFileMissing(err) {
				return fmt.Errorf("check workspace %q running status: %w", ws.Name, err)
			}
			running = false
		}

		if !running {
			workspaceExplicit = true
			if err := runUp(ctx, nil); err != nil {
				return err
			}
			_, _ = color.New(color.FgCyan).Printf("Waiting for OpenCode to be ready...\n")
			if err := waitForReadiness(ctx, ws.Port, ws.ExposePort, client); err != nil {
				return fmt.Errorf("wait for workspace %q readiness: %w", ws.Name, err)
			}
		} else {
			interactive := term.IsTerminal(int(os.Stdin.Fd())) //nolint:gosec // G115: uintptr→int is safe for file descriptors
			resolver := password.DefaultResolver(interactive, cfg.PasswordMode)
			pwSource, peekErr := resolver.Peek(ws.Name)
			if peekErr != nil {
				return fmt.Errorf("check password for workspace %q: %w", ws.Name, peekErr)
			}
			if pwSource == "" {
				workspaceExplicit = true
				if err := runUp(ctx, nil); err != nil {
					return fmt.Errorf("migrate workspace %q: %w", ws.Name, err)
				}
				_, _ = color.New(color.FgCyan).Printf("Waiting for OpenCode to be ready...\n")
				if err := waitForReadiness(ctx, ws.Port, ws.ExposePort, client); err != nil {
					return fmt.Errorf("wait for workspace %q readiness: %w", ws.Name, err)
				}
			}
		}

		mode := resolveFromFlags(cmd, cfg)
		if !ws.ExposePort && mode != config.ModeExec {
			if remoteFlag {
				return fmt.Errorf("remote mode is unavailable for workspace %q because the port is not exposed", ws.Name)
			}
			mode = config.ModeExec
		}
		attachCtx, stop, err := startAttachWatch(ctx, client, ws.Name)
		if err != nil {
			return err
		}
		defer stop()

		var attachErr error
		switch mode {
		case config.ModeExec:
			_, _ = color.New(color.FgCyan).Printf("Attaching to workspace %s (exec mode)...\n", ws.Name)
			attachErr = attachExec(attachCtx, client, targetPath, sessionFlag, continueFlag)
		default:
			_, _ = color.New(color.FgCyan).Printf("Attaching to workspace %s (remote mode)...\n", ws.Name)
			attachErr = attachOnHost(attachCtx, ws, targetPath, cfg.PasswordMode, sessionFlag, continueFlag)
			if errors.Is(attachErr, errPTYUnsupported) {
				if remoteFlag {
					return fmt.Errorf("remote mode requires PTY support, which is not available on this platform; use --exec instead")
				}
				_, _ = color.New(color.FgYellow).Println("PTY not supported, falling back to exec mode...")
				attachErr = attachExec(attachCtx, client, targetPath, sessionFlag, continueFlag)
			}
		}
		if attachErr != nil {
			return fmt.Errorf("attach to workspace %q: %w", ws.Name, attachErr)
		}

		return nil
	},
}

func init() {
	rootCmd.PersistentFlags().StringVarP(&workspaceFlag, "workspace", "w", "default", "workspace name")
	rootCmd.PersistentFlags().BoolVar(&remoteFlag, "remote", false, "Use remote mode (host-side opencode attach)")
	rootCmd.PersistentFlags().BoolVar(&execFlag, "exec", false, "Use exec mode (docker exec opencode inside container)")
	rootCmd.PersistentFlags().Bool("no-color", false, "disable colored output")
	rootCmd.Flags().StringVarP(&sessionFlag, "session", "s", "", "session ID to continue")
	rootCmd.Flags().BoolVarP(&continueFlag, "continue", "c", false, "continue the last session")
	rootCmd.MarkFlagsMutuallyExclusive("remote", "exec")
	rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
}

// resolveTargetPath returns the absolute path from a positional argument.
// Falls back to the current working directory when no argument is given.
func resolveTargetPath(args []string) (string, error) {
	if len(args) == 0 {
		cwd, err := os.Getwd()
		if err != nil {
			return "", fmt.Errorf("get current directory: %w", err)
		}
		return cwd, nil
	}

	expanded, err := config.ExpandPath(args[0])
	if err != nil {
		return "", fmt.Errorf("expand path %q: %w", args[0], err)
	}

	abs, err := filepath.Abs(expanded)
	if err != nil {
		return "", fmt.Errorf("resolve absolute path %q: %w", expanded, err)
	}

	info, err := os.Stat(abs)
	if err != nil {
		if os.IsNotExist(err) {
			return "", fmt.Errorf("path %q does not exist", abs)
		}
		return "", fmt.Errorf("stat path %q: %w", abs, err)
	}

	if !info.IsDir() {
		return "", fmt.Errorf("path %q is not a directory", abs)
	}

	return abs, nil
}

func isBroadPath(path string) bool {
	if path == "/" {
		return true
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return false
	}
	return path == home
}

func confirmBroadPath(ctx context.Context, path string) (bool, error) {
	if !isBroadPath(path) {
		return true, nil
	}

	_, _ = color.New(color.FgYellow).Printf("WARNING: %q is a very broad path — this will mount your entire directory tree into the container.\n", path)
	_, _ = color.New(color.FgYellow, color.Bold).Print("Are you sure? [y/N]: ")

	answer, err := readLineCtx(ctx)
	if err != nil {
		return false, fmt.Errorf("read broad-path confirmation: %w", err)
	}

	trimmed := strings.ToLower(strings.TrimSpace(answer))
	return trimmed == "y" || trimmed == "yes", nil
}

// readLineCtx reads a line from stdin, returning ctx.Err() on cancellation.
// Uses SetReadDeadline to interrupt the blocked read — no goroutine leak.
// Pattern from encoredev/encore (cli/internal/login/deviceauth.go).
func readLineCtx(ctx context.Context) (string, error) {
	type result struct {
		line string
		err  error
	}
	ch := make(chan result, 1)
	go func() {
		reader := bufio.NewReader(os.Stdin)
		line, err := reader.ReadString('\n')
		ch <- result{line, err}
	}()

	select {
	case <-ctx.Done():
		if err := os.Stdin.SetReadDeadline(time.Now()); err == nil {
			<-ch
			_ = os.Stdin.SetReadDeadline(time.Time{})
		}
		return "", ctx.Err()
	case r := <-ch:
		return r.line, r.err
	}
}

// resolveFromFlags returns the effective access mode based on CLI flags and config.
// Priority: --remote/--exec flag → config mode → auto-detect via LookPath.
func resolveFromFlags(_ *cobra.Command, cfg *config.Config) string {
	if remoteFlag {
		return config.ModeRemote
	}
	if execFlag {
		return config.ModeExec
	}
	return config.ResolveMode(cfg.Mode)
}

// Execute is the entrypoint for the CLI. Version info is passed from main via ldflags.
func Execute(version, commit, date string) error {
	appVersion = version
	rootCmd.Version = fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date)

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	rootCmd.SetContext(ctx)

	// Fire non-blocking update check only when stderr is a terminal —
	// skip the HTTP call entirely in non-interactive environments.
	var updateResult <-chan *update.Result
	if term.IsTerminal(int(os.Stderr.Fd())) { //nolint:gosec // Fd() fits int on all supported 64-bit platforms
		updateResult = update.CheckAsync(ctx, version)
	}
	defer func() {
		if updateResult == nil {
			return
		}
		// Non-blocking read: if result not ready (fast command), skip gracefully.
		select {
		case res := <-updateResult:
			if res != nil {
				fmt.Fprint(os.Stderr, update.FormatNotice(res.Current, res.Latest))
			}
		default:
		}
	}()

	return rootCmd.Execute()
}

const (
	readyPollInterval = 200 * time.Millisecond
	readyPollTimeout  = 60 * time.Second
)

func waitForReadiness(ctx context.Context, port int, exposePort bool, dc *docker.Client) error {
	if exposePort {
		return waitForReady(ctx, port, dc)
	}
	return waitForReadyExec(ctx, workspace.BasePort, dc)
}

func waitForReady(ctx context.Context, port int, dc *docker.Client) error {
	url := fmt.Sprintf("http://localhost:%d", port)

	ctx, cancel := context.WithTimeout(ctx, readyPollTimeout)
	defer cancel()

	httpClient := &http.Client{Timeout: 2 * time.Second}
	ticker := time.NewTicker(readyPollInterval)
	defer ticker.Stop()

	for {
		state, exitCode, err := dc.ContainerState(ctx)
		if err == nil && state == "exited" {
			return fmt.Errorf("container exited with code %d before becoming ready", exitCode)
		}

		select {
		case <-ctx.Done():
			return fmt.Errorf("timed out after %s waiting for %s", readyPollTimeout, url)
		case <-ticker.C:
			req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
			if err != nil {
				return fmt.Errorf("create readiness request: %w", err)
			}
			resp, err := httpClient.Do(req) //nolint:gosec // URL is localhost with controlled port
			if err != nil {
				continue
			}
			_ = resp.Body.Close()
			return nil
		}
	}
}

func waitForReadyExec(ctx context.Context, port int, dc *docker.Client) error {
	ctx, cancel := context.WithTimeout(ctx, readyPollTimeout)
	defer cancel()

	ticker := time.NewTicker(readyPollInterval)
	defer ticker.Stop()

	probeArgs := []string{"curl", "-s", fmt.Sprintf("http://localhost:%d", port)}

	for {
		state, exitCode, err := dc.ContainerState(ctx)
		if err == nil && state == "exited" {
			return fmt.Errorf("container exited with code %d before becoming ready", exitCode)
		}

		select {
		case <-ctx.Done():
			return fmt.Errorf("timed out after %s waiting for in-container readiness probe", readyPollTimeout)
		case <-ticker.C:
			if err := dc.Exec(ctx, probeArgs, nil, nil, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
				continue
			}
			return nil
		}
	}
}
