package main

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"os/exec"
	"strings"
	"time"

	"golang.org/x/term"

	"vocat/internal/auth"
	"vocat/internal/config"
	"vocat/internal/store"
	"vocat/internal/update"
)

// envFilePath is the systemd EnvironmentFile that carries VOCAT_ADMIN_PASSWORD.
// EnsureAdmin reseeds the DB from it on every start, so change-password must
// rewrite it or the next restart reverts the password.
const envFilePath = "/etc/vocat/env"

const systemdUnitPath = "/etc/systemd/system/vocat.service"

// defaultDatabasePath is the install-default SQLite location written into the
// systemd unit by scripts/install.sh. Used only when VOCAT_DATABASE_PATH is not
// already set in the operator's environment.
const defaultDatabasePath = "/opt/vocat/data/vocat.db"

// uiPreferencesSettingKey is the same app_settings key the Web UI's
// /api/settings/preferences handler reads and writes (see general_api.go). The
// menu toggles language through it so a single preference is shared with the
// SPA and the backend i18n layer.
const uiPreferencesSettingKey = "ui.preferences"

// loadMenuEnv ensures the menu reaches the production config that systemd
// would otherwise inject. When an operator runs `sudo vocat` on the host, the
// shell has not sourced /etc/vocat/env (a systemd EnvironmentFile, not a shell
// rc) and VOCAT_DATABASE_PATH is unset, so config.Load() would resolve a
// CWD-relative ./data/vocat.db — a different, empty database than
// /opt/vocat/data/vocat.db the service uses. This loads the installed env file
// for VOCAT_ADMIN_PASSWORD and pins VOCAT_DATABASE_PATH to the install default,
// without overriding any value the operator already exported.
func loadMenuEnv() {
	if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok {
		_ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath)
	}
	if data, err := os.ReadFile(envFilePath); err == nil {
		for _, line := range strings.Split(string(data), "\n") {
			line = strings.TrimSpace(line)
			if line == "" || strings.HasPrefix(line, "#") {
				continue
			}
			eq := strings.IndexByte(line, '=')
			if eq < 0 {
				continue
			}
			key := strings.TrimSpace(line[:eq])
			val := strings.TrimSpace(line[eq+1:])
			if _, ok := os.LookupEnv(key); !ok {
				_ = os.Setenv(key, val)
			}
		}
	}
}

// runMenu is the interactive lifecycle menu: toggle language, change password,
// restart the systemd unit, self-update, or fully uninstall vocat. It must run
// as root on the host (needs systemctl + the 0600 env file). Docker deployments
// do not use it.
func runMenu(logger *slog.Logger) error {
	if os.Geteuid() != 0 {
		return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)")
	}
	fd := int(os.Stdin.Fd())
	if !term.IsTerminal(fd) {
		return errors.New("vocat menu requires an interactive terminal")
	}

	loadMenuEnv()

	lang, langErr := loadMenuLanguage()
	menu := newMenu(lang)
	if langErr != nil {
		logger.Warn("menu: load language preference failed; defaulting to English", "error", langErr)
	}
	reader := bufio.NewReader(os.Stdin)

	for {
		fmt.Println()
		fmt.Println(menu.title())
		for _, opt := range menu.options() {
			fmt.Printf("  %s\n", opt)
		}
		fmt.Print(menu.prompt())
		line, err := reader.ReadString('\n')
		if err != nil {
			return fmt.Errorf("read menu choice: %w", err)
		}
		choice := strings.TrimSpace(line)
		switch choice {
		case "1":
			if err := menuToggleLanguage(menu, logger); err != nil {
				fmt.Println(menu.errorPrefix(err))
			}
		case "2":
			if err := menuChangePassword(reader, menu, logger); err != nil {
				fmt.Println(menu.errorPrefix(err))
			}
		case "3":
			if err := menuRestart(menu); err != nil {
				fmt.Println(menu.errorPrefix(err))
			}
		case "4":
			if err := menuUpdate(menu, logger); err != nil {
				fmt.Println(menu.errorPrefix(err))
			}
		case "0":
			if err := menuUninstall(reader, menu); err != nil {
				fmt.Println(menu.errorPrefix(err))
			} else {
				return nil
			}
		default:
			fmt.Println(menu.invalid())
		}
	}
}

