package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"os/exec"
	"os/user"
	"path/filepath"
	"strings"
	"time"
)

const firefoxUserPrefs = `// Managed by tgk-touch kiosk startup. Do not edit by hand.
user_pref("app.normandy.first_run", false);
user_pref("browser.aboutConfig.showWarning", false);
user_pref("browser.crashReports.unsubmittedCheck.autoSubmit2", false);
user_pref("browser.crashReports.unsubmittedCheck.enabled", false);
user_pref("browser.disableResetPrompt", true);
user_pref("browser.download.panel.shown", true);
user_pref("browser.rights.3.shown", true);
user_pref("browser.sessionstore.max_resumed_crashes", 0);
user_pref("browser.sessionstore.resume_from_crash", false);
user_pref("browser.shell.checkDefaultBrowser", false);
user_pref("browser.startup.homepage", "about:blank");
user_pref("browser.startup.page", 0);
user_pref("browser.tabs.warnOnClose", false);
user_pref("browser.warnOnQuitShortcut", false);
user_pref("datareporting.healthreport.uploadEnabled", false);
user_pref("datareporting.policy.dataSubmissionEnabled", false);
user_pref("signon.autofillForms", false);
user_pref("signon.rememberSignons", false);
user_pref("toolkit.startup.max_resumed_crashes", 999999);
user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);
`

func runClient(port int) {
	display := ":0"
	kioskInfof("firefox client loop init display=%s port=%d", display, port)
	currentUser, err := user.Current()
	if err != nil {
		kioskErrorf("read current user failed err=%v", err)
		fmt.Printf("读取当前用户失败: %v\n", err)
		os.Exit(1)
	}
	profileDir := filepath.Join(currentUser.HomeDir, ".tgk-touch-firefox-profile")
	clientURL := "http://127.0.0.1:" + fmt.Sprintf("%d", port)
	remoteDebugPort := port + 10000
	kioskInfof("firefox client config user=%s home=%s profile=%s url=%s remoteDebugPort=%d", currentUser.Username, currentUser.HomeDir, profileDir, clientURL, remoteDebugPort)

	for {
		os.Setenv("DISPLAY", display)
		if !isDisplaySocketReady(display) {
			kioskWarnf("display socket not ready display=%s; waiting before firefox launch", display)
			time.Sleep(2 * time.Second)
			continue
		}
		if !isFirefoxUsable() {
			kioskWarnf("firefox unusable during kiosk loop; waiting before firefox launch")
			time.Sleep(10 * time.Second)
			continue
		}
		if xauthPath := findXAuthorityPath(currentUser, display); xauthPath != "" {
			os.Setenv("XAUTHORITY", xauthPath)
			kioskInfof("xauthority selected path=%s", xauthPath)
		} else {
			os.Unsetenv("XAUTHORITY")
			kioskWarnf("xauthority not found; XAUTHORITY unset")
		}
		if err := prepareFirefoxKioskProfile(profileDir); err != nil {
			kioskErrorf("prepare firefox profile failed profile=%s err=%v", profileDir, err)
			time.Sleep(5 * time.Second)
			continue
		}
		if err := closeExistingFirefox(); err != nil {
			kioskErrorf("close existing firefox failed err=%v", err)
		}
		args := []string{
			"--new-instance",
			"--profile",
			profileDir,
			"--remote-debugging-port",
			fmt.Sprintf("%d", remoteDebugPort),
			"--kiosk",
			clientURL,
		}
		cmd := exec.Command("firefox", args...)
		cmd.Env = firefoxKioskEnv()
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		kioskInfof("starting firefox args=%v", args)

		if err := runFirefoxWithWatchdog(cmd, clientURL, remoteDebugPort); err != nil {
			kioskErrorf("firefox watchdog returned err=%v", err)
			if resetErr := resetFirefoxKioskProfile(profileDir); resetErr != nil {
				kioskErrorf("reset firefox profile after failure failed profile=%s err=%v", profileDir, resetErr)
			}
			time.Sleep(5 * time.Second)
			continue
		}
	}
}

