// PicoClaw Web Console - Web-based chat and management interface
//
// Provides a web UI for chatting with PicoClaw via the Pico Channel WebSocket,
// with configuration management and gateway process control.
//
// Usage:
//
//	go build -o picoclaw-web ./web/backend/
//	./picoclaw-web [config.json]
//	./picoclaw-web -public config.json

package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"net"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"strconv"
	"strings"
	"syscall"
	"time"

	"github.com/sipeed/picoclaw/pkg/config"
	"github.com/sipeed/picoclaw/pkg/logger"
	"github.com/sipeed/picoclaw/pkg/netbind"
	"github.com/sipeed/picoclaw/web/backend/api"
	"github.com/sipeed/picoclaw/web/backend/dashboardauth"
	"github.com/sipeed/picoclaw/web/backend/launcherconfig"
	"github.com/sipeed/picoclaw/web/backend/middleware"
	"github.com/sipeed/picoclaw/web/backend/utils"
)

const (
	appName = "PicoClaw"

	logPath   = "logs"
	panicFile = "launcher_panic.log"
	logFile   = "launcher.log"
)

var (
	appVersion = config.Version

	servers    []*http.Server
	serverAddr string
	// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
	browserLaunchURL string
	apiHandler       *api.Handler

	noBrowser *bool
)

func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
	return !enableConsole || debug
}

func shouldEnableLocalAutoLogin(noBrowser bool, probeHost string) bool {
	return !noBrowser && isLoopbackLaunchHost(probeHost)
}

func isLoopbackLaunchHost(host string) bool {
	host = strings.TrimSpace(host)
	if strings.EqualFold(host, "localhost") {
		return true
	}
	host = strings.Trim(host, "[]")
	if i := strings.LastIndex(host, "%"); i >= 0 {
		host = host[:i]
	}
	ip := net.ParseIP(host)
	return ip != nil && ip.IsLoopback()
}

func launcherBrowserLaunchSuffix(
	needsSetup bool,
	localAutoLogin *middleware.LauncherDashboardLocalAutoLogin,
) string {
	if needsSetup {
		return middleware.LauncherDashboardSetupPath
	}
	if localAutoLogin != nil {
		return localAutoLogin.URLPath()
	}
	return ""
}

func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) {
	if explicitFlag {
		normalized, err := netbind.NormalizeHostInput(flagHost)
		if err != nil {
			return "", false, err
		}
		return normalized, true, nil
	}

	envHost = strings.TrimSpace(envHost)
	if envHost == "" {
		return "", false, nil
	}

	normalized, err := netbind.NormalizeHostInput(envHost)
	if err != nil {
		return "", false, err
	}
	return normalized, true, nil
}

func openLauncherListeners(hostInput string, public bool, port string) (netbind.OpenResult, error) {
	defaultMode := netbind.DefaultLoopback
	if strings.TrimSpace(hostInput) == "" && public {
		defaultMode = netbind.DefaultAny
	}

	plan, err := netbind.BuildPlan(hostInput, defaultMode)
	if err != nil {
		return netbind.OpenResult{}, err
	}
	return netbind.OpenPlan(plan, port)
}

func appendUniqueHost(hosts []string, seen map[string]struct{}, host string) []string {
	host = strings.TrimSpace(host)
	if host == "" {
		return hosts
	}
	key := strings.ToLower(host)
	if _, ok := seen[key]; ok {
		return hosts
	}
	seen[key] = struct{}{}
	return append(hosts, host)
}

func hasWildcardBindHosts(bindHosts []string) bool {
	for _, bindHost := range bindHosts {
		if netbind.IsUnspecifiedHost(bindHost) {
			return true
		}
	}
	return false
}

func wildcardBindHostFamilies(bindHosts []string) (hasIPv4, hasIPv6 bool) {
	for _, bindHost := range bindHosts {
		host := strings.TrimSpace(bindHost)
		if host == "" {
			continue
		}

		if !netbind.IsUnspecifiedHost(host) {
			continue
		}

		ip := net.ParseIP(strings.Trim(host, "[]"))
		if ip == nil {
			continue
		}
		if ip.To4() != nil {
			hasIPv4 = true
			continue
		}
		hasIPv6 = true
	}

	return hasIPv4, hasIPv6
}

