package cmd

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path"
	"runtime"
	"strings"
	"syscall"
	"time"

	"github.com/ddworken/hishtory/client/data"
	"github.com/ddworken/hishtory/client/hctx"
	"github.com/ddworken/hishtory/client/lib"
	"github.com/ddworken/hishtory/shared"
	"github.com/google/uuid"
	"github.com/spf13/cobra"
	"gorm.io/gorm"
)

var offlineInit *bool
var offlineInstall *bool

var installCmd = &cobra.Command{
	Use:    "install",
	Hidden: true,
	Short:  "Copy this binary to ~/.hishtory/ and configure your shell to use it for recording your shell history",
	Args:   cobra.MaximumNArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		secretKey := ""
		if len(args) > 0 {
			secretKey = args[0]
		}
		lib.CheckFatalError(install(secretKey, *offlineInstall))
		if os.Getenv("HISHTORY_SKIP_INIT_IMPORT") == "" {
			db, err := hctx.OpenLocalSqliteDb()
			lib.CheckFatalError(err)
			count, err := countStoredEntries(db)
			lib.CheckFatalError(err)
			if count < 10 {
				fmt.Println("Importing existing shell history...")
				ctx := hctx.MakeContext()
				numImported, err := lib.ImportHistory(ctx, false, false)
				lib.CheckFatalError(err)
				if numImported > 0 {
					fmt.Printf("Imported %v history entries from your existing shell history\n", numImported)
				}
			}
		}
		lib.CheckFatalError(warnIfUnsupportedBashVersion())
	},
}

var initCmd = &cobra.Command{
	Use:     "init",
	Short:   "Re-initialize hiSHtory with a specified secret key",
	GroupID: GROUP_ID_CONFIG,
	Args:    cobra.MaximumNArgs(1),
	Run: func(cmd *cobra.Command, args []string) {
		db, err := hctx.OpenLocalSqliteDb()
		lib.CheckFatalError(err)
		count, err := countStoredEntries(db)
		lib.CheckFatalError(err)
		if count > 0 {
			fmt.Printf("Your current hishtory profile has saved history entries, are you sure you want to run `init` and reset?\nNote: This won't clear any imported history entries from your existing shell\n[y/N]")
			reader := bufio.NewReader(os.Stdin)
			resp, err := reader.ReadString('\n')
			lib.CheckFatalError(err)
			if strings.TrimSpace(resp) != "y" {
				fmt.Printf("Aborting init per user response of %#v\n", strings.TrimSpace(resp))
				return
			}
		}
		secretKey := ""
		if len(args) > 0 {
			secretKey = args[0]
		}
		lib.CheckFatalError(setup(secretKey, *offlineInit))
		if os.Getenv("HISHTORY_SKIP_INIT_IMPORT") == "" {
			fmt.Println("Importing existing shell history...")
			ctx := hctx.MakeContext()
			numImported, err := lib.ImportHistory(ctx, false, false)
			lib.CheckFatalError(err)
			if numImported > 0 {
				fmt.Printf("Imported %v history entries from your existing shell history\n", numImported)
			}
		}
	},
}

var uninstallCmd = &cobra.Command{
	Use:   "uninstall",
	Short: "Completely uninstall hiSHtory and remove your shell history",
	Run: func(cmd *cobra.Command, args []string) {
		ctx := hctx.MakeContext()
		fmt.Printf("Are you sure you want to uninstall hiSHtory and delete all locally saved history data [y/N]")
		reader := bufio.NewReader(os.Stdin)
		resp, err := reader.ReadString('\n')
		lib.CheckFatalError(err)
		if strings.TrimSpace(resp) != "y" {
			fmt.Printf("Aborting uninstall per user response of %#v\n", strings.TrimSpace(resp))
			return
		}
		fmt.Printf("Do you have any feedback on why you're uninstallying hiSHtory? Type any feedback and then hit enter.\nFeedback: ")
		feedbackTxt, err := reader.ReadString('\n')
		lib.CheckFatalError(err)
		feedback := shared.Feedback{
			Date:     time.Now(),
			Feedback: feedbackTxt,
			UserId:   data.UserId(hctx.GetConf(ctx).UserSecret),
		}
		reqBody, err := json.Marshal(feedback)
		lib.CheckFatalError(err)
		_, _ = lib.ApiPost(ctx, "/api/v1/feedback", "application/json", reqBody)
		lib.CheckFatalError(uninstall(ctx))
	},
}

