// Package state is in charge of storing and reading application state.
//
//nolint:forbidigo // Use fmt.Printf for display user messages to stdout.
package state

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path"
	"strings"
	"sync"

	"github.com/samber/lo"
	"gopkg.in/yaml.v2"

	"github.com/grafviktor/goto/internal/config"
	"github.com/grafviktor/goto/internal/constant"
	"github.com/grafviktor/goto/internal/ui/theme"
	"github.com/grafviktor/goto/internal/utils"
	"github.com/grafviktor/goto/internal/version"
)

type View int

const (
	// ViewHostList mode is active when we browse through a list of hostnames.
	ViewHostList View = iota
	// ViewGroupList mode is active when the app displays available host groups.
	ViewGroupList
	// ViewEditItem mode is active when we edit existing or add a new host.
	ViewEditItem
	// ViewMessage mode is active when there was an error when attempted to connect to a remote host.
	ViewMessage
)

// Once - this interface is used to avoid sync.Once restrictions in unit-tests.
type Once interface {
	Do(func())
}

var (
	st        *State
	once      Once = &sync.Once{}
	stateFile      = "state.yaml"
)

type loggerInterface interface {
	Debug(format string, args ...any)
	Warn(format string, args ...any)
	Info(format string, args ...any)
	Error(format string, args ...any)
	Close()
}

// State stores application state.
type State struct {
	// Note that the app load ssh_config_file_path from SSHConfigPath,
	// but persists it to disk using SetSSHConfigPath.
	// This is done to distinguish between --set-ssh-config-path flag usage and
	// and setting the path via command line -s or env variable.
	AppHome                    string                `yaml:"-"`
	AppMode                    constant.AppMode      `yaml:"-"`
	Context                    context.Context       `yaml:"-"`
	CurrentView                View                  `yaml:"-"`
	Group                      string                `yaml:"group,omitempty"`
	Height                     int                   `yaml:"-"`
	IsUserDefinedSSHConfigPath bool                  `yaml:"-"`
	Logger                     loggerInterface       `yaml:"-"`
	LogLevel                   constant.LogLevel     `yaml:"-"`
	ScreenLayout               constant.ScreenLayout `yaml:"screen_layout,omitempty"`
	SetSSHConfigPath           string                `yaml:"ssh_config_path,omitempty"`
	Selected                   int                   `yaml:"selected"`
	SSHConfigEnabled           bool                  `yaml:"enable_ssh_config"`
	SSHConfigPath              string                `yaml:"-"`
	Theme                      string                `yaml:"theme,omitempty"`
	Width                      int                   `yaml:"-"`
}

// Get - returns application state.
func Get() *State {
	return st
}

// IsInitialized - checks if the application state is initialized.
func IsInitialized() bool {
	return st != nil
}

// Initialize - creates application state.
func Initialize(ctx context.Context,
	cfg *config.Configuration,
	lg loggerInterface,
) (*State, error) {
	var err error

	once.Do(func() {
		lg.Debug("[APPSTATE] Create application state")

		// Cannot take this value from config because:
		// 1. We must distinguish between user-defined and default paths
		// 2. If default path does not exist, we must ignore this error(replicate what ssh does)
		// 3. If custom path is provided, but does not exist, we must close the app with error
		// Probably we should not be too smart here and always ignore the error or vice versa - always fail.
		var defaultSSHConfigPath string
		defaultSSHConfigPath, err = utils.SSHConfigDefaultFilePath()
		if err != nil {
			lg.Warn("[APPSTATE] Cannot determine default SSH config file path: %v.", err)
		}

		// Set default values
		st = &State{
			AppMode:       cfg.AppMode,
			AppHome:       cfg.AppHome,
			Context:       ctx,
			Logger:        lg,
			SSHConfigPath: defaultSSHConfigPath,
		}

		// Read state from file
		st.readFromFile()
		// Apply configuration
		err = st.applyConfig(cfg)
	})

	return st, err
}

func (s *State) readFromFile() {
	// Why not unmarshal directly to State? Because we want to distinguish between null values
	// and zero values especially for boolean parameters. Using pointers for that.
	var loadedState struct {
		Selected int    `yaml:"selected"`
		Group    string `yaml:"group"`
		// Using pointers to distinguish between null and zero values.
		Theme            *string `yaml:"theme"`
		ScreenLayout     *string `yaml:"screen_layout"`
		SSHConfigEnabled *bool   `yaml:"enable_ssh_config"`
		SSHConfigPath    *string `yaml:"ssh_config_path"`
	}

	appStateFilePath := path.Join(s.AppHome, stateFile)
	s.Logger.Debug("[APPSTATE] Read application state from: %q", appStateFilePath)
	fileData, err := os.ReadFile(appStateFilePath)
	if err != nil {
		s.Logger.Warn("[APPSTATE] Can't read application state from file. Reason: %v", err)
		fileData = []byte{}
	}

	err = yaml.Unmarshal(fileData, &loadedState)
	if err != nil {
		s.Logger.Error("[APPSTATE] Can't parse application state loaded from file. Reason: %v", err)
	}

	s.Group = loadedState.Group
	s.Selected = loadedState.Selected

	if loadedState.Theme == nil {
		s.Theme = theme.DefaultTheme().Name
	} else {
		s.Theme = *loadedState.Theme
	}

	if loadedState.ScreenLayout == nil {
		s.ScreenLayout = constant.ScreenLayoutDescription
	} else {
		s.ScreenLayout = constant.ScreenLayout(*loadedState.ScreenLayout)
	}

	if loadedState.SSHConfigEnabled == nil {
		// If there is no value for ssh config option, then we enable it by default
		s.SSHConfigEnabled = true
	} else {
		s.SSHConfigEnabled = *loadedState.SSHConfigEnabled
	}

	if loadedState.SSHConfigPath != nil {
		var sshConfigPath string
		sshConfigPath, err = utils.SSHConfigPath(*loadedState.SSHConfigPath)
		if err != nil {
			s.Logger.Error("[APPSTATE] Cannot apply ssh_config file path from state file: %v", err)
		} else {
			s.SSHConfigPath = sshConfigPath
			s.IsUserDefinedSSHConfigPath = true
			// This value is only used when persist to disk, as
			// we want to keep SSHConfigPath across app restarts.
			s.SetSSHConfigPath = sshConfigPath
		}
	}

	s.Logger.Debug("[APPSTATE] Screen layout: '%v'. Focused host id: '%v'", s.ScreenLayout, s.Selected)
}

