package tui

import (
	"albedo/cli/internal/config"
	"albedo/cli/internal/daemon"
	"cmp"
	"context"
	"fmt"
	"maps"
	"slices"
	"strconv"
	"strings"
	"time"

	"charm.land/bubbles/v2/textinput"
	tea "charm.land/bubbletea/v2"
	"github.com/charmbracelet/x/ansi"
)

type LoginDoneMsg struct {
	Name     string
	Settings config.Settings
}

type LoginCancelMsg struct{}

type LoginStep int

const (
	StepChoose LoginStep = iota
	StepName
	StepBaseURL
	StepAPIKey
	StepProtocol
	StepModels
	StepModel
	StepOAuth
	StepOAuthModels
	StepRemove
	StepSaving
)

type customProvider struct {
	ID            string
	Label         string
	Detail        string
	Extension     string
	DefaultName   string
	DefaultURL    string
	FixedProtocol string
	// FixedEndpoint skips the base url: the extension knows where it sends.
	FixedEndpoint bool
}

var customProviders = []customProvider{
	{
		ID:            "add-alibaba",
		Label:         "add or update an Alibaba provider",
		Detail:        "token plan · Qwen, DeepSeek, GLM",
		Extension:     "alibaba",
		DefaultName:   "alibaba",
		DefaultURL:    "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
		FixedProtocol: "chat_completions",
	},
	{
		ID:            "add-anthropic",
		Label:         "add or update an Anthropic API key",
		Detail:        "console billing · Claude models",
		Extension:     "claude",
		DefaultName:   "anthropic",
		FixedProtocol: "chat_completions",
		FixedEndpoint: true,
	},
	{
		ID:         "add-openai",
		Label:      "add or update an OpenAI-compatible provider",
		Extension:  "openai",
		DefaultURL: "https://api.openai.com/v1",
	},
}

func customProviderByID(id string) (customProvider, bool) {
	for _, p := range customProviders {
		if p.ID == id {
			return p, true
		}
	}
	return customProvider{}, false
}

// signInPollInterval is how often the daemon is asked about a running sign-in.
const signInPollInterval = 300 * time.Millisecond

type loginModelsLoadedMsg struct {
	Err    error
	Note   string
	Models []string
	Gen    int
}

// signInsLoadedMsg carries the daemon's sign-ins and accounts, after the first
// read and after every change. Profiles comes from the same settings snapshot.
type signInsLoadedMsg struct {
	Err      error
	Profiles *config.Profiles
	Listed   daemon.SignIns
	Gen      int
}

type signInStartedMsg struct {
	Err error
	ID  string
	URL string
	Gen int
}

type signInStatusMsg struct {
	Err     error
	ID      string
	State   string
	Message string
	Gen     int
}

type signInPollMsg struct {
	ID  string
	Gen int
}

// removal names something /login can delete: a provider in config.json or an
// account in the daemon's credential store.
type removal struct {
	Kind     string // "provider" | "account"
	Provider string
	ID       string // provider name or daemon account id
	Label    string
}

type providerSavedMsg struct {
	Err      error
	Name     string
	Settings config.Settings
}

type LoginModel struct {
	Styles         Styles
	Conn           *daemon.Connection
	openBrowser    func(url string)
	Removing       removal
	Profiles       config.Profiles
	Hint           string
	Name           string
	Provider       string // sign-in provider in flight, or just finished
	LoginID        string // daemon id of the sign-in in flight
	SignInURL      string
	Status         string // daemon progress line while a sign-in runs
	CatalogNote    string
	Error          string
	Draft          config.Settings
	SignIns        []daemon.SignIn
	Accounts       []daemon.Account
	Catalog        []string
	TextInput      textinput.Model
	ChoosePicker   PickerModel
	ProtocolPicker PickerModel
	ModelPicker    PickerModel
	ConfirmPicker  PickerModel
	Generation     int
	Width          int
	Height         int
	Step           LoginStep
}

func (m LoginModel) openBrowserCmd(urlStr string) tea.Cmd {
	return func() tea.Msg {
		if m.openBrowser != nil {
			m.openBrowser(urlStr)
		}
		return nil
	}
}