func countStoredEntries(db *gorm.DB) (int64, error) {
	return lib.RetryingDbFunctionWithResult(func() (int64, error) {
		var count int64
		return count, db.Model(&data.HistoryEntry{}).Count(&count).Error
	})
}

func warnIfUnsupportedBashVersion() error {
	_, err := exec.LookPath("bash")
	if err != nil {
		// bash is not installed, do nothing
		return nil
	}
	cmd := exec.Command("bash", "--version")
	bashVersion, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("failed to check bash version: %w", err)
	}
	if strings.Contains(string(bashVersion), "version 3.") {
		fmt.Printf("Warning: Your current bash version does not support overriding control-r. Please upgrade to at least bash 5 to enable the control-r integration.\n")
	}
	return nil
}

func install(secretKey string, offline bool) error {
	homedir, err := os.UserHomeDir()
	if err != nil {
		return fmt.Errorf("failed to get user's home directory: %w", err)
	}
	err = hctx.MakeHishtoryDir()
	if err != nil {
		return err
	}
	path, err := installBinary(homedir)
	if err != nil {
		return err
	}
	err = configureBashrc(homedir, path)
	if err != nil {
		return err
	}
	err = configureZshrc(homedir, path)
	if err != nil {
		return err
	}
	err = configureFish(homedir, path)
	if err != nil {
		return err
	}
	err = handleUpgradedFeatures()
	if err != nil {
		return err
	}
	_, err = hctx.GetConfig()
	if err != nil {
		// No config, so set up a new installation
		return setup(secretKey, offline)
	}
	// TODO: Only trigger this if the version is old enough
	err = handleDbUpgrades(hctx.MakeContext())
	if err != nil {
		return err
	}
	return nil
}

// Handles people running `hishtory update` when the DB needs updating.
func handleDbUpgrades(ctx context.Context) error {
	db := hctx.GetDb(ctx)
	return lib.RetryingDbFunction(func() error {
		return db.Exec(`UPDATE history_entries SET entry_id = lower(hex(randomblob(12))) WHERE entry_id IS NULL`).Error
	})
}

// Handles people running `hishtory update` from an old version of hishtory that
// doesn't support certain config options that we now default to true. This ensures
// that upgrades get them enabled by default, but if someone has it explicitly disabled,
// we keep it that way.
func handleUpgradedFeatures() error {
	configContents, err := hctx.GetConfigContents()
	if err != nil {
		// No config, so this is a new install and thus there is nothing to do
		return nil
	}
	config, err := hctx.GetConfig()
	if err != nil {
		return err
	}
	if !strings.Contains(string(configContents), "enable_control_r_search") {
		// control-r search is not yet configured, so enable it
		config.ControlRSearchEnabled = true
	}
	if !strings.Contains(string(configContents), "highlight_matches") {
		// highlighting is not yet configured, so enable it
		config.HighlightMatches = true
	}
	return hctx.SetConfig(&config)
}

func installBinary(homedir string) (string, error) {
	clientPath, err := exec.LookPath("hishtory")
	if err != nil {
		clientPath = path.Join(homedir, data.GetHishtoryPath(), "hishtory")
	}
	if _, err := os.Stat(clientPath); err == nil {
		err = syscall.Unlink(clientPath)
		if err != nil {
			return "", fmt.Errorf("failed to unlink %s for install: %w", clientPath, err)
		}
	}
	err = copyFile(os.Args[0], clientPath)
	if err != nil {
		return "", fmt.Errorf("failed to copy hishtory binary to $PATH: %w", err)
	}
	err = os.Chmod(clientPath, 0o700)
	if err != nil {
		return "", fmt.Errorf("failed to set permissions on hishtory binary: %w", err)
	}
	return clientPath, nil
}

func getFishConfigPath(homedir string) string {
	return path.Join(homedir, data.GetHishtoryPath(), "config.fish")
}

