package main

import (
	"context"
	"embed"
	"flag"
	"fmt"
	"io/fs"
	"log/slog"
	"os"
	"os/signal"
	"os/user"
	"path/filepath"
	"syscall"

	"github.com/jcmturner/gokrb5/v8/keytab"
	"github.com/uopi/uopi/internal/access"
	"github.com/uopi/uopi/internal/audit"
	"github.com/uopi/uopi/internal/broker"
	"github.com/uopi/uopi/internal/config"
	"github.com/uopi/uopi/internal/confmgr"
	"github.com/uopi/uopi/internal/controllogic"
	"github.com/uopi/uopi/internal/datasource/epics"
	"github.com/uopi/uopi/internal/datasource/modbus"
	"github.com/uopi/uopi/internal/datasource/pva"
	"github.com/uopi/uopi/internal/datasource/scpi"
	"github.com/uopi/uopi/internal/datasource/servervar"
	"github.com/uopi/uopi/internal/datasource/stub"
	"github.com/uopi/uopi/internal/datasource/synthetic"
	"github.com/uopi/uopi/internal/ldapauth"
	"github.com/uopi/uopi/internal/pamauth"
	"github.com/uopi/uopi/internal/panelacl"
	"github.com/uopi/uopi/internal/server"
	"github.com/uopi/uopi/internal/storage"
	"github.com/uopi/uopi/web"
)

// The web package's embed.go embeds web/dist at build time.
// This blank import ensures the compiler includes the package.
var _ embed.FS

