package launch

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"slices"
	"strings"

	"github.com/ollama/ollama/api"
	"github.com/ollama/ollama/cmd/config"
	modelpkg "github.com/ollama/ollama/types/model"
	"github.com/spf13/cobra"
	"golang.org/x/term"
)

// LauncherState is the launch-owned snapshot used to render the root launcher menu.
type LauncherState struct {
	LastSelection  string
	RunModel       string
	RunModelUsable bool
	Integrations   map[string]LauncherIntegrationState
	AccountState   *AccountState
}

// LauncherIntegrationState is the launch-owned status for one launcher integration.
type LauncherIntegrationState struct {
	Name            string
	DisplayName     string
	Description     string
	Installed       bool
	AutoInstallable bool
	Selectable      bool
	Changeable      bool
	CurrentModel    string
	ModelUsable     bool
	InstallHint     string
	Editor          bool
}

// RunModelRequest controls how the root launcher resolves the chat model.
type RunModelRequest struct {
	ForcePicker          bool
	Policy               *LaunchPolicy
	AccountState         *AccountState
	AccountStateProvider func() *AccountState
	AccountStateUpdates  func(context.Context) <-chan *AccountState
}

// LaunchConfirmMode controls confirmation behavior across launch flows.
type LaunchConfirmMode int

const (
	// LaunchConfirmPrompt prompts the user for confirmation.
	LaunchConfirmPrompt LaunchConfirmMode = iota
	// LaunchConfirmAutoApprove skips prompts and treats confirmation as accepted.
	LaunchConfirmAutoApprove
	// LaunchConfirmRequireYes rejects confirmation requests with a --yes hint.
	LaunchConfirmRequireYes
)

// LaunchMissingModelMode controls local missing-model handling in launch flows.
type LaunchMissingModelMode int

const (
	// LaunchMissingModelPromptToPull prompts to pull a missing local model.
	LaunchMissingModelPromptToPull LaunchMissingModelMode = iota
	// LaunchMissingModelAutoPull pulls a missing local model without prompting.
	LaunchMissingModelAutoPull
	// LaunchMissingModelFail fails immediately when a local model is missing.
	LaunchMissingModelFail
)

// LaunchPolicy controls launch behavior that may vary by caller context.
type LaunchPolicy struct {
	Confirm      LaunchConfirmMode
	MissingModel LaunchMissingModelMode
}

func defaultLaunchPolicy(interactive bool, yes bool) LaunchPolicy {
	policy := LaunchPolicy{
		Confirm:      LaunchConfirmPrompt,
		MissingModel: LaunchMissingModelPromptToPull,
	}
	switch {
	case yes:
		// if yes flag is set, auto approve and auto pull
		policy.Confirm = LaunchConfirmAutoApprove
		policy.MissingModel = LaunchMissingModelAutoPull
	case !interactive:
		// otherwise make sure to stop when needed
		policy.Confirm = LaunchConfirmRequireYes
		policy.MissingModel = LaunchMissingModelFail
	}
	return policy
}

func (p LaunchPolicy) confirmPolicy() launchConfirmPolicy {
	switch p.Confirm {
	case LaunchConfirmAutoApprove:
		return launchConfirmPolicy{yes: true}
	case LaunchConfirmRequireYes:
		return launchConfirmPolicy{requireYesMessage: true}
	default:
		return launchConfirmPolicy{}
	}
}

func (p LaunchPolicy) missingModelPolicy() missingModelPolicy {
	switch p.MissingModel {
	case LaunchMissingModelAutoPull:
		return missingModelAutoPull
	case LaunchMissingModelFail:
		return missingModelFail
	default:
		return missingModelPromptPull
	}
}

// IntegrationLaunchRequest controls the canonical integration launcher flow.
type IntegrationLaunchRequest struct {
	Name                 string
	ModelOverride        string
	ForceConfigure       bool
	ConfigureOnly        bool
	Restore              bool
	ExtraArgs            []string
	Policy               *LaunchPolicy
	AccountState         *AccountState
	AccountStateProvider func() *AccountState
	AccountStateUpdates  func(context.Context) <-chan *AccountState
}

var isInteractiveSession = func() bool {
	return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
}

// Runner executes an integration with the selected model and its resolved
// launch metadata. models is ordered with the primary model first.
type Runner interface {
	Run(model string, models []LaunchModel, args []string) error
	String() string
}

// Editor can edit config files for integrations that support model configuration.
type Editor interface {
	Paths() []string
	Edit(models []LaunchModel) error
	Models() []string
}

// ManagedSingleModel is the narrow launch-owned config path for integrations
// like Hermes that have one primary model selected by launcher, need launcher
// to persist minimal config, and still keep their own model discovery and
// onboarding UX. This stays separate from Runner-only integrations and the
// multi-model Editor flow so Hermes-specific behavior stays scoped to one path.
type ManagedSingleModel interface {
	Paths() []string
	Configure(model string) error
	CurrentModel() string
	Onboard() error
}

// ManagedModelListConfigurer lets managed single-model integrations receive
// the launcher's model list while still preserving one primary selected model.
type ManagedModelListConfigurer interface {
	ConfigureWithModels(primary string, models []LaunchModel) error
}

// ManagedAutodiscoveryIntegration is for managed integrations that do not need
// a launcher-selected model because the app discovers available models itself.
type ManagedAutodiscoveryIntegration interface {
	Paths() []string
	AutodiscoveredModel() string
	AutodiscoveryConfigured() bool
	ConfigureAutodiscovery() error
	Onboard() error
}

// ManagedAutodiscoveryCloudIntegration marks an autodiscovery integration whose
// discovered model catalog depends on the user's local Ollama Cloud auth state.
type ManagedAutodiscoveryCloudIntegration interface {
	UsesOllamaCloud() bool
}

// RestoreHintIntegration can provide a short restore command after launch
// switches an app into a launch-managed mode.
type RestoreHintIntegration interface {
	RestoreHint() string
}

// ConfigurationSuccessIntegration can print a short message after launcher
// successfully switches an app into a launch-managed mode.
type ConfigurationSuccessIntegration interface {
	ConfigurationSuccessMessage() string
}