func configureFish(homedir, binaryPath string) error {
	// Check if fish is installed
	_, err := exec.LookPath("fish")
	if err != nil {
		return nil
	}
	// Create the file we're going to source. Do this no matter what in case there are updates to it.
	configContents := lib.ConfigFishContents
	if os.Getenv("HISHTORY_TEST") != "" {
		testConfig, err := tweakConfigForTests(configContents)
		if err != nil {
			return err
		}
		configContents = testConfig
	}
	err = os.WriteFile(getFishConfigPath(homedir), []byte(configContents), 0o644)
	if err != nil {
		return fmt.Errorf("failed to write config.fish file: %w", err)
	}
	// Check if we need to configure the fishrc
	fishIsConfigured, err := isFishConfigured(homedir)
	if err != nil {
		return fmt.Errorf("failed to check ~/.config/fish/config.fish: %w", err)
	}
	if fishIsConfigured {
		return nil
	}
	// Add to fishrc
	err = os.MkdirAll(path.Join(homedir, ".config/fish"), 0o744)
	if err != nil {
		return fmt.Errorf("failed to create fish config directory: %w", err)
	}
	return addToShellConfig(path.Join(homedir, ".config/fish/config.fish"), getFishConfigFragment(homedir))
}

func getFishConfigFragment(homedir string) string {
	return "\n# Hishtory Config:\nexport PATH=\"$PATH:" + path.Join(homedir, data.GetHishtoryPath()) + "\"\nsource " + getFishConfigPath(homedir) + "\n"
}

func isFishConfigured(homedir string) (bool, error) {
	_, err := os.Stat(path.Join(homedir, ".config/fish/config.fish"))
	if errors.Is(err, os.ErrNotExist) {
		return false, nil
	}
	fishConfig, err := os.ReadFile(path.Join(homedir, ".config/fish/config.fish"))
	if err != nil {
		return false, fmt.Errorf("failed to read ~/.config/fish/config.fish: %w", err)
	}
	return strings.Contains(string(fishConfig), getFishConfigFragment(homedir)), nil
}

func getZshConfigPath(homedir string) string {
	return path.Join(homedir, data.GetHishtoryPath(), "config.zsh")
}

func configureZshrc(homedir, binaryPath string) error {
	// Create the file we're going to source in our zshrc. Do this no matter what in case there are updates to it.
	configContents := lib.ConfigZshContents
	if os.Getenv("HISHTORY_TEST") != "" {
		testConfig, err := tweakConfigForTests(configContents)
		if err != nil {
			return err
		}
		configContents = testConfig
	}
	err := os.WriteFile(getZshConfigPath(homedir), []byte(configContents), 0o644)
	if err != nil {
		return fmt.Errorf("failed to write config.zsh file: %w", err)
	}
	// Check if we need to configure the zshrc
	zshIsConfigured, err := isZshConfigured(homedir)
	if err != nil {
		return fmt.Errorf("failed to check .zshrc: %w", err)
	}
	if zshIsConfigured {
		return nil
	}
	// Add to zshrc
	return addToShellConfig(getZshRcPath(homedir), getZshConfigFragment(homedir))
}

func getZshRcPath(homedir string) string {
	if zdotdir := os.Getenv("ZDOTDIR"); zdotdir != "" {
		return path.Join(zdotdir, ".zshrc")
	}
	return path.Join(homedir, ".zshrc")
}

func getZshConfigFragment(homedir string) string {
	return "\n# Hishtory Config:\nexport PATH=\"$PATH:" + path.Join(homedir, data.GetHishtoryPath()) + "\"\nsource " + getZshConfigPath(homedir) + "\n"
}

func isZshConfigured(homedir string) (bool, error) {
	_, err := os.Stat(getZshRcPath(homedir))
	if errors.Is(err, os.ErrNotExist) {
		return false, nil
	}
	bashrc, err := os.ReadFile(getZshRcPath(homedir))
	if err != nil {
		return false, fmt.Errorf("failed to read zshrc: %w", err)
	}
	return strings.Contains(string(bashrc), getZshConfigFragment(homedir)), nil
}

func getBashConfigPath(homedir string) string {
	return path.Join(homedir, data.GetHishtoryPath(), "config.sh")
}