// NewLoginModel panics if conn is nil. A nil browser opener disables automatic
// browser launches.
func NewLoginModel(conn *daemon.Connection, nameHint string, openBrowser func(string)) LoginModel {
	if conn == nil {
		panic("tui.NewLoginModel requires a daemon connection")
	}
	profiles := config.Profiles{Providers: map[string]config.Settings{}}

	m := LoginModel{
		Conn:        conn,
		Step:        StepChoose,
		Profiles:    profiles,
		Hint:        strings.TrimSpace(nameHint),
		Draft:       config.Settings{Extension: "openai", BaseURL: "https://api.openai.com/v1", Protocol: "responses"},
		TextInput:   newField(),
		Styles:      DefaultStyles,
		openBrowser: openBrowser,
	}
	m.buildChoosePicker()
	return m
}

func (m LoginModel) inputWidth() int {
	// Ink includes its cursor cell in width; Bubbles tracks the cursor separately.
	if m.Step == StepOAuth {
		return max(8, m.Width-25)
	}
	return max(8, m.Width-19)
}

// promptInput re-arms the text input for the next question and reports the
// blink command that shows its cursor. The placeholder is left as the
// previous step left it.
func (m *LoginModel) promptInput(value string, secret bool) tea.Cmd {
	m.TextInput.SetWidth(m.inputWidth())
	return ask(&m.TextInput, value, secret)
}

// pickerFor returns the picker the current step drives.
func (m *LoginModel) pickerFor() *PickerModel {
	switch m.Step {
	case StepProtocol:
		return &m.ProtocolPicker
	case StepRemove:
		return &m.ConfirmPicker
	case StepModels, StepOAuthModels:
		return &m.ModelPicker
	}
	return &m.ChoosePicker
}

// fail shows an error on the current step and keeps waiting for input.
func (m LoginModel) fail(err string) (LoginModel, tea.Cmd) {
	m.Error = err
	return m, nil
}

func (m *LoginModel) SetSize(width, height int) {
	m.Width, m.Height = width, height
	m.TextInput.SetWidth(m.inputWidth())
	m.TextInput.SetValue(m.TextInput.Value())
	for _, p := range []*PickerModel{&m.ChoosePicker, &m.ProtocolPicker, &m.ModelPicker, &m.ConfirmPicker} {
		p.SetSize(width, height)
	}
}

// custom finds the api-key provider the draft's extension belongs to.
func (m LoginModel) custom() (customProvider, bool) {
	i := slices.IndexFunc(customProviders, func(p customProvider) bool { return p.Extension == m.Draft.Extension })
	if i < 0 {
		return customProvider{}, false
	}
	return customProviders[i], true
}

func (m LoginModel) isFixedProtocol() bool {
	p, ok := m.custom()
	return ok && p.FixedProtocol != ""
}

// askEndpoint asks for the base url, or straight for the api key when the
// extension has a fixed endpoint.
func (m *LoginModel) askEndpoint() tea.Cmd {
	if p, ok := m.custom(); ok && p.FixedEndpoint {
		m.Step = StepAPIKey
		m.TextInput.Placeholder = ""
		return m.promptInput("", true)
	}
	m.Step = StepBaseURL
	return m.promptInput(m.Draft.BaseURL, false)
}

func (m *LoginModel) advanceToModels() tea.Cmd {
	m.Catalog = nil
	m.CatalogNote = ""
	m.Step = StepModels
	m.Generation++
	return m.fetchCatalogCmd(m.Draft.Extension, m.Draft.BaseURL, m.Generation)
}

func (m *LoginModel) startCustomProvider(p customProvider) tea.Cmd {
	m.Name = p.DefaultName
	m.Draft = config.Settings{Extension: p.Extension, BaseURL: p.DefaultURL, Protocol: cmp.Or(p.FixedProtocol, "responses")}
	m.Step = StepName
	m.TextInput.Placeholder = p.DefaultName
	return m.promptInput("", false)
}