// RestoreSuccessIntegration can print a short message after launcher restores
// an app back to its default mode.
type RestoreSuccessIntegration interface {
	RestoreSuccessMessage() string
}

// RestoreInstallCheckSkipper lets cleanup-only restore flows run even when the
// external integration binary has already been removed.
type RestoreInstallCheckSkipper interface {
	SkipRestoreInstallCheck() bool
}

// ManagedRuntimeRefresher lets managed integrations refresh any long-lived
// background runtime after launch rewrites their config.
type ManagedRuntimeRefresher interface {
	RefreshRuntimeAfterConfigure() error
}

// ManagedOnboardingValidator lets managed integrations re-check saved
// onboarding state when launcher needs a stronger live readiness signal.
type ManagedOnboardingValidator interface {
	OnboardingComplete() bool
}

// ManagedInteractiveOnboarding lets a managed integration declare whether its
// onboarding step really requires an interactive terminal. Hermes does not.
type ManagedInteractiveOnboarding interface {
	RequiresInteractiveOnboarding() bool
}

// ManagedModelReadinessSkipper lets managed integrations opt out of local
// Ollama model readiness checks when the configured runtime is not the local
// daemon.
type ManagedModelReadinessSkipper interface {
	SkipModelReadiness() bool
}

// RestorableIntegration lets integrations switch back from a launch-managed
// mode to the application's normal/default mode.
type RestorableIntegration interface {
	Restore() error
}

// SupportedIntegration lets an integration report platform support separately
// from whether the underlying app binary is installed.
type SupportedIntegration interface {
	Supported() error
}

// ModelItem represents model metadata before selector-only UI state is derived.
type ModelItem struct {
	Name            string
	Description     string
	Recommended     bool
	VRAMBytes       int64
	MaxOutputTokens int
	RequiredPlan    string
	ToolCapable     bool
	Capabilities    []modelpkg.Capability
	Size            int64
	Details         api.ModelDetails
}

// SelectionItem represents a model row after launch has derived selector-only UI state.
type SelectionItem struct {
	Name              string
	Description       string
	Recommended       bool
	AvailabilityBadge string
}

// LaunchCmd returns the cobra command for launching integrations.
// The runTUI callback is called when the root launcher UI should be shown.
func LaunchCmd(checkServerHeartbeat func(cmd *cobra.Command, args []string) error, runTUI func(cmd *cobra.Command)) *cobra.Command {
	var modelFlag string
	var configFlag bool
	var yesFlag bool
	var restoreFlag bool

	cmd := &cobra.Command{
		Use:   "launch [INTEGRATION] [-- [EXTRA_ARGS...]]",
		Short: "Launch the Ollama menu or an integration",
		Long: `Launch the Ollama interactive menu, or directly launch a specific integration.

Without arguments, this is equivalent to running 'ollama' directly.
Flags and extra arguments require an integration name.

Supported integrations:
  claude          Claude Code
  codex-app       Codex App (aliases: codex-desktop, codex-gui)
  hermes          Hermes Agent
  openclaw        OpenClaw (aliases: clawdbot, moltbot)
  opencode        OpenCode
  codex           Codex
  hermes-desktop  Hermes Desktop
  copilot         Copilot CLI (aliases: copilot-cli)
  omp             OMP
  droid           Droid
  kimi            Kimi Code CLI
  pi              Pi
  pool            Pool
  cline           Cline
  qwen            Qwen Code
  vscode          VS Code (aliases: code)

Examples:
  ollama launch
  ollama launch claude
  ollama launch claude --model <model>
  ollama launch codex-app
  ollama launch codex-app --restore
  ollama launch hermes
  ollama launch hermes-desktop
  ollama launch droid --config (does not auto-launch)
  ollama launch codex --restore
  ollama launch codex -- --sandbox workspace-write`,
		Args: cobra.ArbitraryArgs,
		PreRunE: func(cmd *cobra.Command, args []string) error {
			if restoreFlag || launchCommandCanSkipHeartbeat(args) {
				return nil
			}
			return checkServerHeartbeat(cmd, args)
		},
		RunE: func(cmd *cobra.Command, args []string) error {
			policy := defaultLaunchPolicy(isInteractiveSession(), yesFlag)
			// reset when done to make sure state doens't leak between launches
			restoreConfirmPolicy := withLaunchConfirmPolicy(policy.confirmPolicy())
			defer restoreConfirmPolicy()

			var name string
			var passArgs []string
			dashIdx := cmd.ArgsLenAtDash()

			if dashIdx == -1 {
				if len(args) > 1 {
					return fmt.Errorf("unexpected arguments: %v\nUse '--' to pass extra arguments to the integration", args[1:])
				}
				if len(args) == 1 {
					name = args[0]
				}
			} else {
				if dashIdx > 1 {
					return fmt.Errorf("expected at most 1 integration name before '--', got %d", dashIdx)
				}
				if dashIdx == 1 {
					name = args[0]
				}
				passArgs = args[dashIdx:]
			}

			if name == "" {
				if cmd.Flags().Changed("model") || cmd.Flags().Changed("config") || cmd.Flags().Changed("yes") || cmd.Flags().Changed("restore") || len(passArgs) > 0 {
					return fmt.Errorf("flags and extra args require an integration name, for example: 'ollama launch claude --model qwen3.5'")
				}
				runTUI(cmd)
				return nil
			}

			if !restoreFlag && launchCommandIsClaudeDesktop(name) {
				return errClaudeDesktopUnsupported()
			}

			if modelFlag != "" && isCloudModelName(modelFlag) {
				if client, err := api.ClientFromEnvironment(); err == nil {
					if disabled, _ := cloudStatusDisabled(cmd.Context(), client); disabled {
						fmt.Fprintf(os.Stderr, "Warning: ignoring --model %s because cloud is disabled\n", modelFlag)
						modelFlag = ""
					}
				}
			}

			headlessYes := yesFlag && !isInteractiveSession()
			forceConfigure := configFlag || (modelFlag == "" && !headlessYes)
			if forceConfigure && !configFlag && modelFlag == "" {
				if _, runner, err := LookupIntegration(name); err == nil {
					if _, ok := runner.(ManagedAutodiscoveryIntegration); ok {
						forceConfigure = false
					}
				}
			}
			err := LaunchIntegration(cmd.Context(), IntegrationLaunchRequest{
				Name:           name,
				ModelOverride:  modelFlag,
				ForceConfigure: forceConfigure,
				ConfigureOnly:  configFlag,
				Restore:        restoreFlag,
				ExtraArgs:      passArgs,
				Policy:         &policy,
			})
			if errors.Is(err, ErrCancelled) {
				return nil
			}
			return err
		},
	}

	cmd.Flags().StringVar(&modelFlag, "model", "", "Model to use")
	cmd.Flags().BoolVar(&configFlag, "config", false, "Configure without launching")
	cmd.Flags().BoolVar(&restoreFlag, "restore", false, "Restore an integration to its default profile")
	cmd.Flags().BoolVarP(&yesFlag, "yes", "y", false, "Automatically answer yes to confirmation prompts")
	return cmd
}