func isDisplaySocketReady(display string) bool {
	path := displaySocketPath(display)
	info, err := os.Stat(path)
	ok := err == nil && !info.IsDir()
	kioskDebugf("display socket check display=%s path=%s ok=%v err=%v", display, path, ok, err)
	return ok
}

func displaySocketPath(display string) string {
	name := strings.TrimSpace(display)
	if idx := strings.LastIndex(name, ":"); idx >= 0 {
		name = name[idx+1:]
	}
	if idx := strings.Index(name, "."); idx >= 0 {
		name = name[:idx]
	}
	if name == "" {
		name = "0"
	}
	return filepath.Join("/tmp/.X11-unix", "X"+name)
}

func findXAuthorityPath(currentUser *user.User, display string) string {
	kioskDebugf("xauthority search begin display=%s", display)
	candidates := []string{
		os.Getenv("XAUTHORITY"),
		filepath.Join("/var/run/lightdm/root", display),
		filepath.Join("/run/lightdm/root", display),
	}
	if currentUser != nil && currentUser.HomeDir != "" {
		candidates = append(candidates, filepath.Join(currentUser.HomeDir, ".Xauthority"))
	}

	seen := make(map[string]struct{}, len(candidates))
	for _, candidate := range candidates {
		if candidate == "" {
			continue
		}
		if _, ok := seen[candidate]; ok {
			continue
		}
		seen[candidate] = struct{}{}
		if isUsableXAuthority(candidate) {
			kioskInfof("xauthority candidate usable path=%s", candidate)
			return candidate
		}
		kioskDebugf("xauthority candidate unusable path=%s", candidate)
	}

	for _, pattern := range []string{
		"/run/user/*/gdm/Xauthority",
		"/run/user/*/.Xauthority",
		"/run/user/*/Xauthority",
		"/home/*/.Xauthority",
		"/root/.Xauthority",
		"/var/lib/lightdm/.Xauthority",
		"/var/lib/gdm/.Xauthority",
		"/var/lib/gdm3/.Xauthority",
		"/tmp/xauth_*",
		"/tmp/.Xauthority*",
	} {
		if match := newestUsableXAuthority(pattern); match != "" {
			kioskInfof("xauthority glob matched pattern=%s path=%s", pattern, match)
			return match
		}
		kioskDebugf("xauthority glob no usable match pattern=%s", pattern)
	}
	kioskWarnf("xauthority search exhausted display=%s", display)
	return ""
}

func newestUsableXAuthority(pattern string) string {
	matches, err := filepath.Glob(pattern)
	if err != nil {
		kioskDebugf("xauthority glob invalid pattern=%s err=%v", pattern, err)
		return ""
	}
	kioskDebugf("xauthority glob pattern=%s matches=%d", pattern, len(matches))

	var newestPath string
	var newestModTime time.Time
	for _, match := range matches {
		info, err := os.Stat(match)
		if err != nil || info.IsDir() || info.Size() == 0 {
			continue
		}
		if newestPath == "" || info.ModTime().After(newestModTime) {
			newestPath = match
			newestModTime = info.ModTime()
		}
	}
	return newestPath
}

func isUsableXAuthority(path string) bool {
	info, err := os.Stat(path)
	return err == nil && !info.IsDir() && info.Size() > 0
}

func closeExistingFirefox() error {
	names := []string{"firefox", "firefox-esr", "firefox-bin"}
	kioskInfof("closing existing firefox processes names=%v", names)
	for _, name := range names {
		err := exec.Command("pkill", "-TERM", "-x", name).Run()
		kioskDebugf("pkill TERM name=%s err=%v", name, err)
	}

	deadline := time.Now().Add(5 * time.Second)
	for time.Now().Before(deadline) {
		if !isAnyProcessRunning(names) {
			kioskInfof("existing firefox processes closed after TERM")
			return nil
		}
		kioskDebugf("waiting firefox processes to exit after TERM")
		time.Sleep(500 * time.Millisecond)
	}

	kioskWarnf("firefox still running after TERM; sending KILL")
	for _, name := range names {
		err := exec.Command("pkill", "-KILL", "-x", name).Run()
		kioskDebugf("pkill KILL name=%s err=%v", name, err)
	}
	for i := 0; i < 10; i++ {
		if !isAnyProcessRunning(names) {
			kioskInfof("existing firefox processes closed after KILL")
			return nil
		}
		kioskDebugf("waiting firefox processes to exit after KILL attempt=%d", i+1)
		time.Sleep(200 * time.Millisecond)
	}
	kioskErrorf("firefox process still running after kill")
	return fmt.Errorf("firefox process still running after kill")
}

