package main

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"os"
	"os/exec"
	"os/signal"
	"runtime"
	"strings"
	"sync"
	"syscall"

	"github.com/google/shlex"
	"github.com/robfig/cron/v3"
	"github.com/urfave/cli/v3"
)

const (
	systemConfigDir = "/etc/restray"
	systemStateDir  = "/var/lib/restray"
)

func applyRuntimeContext(configDir, stateDir string, setHome bool) {
	if configDir != "" {
		configDirOverride = configDir
	}
	if stateDir != "" {
		dataDirOverride = stateDir
	}
	if setHome && dataDirOverride != "" {
		os.Setenv("HOME", dataDirOverride)
	}
}

func configureRuntime(ctx context.Context, cmd *cli.Command) (context.Context, error) {
	configDir := cmd.String("config")
	stateDir := cmd.String("state")
	system := runtime.GOOS == "linux" && cmd.Bool("system")
	if system {
		if configDir == "" {
			configDir = systemConfigDir
		}
		if stateDir == "" {
			stateDir = systemStateDir
		}
	}
	applyRuntimeContext(configDir, stateDir, system)
	return ctx, nil
}

func resolveProfile(cfg Config, name string) (Profile, error) {
	if cfg.loadErr != nil {
		return Profile{}, cfg.loadErr
	}
	if name == "" {
		return cfg.Profiles[0], nil
	}
	for _, p := range cfg.Profiles {
		if p.displayName() == name {
			return p, nil
		}
	}
	var names []string
	for _, p := range cfg.Profiles {
		names = append(names, p.displayName())
	}
	return Profile{}, fmt.Errorf("profile %q not found (available: %s)", name, strings.Join(names, ", "))
}

func resolveProfileIndex(cfg Config, key stateKey) (int, Profile, error) {
	if cfg.loadErr != nil {
		return 0, Profile{}, cfg.loadErr
	}
	for idx, prof := range cfg.Profiles {
		if prof.profileKey() == key {
			return idx, prof, nil
		}
	}
	return 0, Profile{}, fmt.Errorf("profile no longer exists")
}

func editorCmd() string {
	if e := os.Getenv("VISUAL"); e != "" {
		return e
	}
	if e := os.Getenv("EDITOR"); e != "" {
		return e
	}
	if runtime.GOOS == "windows" {
		return "notepad"
	}
	return "vi"
}

func cliWeb(context.Context, *cli.Command) error {
	if err := ensureConfigFile(); err != nil {
		return cli.Exit("error: "+err.Error(), 1)
	}
	url, err := ensureWebEditor()
	if err != nil {
		return cli.Exit(fmt.Sprintf("error: %v", err), 1)
	}
	fmt.Println(url)
	openFile(url)

	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
	<-sigCh
	return nil
}

func cliEdit(path string) error {
	editor := editorCmd()
	cmd := exec.Command(editor, path)
	if runtime.GOOS != "windows" || !fileExists(editor) {
		if parts, err := shlex.Split(editor); err == nil && len(parts) > 0 {
			cmd = exec.Command(parts[0], append(parts[1:], path)...)
		}
	}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return cli.Exit("", exitCode(err))
	}
	return nil
}

func cliRunBackend(prof Profile, args ...string) error {
	return cliCommandError(runCLIBackend(prof, args...))
}

func cliBackendCmd(prof Profile, args ...string) (*exec.Cmd, error) {
	p, _ := findBackend(prof)
	if p == "" {
		return nil, fmt.Errorf("%s not found", prof.backendName())
	}
	return backendCmd(prof, args...), nil
}