// useProfile re-selects a saved profile: a sign-in provider with no accounts
// left cannot run without an api key, so it signs in first.
func (m *LoginModel) useProfile(name string, settings config.Settings) tea.Cmd {
	if login, ok := m.signInFor(settings.Extension); ok && !hasKey(settings) && m.signedOut(settings.Extension) {
		return m.startSignIn(login)
	}
	m.Step = StepSaving
	return m.saveProviderCmd(name, settings)
}

// signInFor finds the daemon sign-in a profile extension or provider name uses.
func (m LoginModel) signInFor(provider string) (daemon.SignIn, bool) {
	i := slices.IndexFunc(m.SignIns, func(login daemon.SignIn) bool { return login.Provider == provider })
	if i < 0 {
		return daemon.SignIn{}, false
	}
	return m.SignIns[i], true
}

// signedOut reports a sign-in provider with no stored account, which cannot run
// until the user signs in again.
func (m LoginModel) signedOut(provider string) bool {
	if _, known := m.signInFor(provider); !known {
		return false
	}
	return !slices.ContainsFunc(m.Accounts, func(account daemon.Account) bool { return account.Provider == provider })
}

// accountAt resolves a chooser row back to the account it lists.
func (m LoginModel) accountAt(id string) (daemon.Account, bool) {
	index, err := strconv.Atoi(strings.TrimPrefix(id, "account:"))
	if err != nil || index < 0 || index >= len(m.Accounts) {
		return daemon.Account{}, false
	}
	return m.Accounts[index], true
}

func (m *LoginModel) buildChoosePicker() {
	var items []PickerItem
	add := func(id, label, detail string) {
		items = append(items, PickerItem{ID: id, Label: label, Detail: detail})
	}
	for _, name := range slices.Sorted(maps.Keys(m.Profiles.Providers)) {
		settings := m.Profiles.Providers[name]
		extension := cmp.Or(settings.Extension, "openai")
		detail := fmt.Sprintf("%s · %s", settings.Model, extension)
		if _, signIn := m.signInFor(extension); signIn && hasKey(settings) {
			detail += " · API key"
		} else if m.signedOut(extension) {
			detail += " · signed out"
		}
		if name == m.Profiles.Active {
			detail += " · active"
		}
		add("use:"+name, name, detail)
	}
	for index, account := range m.Accounts {
		add("account:"+strconv.Itoa(index), account.Label, account.Detail)
	}
	for _, p := range customProviders {
		add(p.ID, p.Label, p.Detail)
	}
	for _, login := range m.SignIns {
		add("signin:"+login.Provider, login.Label, login.Detail)
	}

	m.ChoosePicker = NewPickerModel("Provider for new sessions", items, false, "use:"+m.Profiles.Active)
	m.ChoosePicker.SetSize(m.Width, m.Height)
}

// removalFor maps a chooser row to what removing it would delete.
func (m LoginModel) removalFor(id string) (removal, bool) {
	if name, ok := strings.CutPrefix(id, "use:"); ok {
		if _, saved := m.Profiles.Providers[name]; saved {
			return removal{Kind: "provider", ID: name, Label: name}, true
		}
	}
	if account, ok := m.accountAt(id); ok {
		return removal{Kind: "account", Provider: account.Provider, ID: account.ID, Label: account.Label}, true
	}
	return removal{}, false
}

func (m *LoginModel) confirmRemoval(target removal) tea.Cmd {
	detail := "This removes the provider and its saved key"
	if target.Kind == "account" {
		detail = "This removes its saved tokens"
	}
	m.Removing = target
	m.Step = StepRemove
	items := []PickerItem{
		{ID: "keep", Label: "keep", Detail: ""},
		{ID: "remove", Label: "remove", Detail: detail},
	}
	m.ConfirmPicker = NewPickerModel(detail+". Remove "+target.Kind+" "+target.Label+"?", items, false, "keep")
	m.ConfirmPicker.SetSize(m.Width, m.Height)
	return m.ConfirmPicker.Init()
}

// reloadCmd changes the daemon's accounts or the saved profiles, then re-reads
// what /login can choose from.
func (m LoginModel) reloadCmd(change func(context.Context) error) tea.Cmd {
	return func() tea.Msg {
		ctx := context.Background()
		if err := change(ctx); err != nil {
			return signInsLoadedMsg{Err: err, Gen: m.Generation}
		}
		return m.listSignIns(ctx, m.Generation)
	}
}