func configureBashrc(homedir, binaryPath string) error {
	// Create the file we're going to source in our bashrc. Do this no matter what in case there are updates to it.
	configContents := lib.ConfigShContents
	if os.Getenv("HISHTORY_TEST") != "" {
		testConfig, err := tweakConfigForTests(configContents)
		if err != nil {
			return err
		}
		configContents = testConfig
	}
	err := os.WriteFile(getBashConfigPath(homedir), []byte(configContents), 0o644)
	if err != nil {
		return fmt.Errorf("failed to write config.sh file: %w", err)
	}
	// Check if we need to configure the bashrc and configure it if so
	bashRcIsConfigured, err := isBashRcConfigured(homedir)
	if err != nil {
		return fmt.Errorf("failed to check ~/.bashrc: %w", err)
	}
	if !bashRcIsConfigured {
		err = addToShellConfig(path.Join(homedir, ".bashrc"), getBashConfigFragment(homedir))
		if err != nil {
			return err
		}
	}
	// Check if we need to configure the bash_profile and configure it if so
	if doesBashProfileNeedConfig(homedir) {
		bashProfileIsConfigured, err := isBashProfileConfigured(homedir)
		if err != nil {
			return fmt.Errorf("failed to check ~/.bash_profile: %w", err)
		}
		if !bashProfileIsConfigured {
			err = addToShellConfig(path.Join(homedir, ".bash_profile"), getBashConfigFragment(homedir))
			if err != nil {
				return err
			}
		}
	}
	return nil
}

func addToShellConfig(shellConfigPath, configFragment string) error {
	f, err := os.OpenFile(shellConfigPath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644)
	if err != nil {
		return fmt.Errorf("failed to append to %s: %w", shellConfigPath, err)
	}
	defer f.Close()
	_, err = f.WriteString(configFragment)
	if err != nil {
		return fmt.Errorf("failed to append to %s: %w", shellConfigPath, err)
	}
	return nil
}

func getBashConfigFragment(homedir string) string {
	return "\n# Hishtory Config:\nexport PATH=\"$PATH:" + path.Join(homedir, data.GetHishtoryPath()) + "\"\nsource " + getBashConfigPath(homedir) + "\n"
}

func isBashRcConfigured(homedir string) (bool, error) {
	_, err := os.Stat(path.Join(homedir, ".bashrc"))
	if errors.Is(err, os.ErrNotExist) {
		return false, nil
	}
	bashrc, err := os.ReadFile(path.Join(homedir, ".bashrc"))
	if err != nil {
		return false, fmt.Errorf("failed to read bashrc: %w", err)
	}
	return strings.Contains(string(bashrc), getBashConfigFragment(homedir)), nil
}

func doesBashProfileNeedConfig(homedir string) bool {
	if runtime.GOOS == "darwin" {
		// Darwin always needs it configured for #14
		return true
	}
	if runtime.GOOS == "linux" {
		// Only configure it on linux if .bash_profile already exists
		_, err := os.Stat(path.Join(homedir, ".bash_profile"))
		return !errors.Is(err, os.ErrNotExist)
	}
	// Default to not configuring it
	return false
}

func isBashProfileConfigured(homedir string) (bool, error) {
	_, err := os.Stat(path.Join(homedir, ".bash_profile"))
	if errors.Is(err, os.ErrNotExist) {
		return false, nil
	}
	bashrc, err := os.ReadFile(path.Join(homedir, ".bash_profile"))
	if err != nil {
		return false, fmt.Errorf("failed to read bash_profile: %w", err)
	}
	return strings.Contains(string(bashrc), getBashConfigFragment(homedir)), nil
}

func tweakConfigForTests(configContents string) (string, error) {
	substitutionCount := 0
	removedCount := 0
	ret := ""
	split := strings.Split(configContents, "\n")
	for i, line := range split {
		if strings.Contains(line, "# Background Run") {
			ret += strings.ReplaceAll(split[i+1], "# hishtory", "hishtory")
			substitutionCount += 1
		} else if strings.Contains(line, "# Foreground Run") {
			removedCount += 1
			continue
		} else {
			ret += line
		}
		ret += "\n"
	}
	if !(substitutionCount == 2 && removedCount == 2) {
		return "", fmt.Errorf("failed to find substitution line in configConents=%#v", configContents)
	}
	return ret, nil
}

