package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"time"

	"fyne.io/systray"
	"github.com/dustin/go-humanize"
	"github.com/gen2brain/beeep"
)

type backupStatus struct {
	Type       string  `json:"message_type"`
	Percent    float64 `json:"percent_done"`
	Total      uint64  `json:"total_bytes"`
	Bytes      uint64  `json:"bytes_done"`
	TotalFiles uint64  `json:"total_files"`
	FilesDone  uint64  `json:"files_done"`
}

func parseBackupStatus(line string) (backupStatus, bool) {
	var status backupStatus
	if json.Unmarshal([]byte(line), &status) != nil || status.Type != "status" {
		return backupStatus{}, false
	}
	return status, true
}

type lastLineLogger struct {
	last string
	done chan struct{}
}

func (s *lastLineLogger) wait() string {
	<-s.done
	return s.last
}

func logPrefixedLine(name, line string) {
	log.Printf("[%s] %s", name, line)
}

func logPrefixedOutput(name, out string) {
	for _, line := range strings.Split(strings.ReplaceAll(out, "\r\n", "\n"), "\n") {
		if line != "" {
			logPrefixedLine(name, line)
		}
	}
}

func streamLogLines(pipe io.Reader, name string, onLine func(string)) *lastLineLogger {
	sl := &lastLineLogger{done: make(chan struct{})}
	go func() {
		defer close(sl.done)
		scanner := bufio.NewScanner(pipe)
		scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
		for scanner.Scan() {
			line := scanner.Text()
			logPrefixedLine(name, line)
			sl.last = line
			if onLine != nil {
				onLine(line)
			}
		}
		if err := scanner.Err(); err != nil {
			log.Printf("[%s] reading command output: %v", name, err)
			_, _ = io.Copy(io.Discard, pipe)
		}
	}()
	return sl
}

func streamStderr(pipe io.Reader, prof Profile, mStatus prefixedMenuItem) *lastLineLogger {
	return streamLogLines(pipe, prof.displayName(), func(line string) {
		if trimmed := strings.TrimSpace(line); trimmed != "" {
			mStatus.SetTitle(trimmed)
		}
	})
}

func runBackendOnce(key stateKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) {
	cmd := backendCmd(prof, args...)
	var stdout strings.Builder
	cmd.Stdout = &stdout
	stderrPipe, err := cmd.StderrPipe()
	if err != nil {
		return err.Error(), err
	}
	setProfileBusyCmd(key, cmd)

	if err := cmd.Start(); err != nil {
		log.Printf("[%s] start failed: %v", prof.displayName(), err)
		return err.Error(), err
	}

	sl := streamStderr(stderrPipe, prof, mStatus)
	err = cmd.Wait()
	lastStderr := sl.wait()
	if out := stdout.String(); out != "" {
		logPrefixedOutput(prof.displayName(), out)
	}
	if err == nil {
		log.Printf("[%s] done (%s): %s", prof.displayName(), prof.backendName(), args[0])
		return "", nil
	}
	msg, code := classifyBackendError(prof, err, lastStderr)
	if msg != "" {
		mStatus.SetTitle(msg)
	}
	log.Printf("[%s] exit: %v (code %d)", prof.displayName(), err, code)
	return msg, err
}

func retryLockEnabled(prof Profile) bool {
	return prof.backend() == BackendRestic && prof.RetryLock != "" && prof.RetryLock != "0"
}

func runBackendWithRetry(prof Profile, args []string, run func(...string) error) error {
	err := run(args...)
	if err == nil || exitCode(err) != 11 || !retryLockEnabled(prof) {
		return err
	}
	log.Printf("[%s] locked, running unlock and retrying", prof.displayName())
	if err := run("unlock"); err != nil {
		return err
	}
	return run(args...)
}

func runBackend(key stateKey, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) {
	args = addRetryLockArgs(prof, args)
	var msg string
	err := runBackendWithRetry(prof, args, func(args ...string) error {
		if len(args) == 1 && args[0] == "unlock" {
			mStatus.SetTitle("Unlocking repository...")
		}
		var err error
		msg, err = runBackendOnce(key, prof, mStatus, args...)
		return err
	})
	return msg, err
}

