package main

import (
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"syscall"

	"hamlog/internal/applog"

	wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)

// keyAutostartPrograms holds the per-profile list of external programs OpsLog
// launches on startup (JSON array of AutostartProgram).
const keyAutostartPrograms = "autostart.programs"

// AutostartProgram is one external application OpsLog can launch when it starts
// — e.g. WSJT-X, JTAlert, a rotator controller. Stored per profile.
type AutostartProgram struct {
	ID      string `json:"id"`
	Name    string `json:"name"`
	Path    string `json:"path"`
	Args    string `json:"args"`
	Enabled bool   `json:"enabled"`
	// CloseOnExit asks for this program to be closed when OpsLog closes.
	//
	// Per program and not one switch for the list, because the answer differs
	// inside one station: an operator wants WSJT-X gone with the logger and the
	// rotator controller left running.
	CloseOnExit bool `json:"close_on_exit,omitempty"`
}

// AutostartLaunchResult reports what happened for one program when launching.
type AutostartLaunchResult struct {
	ID      string `json:"id"`
	Name    string `json:"name"`
	Status  string `json:"status"` // launched | already_running | missing | disabled | error
	Message string `json:"message"`
}

// GetAutostartPrograms returns the active profile's autostart list.
func (a *App) GetAutostartPrograms() ([]AutostartProgram, error) {
	out := []AutostartProgram{}
	if a.settings == nil {
		return out, nil
	}
	s, _ := a.settings.Get(a.ctx, keyAutostartPrograms)
	if strings.TrimSpace(s) == "" {
		return out, nil
	}
	if err := json.Unmarshal([]byte(s), &out); err != nil {
		return []AutostartProgram{}, nil
	}
	return out, nil
}

// SaveAutostartPrograms persists the autostart list for the active profile.
func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	b, err := json.Marshal(progs)
	if err != nil {
		return err
	}
	return a.settings.Set(a.ctx, keyAutostartPrograms, string(b))
}

// BrowseExecutable opens a native file picker for choosing a program to launch.
func (a *App) BrowseExecutable() (string, error) {
	return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
		Title: "Choose a program to launch on startup",
		Filters: []wruntime.FileFilter{
			{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
			{DisplayName: "All files (*.*)", Pattern: "*.*"},
		},
	})
}

// LaunchAutostartPrograms starts every enabled program that isn't already
// running, returning a per-program result. Used at startup (best effort) and by
// the "Launch now" button in settings.
func (a *App) LaunchAutostartPrograms() []AutostartLaunchResult {
	progs, _ := a.GetAutostartPrograms()
	running := runningProcessNames()
	out := make([]AutostartLaunchResult, 0, len(progs))
	for _, p := range progs {
		if !p.Enabled {
			continue
		}
		out = append(out, launchProgram(p, running))
	}
	return out
}

// LaunchAutostartProgram launches a single program by id on demand (the per-row
// "launch now" action), regardless of its enabled flag.
func (a *App) LaunchAutostartProgram(id string) (AutostartLaunchResult, error) {
	progs, err := a.GetAutostartPrograms()
	if err != nil {
		return AutostartLaunchResult{}, err
	}
	for _, p := range progs {
		if p.ID == id {
			return launchProgram(p, runningProcessNames()), nil
		}
	}
	return AutostartLaunchResult{}, fmt.Errorf("program %q not found", id)
}

// launched remembers the process id of every program OPSLOG started, keyed by
// program id.
//
// Only what we started is ever closed. A copy of WSJT-X the operator opened
// themselves — before OpsLog, for something else entirely — is theirs, and
// closing it because a logger happened to quit would be taking a decision that
// was never asked for. That is also why "already running" stores nothing.
var launched = struct {
	sync.Mutex
	pid map[string]int
}{pid: map[string]int{}}

