package tui

import (
	"context"
	"errors"
	"io"

	"charm.land/bubbles/v2/spinner"
	tea "charm.land/bubbletea/v2"
	"charm.land/lipgloss/v2"
	"github.com/pkg/browser"

	"github.com/gruntwork-io/terragrunt/internal/venv"
	"github.com/gruntwork-io/terragrunt/pkg/log"
	"github.com/gruntwork-io/terragrunt/pkg/options"
)

const (
	welcomeDocsURL    = "https://docs.terragrunt.com/features/catalog/"
	statusChannelSize = 10
)

// StatusFunc receives progress updates during discovery and loading.
type StatusFunc func(msg string)

// LoadFunc performs source discovery and component loading in the background.
// It calls status with human-readable progress updates and sends each
// discovered component entry on componentCh as it is found. It returns an
// error if discovery itself fails; absence of any components is signalled by
// the channel closing without entries, not by an error.
type LoadFunc func(ctx context.Context, status StatusFunc, componentCh chan<- *ComponentEntry) error

// OpenURLFunc opens a URL in the user's browser. Injected so tests can
// substitute a no-op or a recording stub.
type OpenURLFunc func(url string) error

// DiscoveryCompleteMsg is sent when background discovery finishes.
type DiscoveryCompleteMsg struct {
	Err error
}

// StatusUpdateMsg carries a progress update from the LoadFunc.
type StatusUpdateMsg string

type welcomeState int

const (
	welcomeLoading welcomeState = iota
	welcomeNoSources
	welcomeDiscoveryError
)

// componentMsg carries a single newly-discovered component entry from the LoadFunc.
type componentMsg struct {
	entry *ComponentEntry
}

var (
	welcomeTitleStyle = lipgloss.NewStyle().
				Bold(true).
				Foreground(lipgloss.Color("#A8ACB1")).
				Background(lipgloss.Color("#1D252F")).
				Padding(0, 1)

	welcomeBodyStyle = lipgloss.NewStyle().
				Padding(bodyPaddingVertical, bodyPaddingHorizontal)

	welcomeCodeStyle = lipgloss.NewStyle().
				Foreground(lipgloss.Color("#8BE9FD"))

	welcomeHintStyle = lipgloss.NewStyle().
				Foreground(lipgloss.Color("#6272A4"))
)

// WelcomeModel is a BubbleTea model that shows a loading screen while
// discovery runs in the background, then either transitions to the component
// list TUI or settles into a "no sources found" help screen.
type WelcomeModel struct {
	ctx              context.Context
	logger           log.Logger
	venv             venv.Venv
	lastDiscoveryErr error
	opts             *options.TerragruntOptions
	loadFunc         LoadFunc
	openURL          OpenURLFunc
	statusCh         chan string
	componentCh      chan *ComponentEntry
	errCh            chan error
	statusText       string
	spinner          spinner.Model
	state            welcomeState
	width            int
	height           int
}

// ComponentMsg creates a componentMsg for testing.
func ComponentMsg(entry *ComponentEntry) tea.Msg {
	return componentMsg{entry: entry}
}

// NewWelcomeModel creates a WelcomeModel that immediately begins discovery.
func NewWelcomeModel(
	ctx context.Context,
	l log.Logger,
	v venv.Venv,
	opts *options.TerragruntOptions,
	loadFunc LoadFunc,
) WelcomeModel {
	s := spinner.New()
	s.Spinner = spinner.Dot
	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B"))

	return WelcomeModel{
		ctx:         ctx,
		logger:      l,
		opts:        opts,
		loadFunc:    loadFunc,
		openURL:     browser.OpenURL,
		statusCh:    make(chan string, statusChannelSize),
		componentCh: make(chan *ComponentEntry, statusChannelSize),
		errCh:       make(chan error, 1),
		spinner:     s,
		venv:        v,
		statusText:  "Discovering components from your infrastructure...",
		state:       welcomeLoading,
	}
}

// Run launches the catalog experience. It shows a loading screen immediately
// while discovery runs in the background, then transitions to the component
// list if components are found. Post-exit messages are written to errWriter
// after the tea program restores the main terminal.
func Run(ctx context.Context, l log.Logger, v venv.Venv, opts *options.TerragruntOptions, errWriter io.Writer, loadFunc LoadFunc) error {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	model := NewWelcomeModel(ctx, l, v, opts, loadFunc)

	finalModel, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()

	EmitExitMessage(finalModel, errWriter, l)

	if err != nil {
		cause := context.Cause(ctx)
		if errors.Is(cause, context.Canceled) {
			return nil
		}

		if cause != nil {
			return cause
		}

		return err
	}

	return sessionErr(finalModel)
}