// loadMenuLanguage reads the persisted UI preference (the same ui.preferences
// app_setting the Web UI writes) and returns "zh" or "en". When no record
// exists yet it returns "en", matching the Web default in writeUIPreferences.
// Any failure is surfaced to the caller, which logs a warning and keeps the
// default rather than blocking the menu.
func loadMenuLanguage() (string, error) {
	cfg, err := config.Load()
	if err != nil {
		return "en", fmt.Errorf("%w: %v", errMenuConfig, err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	database, err := store.Open(ctx, cfg.DatabasePath)
	if err != nil {
		return "en", fmt.Errorf("%w: %v", errMenuStore, err)
	}
	defer database.Close()

	setting, err := database.AppSetting(ctx, uiPreferencesSettingKey)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			return "en", nil
		}
		return "en", fmt.Errorf("%w: %v", errMenuStore, err)
	}
	var prefs struct {
		Language string `json:"language"`
	}
	if err := json.Unmarshal(setting.Value, &prefs); err != nil {
		return "en", nil
	}
	if prefs.Language == "zh" {
		return "zh", nil
	}
	return "en", nil
}

func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) error {
	cfg, err := config.Load()
	if err != nil {
		return fmt.Errorf("%w: %v", errMenuConfig, err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	database, err := store.Open(ctx, cfg.DatabasePath)
	if err != nil {
		return fmt.Errorf("%w: %v", errMenuStore, err)
	}
	defer database.Close()

	authService, err := auth.New(database, auth.Options{SessionTTL: cfg.SessionTTL})
	if err != nil {
		return fmt.Errorf("%w: %v", errMenuAuth, err)
	}

	fmt.Print(m.currentPassword())
	currentPw, err := readPasswordMasked()
	if err != nil {
		return err
	}
	fmt.Print(m.newPassword())
	newPw, err := readPasswordMasked()
	if err != nil {
		return err
	}
	fmt.Print(m.confirmPassword())
	confirmPw, err := readPasswordMasked()
	if err != nil {
		return err
	}
	fmt.Println()
	if newPw != confirmPw {
		return errPasswordsDiffer
	}
	if err := authService.ChangePassword(ctx, cfg.AdminUsername, currentPw, newPw); err != nil {
		if errors.Is(err, auth.ErrInvalidCredentials) {
			return errCurrentWrong
		}
		return fmt.Errorf("%w: %v", errMenuAuth, err)
	}
	// Persist the new plaintext to the env file so the next EnsureAdmin (on
	// restart) agrees with the hash we just wrote to the DB. Without this the
	// restart reverts the password to whatever the env file still holds.
	if err := rewriteEnvPassword(newPw); err != nil {
		logger.Error("menu: password changed in DB but env file rewrite failed; restart will revert", "error", err)
		return fmt.Errorf("%w: %v", errMenuEnvWrite, err)
	}
	fmt.Println(m.passwordChanged())
	return nil
}

// readPasswordMasked reads a password with echo disabled. term.ReadPassword
// does not return the trailing newline, so we print one for a clean prompt.
func readPasswordMasked() (string, error) {
	fd := int(os.Stdin.Fd())
	bytes, err := term.ReadPassword(fd)
	fmt.Println()
	if err != nil {
		return "", fmt.Errorf("read password: %w", err)
	}
	return string(bytes), nil
}

// rewriteEnvPassword replaces (or appends) the VOCAT_ADMIN_PASSWORD line in the
// systemd EnvironmentFile and keeps the file 0600. The replacement is atomic:
// the temp file lives in the same directory so os.Rename stays on one
// filesystem.
func rewriteEnvPassword(newPassword string) error {
	const key = "VOCAT_ADMIN_PASSWORD="
	var lines []string
	if data, err := os.ReadFile(envFilePath); err == nil {
		lines = strings.Split(string(data), "\n")
	} else if !errors.Is(err, os.ErrNotExist) {
		return err
	}

	replaced := false
	for i, line := range lines {
		if strings.HasPrefix(line, key) {
			lines[i] = key + newPassword
			replaced = true
			break
		}
	}
	if !replaced {
		lines = append(lines, key+newPassword)
	}
	content := strings.Join(lines, "\n")
	if !strings.HasSuffix(content, "\n") {
		content += "\n"
	}

	dir := envFilePath[:strings.LastIndex(envFilePath, "/")]
	tmp, err := os.CreateTemp(dir, ".vocat-env-*")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	defer os.Remove(tmpName)
	if _, err := tmp.WriteString(content); err != nil {
		_ = tmp.Close()
		return err
	}
	if err := tmp.Chmod(0o600); err != nil {
		_ = tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmpName, envFilePath)
}

// menuToggleLanguage flips the persisted language preference between "zh" and
// "en" by writing the same ui.preferences app_setting the Web UI uses, then
// switches the menu's own language so the next prompt renders in the new
// language. The Web SPA picks up the change on its next preferences fetch; the
// menu never needs to call i18n.Set itself since it carries its own lang copy.
func menuToggleLanguage(m *menu, logger *slog.Logger) error {
	cfg, err := config.Load()
	if err != nil {
		return fmt.Errorf("%w: %v", errMenuConfig, err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	database, err := store.Open(ctx, cfg.DatabasePath)
	if err != nil {
		return fmt.Errorf("%w: %v", errMenuStore, err)
	}
	defer database.Close()

	current := m.lang
	next := "zh"
	if current == "zh" {
		next = "en"
	}
	payload, err := json.Marshal(map[string]string{"language": next})
	if err != nil {
		return fmt.Errorf("%w: %v", errMenuStore, err)
	}
	if err := database.UpsertAppSetting(ctx, store.AppSetting{
		Key:   uiPreferencesSettingKey,
		Value: payload,
	}); err != nil {
		return fmt.Errorf("%w: %v", errMenuStore, err)
	}
	m.lang = next
	fmt.Println(m.languageSwitched())
	return nil
}

func menuRestart(m *menu) error {
	if _, err := exec.LookPath("systemctl"); err != nil {
		return errNoSystemctl
	}
	cmd := exec.Command("systemctl", "restart", "vocat")
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out)))
	}
	fmt.Println(m.restarted())
	return nil
}