func doBackupOnce(key stateKey, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int, error) {
	cmd := backendCmd(prof, args...)
	if prof.backend() == BackendRestic {
		cmd.Env = setEnv(cmd.Env, "RESTIC_PROGRESS_FPS", "4")
	}
	stdoutPipe, err := cmd.StdoutPipe()
	if err != nil {
		setProfileFailed(key, err.Error())
		return false, -1, err
	}
	stderrPipe, err := cmd.StderrPipe()
	if err != nil {
		setProfileFailed(key, err.Error())
		return false, -1, err
	}
	setProfileBusyCmd(key, cmd)

	if err := cmd.Start(); err != nil {
		log.Printf("[%s] start failed: %v", prof.displayName(), err)
		setProfileFailed(key, err.Error())
		return false, -1, err
	}

	sl := streamStderr(stderrPipe, prof, mStatus)

	scanner := bufio.NewScanner(stdoutPipe)
	scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
	for scanner.Scan() {
		line := scanner.Text()

		status, ok := parseBackupStatus(line)
		if !ok {
			logPrefixedLine(prof.displayName(), line)
			continue
		}
		switch {
		case status.Total > 0:
			mStatus.SetTitle(fmt.Sprintf("%d%% completed, %s of %s",
				int(status.Percent*100), humanize.Bytes(status.Bytes), humanize.Bytes(status.Total)))
		case status.TotalFiles > 0:
			mStatus.SetTitle(fmt.Sprintf("Scanning: %d / %d files",
				status.FilesDone, status.TotalFiles))
		default:
			mStatus.SetTitle(fmt.Sprintf("Scanning: %d files", status.FilesDone))
		}
	}
	if err := scanner.Err(); err != nil {
		log.Printf("[%s] reading backup output: %v", prof.displayName(), err)
		_, _ = io.Copy(io.Discard, stdoutPipe)
	}

	err = cmd.Wait()
	lastStderr := sl.wait()
	if err != nil {
		msg, code := classifyBackendError(prof, err, lastStderr)
		log.Printf("[%s] exit: %v (code %d)", prof.displayName(), err, code)
		if prof.backend() == BackendRestic && code == 3 {
			log.Printf("[%s] backup completed with warnings (some files could not be read)", prof.displayName())
			return true, 3, nil
		}
		setProfileFailed(key, msg)
		return false, code, err
	}
	log.Printf("[%s] done: backup", prof.displayName())
	return true, 0, nil
}

func doBackup(key stateKey, mStatus prefixedMenuItem, prof Profile, scheduled bool) (bool, int) {
	jsonFlag := "--json"
	if prof.backend() == BackendRustic {
		jsonFlag = "--json-progress"
	}
	args := backupArgs(prof, scheduled, jsonFlag)

	var ok bool
	var code int
	err := runBackendWithRetry(prof, args, func(args ...string) error {
		if len(args) == 1 && args[0] == "unlock" {
			mStatus.SetTitle("Unlocking repository...")
		} else {
			mStatus.SetTitle("Backing up...")
			setProfileFailed(key, "")
		}
		var err error
		ok, code, err = doBackupOnce(key, mStatus, prof, args)
		return err
	})
	if err != nil {
		return false, code
	}
	if ok {
		setLastBackup(key, time.Now())
	}
	return ok, code
}

func runBackup(key stateKey, mStatus prefixedMenuItem, prof Profile, onDone func()) {
	if p, _ := findBackend(prof); p == "" {
		return
	}
	if !acquireProfile(key) {
		return
	}
	setProfileFailed(key, "")
	defer onDone()
	defer releaseProfile(key)

	mStatus.SetTitle("Backing up...")
	ok, code := doBackup(key, mStatus, prof, false)
	if ok {
		if code == 3 {
			notifyError("Backup", "Completed with warnings: some files could not be read")
		} else {
			notifySuccess("Backup")
		}
	} else {
		notifyError("Backup", getProfileFailStatus(key))
	}
}

func envWithBackendPath(env []string, prof Profile) []string {
	p, _ := findBackend(prof)
	if p == "" {
		return env
	}
	dir := filepath.Dir(p)
	for i, e := range env {
		if k, v, ok := strings.Cut(e, "="); ok && strings.EqualFold(k, "PATH") {
			env[i] = k + "=" + dir + string(os.PathListSeparator) + v
			return env
		}
	}
	return append(env, "PATH="+dir)
}

func hookCmd(hook string, prof Profile, extraEnv ...string) *exec.Cmd {
	var cmd *exec.Cmd
	if runtime.GOOS == "windows" {
		cmd = exec.Command("cmd", "/c", hook)
	} else {
		cmd = exec.Command("sh", "-c", hook)
	}
	cmd.Env = envWithBackendPath(profileEnv(prof), prof)
	cmd.Env = append(cmd.Env, extraEnv...)
	cmd.Env = append(cmd.Env, "RESTRAY_PROFILE="+prof.displayName())
	return cmd
}