func main() {
	configPath := flag.String("config", "", "path to TOML config file (optional)")
	flag.Parse()

	log := slog.New(slog.NewTextHandler(os.Stdout, nil))

	cfg, err := config.Load(*configPath)
	if err != nil {
		log.Error("failed to load config", "err", err)
		os.Exit(1)
	}

	// In an unproxied/local deployment (no trusted_user_header) without an explicit
	// default_user, attribute actions to the OS user running the process so the UI
	// and audit log show a real identity instead of anonymous.
	if cfg.Server.TrustedUserHeader == "" && cfg.Server.DefaultUser == "" {
		if u, err := user.Current(); err == nil && u.Username != "" {
			cfg.Server.DefaultUser = u.Username
			log.Info("no default_user configured; defaulting to OS user", "user", u.Username)
		}
	}

	if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil {
		log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err)
		os.Exit(1)
	}

	store, err := storage.New(cfg.Server.StorageDir)
	if err != nil {
		log.Error("failed to open interface store", "err", err)
		os.Exit(1)
	}

	aclStore, err := panelacl.New(cfg.Server.StorageDir)
	if err != nil {
		log.Error("failed to open panel ACL store", "err", err)
		os.Exit(1)
	}

	cfgStore, err := confmgr.New(cfg.Server.StorageDir)
	if err != nil {
		log.Error("failed to open configuration store", "err", err)
		os.Exit(1)
	}

	webFS, err := fs.Sub(web.FS, "dist")
	if err != nil {
		log.Error("failed to sub web dist", "err", err)
		os.Exit(1)
	}

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

	brk := broker.New(ctx, log, broker.WithMaxUpdateRate(cfg.Server.MaxUpdateRateHz))

	// Stub data source: built-in simulated signals (sine, ramp, noise, setpoint).
	// Enabled by default; disable via [datasource.stub] enabled = false in config.
	if cfg.Datasource.Stub.Enabled {
		stubDS := stub.New()
		if err := stubDS.Connect(ctx); err != nil {
			log.Error("stub connect", "err", err)
			os.Exit(1)
		}
		brk.Register(stubDS)
	}

	// Synthetic data source: computes derived signals from upstream sources via
	// configurable DSP pipelines defined in synthetic.json inside the storage dir.
	var synthDS *synthetic.Synthetic
	if cfg.Datasource.Synthetic.Enabled {
		synthDS = synthetic.New(cfg.Server.StorageDir, brk, log)
		if err := synthDS.Connect(ctx); err != nil {
			log.Warn("synthetic connect failed — continuing without synthetic signals", "err", err)
			synthDS = nil
		} else {
			brk.Register(synthDS)
		}
	}

	// EPICS Channel Access data source.
	// The pure-Go implementation is used by default (no CGo required).
	// Build with -tags epics to use the CGo-based libca implementation instead.
	if cfg.Datasource.EPICS.Enabled && epics.Available() {
		ds := epics.New(
			cfg.Datasource.EPICS.CAAddrList,
			cfg.Datasource.EPICS.ArchiveURL,
			cfg.Datasource.EPICS.ChannelFinderURL,
			cfg.Datasource.EPICS.AutoSyncFilter,
			cfg.Datasource.EPICS.AutoSyncFromArchiver,
			cfg.Datasource.EPICS.PVNames,
		)
		if err := ds.Connect(ctx); err != nil {
			log.Error("epics connect", "err", err)
		} else {
			brk.Register(ds)
		}
	}

	// PV Access data source.
	if cfg.Datasource.PVA.Enabled {
		pvaDS := pva.New(cfg.Datasource.PVA.AddrList)
		if err := pvaDS.Connect(ctx); err != nil {
			log.Error("pva connect", "err", err)
		} else {
			brk.Register(pvaDS)
		}
	}

	// Modbus TCP data source: polls configured holding/input/coil/discrete
	// registers on one or more devices. Disabled unless [datasource.modbus] is
	// configured with devices.
	if cfg.Datasource.Modbus.Enabled {
		mbDS, err := modbus.New(cfg.Datasource.Modbus)
		if err != nil {
			log.Error("modbus init", "err", err)
		} else if err := mbDS.Connect(ctx); err != nil {
			log.Error("modbus connect", "err", err)
		} else {
			brk.Register(mbDS)
			context.AfterFunc(ctx, mbDS.Close)
		}
	}

	// SCPI data source: polls instrument channels over raw TCP sockets.
	// Disabled unless [datasource.scpi] is configured with instruments.
	if cfg.Datasource.SCPI.Enabled {
		scpiDS, err := scpi.New(cfg.Datasource.SCPI)
		if err != nil {
			log.Error("scpi init", "err", err)
		} else if err := scpiDS.Connect(ctx); err != nil {
			log.Error("scpi connect", "err", err)
		} else {
			brk.Register(scpiDS)
			context.AfterFunc(ctx, scpiDS.Close)
		}
	}

	// Server variables: a small persistent key/value source ("srv") that the
	// control-logic engine writes (e.g. sequence state) and panels can read.
	srvVars, err := servervar.New(cfg.Server.StorageDir)
	if err != nil {
		log.Error("server variables init", "err", err)
		os.Exit(1)
	}
	brk.Register(srvVars)

	// Build the role-based access policy from config: each [[groups]] entry lists
	// members by role (viewer/operator/logiceditor/auditor/admin) and an optional
	// parent for nesting. Every user is an implicit viewer of the built-in public
	// group; a config with no roles at all is fully open (everyone admin).
	specs := make([]access.GroupSpec, 0, len(cfg.Groups))
	for _, g := range cfg.Groups {
		members := make(map[string]access.Role)
		assign := func(users []string, role access.Role) {
			for _, u := range users {
				members[u] = role
			}
		}
		assign(g.Viewers, access.RoleViewer)
		assign(g.Operators, access.RoleOperator)
		assign(g.LogicEditors, access.RoleLogic)
		assign(g.Auditors, access.RoleAuditor)
		assign(g.Admins, access.RoleAdmin)
		specs = append(specs, access.GroupSpec{Name: g.Name, Parent: g.Parent, Members: members})
	}
	policy := access.New(cfg.Server.DefaultUser, specs)
	// Once enabled, runtime admin-pane changes persist to {storage_dir}/access.json,
	// which (when present on a later startup) supersedes the TOML access config.
	if err := policy.EnablePersistence(cfg.Server.StorageDir); err != nil {
		log.Error("failed to load access store", "err", err)
		os.Exit(1)
	}

	// Audit log: when enabled, every system-affecting action (user/automated
	// signal writes, interface and control-logic mutations) is recorded to SQLite.
	// When disabled a no-op recorder is used so call sites need no guards.
	recorder := audit.Nop()
	if cfg.Audit.Enabled {
		dbPath := cfg.Audit.DBPath
		if dbPath == "" {
			dbPath = filepath.Join(cfg.Server.StorageDir, "audit.db")
		}
		r, err := audit.NewSQLite(dbPath, log)
		if err != nil {
			log.Error("failed to open audit log", "path", dbPath, "err", err)
			os.Exit(1)
		}
		defer r.Close()
		recorder = r
		log.Info("audit log enabled", "path", dbPath)
	}

	// Server-side control logic: flow graphs that run continuously under the root
	// context, independent of any panel. The store is loaded from disk and the
	// engine starts all enabled graphs.
	ctrlStore, err := controllogic.NewStore(cfg.Server.StorageDir)
	if err != nil {
		log.Error("failed to open control logic store", "err", err)
		os.Exit(1)
	}
	ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, cfgStore, recorder, log)

	// Dialog hub: control-logic action.dialog nodes push notifications/inputs to
	// connected clients (filtered by user/group) and route input responses back
	// to server variables. Installed before Reload so running graphs can emit.
	dialogs := server.NewDialogHub(brk, policy, recorder, log)
	ctrlEngine.SetNotifier(dialogs)

	// Debug hub: control-logic editors observe live graphs or dry-run unsaved
	// edits; the engine pushes per-node execution events through it.
	debugHub := server.NewDebugHub(ctrlEngine, log)
	ctrlEngine.SetDebugObserver(debugHub)
	ctrlEngine.Reload()

	// Native SPNEGO/Kerberos authentication (optional): load the service keytab so
	// the server can validate browser Negotiate tickets and resolve users directly.
	var krbKeytab *keytab.Keytab
	if cfg.Server.Kerberos.Enabled {
		if cfg.Server.Kerberos.Keytab == "" {
			log.Error("kerberos enabled but no keytab configured (server.kerberos.keytab)")
			os.Exit(1)
		}
		kt, err := keytab.Load(cfg.Server.Kerberos.Keytab)
		if err != nil {
			log.Error("failed to load kerberos keytab", "path", cfg.Server.Kerberos.Keytab, "err", err)
			os.Exit(1)
		}
		krbKeytab = kt
		log.Info("native SPNEGO/Kerberos authentication enabled",
			"keytab", cfg.Server.Kerberos.Keytab, "service_principal", cfg.Server.Kerberos.ServicePrincipal)
	}

	// Built-in HTTP Basic authentication (optional): challenge the browser with a
	// login dialog and validate credentials against either the host PAM stack
	// (basic_auth) or an LDAP directory (ldap), so users log in with their normal
	// accounts. The three built-in auth methods (kerberos, basic_auth, ldap) are
	// mutually exclusive.
	var basicAuthFn func(user, pass string) error
	var basicAuthRealm string
	enabledAuth := 0
	for _, on := range []bool{cfg.Server.Kerberos.Enabled, cfg.Server.BasicAuth.Enabled, cfg.Server.LDAP.Enabled} {
		if on {
			enabledAuth++
		}
	}
	if enabledAuth > 1 {
		log.Error("server.kerberos, server.basic_auth and server.ldap are mutually exclusive; enable only one")
		os.Exit(1)
	}
	if cfg.Server.BasicAuth.Enabled {
		if !pamauth.Available {
			log.Error("basic_auth enabled but this binary lacks PAM support; rebuild with: make backend-pam (or use server.ldap, which needs no cgo)")
			os.Exit(1)
		}
		service := cfg.Server.BasicAuth.PAMService
		if service == "" {
			service = "uopi"
		}
		basicAuthFn = func(user, pass string) error {
			return pamauth.Authenticate(service, user, pass)
		}
		basicAuthRealm = "uopi"
		log.Info("built-in HTTP Basic authentication enabled (PAM)", "pam_service", service)
	}
	if cfg.Server.LDAP.Enabled {
		ldapAuth, err := ldapauth.New(ldapauth.Config{
			URIs:               cfg.Server.LDAP.URIs,
			SearchBase:         cfg.Server.LDAP.SearchBase,
			UserAttr:           cfg.Server.LDAP.UserAttr,
			UserObjectClass:    cfg.Server.LDAP.UserObjectClass,
			BindDN:             cfg.Server.LDAP.BindDN,
			BindPassword:       cfg.Server.LDAP.BindPassword,
			StartTLS:           cfg.Server.LDAP.StartTLS,
			CACertFile:         cfg.Server.LDAP.CACert,
			InsecureSkipVerify: cfg.Server.LDAP.InsecureSkipVerify,
		})
		if err != nil {
			log.Error("ldap auth misconfigured", "err", err)
			os.Exit(1)
		}
		basicAuthFn = ldapAuth.Authenticate
		basicAuthRealm = "uopi"
		log.Info("built-in HTTP Basic authentication enabled (LDAP)", "uri", cfg.Server.LDAP.URIs, "search_base", cfg.Server.LDAP.SearchBase)
	}
	if basicAuthFn != nil && !cfg.Server.TLS.Enabled {
		log.Warn("HTTP Basic authentication enabled without TLS; credentials are sent in clear text — enable server.tls unless on a fully isolated network")
	}

	// Built-in TLS (optional): terminate HTTPS directly, recommended whenever
	// Basic auth is enabled.
	var tlsCert, tlsKey string
	if cfg.Server.TLS.Enabled {
		if cfg.Server.TLS.Cert == "" || cfg.Server.TLS.Key == "" {
			log.Error("server.tls enabled but cert/key not configured (server.tls.cert, server.tls.key)")
			os.Exit(1)
		}
		tlsCert, tlsKey = cfg.Server.TLS.Cert, cfg.Server.TLS.Key
		log.Info("built-in TLS enabled", "cert", tlsCert)
	}

	srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, krbKeytab, cfg.Server.Kerberos.ServicePrincipal, basicAuthFn, basicAuthRealm, tlsCert, tlsKey, cfg.Server.TLS.RedirectFrom, cfg.UI.DefaultZoom, log)

	if err := srv.Start(ctx); err != nil {
		fmt.Fprintf(os.Stderr, "server error: %v\n", err)
		os.Exit(1)
	}
}