func wildcardAdvertiseIP(bindHosts []string, ipv4, ipv6 string) string {
	hasIPv4Wildcard, hasIPv6Wildcard := wildcardBindHostFamilies(bindHosts)
	v4 := strings.TrimSpace(ipv4)
	v6 := strings.TrimSpace(ipv6)

	switch {
	case hasIPv4Wildcard && hasIPv6Wildcard:
		if v6 != "" {
			return v6
		}
		return v4
	case hasIPv6Wildcard:
		return v6
	case hasIPv4Wildcard:
		return v4
	default:
		return ""
	}
}

func advertiseIPForWildcardBindHosts(bindHosts []string) string {
	return wildcardAdvertiseIP(bindHosts, utils.GetLocalIPv4(), utils.GetLocalIPv6())
}

func appendLauncherConsoleHostList(hosts []string, seen map[string]struct{}, values []string) []string {
	for _, value := range values {
		hosts = appendUniqueHost(hosts, seen, value)
	}
	return hosts
}

func shouldShowLocalhostConsoleEntry(hostInput string) bool {
	normalizedHostInput := strings.TrimSpace(hostInput)
	if normalizedHostInput == "" {
		return true
	}

	for token := range strings.SplitSeq(normalizedHostInput, ",") {
		token = strings.TrimSpace(token)
		if token == "" {
			continue
		}
		if token == "*" || strings.EqualFold(token, "localhost") {
			return true
		}

		ip := net.ParseIP(strings.Trim(token, "[]"))
		if ip == nil {
			continue
		}
		if ip4 := ip.To4(); ip4 != nil {
			if ip4.String() == "127.0.0.1" || ip4.String() == "0.0.0.0" {
				return true
			}
			continue
		}
		if ip.String() == "::1" || ip.String() == "::" {
			return true
		}
	}

	return false
}

func isConsoleDisplayGlobalIPv6(ip net.IP) bool {
	if ip == nil || ip.IsLoopback() || ip.To4() != nil {
		return false
	}
	ip = ip.To16()
	if ip == nil {
		return false
	}
	return ip[0]&0xe0 == 0x20
}

func launcherConsoleHostsWithLocalAddrs(
	hostInput string,
	public bool,
	ipv4s []string,
	globalIPv6s []string,
) []string {
	hosts := make([]string, 0, 8)
	seen := make(map[string]struct{}, 8)

	if shouldShowLocalhostConsoleEntry(hostInput) {
		hosts = appendUniqueHost(hosts, seen, "localhost")
	}

	normalizedHostInput := strings.TrimSpace(hostInput)
	if normalizedHostInput == "" {
		if public {
			hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
			hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
		}
		return hosts
	}

	hasStar := false
	hasIPv4Any := false
	hasIPv6Any := false
	for _, token := range strings.Split(normalizedHostInput, ",") {
		switch strings.TrimSpace(token) {
		case "*":
			hasStar = true
		case "0.0.0.0":
			hasIPv4Any = true
		case "::":
			hasIPv6Any = true
		}
	}

	if hasStar {
		hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
		hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
		return hosts
	}

	for _, token := range strings.Split(normalizedHostInput, ",") {
		token = strings.TrimSpace(token)
		if token == "" || strings.EqualFold(token, "localhost") || netbind.IsLoopbackHost(token) {
			continue
		}

		ip := net.ParseIP(strings.Trim(token, "[]"))
		switch {
		case token == "::":
			hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
		case token == "0.0.0.0":
			hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
		case ip != nil && ip.To4() != nil:
			if hasIPv4Any {
				continue
			}
			hosts = appendUniqueHost(hosts, seen, ip.String())
		case ip != nil:
			if hasIPv6Any {
				continue
			}
			if isConsoleDisplayGlobalIPv6(ip) {
				hosts = appendUniqueHost(hosts, seen, ip.String())
			}
		default:
			hosts = appendUniqueHost(hosts, seen, token)
		}
	}

	return hosts
}