func (m LoginModel) removeCmd(target removal) tea.Cmd {
	return m.reloadCmd(func(ctx context.Context) error {
		if target.Kind == "account" {
			return daemon.RemoveAccount(ctx, m.Conn, target.Provider, target.ID)
		}
		return daemon.DeleteProvider(ctx, m.Conn, target.ID)
	})
}

func (m LoginModel) selectAccountCmd(account daemon.Account) tea.Cmd {
	return m.reloadCmd(func(ctx context.Context) error {
		return daemon.SelectAccount(ctx, m.Conn, account.Provider, account.ID)
	})
}

func (m *LoginModel) backToChoose() tea.Cmd {
	m.Step = StepChoose
	m.buildChoosePicker()
	return m.ChoosePicker.Init()
}

// startSignIn hands the provider to the daemon, which owns the PKCE pair, the
// callback listener, and the token exchange.
func (m *LoginModel) startSignIn(login daemon.SignIn) tea.Cmd {
	m.Provider = login.Provider
	m.Name = login.Provider
	m.Draft = config.Settings{Extension: login.Provider, Protocol: login.Protocol}
	if saved, ok := m.Profiles.Providers[login.Provider]; ok {
		m.Draft.Model = saved.Model
	}
	m.Step = StepOAuth
	m.Generation++
	// The cursor stays steady here; only the daemon's sign-in command runs.
	m.TextInput.Placeholder = ""
	m.promptInput("", false)
	gen := m.Generation
	return func() tea.Msg {
		started, err := daemon.StartSignIn(context.Background(), m.Conn, login.Provider)
		return signInStartedMsg{ID: started.ID, URL: started.URL, Err: err, Gen: gen}
	}
}

func (m LoginModel) pollSignInCmd(id string, gen int) tea.Cmd {
	return func() tea.Msg {
		status, err := daemon.PollSignIn(context.Background(), m.Conn, id)
		return signInStatusMsg{ID: id, State: status.State, Message: status.Message, Err: err, Gen: gen}
	}
}

func signInPollTickCmd(id string, gen int) tea.Cmd {
	return tea.Tick(signInPollInterval, func(time.Time) tea.Msg {
		return signInPollMsg{ID: id, Gen: gen}
	})
}

func (m LoginModel) inputSignInCmd(id, input string, gen int) tea.Cmd {
	return func() tea.Msg {
		if err := daemon.SignInInput(context.Background(), m.Conn, id, input); err != nil {
			return signInStatusMsg{ID: id, Err: err, Gen: gen}
		}
		return signInPollMsg{ID: id, Gen: gen}
	}
}

// cancelSignInCmd drops a sign-in this client no longer waits for, closing the
// daemon's callback listener.
func (m LoginModel) cancelSignInCmd(id string) tea.Cmd {
	if id == "" || m.Conn == nil {
		return nil
	}
	return func() tea.Msg {
		_ = daemon.CancelSignIn(context.Background(), m.Conn, id)
		return nil
	}
}

// endSignIn forgets the sign-in in flight and returns the command cancelling it.
func (m *LoginModel) endSignIn() tea.Cmd {
	id := m.LoginID
	m.LoginID = ""
	m.SignInURL = ""
	m.Status = ""
	return m.cancelSignInCmd(id)
}

// Close cancels a sign-in still in flight when the login screen goes away.
func (m *LoginModel) Close() tea.Cmd {
	return m.endSignIn()
}

// applyHint routes a name given to `/login <name>`: a saved profile is chosen as
// it stands, a sign-in provider starts its sign-in, anything else is a new
// openai-compatible provider.
func (m *LoginModel) applyHint() tea.Cmd {
	hint := m.Hint
	m.Hint = ""
	name, err := config.ValidateProviderName(hint)
	if err != nil {
		m.Error = err.Error()
		m.Step = StepChoose
		return m.ChoosePicker.Init()
	}
	m.Name = name
	if saved, ok := m.Profiles.Providers[name]; ok {
		m.Draft = saved
		return m.useProfile(name, saved)
	}
	if login, ok := m.signInFor(name); ok {
		return m.startSignIn(login)
	}
	return m.askEndpoint()
}