func launchCommandCanSkipHeartbeat(args []string) bool {
	if len(args) == 0 {
		return false
	}
	return launchCommandIsClaudeDesktop(args[0])
}

func launchCommandIsClaudeDesktop(name string) bool {
	canonical, _, err := LookupIntegration(name)
	return err == nil && canonical == claudeDesktopIntegrationName
}

type launcherClient struct {
	apiClient             *api.Client
	inventory             *modelInventory
	recommendationsLoaded bool
	recommendationItems   []ModelItem
	accountState          *AccountState
	accountStateProvider  func() *AccountState
	accountStateUpdates   func(context.Context) <-chan *AccountState
	policy                LaunchPolicy
}

func newLauncherClient(policy LaunchPolicy) (*launcherClient, error) {
	apiClient, err := api.ClientFromEnvironment()
	if err != nil {
		return nil, err
	}

	return &launcherClient{
		apiClient: apiClient,
		inventory: newModelInventory(apiClient),
		policy:    policy,
	}, nil
}

func (c *launcherClient) modelInventory() *modelInventory {
	if c.inventory == nil {
		c.inventory = newModelInventory(c.apiClient)
	}
	return c.inventory
}

// BuildLauncherState returns the launch-owned root launcher menu snapshot.
func BuildLauncherState(ctx context.Context) (*LauncherState, error) {
	launchClient, err := newLauncherClient(defaultLaunchPolicy(isInteractiveSession(), false))
	if err != nil {
		return nil, err
	}
	return launchClient.buildLauncherState(ctx)
}

// ResolveRunModel returns the model that should be used for interactive chat.
func ResolveRunModel(ctx context.Context, req RunModelRequest) (string, error) {
	// Called by the launcher TUI "Run a model" action (cmd/runLauncherAction),
	// which resolves models separately from LaunchIntegration. Callers can pass
	// Policy directly; otherwise we fall back to ambient --yes/session defaults.
	policy := defaultLaunchPolicy(isInteractiveSession(), currentLaunchConfirmPolicy.yes)
	if req.Policy != nil {
		policy = *req.Policy
	}

	launchClient, err := newLauncherClient(policy)
	if err != nil {
		return "", err
	}
	launchClient.accountState = req.AccountState
	launchClient.accountStateProvider = req.AccountStateProvider
	launchClient.accountStateUpdates = req.AccountStateUpdates
	return launchClient.resolveRunModel(ctx, req)
}

// LaunchIntegration runs the canonical launcher flow for one integration.
func LaunchIntegration(ctx context.Context, req IntegrationLaunchRequest) error {
	name, runner, err := LookupIntegration(req.Name)
	if err != nil {
		return err
	}

	if name == claudeDesktopIntegrationName && !req.Restore {
		return errClaudeDesktopUnsupported()
	}

	policy := launchIntegrationPolicy(req)
	if req.Restore {
		return restoreIntegration(name, runner, req)
	}
	if policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() && req.ModelOverride == "" {
		if _, ok := runner.(ManagedAutodiscoveryIntegration); !ok {
			return fmt.Errorf("headless --yes launch for %s requires --model <model>", name)
		}
	}

	launchClient, saved, err := prepareIntegrationLaunch(name, policy)
	if err != nil {
		return err
	}
	launchClient.accountState = req.AccountState
	launchClient.accountStateProvider = req.AccountStateProvider
	launchClient.accountStateUpdates = req.AccountStateUpdates

	if autodiscovery, ok := runner.(ManagedAutodiscoveryIntegration); ok {
		if err := EnsureIntegrationInstalled(name, runner); err != nil {
			return err
		}
		return launchClient.launchManagedAutodiscoveryIntegration(ctx, name, runner, autodiscovery, saved, req)
	}

	if managed, ok := runner.(ManagedSingleModel); ok {
		if err := EnsureIntegrationInstalled(name, runner); err != nil {
			return err
		}
		return launchClient.launchManagedSingleIntegration(ctx, name, runner, managed, saved, req)
	}

	if !req.ConfigureOnly {
		if err := EnsureIntegrationInstalled(name, runner); err != nil {
			return err
		}
	}

	if editor, ok := runner.(Editor); ok {
		return launchClient.launchEditorIntegration(ctx, name, runner, editor, saved, req)
	}
	return launchClient.launchSingleIntegration(ctx, name, runner, saved, req)
}

func restoreIntegration(name string, runner Runner, req IntegrationLaunchRequest) error {
	if req.ModelOverride != "" || req.ConfigureOnly || len(req.ExtraArgs) > 0 {
		return fmt.Errorf("--restore cannot be combined with --model, --config, or extra args")
	}
	restorable, ok := runner.(RestorableIntegration)
	if !ok {
		return fmt.Errorf("%s does not support --restore", name)
	}
	if skipper, ok := runner.(RestoreInstallCheckSkipper); !ok || !skipper.SkipRestoreInstallCheck() {
		if err := EnsureIntegrationInstalled(name, runner); err != nil {
			return err
		}
	}
	if err := restorable.Restore(); err != nil {
		return err
	}
	printRestoreSuccess(restorable)
	return nil
}