func runCLIBackend(prof Profile, args ...string) error {
	cmd, err := cliBackendCmd(prof, args...)
	if err != nil {
		return err
	}
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

func cliRunBackendWithRetry(prof Profile, args []string) error {
	return cliCommandError(runBackendWithRetry(prof, args, func(args ...string) error {
		return runCLIBackend(prof, args...)
	}))
}

func runLoggedCommand(prof Profile, cmd *exec.Cmd) error {
	stdoutPipe, err := cmd.StdoutPipe()
	if err != nil {
		return err
	}
	stderrPipe, err := cmd.StderrPipe()
	if err != nil {
		return err
	}
	if err := cmd.Start(); err != nil {
		return err
	}
	var wg sync.WaitGroup
	for _, pipe := range []io.Reader{stdoutPipe, stderrPipe} {
		wg.Add(1)
		go func(pipe io.Reader) {
			defer wg.Done()
			scanner := bufio.NewScanner(pipe)
			scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
			for scanner.Scan() {
				logPrefixedLine(prof.displayName(), scanner.Text())
			}
			if err := scanner.Err(); err != nil {
				log.Printf("[%s] reading command output: %v", prof.displayName(), err)
				_, _ = io.Copy(io.Discard, pipe)
			}
		}(pipe)
	}
	err = cmd.Wait()
	wg.Wait()
	return err
}

func runCLIBackendLogged(prof Profile, args ...string) error {
	cmd, err := cliBackendCmd(prof, args...)
	if err != nil {
		return err
	}
	return runLoggedCommand(prof, cmd)
}

func cliRunScheduledBackend(prof Profile, args []string) error {
	return cliCommandError(runBackendWithRetry(prof, args, func(args ...string) error {
		return runCLIBackendLogged(prof, args...)
	}))
}

func cliCommandError(err error) error {
	if err == nil {
		return nil
	}
	if code := exitCode(err); code > 0 {
		return cli.Exit("", code)
	}
	return cli.Exit("error: "+err.Error(), 1)
}

func cliRunHook(hook string, prof Profile, extraEnv ...string) error {
	if hook == "" {
		return cli.Exit("error: no hook configured", 1)
	}
	cmd := hookCmd(hook, prof, extraEnv...)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return cli.Exit("", exitCode(err))
	}
	return nil
}

func cliRunHookLogged(hook string, prof Profile, extraEnv ...string) error {
	if hook == "" {
		return cli.Exit("error: no hook configured", 1)
	}
	return cliCommandError(runLoggedCommand(prof, hookCmd(hook, prof, extraEnv...)))
}

func cliBackup(prof Profile, scheduled bool) error {
	return cliRunBackendWithRetry(prof, backupArgs(prof, scheduled))
}

func cliPrune(prof Profile) error {
	return cliRunBackendWithRetry(prof, addRetryLockArgs(prof, forgetArgs(prof)))
}

func cliCheck(prof Profile) error {
	return cliRunBackendWithRetry(prof, addRetryLockArgs(prof, checkArgs(prof)))
}

func cliUnlock(prof Profile) error {
	if !prof.backend().supportsUnlock() {
		return cli.Exit("error: "+backendUnavailable(prof, "unlock"), 1)
	}
	return cliRunBackend(prof, "unlock")
}

func cliShell(prof Profile) error {
	shell := os.Getenv("SHELL")
	if shell == "" {
		if runtime.GOOS == "windows" {
			shell = "cmd"
		} else {
			shell = "/bin/sh"
		}
	}
	env := envWithBackendPath(profileEnv(prof), prof)
	fmt.Fprintf(os.Stderr, "Restray: environment for profile %q loaded. Run \"%s\" for commands. Exit to return.\n", prof.displayName(), prof.backendName())
	cmd := exec.Command(shell)
	cmd.Env = env
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return cli.Exit("", exitCode(err))
	}
	return nil
}

func cliMount(prof Profile) error {
	if !mountSupported(prof) {
		return cli.Exit("error: "+backendUnavailable(prof, "mount"), 1)
	}
	dir, err := os.MkdirTemp("", "restray-mount-")
	if err != nil {
		return cli.Exit(fmt.Sprintf("error: %v", err), 1)
	}
	defer os.Remove(dir)

	fmt.Fprintf(os.Stderr, "Mounting at %s (press Ctrl+C to unmount)\n", dir)
	return cliRunBackend(prof, mountArgs(prof, dir)...)
}