// menuUpdate delegates to the self-updater in internal/update. It resolves the
// repo the same way update.Run itself does ($VOCAT_REPO or the default) and lets
// that package handle the check/download/verify/replace/restart flow. The
// running menu process keeps the old binary until the operator exits; only the
// systemd service runs the new build after restartService.
func menuUpdate(m *menu, logger *slog.Logger) error {
	repo := strings.TrimSpace(os.Getenv("VOCAT_REPO"))
	if repo == "" {
		repo = update.DefaultRepository
	}
	fmt.Println(m.updateChecking())
	if err := update.Run(logger, []string{"--repo", repo}); err != nil {
		return fmt.Errorf("%w: %v", errUpdateFailed, err)
	}
	return nil
}

// menuUninstall performs full removal: stop/disable the unit, delete the unit,
// remove /opt/vocat (binary + data + SQLite DB), remove the env file, reload
// systemd, and best-effort delete the vocat user.
func menuUninstall(reader *bufio.Reader, m *menu) error {
	fmt.Println(m.uninstallWarn())
	fmt.Print(m.uninstallConfirm())
	line, err := reader.ReadString('\n')
	if err != nil {
		return fmt.Errorf("read confirmation: %w", err)
	}
	if strings.TrimSpace(line) != "yes" {
		fmt.Println(m.uninstallCancelled())
		return nil
	}

	runIgnore := func(name string, args ...string) {
		_ = exec.Command(name, args...).Run()
	}
	runIgnore("systemctl", "stop", "vocat")
	runIgnore("systemctl", "disable", "vocat")
	_ = os.Remove(systemdUnitPath)
	_ = os.RemoveAll("/opt/vocat")
	_ = os.Remove(envFilePath)
	_ = os.Remove("/etc/vocat") // succeeds only when empty
	runIgnore("systemctl", "daemon-reload")
	runIgnore("userdel", "vocat")

	fmt.Println(m.uninstalled())
	return nil
}

// menu-local sentinel errors so callers can map them to localized messages.
var (
	errCurrentWrong    = errors.New("menu: current password is incorrect")
	errPasswordsDiffer = errors.New("menu: passwords do not match")
	errNoSystemctl     = errors.New("menu: systemctl not found")
	errRestartFailed   = errors.New("menu: restart failed")
	errUpdateFailed    = errors.New("menu: update failed")
	errMenuConfig      = errors.New("menu: load configuration")
	errMenuStore       = errors.New("menu: open database")
	errMenuAuth        = errors.New("menu: auth service")
	errMenuEnvWrite    = errors.New("menu: write env file")
)