// sessionErr extracts the failure the final model recorded during its
// session, if any. The detailed, styled rendering has already been shown
// in the TUI (or stashed as the exit message), so the returned error is
// the concise form the CLI reports.
func sessionErr(finalModel tea.Model) error {
	type errCarrier interface{ Err() error }

	if m, ok := finalModel.(errCarrier); ok {
		return m.Err()
	}

	return nil
}

// Init implements tea.Model. It starts the spinner and kicks off discovery.
func (m WelcomeModel) Init() tea.Cmd {
	return tea.Batch(m.spinner.Tick, m.startDiscovery(), m.listenForStatus(), m.listenForComponent())
}

// Update implements tea.Model.
func (m WelcomeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case DiscoveryCompleteMsg:
		return m.handleDiscoveryComplete(msg)
	case componentMsg:
		return m.handleComponentMsg(msg)
	case StatusUpdateMsg:
		m.statusText = string(msg)

		return m, m.listenForStatus()
	case tea.KeyPressMsg:
		switch msg.String() {
		case "q", "esc", "ctrl+c":
			return m, tea.Quit
		case "h":
			if m.state == welcomeNoSources {
				if err := m.openURL(welcomeDocsURL); err != nil {
					m.logger.Warnf("Could not open docs URL: %v", err)
				}
			}
		}
	case tea.WindowSizeMsg:
		m.width = msg.Width
		m.height = msg.Height
	}

	if m.state == welcomeLoading {
		var cmd tea.Cmd

		m.spinner, cmd = m.spinner.Update(msg)

		return m, cmd
	}

	return m, nil
}

// View implements tea.Model.
func (m WelcomeModel) View() tea.View {
	var content string

	switch m.state {
	case welcomeLoading:
		content = m.loadingView()
	case welcomeNoSources:
		content = m.noSourcesView()
	case welcomeDiscoveryError:
		content = m.discoveryErrorView()
	}

	if m.width > 0 && m.height > 0 {
		content = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content)
	}

	v := tea.NewView(content)
	v.AltScreen = true

	return v
}

// Err returns the discovery failure that ended the session, or nil when
// discovery succeeded or is still running. Run propagates it as the
// command error after the user dismisses the error screen, so the catalog
// command exits nonzero.
func (m WelcomeModel) Err() error {
	return m.lastDiscoveryErr
}

// ExitMessage returns the styled callout describing the discovery failure,
// or "" when there is none. The error screen lives in the alt screen and
// vanishes when the program exits, so EmitExitMessage reprints the details
// into the user's scrollback while the returned error stays concise.
func (m WelcomeModel) ExitMessage() string {
	if m.lastDiscoveryErr == nil {
		return ""
	}

	return formatSourceFailureNotice(m.lastDiscoveryErr, valuesBoxAccentRed)
}

// WithOpenURL replaces the function used to open URLs in the browser.
func (m WelcomeModel) WithOpenURL(fn OpenURLFunc) WelcomeModel {
	m.openURL = fn

	return m
}

func (m WelcomeModel) discoveryErrorView() string {
	// One slot per fixed line surrounding the detail block.
	const fixedRows = 8

	title := welcomeTitleStyle.Render(" Terragrunt Catalog ")

	detail := m.discoveryErrorDetail()

	rows := make([]string, 0, len(detail)+fixedRows)

	rows = append(rows,
		"",
		"An error occurred while discovering catalog sources:",
		"",
	)

	rows = append(rows, detail...)

	rows = append(rows,
		"",
		"Please check your network connection, authentication, and",
		"catalog configuration, then try again.",
		"",
		welcomeHintStyle.Render("q/esc: exit"),
	)

	body := welcomeBodyStyle.Render(lipgloss.JoinVertical(lipgloss.Left, rows...))

	return lipgloss.JoinVertical(lipgloss.Center, title, body)
}

// discoveryErrorDetail returns the body lines describing the discovery
// failure. Per-source load failures render a summary plus one line per
// failed source, so "all N sources failed" reads differently from the
// no-sources welcome screen.
func (m WelcomeModel) discoveryErrorDetail() []string {
	errMsg := "unknown error"
	if m.lastDiscoveryErr != nil {
		errMsg = m.lastDiscoveryErr.Error()
	}

	var srcErr *SourceLoadError
	if !errors.As(m.lastDiscoveryErr, &srcErr) {
		return []string{welcomeCodeStyle.Render("  " + errMsg)}
	}

	rows := []string{welcomeCodeStyle.Render("  " + errMsg + ":")}

	for _, f := range srcErr.Failures {
		rows = append(rows,
			"",
			welcomeCodeStyle.Render("    "+f.URL),
			welcomeHintStyle.Render("      "+f.Err.Error()),
		)
	}

	return rows
}