func copyFile(src, dst string) error {
	sourceFileStat, err := os.Stat(src)
	if err != nil {
		return err
	}

	if !sourceFileStat.Mode().IsRegular() {
		return fmt.Errorf("%s is not a regular file", src)
	}

	source, err := os.Open(src)
	if err != nil {
		return err
	}
	defer source.Close()

	destination, err := os.Create(dst)
	if err != nil {
		return err
	}

	_, err = io.Copy(destination, source)
	if err != nil {
		return err
	}

	return destination.Close()
}

func uninstall(ctx context.Context) error {
	homedir := hctx.GetHome(ctx)
	err := stripLines(path.Join(homedir, ".bashrc"), getBashConfigFragment(homedir))
	if err != nil {
		return err
	}
	err = stripLines(getZshRcPath(homedir), getZshConfigFragment(homedir))
	if err != nil {
		return err
	}
	err = stripLines(path.Join(homedir, ".config/fish/config.fish"), getFishConfigFragment(homedir))
	if err != nil {
		return err
	}
	err = os.RemoveAll(path.Join(homedir, data.GetHishtoryPath()))
	if err != nil {
		return err
	}
	fmt.Println("Successfully uninstalled hishtory, please restart your terminal...")
	return nil
}

func stripLines(filePath, lines string) error {
	if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) {
		// File does not exist, nothing to do
		return nil
	}
	origContents, err := os.ReadFile(filePath)
	if err != nil {
		return err
	}
	linesToBeRemoved := make(map[string]bool, 0)
	for _, line := range strings.Split(lines, "\n") {
		if strings.TrimSpace(line) != "" {
			linesToBeRemoved[line] = true
		}
	}
	ret := ""
	for _, line := range strings.Split(string(origContents), "\n") {
		if !linesToBeRemoved[line] {
			ret += line
			ret += "\n"
		}
	}
	return os.WriteFile(filePath, []byte(ret), 0644)
}

func setup(userSecret string, isOffline bool) error {
	if userSecret == "" {
		userSecret = uuid.Must(uuid.NewRandom()).String()
	}
	fmt.Println("Setting secret hishtory key to " + string(userSecret))

	// Create and set the config
	var config hctx.ClientConfig
	config.UserSecret = userSecret
	config.IsEnabled = true
	config.DeviceId = uuid.Must(uuid.NewRandom()).String()
	config.ControlRSearchEnabled = true
	config.IsOffline = isOffline
	err := hctx.SetConfig(&config)
	if err != nil {
		return fmt.Errorf("failed to persist config to disk: %w", err)
	}

	// Drop all existing data
	db, err := hctx.OpenLocalSqliteDb()
	if err != nil {
		return err
	}
	db.Exec("DELETE FROM history_entries")

	// Bootstrap from remote date
	if config.IsOffline {
		return nil
	}
	ctx := hctx.MakeContext()
	registerPath := "/api/v1/register?user_id=" + data.UserId(userSecret) + "&device_id=" + config.DeviceId
	if os.Getenv("HISHTORY_TEST") != "" {
		registerPath += "&is_integration_test_device=true"
	}
	_, err = lib.ApiGet(ctx, registerPath)
	if err != nil {
		return fmt.Errorf("failed to register device with backend: %w", err)
	}

	respBody, err := lib.ApiGet(ctx, "/api/v1/bootstrap?user_id="+data.UserId(userSecret)+"&device_id="+config.DeviceId)
	if err != nil {
		return fmt.Errorf("failed to bootstrap device from the backend: %w", err)
	}
	var retrievedEntries []*shared.EncHistoryEntry
	err = json.Unmarshal(respBody, &retrievedEntries)
	if err != nil {
		return fmt.Errorf("failed to load JSON response: %w", err)
	}
	for _, entry := range retrievedEntries {
		decEntry, err := data.DecryptHistoryEntry(userSecret, *entry)
		if err != nil {
			return fmt.Errorf("failed to decrypt history entry from server: %w", err)
		}
		lib.AddToDbIfNew(db, decEntry)
	}

	return nil
}

func init() {
	rootCmd.AddCommand(installCmd)
	rootCmd.AddCommand(initCmd)
	rootCmd.AddCommand(uninstallCmd)

	offlineInit = initCmd.Flags().Bool("offline", false, "Install hiSHtory in offline mode wiht all syncing capabilities disabled")
	offlineInstall = installCmd.Flags().Bool("offline", false, "Install hiSHtory in offline mode wiht all syncing capabilities disabled")
}