func launchIntegrationPolicy(req IntegrationLaunchRequest) LaunchPolicy {
	// TUI does not set a policy, whereas ollama launch <app> does as it can
	// have flags which change the behavior.
	if req.Policy != nil {
		return *req.Policy
	}
	return defaultLaunchPolicy(isInteractiveSession(), false)
}

func prepareIntegrationLaunch(name string, policy LaunchPolicy) (*launcherClient, *config.IntegrationConfig, error) {
	launchClient, err := newLauncherClient(policy)
	if err != nil {
		return nil, nil, err
	}
	saved, _ := loadStoredIntegrationConfig(name)
	return launchClient, saved, nil
}

func (c *launcherClient) buildLauncherState(ctx context.Context) (*LauncherState, error) {
	_, _ = c.modelInventory().Load(ctx)

	state := &LauncherState{
		LastSelection: config.LastSelection(),
		RunModel:      config.LastModel(),
		Integrations:  make(map[string]LauncherIntegrationState),
	}
	runModelUsable, err := c.savedModelUsable(ctx, state.RunModel)
	if err != nil {
		runModelUsable = false
	}
	state.RunModelUsable = runModelUsable

	for _, info := range ListIntegrationInfos() {
		integrationState, err := c.buildLauncherIntegrationState(ctx, info)
		if err != nil {
			return nil, err
		}
		state.Integrations[info.Name] = integrationState
	}

	return state, nil
}

func (c *launcherClient) buildLauncherIntegrationState(ctx context.Context, info IntegrationInfo) (LauncherIntegrationState, error) {
	integration, err := integrationFor(info.Name)
	if err != nil {
		return LauncherIntegrationState{}, err
	}
	var currentModel string
	var usable bool
	if autodiscovery, ok := integration.spec.Runner.(ManagedAutodiscoveryIntegration); ok {
		currentModel, usable, err = c.launcherManagedAutodiscoveryState(ctx, info.Name, autodiscovery)
		if err != nil {
			return LauncherIntegrationState{}, err
		}
	} else if managed, ok := integration.spec.Runner.(ManagedSingleModel); ok {
		currentModel, usable, err = c.launcherManagedModelState(ctx, info.Name, managed)
		if err != nil {
			return LauncherIntegrationState{}, err
		}
	} else {
		currentModel, usable, err = c.launcherModelState(ctx, info.Name, integration.editor)
		if err != nil {
			return LauncherIntegrationState{}, err
		}
	}

	return LauncherIntegrationState{
		Name:            info.Name,
		DisplayName:     info.DisplayName,
		Description:     info.Description,
		Installed:       integration.installed,
		AutoInstallable: integration.autoInstallable,
		Selectable:      integration.installed || integration.autoInstallable,
		Changeable:      integration.installed || integration.autoInstallable,
		CurrentModel:    currentModel,
		ModelUsable:     usable,
		InstallHint:     integration.installHint,
		Editor:          integration.editor,
	}, nil
}

func (c *launcherClient) launcherModelState(ctx context.Context, name string, isEditor bool) (string, bool, error) {
	cfg, loadErr := loadStoredIntegrationConfig(name)
	hasModels := loadErr == nil && len(cfg.Models) > 0
	if !hasModels {
		return "", false, nil
	}

	if isEditor {
		filtered := c.filterDisabledCloudModels(ctx, cfg.Models)
		if len(filtered) > 0 {
			return filtered[0], true, nil
		}
		return cfg.Models[0], false, nil
	}

	model := cfg.Models[0]
	usable, usableErr := c.savedModelUsable(ctx, model)
	return model, usableErr == nil && usable, nil
}

func (c *launcherClient) launcherManagedModelState(ctx context.Context, name string, managed ManagedSingleModel) (string, bool, error) {
	current := managed.CurrentModel()
	if current == "" {
		cfg, loadErr := loadStoredIntegrationConfig(name)
		if loadErr == nil {
			current = primaryModelFromConfig(cfg)
		}
		if current != "" {
			return current, false, nil
		}
	}
	if current == "" {
		return "", false, nil
	}

	if skips, ok := managed.(ManagedModelReadinessSkipper); ok && skips.SkipModelReadiness() {
		return current, true, nil
	}

	usable, err := c.savedModelUsable(ctx, current)
	if err != nil {
		return current, false, err
	}
	return current, usable, nil
}

func (c *launcherClient) launcherManagedAutodiscoveryState(ctx context.Context, name string, autodiscovery ManagedAutodiscoveryIntegration) (string, bool, error) {
	if autodiscovery.AutodiscoveryConfigured() {
		return autodiscovery.AutodiscoveredModel(), c.managedAutodiscoveryUsable(ctx, autodiscovery), nil
	}

	cfg, loadErr := loadStoredIntegrationConfig(name)
	if loadErr == nil {
		if current := primaryModelFromConfig(cfg); current != "" {
			return current, false, nil
		}
	}
	return "", false, nil
}

func (c *launcherClient) resolveRunModel(ctx context.Context, req RunModelRequest) (string, error) {
	current := config.LastModel()
	if !req.ForcePicker && current != "" && c.policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() {
		if err := c.ensureModelsReady(ctx, []string{current}); err != nil {
			return "", err
		}
		fmt.Fprintf(os.Stderr, "Headless mode: auto-selected last used model %q\n", current)
		return current, nil
	}

	if !req.ForcePicker {
		usable, err := c.savedModelUsable(ctx, current)
		if err != nil {
			return "", err
		}
		if usable {
			if err := c.ensureModelsReady(ctx, []string{current}); err != nil {
				return "", err
			}
			return current, nil
		}
	}

	model, err := c.selectSingleModelWithSelector(ctx, "Select model to run:", current, DefaultSingleSelector)
	if err != nil {
		return "", err
	}
	if model != current {
		if err := config.SetLastModel(model); err != nil {
			return "", err
		}
	}
	return model, nil
}