func (m *LoginModel) buildProtocolPicker() {
	items := []PickerItem{
		{ID: "responses", Label: "responses", Detail: "OpenAI Responses API"},
		{ID: "chat_completions", Label: "chat completions", Detail: "widely supported by compatible endpoints"},
	}
	m.ProtocolPicker = NewPickerModel("api protocol", items, false, m.Draft.Protocol)
	m.ProtocolPicker.SetSize(m.Width, m.Height)
}

func (m *LoginModel) buildModelPicker() {
	items := make([]PickerItem, 0, len(m.Catalog)+1)
	for _, name := range m.Catalog {
		items = append(items, PickerItem{ID: "model:" + name, Label: name})
	}
	items = append(items, PickerItem{ID: "manual", Label: "enter a model ID manually"})

	m.ModelPicker = NewPickerModel("model", items, true, "model:"+m.Draft.Model)
	m.ModelPicker.SetSize(m.Width, m.Height)
}

func (m LoginModel) Init() tea.Cmd {
	gen := m.Generation
	return func() tea.Msg { return m.listSignIns(context.Background(), gen) }
}

// listSignIns reads what the daemon can sign in with, the accounts it holds,
// and which profiles it holds an api key for.
func (m LoginModel) listSignIns(ctx context.Context, gen int) signInsLoadedMsg {
	listed, err := daemon.SignInList(ctx, m.Conn)
	if err != nil {
		return signInsLoadedMsg{Err: err, Gen: gen}
	}
	settings, err := daemon.GetSettings(ctx, m.Conn)
	return signInsLoadedMsg{Listed: listed, Profiles: &settings.Profiles, Err: err, Gen: gen}
}

// hasKey reports a profile with an api key, entered now or held by the daemon.
func hasKey(settings config.Settings) bool {
	return settings.APIKey != "" || settings.HasKey
}

func (m LoginModel) fetchCatalogCmd(ext, endpoint string, gen int) tea.Cmd {
	return func() tea.Msg {
		note := "Could not load the models.dev catalog. Enter a model ID manually."
		if m.Conn == nil {
			return loginModelsLoadedMsg{Note: note, Gen: gen}
		}
		models, err := daemon.ListModels(context.Background(), m.Conn, ext, endpoint)
		names := make([]string, len(models))
		for i, model := range models {
			names[i] = model.ID
		}
		if err != nil {
			return loginModelsLoadedMsg{Err: err, Note: note, Gen: gen}
		}
		if len(names) == 0 {
			note = "No matching models in models.dev. Enter a model ID manually."
		}
		return loginModelsLoadedMsg{Models: names, Note: note, Gen: gen}
	}
}

// saveProviderCmd saves the profile and optional key through the daemon.
func (m LoginModel) saveProviderCmd(name string, settings config.Settings) tea.Cmd {
	return func() tea.Msg {
		validated, err := settings.Validate()
		if err != nil {
			return providerSavedMsg{Name: name, Settings: settings, Err: err}
		}
		err = daemon.SaveProvider(context.Background(), m.Conn, name, validated)
		return providerSavedMsg{Name: name, Settings: validated, Err: err}
	}
}