func runHook(hook string, prof Profile, extraEnv ...string) error {
	if hook == "" {
		return nil
	}
	cmd := hookCmd(hook, prof, extraEnv...)
	log.Printf("[%s] running hook: %s", prof.displayName(), hook)
	out, err := cmd.CombinedOutput()
	if len(out) > 0 {
		logPrefixedOutput(prof.displayName(), string(out))
	}
	if err != nil {
		log.Printf("[%s] hook %q failed: %v", prof.displayName(), hook, err)
	}
	return err
}

func scheduledOpNames(prof Profile) []string {
	var ops []string
	if prof.Schedule.BackupEnabled() {
		ops = append(ops, "backup")
	}
	if prof.Schedule.Prune && len(prof.Prune.Args) > 0 {
		ops = append(ops, "prune")
	}
	if prof.Schedule.Check {
		ops = append(ops, "check")
	}
	return ops
}

func runScheduledOperations(
	prof Profile,
	interrupted func() bool,
	runBackup func() (bool, string),
	runPrune func() (bool, string),
	runCheck func() (bool, string),
) (bool, string) {
	isInterrupted := func() bool {
		return interrupted != nil && interrupted()
	}

	failed := false
	failMsg := ""
	skipRemaining := false

	if !isInterrupted() && prof.Schedule.BackupEnabled() {
		if ok, msg := runBackup(); !ok {
			failed = true
			failMsg = msg
			skipRemaining = true
		}
	}
	if !isInterrupted() && !skipRemaining && prof.Schedule.Prune && len(prof.Prune.Args) > 0 {
		if ok, msg := runPrune(); !ok {
			failed = true
			if failMsg == "" {
				failMsg = msg
			}
		}
	}
	if !isInterrupted() && !skipRemaining && prof.Schedule.Check {
		if ok, msg := runCheck(); !ok {
			failed = true
			if failMsg == "" {
				failMsg = msg
			}
		}
	}

	return failed, failMsg
}

func runScheduled(key stateKey, mStatus prefixedMenuItem, prof Profile, onDone func()) {
	if p, _ := findBackend(prof); p == "" {
		return
	}
	if !acquireProfile(key) {
		return
	}
	defer onDone()
	defer releaseProfile(key)
	runScheduledAcquired(key, mStatus, prof)
}

func runScheduledAcquired(key stateKey, mStatus prefixedMenuItem, prof Profile) {
	setProfileFailed(key, "")
	if errMsg := prof.scheduleError(); errMsg != "" {
		mStatus.SetTitle(errMsg)
		setProfileFailed(key, errMsg)
		notifyError("Schedule", errMsg)
		return
	}

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

	if prof.PreHook != "" {
		mStatus.SetTitle("Running pre-hook...")
		if err := runHook(prof.PreHook, prof, hookEnv...); err != nil {
			setProfileFailed(key, "Pre-hook failed")
			notifyError("Schedule", "Pre-hook failed")
			return
		}
	}

	failed, _ := runScheduledOperations(
		prof,
		nil,
		func() (bool, string) {
			mStatus.SetTitle("Backing up...")
			if ok, _ := doBackup(key, mStatus, prof, true); !ok {
				return false, getProfileFailStatus(key)
			}
			return true, ""
		},
		func() (bool, string) {
			mStatus.SetTitle("Pruning repository...")
			if msg, err := runBackend(key, prof, mStatus, forgetArgs(prof)...); err != nil {
				setProfileFailed(key, msg)
				return false, msg
			}
			return true, ""
		},
		func() (bool, string) {
			mStatus.SetTitle("Checking repository...")
			if msg, err := runBackend(key, prof, mStatus, checkArgs(prof)...); err != nil {
				setProfileFailed(key, msg)
				return false, msg
			}
			return true, ""
		},
	)

	if failed {
		notifyError("Schedule", getProfileFailStatus(key))
	} else {
		notifySuccess("Schedule")
	}

	if prof.PostHook != "" {
		mStatus.SetTitle("Running post-hook...")
		postEnv := hookEnv
		if failed {
			postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(key))
		}
		if err := runHook(prof.PostHook, prof, postEnv...); err != nil {
			setProfileFailed(key, "Post-hook failed")
			notifyError("Schedule", "Post-hook failed")
		}
	}
}

func addRetryLockArgs(prof Profile, args []string) []string {
	if retryLockEnabled(prof) {
		args = append(args, "--retry-lock", prof.RetryLock)
	}
	return args
}