func isAnyProcessRunning(names []string) bool {
	for _, name := range names {
		if exec.Command("pgrep", "-x", name).Run() == nil {
			kioskDebugf("process running name=%s", name)
			return true
		}
	}
	return false
}

func runFirefoxWithWatchdog(cmd *exec.Cmd, clientURL string, remoteDebugPort int) error {
	kioskInfof("firefox process start begin url=%s remoteDebugPort=%d", clientURL, remoteDebugPort)
	if err := cmd.Start(); err != nil {
		kioskErrorf("firefox process start failed err=%v", err)
		return err
	}
	if cmd.Process != nil {
		kioskInfof("firefox process started pid=%d", cmd.Process.Pid)
	}

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

	startedAt := time.Now()
	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case err := <-done:
			kioskWarnf("firefox process exited err=%v", err)
			return err
		case <-ticker.C:
			healthy, err := isFirefoxKioskHealthy(clientURL, remoteDebugPort)
			if err != nil && time.Since(startedAt) < 30*time.Second {
				kioskDebugf("firefox health check pending during startup grace elapsed=%s err=%v", time.Since(startedAt).Round(time.Second), err)
				continue
			}
			if err != nil || !healthy {
				if err == nil {
					err = fmt.Errorf("firefox is not showing kiosk page in fullscreen")
				}
				kioskWarnf("firefox health check failed elapsed=%s healthy=%v err=%v; closing firefox", time.Since(startedAt).Round(time.Second), healthy, err)
				if closeErr := closeExistingFirefox(); closeErr != nil {
					kioskErrorf("close firefox after health failure failed err=%v", closeErr)
				}
				select {
				case waitErr := <-done:
					if waitErr != nil {
						kioskWarnf("firefox stopped after health failure waitErr=%v", waitErr)
						return fmt.Errorf("%w; firefox stopped: %v", err, waitErr)
					}
					kioskWarnf("firefox stopped after health failure")
					return err
				case <-time.After(5 * time.Second):
					kioskWarnf("firefox did not exit within watchdog close timeout")
					return err
				}
			}
			kioskDebugf("firefox health check ok elapsed=%s", time.Since(startedAt).Round(time.Second))
		}
	}
}

func isFirefoxKioskHealthy(clientURL string, remoteDebugPort int) (bool, error) {
	hasKioskURL, err := firefoxHasKioskURL(clientURL, remoteDebugPort)
	if err != nil || !hasKioskURL {
		kioskWarnf("firefox kiosk url check failed hasKioskURL=%v err=%v", hasKioskURL, err)
		return false, err
	}

	fullscreen, err := firefoxHasFullscreenWindow()
	if err != nil {
		kioskWarnf("firefox fullscreen check errored err=%v", err)
		return false, err
	}
	if !fullscreen {
		kioskWarnf("firefox fullscreen check failed")
	}
	return fullscreen, nil
}

type firefoxDebugTarget struct {
	Type string `json:"type"`
	URL  string `json:"url"`
}