func (c *launcherClient) launchSingleIntegration(ctx context.Context, name string, runner Runner, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
	target, _, err := c.resolveSingleIntegrationTarget(ctx, runner, primaryModelFromConfig(saved), req)
	if err != nil {
		return err
	}
	if target == "" {
		return nil
	}

	current := primaryModelFromConfig(saved)
	if target != current {
		if err := config.SaveIntegration(name, []string{target}); err != nil {
			return fmt.Errorf("failed to save: %w", err)
		}
	}

	return launchAfterConfiguration(name, runner, target, c.resolveRunModels(ctx, []string{target}), req)
}

func (c *launcherClient) launchEditorIntegration(ctx context.Context, name string, runner Runner, editor Editor, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
	models, needsConfigure := c.resolveEditorLaunchModels(ctx, saved, req)

	if needsConfigure {
		selected, err := c.selectMultiModelsForIntegration(ctx, runner, models)
		if err != nil {
			return err
		}
		models = selected
	} else if len(models) > 0 {
		if err := c.ensureModelsReady(ctx, models[:1]); err != nil {
			return err
		}
	}

	if len(models) == 0 {
		return nil
	}

	var launchModels []LaunchModel
	liveConfigMatches := slices.Equal(editor.Models(), models)
	if needsConfigure || req.ModelOverride != "" || !savedMatchesModels(saved, models) || !liveConfigMatches {
		launchModels = c.modelInventory().Resolve(ctx, models)
		if err := prepareEditorIntegration(name, editor, launchModels); err != nil {
			return err
		}
	} else {
		launchModels = c.resolveRunModels(ctx, models)
	}

	return launchAfterConfiguration(name, runner, models[0], launchModels, req)
}

func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, name string, runner Runner, managed ManagedSingleModel, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
	current := managed.CurrentModel()
	selectionCurrent := current
	if selectionCurrent == "" {
		selectionCurrent = primaryModelFromConfig(saved)
	}

	target, needsConfigure, err := c.resolveSingleIntegrationTarget(ctx, runner, selectionCurrent, req)
	if err != nil {
		return err
	}
	if target == "" {
		return nil
	}

	// current is the live managed app config; target may come from saved launch
	// state. Rewrite when the live config is missing or has drifted so the app
	// config converges with the model which launch is about to use.
	liveConfigMissing := current == ""
	liveConfigDrifted := current != "" && target != current
	configured := false
	if needsConfigure || req.ModelOverride != "" || liveConfigMissing || liveConfigDrifted || !savedMatchesModels(saved, []string{target}) {
		configureModels, err := c.managedSingleConfigureModels(ctx, managed, target)
		if err != nil {
			return err
		}
		if err := prepareManagedSingleIntegration(name, managed, target, c.modelInventory().Resolve(ctx, configureModels)); err != nil {
			return err
		}
		if refresher, ok := managed.(ManagedRuntimeRefresher); ok {
			if err := refresher.RefreshRuntimeAfterConfigure(); err != nil {
				return err
			}
		}
		configured = true
	}

	if !managedIntegrationOnboarded(saved, managed) {
		if !isInteractiveSession() && managedRequiresInteractiveOnboarding(managed) {
			return fmt.Errorf("%s still needs interactive gateway setup; run 'ollama launch %s' in a terminal to finish onboarding", runner, name)
		}
		if err := managed.Onboard(); err != nil {
			return err
		}
	}

	if configured {
		if !printConfigurationSuccess(managed) {
			printRestoreHint(managed)
		}
	}

	if req.ConfigureOnly {
		return nil
	}

	return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs)
}

func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Context, name string, runner Runner, autodiscovery ManagedAutodiscoveryIntegration, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
	if req.ModelOverride != "" {
		return fmt.Errorf("%s discovers models automatically; omit --model", runner)
	}

	target := autodiscovery.AutodiscoveredModel()
	if err := c.ensureManagedAutodiscoveryUsable(ctx, autodiscovery, target); err != nil {
		return err
	}

	needsConfigure := req.ForceConfigure || req.ConfigureOnly || !autodiscovery.AutodiscoveryConfigured() || !savedMatchesModels(saved, []string{target})

	if needsConfigure {
		if err := prepareManagedAutodiscoveryIntegration(name, autodiscovery, target); err != nil {
			return err
		}
		if refresher, ok := autodiscovery.(ManagedRuntimeRefresher); ok {
			if err := refresher.RefreshRuntimeAfterConfigure(); err != nil {
				return err
			}
		}
	}

	if !managedIntegrationOnboarded(saved, autodiscovery) {
		if !isInteractiveSession() && managedRequiresInteractiveOnboarding(autodiscovery) {
			return fmt.Errorf("%s still needs interactive gateway setup; run 'ollama launch %s' in a terminal to finish onboarding", runner, name)
		}
		if err := autodiscovery.Onboard(); err != nil {
			return err
		}
	}

	if !printConfigurationSuccess(autodiscovery) {
		printRestoreHint(autodiscovery)
	}

	if req.ConfigureOnly {
		return nil
	}

	return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs)
}

func (c *launcherClient) managedAutodiscoveryUsable(ctx context.Context, autodiscovery ManagedAutodiscoveryIntegration) bool {
	if !managedAutodiscoveryUsesOllamaCloud(autodiscovery) {
		return true
	}
	if disabled, known := cloudStatusDisabled(ctx, c.apiClient); known && disabled {
		return false
	}
	return true
}

func (c *launcherClient) ensureManagedAutodiscoveryUsable(ctx context.Context, autodiscovery ManagedAutodiscoveryIntegration, label string) error {
	if !managedAutodiscoveryUsesOllamaCloud(autodiscovery) {
		return nil
	}
	return ensureCloudAuth(ctx, c.apiClient, label)
}

func managedAutodiscoveryUsesOllamaCloud(autodiscovery ManagedAutodiscoveryIntegration) bool {
	cloud, ok := autodiscovery.(ManagedAutodiscoveryCloudIntegration)
	return ok && cloud.UsesOllamaCloud()
}