func (m WelcomeModel) handleComponentMsg(msg componentMsg) (tea.Model, tea.Cmd) {
	// First component: transition to the catalog list immediately
	newModel := NewModelStreaming(m.ctx, m.logger, m.venv, m.opts, msg.entry, m.componentCh, m.errCh)
	width, height := m.width, m.height

	initCmds := []tea.Cmd{newModel.Init()}
	if width > 0 && height > 0 {
		initCmds = append(initCmds, func() tea.Msg {
			return tea.WindowSizeMsg{Width: width, Height: height}
		})
	}

	return newModel, tea.Batch(initCmds...)
}

func (m WelcomeModel) handleDiscoveryComplete(msg DiscoveryCompleteMsg) (tea.Model, tea.Cmd) {
	if msg.Err != nil {
		// No components arrived before the failure (otherwise the streaming
		// Model would be handling this message), so the session has nothing
		// to show. Render the error screen; the stashed error is returned
		// by Run when the user quits and its details are reprinted
		// via ExitMessage. Logging here would shred the alt-screen
		// rendering, so nothing is logged.
		m.lastDiscoveryErr = msg.Err
		m.state = welcomeDiscoveryError

		return m, nil
	}

	// No components were ever discovered, so show the welcome screen.
	m.state = welcomeNoSources

	return m, nil
}

// listenForComponent returns a Cmd that delivers the next component on
// componentCh as a componentMsg. When componentCh is closed, it drains
// errCh for the loadFunc result and emits DiscoveryCompleteMsg.
//
// Routing completion through the same Cmd as the component stream keeps
// message ordering deterministic: every component is observed before
// DiscoveryCompleteMsg regardless of goroutine scheduling. A sibling Cmd
// emitting DiscoveryCompleteMsg directly would race this one, and the
// welcome to streaming swap could drop completion and leave the streaming
// Model's loading flag stuck on.
func (m WelcomeModel) listenForComponent() tea.Cmd {
	ch := m.componentCh
	if ch == nil {
		return nil
	}

	errCh := m.errCh

	return func() tea.Msg {
		c, ok := <-ch
		if !ok {
			var err error
			if errCh != nil {
				err = <-errCh
			}

			return DiscoveryCompleteMsg{Err: err}
		}

		return componentMsg{entry: c}
	}
}

func (m WelcomeModel) listenForStatus() tea.Cmd {
	ch := m.statusCh

	return func() tea.Msg {
		status, ok := <-ch
		if !ok {
			return nil
		}

		return StatusUpdateMsg(status)
	}
}

func (m WelcomeModel) loadingView() string {
	title := welcomeTitleStyle.Render(" Terragrunt Catalog ")

	body := welcomeBodyStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
		"",
		m.spinner.View()+" "+m.statusText,
	))

	return lipgloss.JoinVertical(lipgloss.Center, title, body)
}

func (m WelcomeModel) noSourcesView() string {
	title := welcomeTitleStyle.Render(" Terragrunt Catalog ")

	body := welcomeBodyStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
		"",
		"No catalog sources were discovered in your infrastructure.",
		"",
		"To get started, you can either:",
		"",
		"  1. Add a catalog block to your root terragrunt.hcl:",
		"",
		welcomeCodeStyle.Render("     catalog {"),
		welcomeCodeStyle.Render(`       urls = ["github.com/your-org/your-modules"]`),
		welcomeCodeStyle.Render("     }"),
		"",
		"  2. Add terraform.source attributes to your unit configurations.",
		"     The catalog will automatically discover referenced components.",
		"",
		welcomeHintStyle.Render("h: open docs in browser  q/esc: exit"),
	))

	return lipgloss.JoinVertical(lipgloss.Center, title, body)
}

func (m WelcomeModel) startDiscovery() tea.Cmd {
	ctx := m.ctx
	loadFunc := m.loadFunc
	statusCh := m.statusCh
	componentCh := m.componentCh
	errCh := m.errCh

	return func() tea.Msg {
		defer close(statusCh)
		defer close(errCh)
		defer close(componentCh)

		err := loadFunc(ctx, func(msg string) {
			select {
			case statusCh <- msg:
			default:
			}
		}, componentCh)

		// Stash the loadFunc error on errCh; the listener reads it after
		// componentCh closes, so DiscoveryCompleteMsg arrives strictly
		// after every component regardless of goroutine scheduling.
		if err != nil {
			errCh <- err
		}

		return nil
	}
}