func cliSchedule(prof Profile) error {
	if errMsg := prof.scheduleError(); errMsg != "" {
		return cli.Exit("error: "+errMsg, 1)
	}
	p, _ := findBackend(prof)
	if p == "" {
		return cli.Exit("error: "+prof.backendName()+" not found", 1)
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	hookEnv := []string{
		"RESTRAY_OPERATIONS=" + strings.Join(scheduledOpNames(prof), ","),
		"RESTRAY_SCHEDULED=true",
	}

	if prof.PreHook != "" {
		log.Printf("[%s] running pre-hook", prof.displayName())
		if err := cliRunHookLogged(prof.PreHook, prof, hookEnv...); err != nil {
			return cli.Exit("error: pre-hook failed", 1)
		}
	}

	failed, failMsg := runScheduledOperations(
		prof,
		func() bool { return ctx.Err() != nil },
		func() (bool, string) {
			log.Printf("[%s] running backup", prof.displayName())
			if err := cliRunScheduledBackend(prof, backupArgs(prof, true)); err != nil {
				return false, "backup failed"
			}
			return true, ""
		},
		func() (bool, string) {
			log.Printf("[%s] running prune", prof.displayName())
			if err := cliRunScheduledBackend(prof, addRetryLockArgs(prof, forgetArgs(prof))); err != nil {
				return false, "prune failed"
			}
			return true, ""
		},
		func() (bool, string) {
			log.Printf("[%s] running check", prof.displayName())
			if err := cliRunScheduledBackend(prof, addRetryLockArgs(prof, checkArgs(prof))); err != nil {
				return false, "check failed"
			}
			return true, ""
		},
	)

	interrupted := ctx.Err() != nil
	if prof.PostHook != "" {
		stop()
		log.Printf("[%s] running post-hook", prof.displayName())
		postEnv := hookEnv
		if failed || interrupted {
			errMsg := failMsg
			if errMsg == "" {
				errMsg = "operation failed"
			}
			if interrupted {
				errMsg = "interrupted by user"
			}
			postEnv = append(postEnv, "RESTRAY_ERROR="+errMsg)
		}
		if err := cliRunHookLogged(prof.PostHook, prof, postEnv...); err != nil {
			return cli.Exit("error: post-hook failed", 1)
		}
	}

	if failed || interrupted {
		return cli.Exit("error: schedule failed", 1)
	}
	return nil
}

func buildDaemonSchedule() (*cron.Cron, error) {
	cfg := loadConfig()
	if cfg.loadErr != nil {
		return nil, cfg.loadErr
	}
	sched := cron.New()
	for _, prof := range cfg.Profiles {
		if prof.Schedule.Cron == "" {
			continue
		}
		key := prof.profileKey()
		name := prof.displayName()
		if errMsg := prof.scheduleDefinitionError(); errMsg != "" {
			log.Printf("[%s] skipping schedule: %s", name, errMsg)
			continue
		}
		_, err := sched.AddFunc(prof.Schedule.Cron, func() {
			runScheduleCallback(key, nil, func(_ Config, _ int, prof Profile) {
				log.Printf("[%s] starting scheduled run", prof.displayName())
				if err := cliSchedule(prof); err != nil {
					log.Printf("[%s] scheduled run failed: %v", prof.displayName(), err)
				}
			})
		})
		if err != nil {
			log.Printf("[%s] skipping schedule: invalid cron expression %q: %v", name, prof.Schedule.Cron, err)
			continue
		}
	}
	if len(sched.Entries()) == 0 {
		return nil, errors.New("no profiles have a valid schedule configured")
	}
	return sched, nil
}

func cliDaemon(context.Context, *cli.Command) error {
	sched, err := buildDaemonSchedule()
	if err != nil {
		return cli.Exit(fmt.Sprintf("error: %v", err), 1)
	}

	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
	sched.Start()
	log.Print("Scheduler running, press Ctrl+C to stop")

	for sig := range sigCh {
		if sig != syscall.SIGHUP {
			break
		}
		next, err := buildDaemonSchedule()
		if err != nil {
			log.Printf("error: reload failed, keeping previous schedule: %v", err)
			continue
		}
		<-sched.Stop().Done()
		sched = next
		sched.Start()
		log.Print("Scheduler reloaded")
	}

	<-sched.Stop().Done()
	return nil
}

func profileFlag() *cli.StringFlag {
	return &cli.StringFlag{
		Name:    "profile",
		Aliases: []string{"p"},
		Usage:   "profile name (default: first profile)",
	}
}

func profileAction(fn func(Profile) error) cli.ActionFunc {
	return func(_ context.Context, cmd *cli.Command) error {
		prof, err := resolveProfile(loadConfig(), cmd.String("profile"))
		if err != nil {
			return cli.Exit(fmt.Sprintf("error: %v", err), 1)
		}
		return fn(prof)
	}
}

func buildCLI() *cli.Command {
	cli.VersionPrinter = func(cmd *cli.Command) {
		fmt.Fprintf(cmd.Root().Writer, "restray %s compiled with %s on %s/%s\n", cmd.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
	}
	flags := []cli.Flag{
		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "configuration directory"},
		&cli.StringFlag{Name: "state", Aliases: []string{"s"}, Usage: "state directory"},
	}
	if runtime.GOOS == "linux" {
		flags = append(flags, &cli.BoolFlag{Name: "system", Usage: "use system configuration and state directories"})
	}
	flags = append(flags,
		&cli.BoolFlag{Name: "verbose", Usage: "also write tray logs to stderr"},
		profileFlag(),
	)
	return &cli.Command{
		Name:                  "restray",
		Usage:                 "A powerful but friendly backup program powered by restic",
		Version:               version,
		EnableShellCompletion: true,
		Before:                configureRuntime,
		Action: func(_ context.Context, cmd *cli.Command) error {
			runTray(cmd.Bool("verbose"))
			return nil
		},
		Flags: flags,
		Commands: []*cli.Command{
			{
				Name:   "daemon",
				Usage:  "Launch restray (built-in scheduler only)",
				Action: cliDaemon,
			},
			{
				Name:   "schedule",
				Usage:  "Run full schedule with hooks",
				Action: profileAction(cliSchedule),
			},
			{
				Name:    "operation",
				Aliases: []string{"op"},
				Usage:   "Run a restray operation",
				Commands: []*cli.Command{
					{Name: "backup", Usage: "Run backup", Action: profileAction(func(p Profile) error { return cliBackup(p, false) })},
					{Name: "prune", Usage: "Prune old snapshots", Action: profileAction(cliPrune)},
					{Name: "check", Usage: "Verify repository integrity", Action: profileAction(cliCheck)},
					{Name: "unlock", Usage: "Remove stale locks", Action: profileAction(cliUnlock)},
					{Name: "shell", Usage: "Open shell with selected backend environment", Action: profileAction(cliShell)},
					{Name: "mount", Usage: "Mount repository (Ctrl+C to unmount)", Action: profileAction(cliMount)},
					{Name: "pre-hook", Usage: "Run pre-hook command", Action: profileAction(func(p Profile) error { return cliRunHook(p.PreHook, p) })},
					{Name: "post-hook", Usage: "Run post-hook command", Action: profileAction(func(p Profile) error { return cliRunHook(p.PostHook, p) })},
				},
			},
			{
				Name:    "configure",
				Aliases: []string{"co"},
				Usage:   "Create/open config or env files",
				Commands: []*cli.Command{
					{
						Name:  "config",
						Usage: "Create/open config file in editor",
						Action: func(context.Context, *cli.Command) error {
							if err := ensureConfigFile(); err != nil {
								return cli.Exit("error: "+err.Error(), 1)
							}
							return cliEdit(configPath())
						},
					},
					{
						Name:  "env",
						Usage: "Create/open env file in editor",
						Action: profileAction(func(p Profile) error {
							if err := ensureEnvFile(p.EnvFile); err != nil {
								return cli.Exit("error: "+err.Error(), 1)
							}
							return cliEdit(p.EnvFile)
						}),
					},
					{
						Name:   "web",
						Usage:  "Start the config web editor and print its URL",
						Action: cliWeb,
					},
				},
			},
		},
	}
}

func runCLI(args []string) int {
	exitCodeResult := 0
	cli.OsExiter = func(code int) { exitCodeResult = code }

	fullArgs := append([]string{"restray"}, args...)
	if err := buildCLI().Run(context.Background(), fullArgs); err != nil && exitCodeResult == 0 {
		exitCodeResult = 1
	}
	return exitCodeResult
}