func backupArgs(prof Profile, scheduled bool, extra ...string) []string {
	args := addRetryLockArgs(prof, append([]string{"backup"}, extra...))
	args = append(args, prof.Backup.Args...)
	if scheduled {
		args = append(args, prof.Backup.ArgsScheduled...)
	}
	args = append(args, prof.Backup.Paths...)
	return args
}

func forgetArgs(prof Profile) []string {
	return append([]string{"forget", "--prune"}, prof.Prune.Args...)
}

func checkArgs(prof Profile) []string {
	return append([]string{"check"}, prof.Check.Args...)
}

func mountArgs(prof Profile, dir string) []string {
	args := []string{"mount"}
	if prof.backend() == BackendRestic {
		args = append(args, "--no-lock")
	}
	args = append(args, prof.Mount.Args...)
	return append(args, dir)
}

func isProfileMounted(key stateKey) bool {
	state.mu.Lock()
	defer state.mu.Unlock()
	return state.mountCmds[key] != nil
}

func startMount(key stateKey, prof Profile, mMount *systray.MenuItem, onDone func()) {
	if !mountSupported(prof) {
		notifyError("Mount", backendUnavailable(prof, "mount"))
		onDone()
		return
	}
	dir, err := os.MkdirTemp("", "restray-mount-")
	if err != nil {
		notifyError("Mount", err.Error())
		onDone()
		return
	}

	cmd := backendCmd(prof, mountArgs(prof, dir)...)
	var stderr strings.Builder
	cmd.Stderr = &stderr

	if err := cmd.Start(); err != nil {
		os.Remove(dir)
		notifyError("Mount", err.Error())
		onDone()
		return
	}

	state.mu.Lock()
	state.mountCmds[key] = cmd
	state.mu.Unlock()

	mMount.SetTitle("Unmount")

	done := make(chan error, 1)
	go func() { done <- cmd.Wait() }()

	mounted := false
	ticker := time.NewTicker(250 * time.Millisecond)
	defer ticker.Stop()
	timeout := time.NewTimer(30 * time.Second)
	defer timeout.Stop()
wait:
	for {
		select {
		case <-done:
			break wait
		case <-timeout.C:
			interruptProcess(cmd.Process)
			<-done
			break wait
		case <-ticker.C:
			entries, _ := os.ReadDir(dir)
			if len(entries) > 0 {
				mounted = true
				break wait
			}
		}
	}
	if mounted {
		openFile(dir)
		<-done
	}

	state.mu.Lock()
	delete(state.mountCmds, key)
	stopped := state.mountStopping[key]
	delete(state.mountStopping, key)
	state.mu.Unlock()
	os.Remove(dir)

	if msg := stderr.String(); msg != "" && !stopped {
		notifyError("Mount", strings.TrimSpace(msg))
	}

	onDone()
}

func stopProfileMount(key stateKey) {
	state.mu.Lock()
	defer state.mu.Unlock()
	if cmd := state.mountCmds[key]; cmd != nil && cmd.Process != nil {
		state.mountStopping[key] = true
		interruptProcess(cmd.Process)
	}
}

func stopAllMounts() {
	state.mu.Lock()
	defer state.mu.Unlock()
	for key, cmd := range state.mountCmds {
		if cmd != nil && cmd.Process != nil {
			state.mountStopping[key] = true
			interruptProcess(cmd.Process)
		}
	}
}

func notifyMode() string {
	state.mu.Lock()
	defer state.mu.Unlock()
	return state.notifications
}

func notifyError(operation, msg string) {
	mode := notifyMode()
	if mode != "all" && mode != "errors" {
		return
	}
	beeep.Alert(operation+" failed", msg, currentAlertIcon())
}

func notifySuccess(operation string) {
	if notifyMode() != "all" {
		return
	}
	beeep.Notify(operation+" complete", operation+" completed successfully", "")
}

func openConsole(prof Profile, profileCount int, terminal, tailFile string) {
	vars, err := parseEnvFile(prof.EnvFile)
	if err != nil {
		log.Printf("console: failed to read env file: %v", err)
		return
	}

	consoleMsg := fmt.Sprintf("Restray: environment loaded in shell. Run \"%s\" for commands.", prof.backendName())
	if profileCount > 1 {
		consoleMsg = fmt.Sprintf("Restray: environment for profile \"%s\" loaded in shell. Run \"%s\" for commands.", prof.displayName(), prof.backendName())
	}

	cmd := consoleCmd(vars, prof, consoleMsg, "Restray: tailing log.", tailFile, terminal)
	if cmd != nil {
		cmd.Start()
	}
}