func firefoxHasKioskURL(clientURL string, remoteDebugPort int) (bool, error) {
	httpClient := http.Client{Timeout: 2 * time.Second}
	debugURL := fmt.Sprintf("http://127.0.0.1:%d/json/list", remoteDebugPort)
	kioskDebugf("firefox remote debug request url=%s", debugURL)
	resp, err := httpClient.Get(debugURL)
	if err != nil {
		kioskDebugf("firefox remote debug request failed url=%s err=%v", debugURL, err)
		return false, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		kioskWarnf("firefox remote debug bad status url=%s status=%d", debugURL, resp.StatusCode)
		return false, fmt.Errorf("firefox remote debugging returned status %d", resp.StatusCode)
	}

	var targets []firefoxDebugTarget
	if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
		kioskWarnf("firefox remote debug json decode failed url=%s err=%v", debugURL, err)
		return false, err
	}
	kioskDebugf("firefox remote debug targets=%d expectedURL=%s", len(targets), clientURL)
	for _, target := range targets {
		kioskDebugf("firefox target type=%s url=%s", target.Type, target.URL)
		if target.Type == "page" && isKioskURL(target.URL, clientURL) {
			kioskDebugf("firefox kiosk url matched targetURL=%s", target.URL)
			return true, nil
		}
	}
	kioskWarnf("firefox kiosk url not found expectedURL=%s targets=%d", clientURL, len(targets))
	return false, nil
}

func isKioskURL(rawURL string, clientURL string) bool {
	base := strings.TrimRight(clientURL, "/")
	url := strings.TrimSpace(rawURL)
	return url == base ||
		strings.HasPrefix(url, base+"/") ||
		strings.HasPrefix(url, base+"#") ||
		strings.HasPrefix(url, base+"?")
}

func firefoxHasFullscreenWindow() (bool, error) {
	if !isCommandAvailable("xdotool") || !isCommandAvailable("xprop") {
		kioskDebugf("fullscreen check skipped because xdotool/xprop unavailable")
		return true, nil
	}

	windowIDs := make(map[string]struct{})
	for _, className := range []string{"firefox", "firefox-esr", "Navigator"} {
		out, err := exec.Command("xdotool", "search", "--onlyvisible", "--class", className).Output()
		if err != nil {
			kioskDebugf("xdotool search failed class=%s err=%v", className, err)
			continue
		}
		for _, id := range strings.Fields(string(out)) {
			windowIDs[id] = struct{}{}
		}
	}
	if len(windowIDs) == 0 {
		kioskWarnf("fullscreen check found no visible firefox windows")
		return false, nil
	}
	kioskDebugf("fullscreen check candidate windows=%d", len(windowIDs))

	for id := range windowIDs {
		out, err := exec.Command("xprop", "-id", id, "_NET_WM_STATE").Output()
		if err != nil {
			kioskDebugf("xprop failed window=%s err=%v", id, err)
			continue
		}
		if strings.Contains(string(out), "_NET_WM_STATE_FULLSCREEN") {
			kioskDebugf("fullscreen window matched window=%s", id)
			return true, nil
		}
	}
	kioskWarnf("no firefox window is fullscreen")
	return false, nil
}

func isCommandAvailable(name string) bool {
	ok := exec.Command("which", name).Run() == nil
	kioskDebugf("command availability name=%s ok=%v", name, ok)
	return ok
}

func firefoxKioskEnv() []string {
	env := make([]string, 0, len(os.Environ())+4)
	filteredKeys := make([]string, 0)
	for _, item := range os.Environ() {
		key, _, ok := strings.Cut(item, "=")
		if !ok {
			continue
		}
		if key == "MOZ_SAFE_MODE_RESTART" ||
			key == "MOZ_CRASHREPORTER_RESTART_ARG_0" ||
			strings.HasPrefix(key, "MOZ_CRASHREPORTER_RESTART_ARG_") {
			filteredKeys = append(filteredKeys, key)
			continue
		}
		env = append(env, item)
	}

	env = append(env,
		"MOZ_DISABLE_SAFE_MODE_KEY=1",
		"MOZ_CRASHREPORTER_DISABLE=1",
		"MOZ_CRASHREPORTER_NO_REPORT=1",
	)
	kioskDebugf("firefox env prepared filteredKeys=%v addedKeys=%v total=%d", filteredKeys, []string{"MOZ_DISABLE_SAFE_MODE_KEY", "MOZ_CRASHREPORTER_DISABLE", "MOZ_CRASHREPORTER_NO_REPORT"}, len(env))
	return env
}