// CloseAutostartPrograms closes the programs marked "close with OpsLog" — the
// ones OpsLog itself launched this session.
//
// A polite close, never a kill: taskkill without /F posts WM_CLOSE, so WSJT-X
// writes its settings and its log the way it would if the operator had clicked
// the cross. Forcing it would lose exactly the state an operator cares about,
// and a program that ignores a close request is entitled to.
func (a *App) CloseAutostartPrograms() {
	progs, _ := a.GetAutostartPrograms()
	for _, p := range progs {
		if !p.CloseOnExit {
			continue
		}
		launched.Lock()
		pid, ok := launched.pid[p.ID]
		delete(launched.pid, p.ID)
		launched.Unlock()
		if !ok || pid <= 0 {
			continue // not started by us this session — not ours to close
		}
		name := strings.TrimSpace(p.Name)
		if name == "" {
			name = filepath.Base(p.Path)
		}
		cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid))
		cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
		if out, err := cmd.CombinedOutput(); err != nil {
			applog.Printf("autostart: could not close %s (pid %d): %v — %s", name, pid, err, strings.TrimSpace(string(out)))
			continue
		}
		applog.Printf("autostart: asked %s (pid %d) to close", name, pid)
	}
}

// launchProgram starts one program unless its executable is already running.
func launchProgram(p AutostartProgram, running map[string]bool) AutostartLaunchResult {
	res := AutostartLaunchResult{ID: p.ID, Name: p.Name}
	path := strings.TrimSpace(p.Path)
	if path == "" {
		res.Status, res.Message = "error", "no path configured"
		return res
	}
	if _, err := os.Stat(path); err != nil {
		res.Status, res.Message = "missing", "executable not found: "+path
		return res
	}
	// Skip if a process with the same executable name is already running, so we
	// never spawn a second copy of WSJT-X / JTAlert / etc.
	base := strings.ToLower(filepath.Base(path))
	if running[base] {
		res.Status, res.Message = "already_running", base+" already running"
		return res
	}
	cmd := exec.Command(path, splitArgs(p.Args)...)
	cmd.Dir = filepath.Dir(path) // many ham apps expect their own folder as CWD
	if err := cmd.Start(); err != nil {
		res.Status, res.Message = "error", err.Error()
		return res
	}
	// Remembered so it can be closed again on exit, if asked. Recorded BEFORE
	// the wait goroutine, which releases the handle.
	if cmd.Process != nil {
		launched.Lock()
		launched.pid[p.ID] = cmd.Process.Pid
		launched.Unlock()
	}
	// Don't wait on the child — it runs independently of OpsLog. Release the
	// handle so we don't accumulate zombies.
	go func() { _ = cmd.Wait() }()
	res.Status, res.Message = "launched", "started "+base
	return res
}

// splitArgs does a minimal shell-like split of an argument string, honouring
// double quotes so a path with spaces stays one argument.
func splitArgs(s string) []string {
	s = strings.TrimSpace(s)
	if s == "" {
		return nil
	}
	var args []string
	var cur strings.Builder
	inQuote := false
	for _, r := range s {
		switch {
		case r == '"':
			inQuote = !inQuote
		case r == ' ' && !inQuote:
			if cur.Len() > 0 {
				args = append(args, cur.String())
				cur.Reset()
			}
		default:
			cur.WriteRune(r)
		}
	}
	if cur.Len() > 0 {
		args = append(args, cur.String())
	}
	return args
}

// runningProcessNames returns the set of lowercase executable names currently
// running, via the Windows `tasklist`. Best effort — on failure the set is
// empty (we then just attempt to launch, which is acceptable).
func runningProcessNames() map[string]bool {
	out := map[string]bool{}
	cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
	cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
	data, err := cmd.Output()
	if err != nil {
		applog.Printf("autostart: tasklist failed: %v", err)
		return out
	}
	for _, line := range strings.Split(string(data), "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		// CSV row: "image.exe","PID",... — take the first quoted field.
		field := line
		if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") {
			field = line[1 : i+1]
		}
		field = strings.Trim(field, "\"")
		if field != "" {
			out[strings.ToLower(field)] = true
		}
	}
	return out
}