func launcherConsoleHosts(hostInput string, public bool) []string {
	return launcherConsoleHostsWithLocalAddrs(
		hostInput,
		public,
		utils.GetLocalIPv4s(),
		utils.GetGlobalIPv6s(),
	)
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value != "" {
			return value
		}
	}
	return ""
}

func main() {
	port := flag.String("port", "18800", "Port to listen on")
	host := flag.String("host", "", "Host to listen on (overrides -public when set)")
	public := flag.Bool("public", false, "Listen on all interfaces (dual-stack) instead of localhost only")
	noBrowser = flag.Bool("no-browser", false, "Do not auto-open browser on startup")
	lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale")
	console := flag.Bool("console", false, "Console mode, no GUI")

	var debug bool
	flag.BoolVar(&debug, "d", false, "Enable debug logging")
	flag.BoolVar(&debug, "debug", false, "Enable debug logging")

	flag.Usage = func() {
		fmt.Fprintf(os.Stderr, "%s Launcher - Web console and gateway manager\n\n", appName)
		fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
		fmt.Fprintf(os.Stderr, "Arguments:\n")
		fmt.Fprintf(os.Stderr, "  config.json    Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
		fmt.Fprintf(os.Stderr, "Options:\n")
		flag.PrintDefaults()
		fmt.Fprintf(os.Stderr, "\nExamples:\n")
		fmt.Fprintf(os.Stderr, "  %s\n", os.Args[0])
		fmt.Fprintf(os.Stderr, "      Use default config path in GUI mode\n")
		fmt.Fprintf(os.Stderr, "  %s ./config.json\n", os.Args[0])
		fmt.Fprintf(os.Stderr, "      Specify a config file\n")
		fmt.Fprintf(
			os.Stderr,
			"  %s -public ./config.json\n",
			os.Args[0],
		)
		fmt.Fprintf(os.Stderr, "      Allow access from other devices on the local network\n")
		fmt.Fprintf(os.Stderr, "  %s -host :: ./config.json\n", os.Args[0])
		fmt.Fprintf(os.Stderr, "      Bind launcher host explicitly with exact host semantics\n")
		fmt.Fprintf(os.Stderr, "  %s -console -d ./config.json\n", os.Args[0])
		fmt.Fprintf(os.Stderr, "      Run in the terminal with debug logs enabled\n")
	}
	flag.Parse()

	// Initialize logger
	picoHome := utils.GetPicoclawHome()

	f := filepath.Join(picoHome, logPath, panicFile)
	panicFunc, err := logger.InitPanic(f)
	if err != nil {
		panic(fmt.Sprintf("error initializing panic log: %v", err))
	}
	defer panicFunc()

	enableConsole := *console
	fileLoggingEnabled := shouldEnableLauncherFileLogging(enableConsole, debug)
	if fileLoggingEnabled {
		// GUI mode writes launcher logs to file. Debug mode keeps file logging enabled in console mode too.
		if !debug {
			logger.DisableConsole()
		}

		f := filepath.Join(picoHome, logPath, logFile)
		if err = logger.EnableFileLogging(f); err != nil {
			panic(fmt.Sprintf("error enabling file logging: %v", err))
		}
		defer logger.DisableFileLogging()
	}
	if debug {
		logger.SetLevel(logger.DEBUG)
	}

	// Set language from command line or auto-detect
	if *lang != "" {
		SetLanguage(*lang)
	}

	// Resolve config path
	configPath := utils.GetDefaultConfigPath()
	if flag.NArg() > 0 {
		configPath = flag.Arg(0)
	}

	absPath, err := filepath.Abs(configPath)
	if err != nil {
		logger.Fatalf("Failed to resolve config path: %v", err)
	}
	err = utils.EnsureOnboarded(absPath)
	if err != nil {
		logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err)
	}
	if !debug {
		logger.SetLevelFromString(config.ResolveGatewayLogLevel(absPath))
	}

	logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion))
	logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome))
	if debug {
		logger.InfoC("web", "Debug mode enabled")
		logger.DebugC(
			"web",
			fmt.Sprintf(
				"Launcher flags: console=%t host=%q public=%t no_browser=%t config=%s",
				enableConsole,
				*host,
				*public,
				*noBrowser,
				absPath,
			),
		)
	}

	var explicitPort bool
	var explicitPublic bool
	var explicitHost bool
	flag.Visit(func(f *flag.Flag) {
		switch f.Name {
		case "port":
			explicitPort = true
		case "host":
			explicitHost = true
		case "public":
			explicitPublic = true
		}
	})

	launcherPath := launcherconfig.PathForAppConfig(absPath)
	launcherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default())
	if err != nil {
		logger.ErrorC("web", fmt.Sprintf("Warning: Failed to load %s: %v", launcherPath, err))
		launcherCfg = launcherconfig.Default()
	}

	effectivePort := *port
	effectivePublic := *public
	if !explicitPort {
		effectivePort = strconv.Itoa(launcherCfg.Port)
	}
	if !explicitPublic {
		effectivePublic = launcherCfg.Public
	}
	envHost := strings.TrimSpace(os.Getenv(launcherconfig.EnvLauncherHost))

	hostInput, hostOverrideActive, err := resolveLauncherHostInput(*host, explicitHost, envHost)
	if err != nil {
		logger.Fatalf("Invalid host %q: %v", firstNonEmpty(strings.TrimSpace(*host), envHost), err)
	}
	if hostOverrideActive {
		effectivePublic = false
	}

	if !explicitHost && hostOverrideActive {
		logger.InfoC("web", "Using launcher host from environment PICOCLAW_LAUNCHER_HOST")
	}

	if hostOverrideActive && explicitPublic {
		logger.InfoC("web", "Ignoring -public because launcher host was explicitly set")
	}

	portNum, err := strconv.Atoi(effectivePort)
	if err != nil || portNum < 1 || portNum > 65535 {
		if err == nil {
			err = errors.New("must be in range 1-65535")
		}
		logger.Fatalf("Invalid port %q: %v", effectivePort, err)
	}

	openResult, err := openLauncherListeners(hostInput, effectivePublic, effectivePort)
	if err != nil {
		logger.Fatalf("Failed to open launcher listener(s): %v", err)
	}
	listeners := openResult.Listeners

	dashboardSessionCookie, dashErr := middleware.NewLauncherDashboardSessionCookie()
	if dashErr != nil {
		logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
	}

	// Open the bcrypt password store (creates the DB file on first run).
	authStore, authStoreErr := dashboardauth.New(picoHome)
	var passwordStore api.PasswordStore
	if authStoreErr == nil {
		passwordStore = authStore
		defer authStore.Close()
	} else if errors.Is(authStoreErr, dashboardauth.ErrUnsupportedPlatform) {
		logger.InfoC(
			"web",
			fmt.Sprintf(
				"Dashboard SQLite password store unavailable on this platform; using launcher-config password storage: %v",
				authStoreErr,
			),
		)
		passwordStore = launcherconfig.NewPasswordStore(launcherPath, launcherCfg)
		authStoreErr = nil
	} else {
		logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
	}

	migrationResult, migrationErr := launcherconfig.MigrateLegacyLauncherToken(
		context.Background(),
		passwordStore,
		launcherPath,
		launcherCfg,
	)
	if migrationErr != nil {
		logger.Fatalf("Failed to migrate legacy launcher token to password login: %v", migrationErr)
	}
	if migrationResult.Migrated {
		logger.InfoC("web", "Migrated legacy launcher token to dashboard password login")
	}
	if migrationResult.CleanupErr != nil {
		logger.WarnC(
			"web",
			fmt.Sprintf(
				"Legacy launcher token password migration succeeded, but failed to remove launcher_token from %s: %v",
				launcherPath,
				migrationResult.CleanupErr,
			),
		)
	}

	var localAutoLogin *middleware.LauncherDashboardLocalAutoLogin
	needsInitialSetup := false
	if passwordStore != nil {
		initialized, initErr := passwordStore.IsInitialized(context.Background())
		if initErr != nil {
			logger.ErrorC("web", fmt.Sprintf("Warning: could not check dashboard password state: %v", initErr))
		} else if !initialized {
			needsInitialSetup = true
		} else if shouldEnableLocalAutoLogin(*noBrowser, openResult.ProbeHost) {
			localAutoLogin, err = middleware.NewLauncherDashboardLocalAutoLogin(5 * time.Minute)
			if err != nil {
				logger.Fatalf("Failed to create local auto-login grant: %v", err)
			}
		}
	}

	// Initialize Server components
	mux := http.NewServeMux()

	api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
		SessionCookie: dashboardSessionCookie,
		PasswordStore: passwordStore,
		StoreError:    authStoreErr,
	})

	// API Routes (e.g. /api/status)
	apiHandler = api.NewHandler(absPath)
	apiHandler.SetDebug(debug)
	if _, err = apiHandler.EnsurePicoChannel(); err != nil {
		logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
	}
	apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
	apiHandler.SetServerBindHost(hostInput, hostOverrideActive)
	apiHandler.RegisterRoutes(mux)

	// Frontend Embedded Assets
	registerEmbedRoutes(mux)

	accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)
	if err != nil {
		logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
	}

	dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{
		ExpectedCookie: dashboardSessionCookie,
		LocalAutoLogin: localAutoLogin,
	}, accessControlledMux)

	// Apply middleware stack
	handler := middleware.Recoverer(
		middleware.Logger(
			middleware.ReferrerPolicyNoReferrer(
				middleware.JSONContentType(dashAuth),
			),
		),
	)

	// Print startup banner (console mode only).
	if enableConsole || debug {
		consoleHosts := launcherConsoleHosts(hostInput, effectivePublic)

		fmt.Print(utils.Banner)
		fmt.Println()
		if needsInitialSetup {
			if *noBrowser {
				fmt.Println("  First-time setup: open /launcher-setup to create the dashboard password.")
			} else {
				fmt.Println("  Launcher will open /launcher-setup automatically.")
			}
			fmt.Println()
		}
		fmt.Println("  Dashboard address:")
		fmt.Println()
		for _, host := range consoleHosts {
			fmt.Printf("    >> http://%s <<\n", net.JoinHostPort(host, effectivePort))
		}
		fmt.Println()
	}

	// Log startup info to file
	for _, ln := range listeners {
		logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", ln.Addr().String()))
	}
	if hasWildcardBindHosts(openResult.BindHosts) {
		if ip := advertiseIPForWildcardBindHosts(openResult.BindHosts); ip != "" {
			logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort)))
		}
	}

	// Share the local URL with the launcher runtime.
	serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort))
	browserLaunchURL = serverAddr + launcherBrowserLaunchSuffix(needsInitialSetup, localAutoLogin)

	// Auto-open browser will be handled by the launcher runtime.

	// Auto-start gateway after backend starts listening.
	go func() {
		time.Sleep(1 * time.Second)
		apiHandler.TryAutoStartGateway()
	}()

	// Start the server(s) in goroutines.
	servers = make([]*http.Server, 0, len(listeners))
	for _, ln := range listeners {
		srv := &http.Server{Handler: handler}
		servers = append(servers, srv)

		go func(s *http.Server, l net.Listener) {
			logger.InfoC("web", fmt.Sprintf("Server listening on %s", l.Addr().String()))
			if serveErr := s.Serve(l); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
				logger.Fatalf("Server failed to start on %s: %v", l.Addr().String(), serveErr)
			}
		}(srv, ln)
	}

	defer shutdownApp()

	// Start system tray or run in console mode
	if enableConsole {
		if !*noBrowser {
			// Auto-open browser after systray is ready (if not disabled)
			// Check no-browser flag via environment or pass as parameter if needed
			if err := openBrowser(); err != nil {
				logger.Errorf("Warning: Failed to auto-open browser: %v", err)
			}
		}

		sigChan := make(chan os.Signal, 1)
		signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

		// Main event loop - wait for signals or config changes
		for {
			select {
			case <-sigChan:
				logger.Info("Shutting down...")

				return
			}
		}
	} else {
		// GUI mode: start system tray
		runTray()
	}
}