// ---- i18n ----

type menu struct{ lang string }

func newMenu(lang string) *menu { return &menu{lang: lang} }

// msg returns the localized string for a key. Each key carries [zh, en].
func (m *menu) msg(key string) string {
	const zh, en = 0, 1
	table := map[string][2]string{
		"title":        {"vocat 管理菜单", "vocat management menu"},
		"opt_lang":     {"1) 切换中英文", "1) Toggle language"},
		"opt_change":   {"2) 修改账号密码", "2) Change admin password"},
		"opt_restart":  {"3) 重启软件", "3) Restart software"},
		"opt_update":   {"4) 更新软件", "4) Update software"},
		"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
		"prompt":       {"请选择: ", "Select: "},
		"invalid":      {"无效选项，请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
		"cur_pw":       {"当前密码: ", "Current password: "},
		"new_pw":       {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
		"confirm_pw":   {"确认新密码: ", "Confirm new password: "},
		"pw_changed":   {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
		"lang_switched": {
			"语言已切换。Web 界面下次刷新后同步。",
			"Language switched. The web UI syncs on next refresh.",
		},
		"upd_checking": {"正在检查更新…", "Checking for updates…"},
		"restarted":    {"软件已重启。", "Software restarted."},
		"uninstall_warn": {
			"警告: 将删除程序、数据与配置,且不可恢复!",
			"WARNING: removes the program, data and config. Irreversible!",
		},
		"uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
		"uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."},
		"uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
	}
	entry, ok := table[key]
	if !ok {
		return key
	}
	if m.lang == "en" {
		return entry[en]
	}
	return entry[zh]
}

func (m *menu) title() string      { return m.msg("title") }
func (m *menu) prompt() string     { return m.msg("prompt") }
func (m *menu) invalid() string    { return m.msg("invalid") }
func (m *menu) currentPassword() string  { return m.msg("cur_pw") }
func (m *menu) newPassword() string      { return m.msg("new_pw") }
func (m *menu) confirmPassword() string  { return m.msg("confirm_pw") }
func (m *menu) passwordChanged() string  { return m.msg("pw_changed") }
func (m *menu) languageSwitched() string { return m.msg("lang_switched") }
func (m *menu) updateChecking() string   { return m.msg("upd_checking") }
func (m *menu) restarted() string        { return m.msg("restarted") }
func (m *menu) uninstallWarn() string    { return m.msg("uninstall_warn") }
func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") }
func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled") }
func (m *menu) uninstalled() string      { return m.msg("uninstalled") }

func (m *menu) options() []string {
	return []string{
		m.msg("opt_lang"),
		m.msg("opt_change"),
		m.msg("opt_restart"),
		m.msg("opt_update"),
		m.msg("opt_uninstall"),
	}
}

func (m *menu) errorPrefix(err error) string {
	switch {
	case errors.Is(err, errCurrentWrong):
		if m.lang == "en" {
			return "Current password is incorrect."
		}
		return "当前密码不正确。"
	case errors.Is(err, errPasswordsDiffer):
		if m.lang == "en" {
			return "Passwords do not match."
		}
		return "两次输入的密码不一致。"
	case errors.Is(err, errNoSystemctl):
		if m.lang == "en" {
			return "systemctl not found."
		}
		return "未找到 systemctl。"
	case errors.Is(err, errRestartFailed):
		if m.lang == "en" {
			return "Restart failed."
		}
		return "重启失败。"
	case errors.Is(err, errUpdateFailed):
		if m.lang == "en" {
			return "Update failed."
		}
		return "更新失败。"
	case errors.Is(err, errMenuConfig):
		if m.lang == "en" {
			return "Failed to load configuration."
		}
		return "加载配置失败。"
	case errors.Is(err, errMenuStore):
		if m.lang == "en" {
			return "Failed to open the database."
		}
		return "打开数据库失败。"
	case errors.Is(err, errMenuAuth):
		if m.lang == "en" {
			return "Auth service error."
		}
		return "认证服务错误。"
	case errors.Is(err, errMenuEnvWrite):
		if m.lang == "en" {
			return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + envFilePath + "."
		}
		return "数据库密码已修改，但环境变量文件写入失败——重启后将回滚。请检查 " + envFilePath + "。"
	default:
		if m.lang == "en" {
			return "Error: " + err.Error()
		}
		return "错误: " + err.Error()
	}
}