func printRestoreHint(integration any) {
	hint, ok := integration.(RestoreHintIntegration)
	if !ok {
		return
	}
	if msg := strings.TrimSpace(hint.RestoreHint()); msg != "" {
		fmt.Fprintln(os.Stderr, msg)
	}
}

func printConfigurationSuccess(integration any) bool {
	success, ok := integration.(ConfigurationSuccessIntegration)
	if !ok {
		return false
	}
	if msg := strings.TrimSpace(success.ConfigurationSuccessMessage()); msg != "" {
		fmt.Fprintln(os.Stderr, msg)
		return true
	}
	return false
}

func printRestoreSuccess(integration any) {
	success, ok := integration.(RestoreSuccessIntegration)
	if !ok {
		return
	}
	if msg := strings.TrimSpace(success.RestoreSuccessMessage()); msg != "" {
		fmt.Fprintln(os.Stderr, msg)
	}
}

func (c *launcherClient) managedSingleConfigureModels(ctx context.Context, managed ManagedSingleModel, target string) ([]string, error) {
	models := []string{target}
	if _, ok := managed.(ManagedModelListConfigurer); !ok {
		return models, nil
	}

	items, _, err := c.loadSelectableModels(ctx, []string{target}, target, "no models available")
	if err != nil {
		// Managed integrations that can use a model catalog should still be
		// configurable with an explicit target even if the broader inventory
		// cannot be loaded in the moment.
		//nolint:nilerr
		return models, nil
	}

	for _, item := range items {
		models = append(models, item.Name)
	}
	return dedupeModelList(models), nil
}

func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, runner Runner, current string, req IntegrationLaunchRequest) (string, bool, error) {
	target := req.ModelOverride
	needsConfigure := req.ForceConfigure
	skipReadiness := false
	if skipper, ok := runner.(ManagedModelReadinessSkipper); ok {
		skipReadiness = skipper.SkipModelReadiness()
	}

	if target == "" {
		target = current
		usable := skipReadiness && target != ""
		if !skipReadiness {
			var err error
			usable, err = c.savedModelUsable(ctx, target)
			if err != nil {
				return "", false, err
			}
		}
		if !usable {
			needsConfigure = true
		}
	}

	if needsConfigure && req.ModelOverride == "" {
		selected, err := c.selectSingleModelWithSelectorReady(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector, !skipReadiness)
		if err != nil {
			return "", false, err
		}
		target = selected
	} else if !skipReadiness {
		if err := c.ensureModelsReady(ctx, []string{target}); err != nil {
			return "", false, err
		}
	}

	if target == "" {
		return "", false, nil
	}

	return target, needsConfigure, nil
}

func savedIntegrationOnboarded(saved *config.IntegrationConfig) bool {
	return saved != nil && saved.Onboarded
}

func managedIntegrationOnboarded(saved *config.IntegrationConfig, managed any) bool {
	if !savedIntegrationOnboarded(saved) {
		return false
	}
	validator, ok := managed.(ManagedOnboardingValidator)
	if !ok {
		return true
	}
	return validator.OnboardingComplete()
}

// Most managed integrations treat onboarding as an interactive terminal step.
// Hermes opts out because its launch-owned onboarding is just bookkeeping, so
// headless launches should not be blocked once config is already prepared.
func managedRequiresInteractiveOnboarding(managed any) bool {
	onboarding, ok := managed.(ManagedInteractiveOnboarding)
	if !ok {
		return true
	}
	return onboarding.RequiresInteractiveOnboarding()
}

func (c *launcherClient) selectSingleModelWithSelector(ctx context.Context, title, current string, selector SingleSelector) (string, error) {
	return c.selectSingleModelWithSelectorReady(ctx, title, current, selector, true)
}

func (c *launcherClient) latestAccountState() *AccountState {
	if c.accountStateProvider != nil {
		return c.accountStateProvider()
	}
	return c.accountState
}

func (c *launcherClient) selectSingleModelWithSelectorReady(ctx context.Context, title, current string, selector SingleSelector, ensureReady bool) (string, error) {
	if selector == nil && DefaultSingleSelectorWithUpdates == nil {
		return "", fmt.Errorf("no selector configured")
	}

	items, _, err := c.loadSelectableModels(ctx, nil, current, "no models available, run 'ollama pull <model>' first")
	if err != nil {
		return "", err
	}

	for {
		accountState := c.latestAccountState()
		selectionItems := SelectionItemsWithAccountState(items, accountState)
		var updates <-chan []SelectionItem
		if DefaultSingleSelectorWithUpdates != nil {
			updates = c.selectionItemUpdates(ctx, items, accountState)
		}
		selected, err := runSingleSelector(title, selectionItems, current, updates, selector)
		if err != nil {
			return "", err
		}
		if selected == "" {
			return "", ErrCancelled
		}
		if ensureReady {
			if err := c.ensureModelsReady(ctx, []string{selected}); err != nil {
				if errors.Is(err, errUpgradeCancelled) {
					current = selected
					continue
				}
				return "", err
			}
		}
		return selected, nil
	}
}

func (c *launcherClient) selectMultiModelsForIntegration(ctx context.Context, runner Runner, preChecked []string) ([]string, error) {
	if DefaultMultiSelector == nil && DefaultMultiSelectorWithUpdates == nil {
		return nil, fmt.Errorf("no selector configured")
	}

	current := firstModel(preChecked)
	items, orderedChecked, err := c.loadSelectableModels(ctx, preChecked, current, "no models available")
	if err != nil {
		return nil, err
	}

	for {
		accountState := c.latestAccountState()
		selectionItems := SelectionItemsWithAccountState(items, accountState)
		var updates <-chan []SelectionItem
		if DefaultMultiSelectorWithUpdates != nil {
			updates = c.selectionItemUpdates(ctx, items, accountState)
		}
		selected, err := runMultiSelector(fmt.Sprintf("Select models for %s:", runner), selectionItems, orderedChecked, updates)
		if err != nil {
			return nil, err
		}
		accepted, skipped, err := c.selectReadyModelsForSave(ctx, selected)
		if err != nil {
			if errors.Is(err, errUpgradeCancelled) {
				orderedChecked = append([]string(nil), selected...)
				continue
			}
			return nil, err
		}
		for _, skip := range skipped {
			fmt.Fprintf(os.Stderr, "Skipped %s: %s\n", skip.model, skip.reason)
		}
		return accepted, nil
	}
}