func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) {
	switch msg := msg.(type) {
	case signInsLoadedMsg:
		if msg.Gen != m.Generation {
			return m, nil
		}
		m.SignIns = msg.Listed.Logins
		m.Accounts = msg.Listed.Accounts
		if msg.Profiles != nil {
			m.Profiles = *msg.Profiles
		}

		if msg.Err != nil && m.Error == "" {
			m.Error = msg.Err.Error()
		}
		if m.Step == StepSaving {
			m.Removing = removal{}
			m.Step = StepChoose
			if msg.Err == nil {
				m.Error = ""
			}
			return m, m.backToChoose()
		}
		m.buildChoosePicker()
		if m.Hint != "" {
			return m, m.applyHint()
		}
		if m.Step != StepChoose {
			return m, nil
		}
		if len(m.Profiles.Providers) == 0 && len(m.Accounts) == 0 {
			m.Step = StepName
			m.TextInput.Placeholder = ""
			return m, m.promptInput("", false)
		}
		return m, m.ChoosePicker.Init()

	case signInStartedMsg:
		if msg.Gen != m.Generation || m.Step != StepOAuth {
			// Stale start: cancel the sign-in this client no longer shows.
			return m, m.cancelSignInCmd(msg.ID)
		}
		if msg.Err != nil {
			m.Error = operationError(msg.Err, "", "Sign-in may have started; check your accounts before starting another. Its outcome cannot be confirmed from this response.")
			return m, nil
		}
		m.LoginID = msg.ID
		m.SignInURL = msg.URL
		m.Status = ""

		cmds := []tea.Cmd{textinput.Blink}
		if msg.URL != "" {
			cmds = append(cmds, m.openBrowserCmd(msg.URL))
		}
		cmds = append(cmds, m.pollSignInCmd(msg.ID, m.Generation))
		return m, tea.Batch(cmds...)

	case signInPollMsg:
		if msg.Gen != m.Generation || m.Step != StepOAuth || msg.ID != m.LoginID {
			return m, m.cancelSignInCmd(msg.ID)
		}
		return m, m.pollSignInCmd(msg.ID, msg.Gen)

	case signInStatusMsg:
		if msg.Gen != m.Generation || m.Step != StepOAuth || msg.ID != m.LoginID {
			// A stale result: cancel the sign-in this client no longer shows.
			return m, m.cancelSignInCmd(msg.ID)
		}
		if msg.Err != nil {
			m.Error = msg.Err.Error()
			return m, m.endSignIn()
		}
		m.Status = msg.Message
		switch msg.State {
		case "done":
			m.LoginID = ""
			m.Step = StepOAuthModels
			m.Generation++
			return m, m.fetchCatalogCmd(m.Provider, "", m.Generation)
		case "failed":
			m.LoginID = ""
			m.Error = msg.Message
			return m, nil
		default:
			return m, signInPollTickCmd(msg.ID, m.Generation)
		}

	case loginModelsLoadedMsg:
		if msg.Gen != m.Generation || (m.Step != StepModels && m.Step != StepOAuthModels) {
			return m, nil
		}
		m.Catalog = msg.Models
		if m.Catalog == nil {
			m.Catalog = []string{}
		}
		m.CatalogNote = msg.Note
		m.buildModelPicker()
		return m, m.ModelPicker.Init()

	case providerSavedMsg:
		if msg.Err != nil {
			m.Error = msg.Err.Error()
			if m.Name == msg.Name && m.Draft.Model != "" {
				m.Step = StepModel
				return m, m.promptInput(m.Draft.Model, false)
			}
			return m, m.backToChoose()
		}
		return m, func() tea.Msg {
			return LoginDoneMsg{Name: msg.Name, Settings: msg.Settings}
		}

	case tea.KeyPressMsg:
		key := msg.String()
		if m.Step == StepChoose && (key == "delete" || key == "d") {
			if item, ok := m.ChoosePicker.Highlighted(); ok {
				if target, ok := m.removalFor(item.ID); ok {
					return m, m.confirmRemoval(target)
				}
			}
			return m, nil
		}
		if key == "esc" || key == "ctrl+c" || key == "ctrl+d" {
			pickerStep := m.Step == StepChoose || m.Step == StepProtocol || m.Step == StepModels || m.Step == StepOAuthModels || m.Step == StepRemove
			if !pickerStep || key == "ctrl+d" {
				cancel := m.Close()
				m.Generation++
				return m, tea.Batch(cancel, func() tea.Msg { return LoginCancelMsg{} })
			}
		}

	case PickerSelectMsg:
		switch m.Step {
		case StepChoose:
			if p, ok := customProviderByID(msg.ID); ok {
				return m, m.startCustomProvider(p)
			}
			if provider, ok := strings.CutPrefix(msg.ID, "signin:"); ok {
				if login, ok := m.signInFor(provider); ok {
					return m, m.startSignIn(login)
				}
			}
			// Choosing an account selects it; d removes it.
			if account, ok := m.accountAt(msg.ID); ok {
				m.Step = StepSaving
				return m, m.selectAccountCmd(account)
			}
			if name, ok := strings.CutPrefix(msg.ID, "use:"); ok {
				if s, ok := m.Profiles.Providers[name]; ok {
					return m, m.useProfile(name, s)
				}
			}

		case StepRemove:
			if msg.ID == "remove" {
				m.Step = StepSaving
				return m, m.removeCmd(m.Removing)
			}
			return m, m.backToChoose()

		case StepProtocol:
			m.Draft.Protocol = msg.ID
			return m, m.advanceToModels()

		case StepModels, StepOAuthModels:
			if msg.ID == "manual" {
				m.Step = StepModel
				m.TextInput.Placeholder = ""
				return m, m.promptInput("", false)
			}
			if modelName, ok := strings.CutPrefix(msg.ID, "model:"); ok {
				m.Draft.Model = modelName
				m.Step = StepSaving
				return m, m.saveProviderCmd(m.Name, m.Draft)
			}
		}

	case PickerCancelMsg:
		switch m.Step {
		case StepChoose, StepOAuthModels:
			return m, func() tea.Msg { return LoginCancelMsg{} }
		case StepProtocol:
			m.Step = StepAPIKey
			return m, m.promptInput("", true)
		case StepModels:
			m.Generation++
			if m.isFixedProtocol() {
				m.Step = StepAPIKey
				return m, m.promptInput(m.Draft.APIKey, true)
			}
			m.Step = StepBaseURL
			return m, m.promptInput(m.Draft.BaseURL, false)
		case StepRemove:
			return m, m.backToChoose()
		}
	}

	// A picker step routes everything to its picker; the rest to the input.
	switch m.Step {
	case StepChoose, StepProtocol, StepRemove, StepModels, StepOAuthModels:
		p := m.pickerFor()
		var cmd tea.Cmd
		*p, cmd = p.Update(msg)
		return m, cmd
	}

	if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "enter" {
		switch m.Step {
		case StepName:
			m.TextInput.SetCursor(0)
			name, err := config.ValidateProviderName(strings.TrimSpace(m.TextInput.Value()))
			if err != nil {
				return m.fail(err.Error())
			}
			m.Name = name
			m.Error = ""

			// A name that is a sign-in provider — or a saved profile for one —
			// signs in instead of asking for an api key, unless the profile
			// keeps a key or this is that extension's api-key flow.
			flow := m.Draft.Extension
			extension := name
			if saved, known := m.Profiles.Providers[name]; known {
				extension = saved.Extension
				m.Draft = saved
			}
			if login, ok := m.signInFor(extension); ok && !hasKey(m.Draft) && extension != flow {
				return m, m.startSignIn(login)
			}
			return m, m.askEndpoint()

		case StepBaseURL:
			m.TextInput.SetCursor(0)
			val := cmp.Or(strings.TrimSpace(m.TextInput.Value()), m.Draft.BaseURL)
			endpoint, err := config.ValidateEndpoint(val)
			if err != nil {
				return m.fail(err.Error())
			}
			if endpoint != m.Draft.BaseURL {
				m.Draft.APIKey, m.Draft.HasKey = "", false
			}
			m.Draft.BaseURL = endpoint
			m.Error = ""
			m.Step = StepAPIKey
			m.TextInput.Placeholder = ""
			return m, m.promptInput("", true)

		case StepAPIKey:
			m.TextInput.SetCursor(0)
			switch val := cmp.Or(m.TextInput.Value(), m.Draft.APIKey); {
			case val == "" && m.Draft.HasKey:
				// Blank keeps the key the daemon holds.
			case val == "" || strings.ContainsFunc(val, func(r rune) bool { return r <= 0x20 || r == 0x7f }):
				return m.fail("Enter an API key without spaces or control characters.")
			default:
				m.Draft.APIKey = val
			}
			m.Error = ""
			if m.isFixedProtocol() {
				return m, m.advanceToModels()
			}
			m.Step = StepProtocol
			m.buildProtocolPicker()
			return m, m.ProtocolPicker.Init()

		case StepOAuth:
			val := strings.TrimSpace(m.TextInput.Value())
			if val != "" && m.LoginID != "" {
				m.Status = "exchanging authorization code…"
				m.Error = ""
				return m, m.inputSignInCmd(m.LoginID, val, m.Generation)
			}

		case StepModel:
			m.TextInput.SetCursor(0)
			val := strings.TrimSpace(m.TextInput.Value())
			if err := validateModelID(val); err != nil {
				return m.fail(err.Error())
			}
			m.Draft.Model = val
			m.Error = ""
			m.Step = StepSaving
			return m, m.saveProviderCmd(m.Name, m.Draft)
		}
	}

	var cmd tea.Cmd
	m.TextInput, cmd = m.TextInput.Update(msg)
	return m, cmd
}