func prepareFirefoxKioskProfile(profileDir string) error {
	kioskInfof("prepare firefox kiosk profile begin profile=%s", profileDir)
	if err := os.MkdirAll(profileDir, 0755); err != nil {
		kioskErrorf("create firefox kiosk profile failed profile=%s err=%v", profileDir, err)
		return fmt.Errorf("create firefox kiosk profile: %w", err)
	}
	if err := os.WriteFile(filepath.Join(profileDir, "user.js"), []byte(firefoxUserPrefs), 0644); err != nil {
		kioskErrorf("write firefox kiosk prefs failed profile=%s err=%v", profileDir, err)
		return fmt.Errorf("write firefox kiosk prefs: %w", err)
	}
	kioskInfof("firefox kiosk prefs written profile=%s", profileDir)
	if err := cleanFirefoxRecoveryState(profileDir); err != nil {
		kioskErrorf("clean firefox recovery state failed profile=%s err=%v", profileDir, err)
		return fmt.Errorf("clean firefox recovery state: %w", err)
	}
	kioskInfof("prepare firefox kiosk profile done profile=%s", profileDir)
	return nil
}

func resetFirefoxKioskProfile(profileDir string) error {
	kioskWarnf("reset firefox kiosk profile begin profile=%s", profileDir)
	if profileDir == "" || filepath.Base(profileDir) != ".tgk-touch-firefox-profile" {
		kioskErrorf("refuse to reset unexpected firefox profile path=%s", profileDir)
		return fmt.Errorf("refuse to reset unexpected firefox profile path: %s", profileDir)
	}
	if err := os.RemoveAll(profileDir); err != nil {
		kioskErrorf("remove firefox kiosk profile failed profile=%s err=%v", profileDir, err)
		return fmt.Errorf("reset firefox kiosk profile: %w", err)
	}
	kioskInfof("firefox kiosk profile removed profile=%s", profileDir)
	return prepareFirefoxKioskProfile(profileDir)
}

func cleanFirefoxRecoveryState(profileDir string) error {
	kioskInfof("clean firefox recovery state begin profile=%s", profileDir)
	for _, name := range []string{
		".parentlock",
		".startup-incomplete",
		"lock",
		"parent.lock",
		"sessionCheckpoints.json",
		"sessionstore.js",
		"sessionstore.jsonlz4",
	} {
		if err := removeIfExists(filepath.Join(profileDir, name)); err != nil {
			kioskErrorf("remove firefox recovery file failed profile=%s name=%s err=%v", profileDir, name, err)
			return err
		}
	}

	backupDir := filepath.Join(profileDir, "sessionstore-backups")
	entries, err := os.ReadDir(backupDir)
	if err != nil {
		if os.IsNotExist(err) {
			kioskDebugf("firefox session backup dir not found path=%s", backupDir)
			return nil
		}
		kioskErrorf("read firefox session backup dir failed path=%s err=%v", backupDir, err)
		return err
	}
	kioskDebugf("firefox session backup entries=%d path=%s", len(entries), backupDir)
	for _, entry := range entries {
		if entry.IsDir() {
			continue
		}
		if err := removeIfExists(filepath.Join(backupDir, entry.Name())); err != nil {
			kioskErrorf("remove firefox session backup failed path=%s name=%s err=%v", backupDir, entry.Name(), err)
			return err
		}
	}
	kioskInfof("clean firefox recovery state done profile=%s", profileDir)
	return nil
}

func removeIfExists(path string) error {
	err := os.Remove(path)
	if err == nil || os.IsNotExist(err) {
		if err == nil {
			kioskDebugf("removed file path=%s", path)
		} else {
			kioskDebugf("remove skipped; file not found path=%s", path)
		}
		return nil
	}
	kioskErrorf("remove file failed path=%s err=%v", path, err)
	return err
}