func runSingleSelector(title string, items []SelectionItem, current string, updates <-chan []SelectionItem, fallback SingleSelector) (string, error) {
	if DefaultSingleSelectorWithUpdates != nil {
		return DefaultSingleSelectorWithUpdates(title, items, current, updates)
	}
	if fallback == nil {
		return "", fmt.Errorf("no selector configured")
	}
	return fallback(title, items, current)
}

func runMultiSelector(title string, items []SelectionItem, preChecked []string, updates <-chan []SelectionItem) ([]string, error) {
	if DefaultMultiSelectorWithUpdates != nil {
		return DefaultMultiSelectorWithUpdates(title, items, preChecked, updates)
	}
	if DefaultMultiSelector == nil {
		return nil, fmt.Errorf("no selector configured")
	}
	return DefaultMultiSelector(title, items, preChecked)
}

func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []string, current, emptyMessage string) ([]ModelItem, []string, error) {
	inventory, err := c.modelInventory().Load(ctx)
	if err != nil {
		return nil, nil, err
	}
	recommendations := c.recommendations(ctx)

	cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
	items, orderedChecked, _, _ := buildModelListWithRecommendations(inventory, recommendations, preChecked, current)
	if cloudDisabled {
		items = filterCloudItems(items)
		orderedChecked = c.filterDisabledCloudModels(ctx, orderedChecked)
	}
	if len(items) == 0 {
		return nil, nil, errors.New(emptyMessage)
	}
	return items, orderedChecked, nil
}

func (c *launcherClient) recommendations(ctx context.Context) []ModelItem {
	if c.recommendationsLoaded {
		return append([]ModelItem(nil), c.recommendationItems...)
	}

	recommendations, err := c.requestRecommendations(ctx)
	if err != nil || len(recommendations) == 0 {
		// Fail open: recommendation issues should not block launch flows.
		// Fall back to built-in recommendations until server data is available.
		fallback := append([]ModelItem(nil), recommendedModels...)
		setDynamicCloudModelLimits(cloudModelLimitsFromRecommendations(fallback))
		c.recommendationItems = fallback
		c.recommendationsLoaded = true
		return append([]ModelItem(nil), fallback...)
	}
	setDynamicCloudModelLimits(cloudModelLimitsFromRecommendations(recommendations))
	c.recommendationItems = recommendations
	c.recommendationsLoaded = true
	return append([]ModelItem(nil), recommendations...)
}

func (c *launcherClient) requestRecommendations(ctx context.Context) ([]ModelItem, error) {
	resp, err := c.apiClient.ModelRecommendationsExperimental(ctx)
	if err != nil {
		return nil, err
	}
	items := make([]ModelItem, 0, len(resp.Recommendations))
	seen := make(map[string]struct{}, len(resp.Recommendations))

	for _, rec := range resp.Recommendations {
		name := strings.TrimSpace(rec.Model)
		if name == "" {
			continue
		}
		if _, ok := seen[name]; ok {
			continue
		}
		seen[name] = struct{}{}

		if isCloudModelName(name) && (rec.ContextLength <= 0 || rec.MaxOutputTokens <= 0) {
			slog.Warn("skipping cloud recommendation with missing limits", "model", name)
			continue
		}

		description := strings.TrimSpace(rec.Description)
		if description == "" {
			description = "Recommended model"
		}

		items = append(items, ModelItem{
			Name:            name,
			Description:     description,
			Recommended:     true,
			VRAMBytes:       rec.VRAMBytes,
			MaxOutputTokens: rec.MaxOutputTokens,
			RequiredPlan:    strings.TrimSpace(rec.RequiredPlan),
			Details: api.ModelDetails{
				ContextLength: rec.ContextLength,
			},
		})
	}

	return items, nil
}

func (c *launcherClient) ensureModelsReady(ctx context.Context, models []string) error {
	models = dedupeModelList(models)
	if len(models) == 0 {
		return nil
	}

	cloudModels := make(map[string]bool, len(models))
	for _, model := range models {
		isCloudModel := isCloudModelName(model)
		if isCloudModel {
			cloudModels[model] = true
			if err := c.ensureCloudModelAccess(ctx, model); err != nil {
				return err
			}
		}
		if err := showOrPullWithPolicy(ctx, c.apiClient, model, c.policy.missingModelPolicy(), isCloudModel); err != nil {
			return err
		}
	}
	return ensureAuth(ctx, c.apiClient, cloudModels, models)
}

func dedupeModelList(models []string) []string {
	deduped := make([]string, 0, len(models))
	seen := make(map[string]bool, len(models))
	for _, model := range models {
		if model == "" || seen[model] {
			continue
		}
		seen[model] = true
		deduped = append(deduped, model)
	}
	return deduped
}

type skippedModel struct {
	model  string
	reason string
}

func (c *launcherClient) selectReadyModelsForSave(ctx context.Context, selected []string) ([]string, []skippedModel, error) {
	selected = dedupeModelList(selected)
	accepted := make([]string, 0, len(selected))
	skipped := make([]skippedModel, 0, len(selected))

	for _, model := range selected {
		if err := c.ensureModelsReady(ctx, []string{model}); err != nil {
			if errors.Is(err, errUpgradeCancelled) {
				return nil, nil, err
			}
			if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
				return nil, nil, err
			}
			skipped = append(skipped, skippedModel{
				model:  model,
				reason: skippedModelReason(model, err),
			})
			continue
		}
		accepted = append(accepted, model)
	}

	return accepted, skipped, nil
}

func skippedModelReason(model string, err error) string {
	if errors.Is(err, errUpgradeCancelled) {
		return "upgrade was cancelled"
	}
	if errors.Is(err, ErrCancelled) {
		if isCloudModelName(model) {
			return "sign in was cancelled"
		}
		return "download was cancelled"
	}
	return err.Error()
}