//nolint:gocognit // this function performs a single task and can't be splitted
func (s *State) applyConfig(cfg *config.Configuration) error {
	if utils.StringEmpty(&cfg.AppMode) {
		s.AppMode = constant.AppModeType.StartUI
	} else {
		s.AppMode = cfg.AppMode
	}

	if utils.StringEmpty(&cfg.LogLevel) {
		s.LogLevel = constant.LogLevelType.INFO
	} else {
		s.LogLevel = cfg.LogLevel
	}

	if cfg.DisableFeature != "" {
		if cfg.DisableFeature == config.FeatureSSHConfig {
			s.SSHConfigEnabled = false
		} else {
			return fmt.Errorf("feature %q is not supported", cfg.DisableFeature)
		}
	}

	if cfg.EnableFeature != "" {
		if cfg.EnableFeature == config.FeatureSSHConfig {
			s.SSHConfigEnabled = true
		} else {
			return fmt.Errorf("feature %q is not supported", cfg.EnableFeature)
		}
	}

	// This will only trigger if user used --set-ssh-config-path flag.
	// The app will close after persisting the new path.
	// We do not need to assign the value to s.SSHConfigPath as the app will exit now.
	if !utils.StringEmpty(&cfg.SetSSHConfigPath) {
		userDefinedPath, err := utils.SSHConfigPath(cfg.SetSSHConfigPath)
		if err != nil {
			return fmt.Errorf("cannot set ssh config file path: %w", err)
		}
		if !s.SSHConfigEnabled {
			fmt.Println("[CONFIG] Warning: ssh_config support is disabled. The specified path will not be used.")
		}
		s.SetSSHConfigPath = userDefinedPath
	}

	if !utils.StringEmpty(&cfg.SSHConfigPath) {
		if !s.SSHConfigEnabled {
			return errors.New("you must enable ssh_config support to use this option")
		}
		userDefinedPath, err := utils.SSHConfigPath(cfg.SSHConfigPath)
		if err != nil {
			return fmt.Errorf("cannot set ssh config file path: %w", err)
		}
		s.SSHConfigPath = userDefinedPath
		s.IsUserDefinedSSHConfigPath = true
	}

	if !utils.StringEmpty(&cfg.SetTheme) {
		installedThemes := theme.ListInstalled(cfg.AppHome, s.Logger)
		if !lo.Contains(installedThemes, cfg.SetTheme) {
			installedThemesStr := strings.Join(installedThemes, ", ")
			return fmt.Errorf("cannot find theme %q, installed themes: %v", cfg.SetTheme, installedThemesStr)
		}
		s.Theme = cfg.SetTheme
	}

	return nil
}

// Persist saves app state to disk.
func (s *State) Persist() error {
	appStateFilePath := path.Join(s.AppHome, stateFile)
	s.Logger.Debug("[APPSTATE] Persist application state to file: %q", appStateFilePath)
	result, err := yaml.Marshal(s)
	if err != nil {
		s.Logger.Error("[APPSTATE] Cannot marshall application state. %v", err)
		return err
	}

	err = os.WriteFile(appStateFilePath, result, 0o600)
	if err != nil {
		s.Logger.Error("[APPSTATE] Cannot save application state. %v", err)
		return err
	}

	return nil
}

func (s *State) print() {
	fmt.Printf("App home:          %s\n", s.AppHome)
	fmt.Printf("Log level:         %s\n", s.LogLevel)
	fmt.Printf("SSH config status: %s\n", lo.Ternary(s.SSHConfigEnabled, "enabled", "disabled"))
	if s.SSHConfigEnabled {
		fmt.Printf("SSH config path:   %s\n", s.SSHConfigPath)
	}
}

// PrintConfig outputs user-definable parameters in the console.
func (s *State) PrintConfig() {
	version.Print()
	fmt.Println()
	s.print()
}

func (s *State) LogDetails() {
	s.Logger.Info("[CONFIG] Application home folder: %q\n", s.AppHome)
	s.Logger.Info("[CONFIG] Application log level:   %q\n", s.LogLevel)
	s.Logger.Info("[CONFIG] SSH config status:       %q\n", lo.Ternary(s.SSHConfigEnabled, "enabled", "disabled"))
	if s.SSHConfigEnabled {
		s.Logger.Info("[CONFIG] SSH config path:         %q\n", s.SSHConfigPath)
	}
}