func (m LoginModel) View() string {
	var b strings.Builder
	line := func(s string) {
		b.WriteString(s)
		b.WriteByte('\n')
	}
	line(titleRule(m.Width, brand("albedo")+" "+m.Styles.Muted.Render("/login"), m.Styles.Faint.Render(m.Name)))
	location := "Provider configuration stored in " + config.HomeDir()
	if m.Width > 0 && ansi.StringWidth(location) > m.Width && ansi.StringWidth(config.HomeDir()) <= m.Width {
		location = "Provider configuration stored in\n" + config.HomeDir()
	}
	line(m.Styles.Faint.Render(ansi.Wrap(location, m.Width, "")))
	if m.Error != "" {
		line(m.Styles.Error.Render(ansi.Wrap(m.Error, m.Width, "")))
	}

	switch m.Step {
	case StepChoose:
		line(m.ChoosePicker.View())
		b.WriteString(ansi.Wrap(keyHints(hint{"enter", "select"}, hint{"d", "remove"}, hint{"esc", "cancel"}), m.Width, ""))
	case StepProtocol:
		b.WriteString(m.ProtocolPicker.View())
	case StepRemove:
		b.WriteString(m.ConfirmPicker.View())
	case StepModels, StepOAuthModels:
		if m.Catalog == nil {
			b.WriteString(m.Styles.Faint.Render("loading models…"))
			break
		}
		if m.CatalogNote != "" {
			line(m.Styles.Faint.Render(m.CatalogNote))
		}
		b.WriteString(m.ModelPicker.View())
	case StepOAuth:
		line(cmp.Or(m.Status, "starting sign-in…"))
		if m.SignInURL != "" {
			line(m.Styles.Faint.Render(ansi.Hardwrap(m.SignInURL, m.Width, true)))
		}
		b.WriteString("Callback URL or code: ")
		line(m.TextInput.View())
		b.WriteString(ansi.Wrap(m.Styles.Faint.Render("Browser sign-in completes automatically")+m.Styles.Decor.Render(" · ")+keyHints(hint{"enter", "submit code"}, hint{"esc", "cancel"}), m.Width, ""))
	case StepSaving:
		state := "saving provider…"
		if m.Removing.Kind != "" {
			state = "removing " + m.Removing.Kind + "…"
		}
		b.WriteString(m.Styles.Faint.Render(state))
	default:
		stepLabels := map[LoginStep]string{
			StepName: "provider name", StepBaseURL: "API base URL",
			StepAPIKey: "API key", StepModel: "model ID",
		}
		line(stepLabels[m.Step] + ": " + m.TextInput.View())
		var hints []hint
		if m.Step == StepAPIKey && m.Draft.APIKey != "" {
			hints = append(hints, hint{"enter", "keeps the saved key"})
		}
		if m.Step == StepName {
			for _, login := range m.SignIns {
				hints = append(hints, hint{"", "use " + login.Provider + " to sign in"})
			}
		}
		hints = append(hints, hint{"enter", "continue"}, hint{"esc", "cancel"})
		b.WriteString(ansi.Wrap(keyHints(hints...), m.Width, ""))
	}
	return b.String()
}