func (c *launcherClient) resolveEditorLaunchModels(ctx context.Context, saved *config.IntegrationConfig, req IntegrationLaunchRequest) ([]string, bool) {
	if req.ForceConfigure {
		return editorPreCheckedModels(saved, req.ModelOverride), true
	}

	if req.ModelOverride != "" {
		models := append([]string{req.ModelOverride}, additionalSavedModels(saved, req.ModelOverride)...)
		models = c.filterDisabledCloudModels(ctx, models)
		return models, len(models) == 0
	}

	if saved == nil || len(saved.Models) == 0 {
		return nil, true
	}

	models := c.filterDisabledCloudModels(ctx, saved.Models)
	return models, len(models) == 0
}

func (c *launcherClient) filterDisabledCloudModels(ctx context.Context, models []string) []string {
	// if connection cannot be established or there is a 404, cloud models will continue to be displayed
	cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
	if !cloudDisabled {
		return append([]string(nil), models...)
	}

	filtered := make([]string, 0, len(models))
	for _, model := range models {
		if !isCloudModelName(model) {
			filtered = append(filtered, model)
		}
	}
	return filtered
}

func (c *launcherClient) savedModelUsable(ctx context.Context, name string) (bool, error) {
	inventory, err := c.modelInventory().Load(ctx)
	if err != nil {
		return c.showBasedModelUsable(ctx, name)
	}
	return c.singleModelUsable(ctx, name, inventory), nil
}

func (c *launcherClient) showBasedModelUsable(ctx context.Context, name string) (bool, error) {
	if name == "" {
		return false, nil
	}

	info, err := c.apiClient.Show(ctx, &api.ShowRequest{Model: name})
	if err != nil {
		var statusErr api.StatusError
		if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound {
			return false, nil
		}
		return false, err
	}

	if isCloudModelName(name) || info.RemoteModel != "" {
		cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)

		return !cloudDisabled, nil
	}

	return true, nil
}

func (c *launcherClient) singleModelUsable(ctx context.Context, name string, inventory []LaunchModel) bool {
	if name == "" {
		return false
	}
	if isCloudModelName(name) {
		cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
		return !cloudDisabled
	}
	return hasLocalModel(inventory, name)
}

func hasLocalModel(inventory []LaunchModel, name string) bool {
	for _, model := range inventory {
		if model.Remote {
			continue
		}
		if model.Name == name || strings.HasPrefix(model.Name, name+":") {
			return true
		}
	}
	return false
}

func (c *launcherClient) resolveRunModels(ctx context.Context, models []string) []LaunchModel {
	return c.modelInventory().Resolve(ctx, models)
}

func runIntegration(runner Runner, modelName string, models []LaunchModel, args []string) error {
	if len(models) == 0 && modelName != "" {
		models = launchModelsFromNames([]string{modelName})
	}
	return runner.Run(modelName, models, args)
}

func launchAfterConfiguration(name string, runner Runner, model string, models []LaunchModel, req IntegrationLaunchRequest) error {
	if req.ConfigureOnly {
		launch, err := ConfirmPrompt(fmt.Sprintf("Launch %s now?", runner))
		if err != nil {
			return err
		}
		if !launch {
			return nil
		}
	}
	if err := EnsureIntegrationInstalled(name, runner); err != nil {
		return err
	}
	return runIntegration(runner, model, models, req.ExtraArgs)
}

func loadStoredIntegrationConfig(name string) (*config.IntegrationConfig, error) {
	cfg, err := config.LoadIntegration(name)
	if err == nil {
		return cfg, nil
	}
	if !errors.Is(err, os.ErrNotExist) {
		return nil, err
	}

	spec, specErr := LookupIntegrationSpec(name)
	if specErr != nil {
		return nil, err
	}

	for _, alias := range spec.Aliases {
		legacy, legacyErr := config.LoadIntegration(alias)
		if legacyErr == nil {
			migrateLegacyIntegrationConfig(spec.Name, legacy)
			if migrated, migratedErr := config.LoadIntegration(spec.Name); migratedErr == nil {
				return migrated, nil
			}
			return legacy, nil
		}
		if legacyErr != nil && !errors.Is(legacyErr, os.ErrNotExist) {
			return nil, legacyErr
		}
	}

	return nil, err
}

func migrateLegacyIntegrationConfig(canonical string, legacy *config.IntegrationConfig) {
	if legacy == nil {
		return
	}

	_ = config.SaveIntegration(canonical, append([]string(nil), legacy.Models...))
	if len(legacy.Aliases) > 0 {
		_ = config.SaveAliases(canonical, cloneAliases(legacy.Aliases))
	}
	if legacy.Onboarded {
		_ = config.MarkIntegrationOnboarded(canonical)
	}
}

func primaryModelFromConfig(cfg *config.IntegrationConfig) string {
	if cfg == nil || len(cfg.Models) == 0 {
		return ""
	}
	return cfg.Models[0]
}

func cloneAliases(aliases map[string]string) map[string]string {
	if len(aliases) == 0 {
		return make(map[string]string)
	}

	cloned := make(map[string]string, len(aliases))
	for key, value := range aliases {
		cloned[key] = value
	}
	return cloned
}

func firstModel(models []string) string {
	if len(models) == 0 {
		return ""
	}
	return models[0]
}

func savedMatchesModels(saved *config.IntegrationConfig, models []string) bool {
	if saved == nil {
		return false
	}
	return slices.Equal(saved.Models, models)
}

func editorPreCheckedModels(saved *config.IntegrationConfig, override string) []string {
	if override == "" {
		if saved == nil {
			return nil
		}
		return append([]string(nil), saved.Models...)
	}
	return append([]string{override}, additionalSavedModels(saved, override)...)
}

func additionalSavedModels(saved *config.IntegrationConfig, exclude string) []string {
	if saved == nil {
		return nil
	}

	var models []string
	for _, model := range saved.Models {
		if model != exclude {
			models = append(models, model)
		}
	}
	return models
}
