package main

import (
	"bytes"
	"context"
	"database/sql"
	"embed"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io/fs"
	"log"
	"math"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"reflect"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"syscall"
	"time"

	mqtt "github.com/eclipse/paho.mqtt.golang"
	"github.com/fsnotify/fsnotify"
	_ "github.com/mattn/go-sqlite3"
	"github.com/robinson/gos7"
	"gopkg.in/yaml.v3"
)

//go:embed static
var staticFiles embed.FS

const version = "0.9.3"

// ---------------------------------------------------------------------------
// Config structs
// ---------------------------------------------------------------------------

type Config struct {
	Server     ServerConfig     `yaml:"server"`
	PLC        PLCConfig        `yaml:"plc"`
	Thresholds ThresholdsConfig `yaml:"thresholds"`
	Trend      TrendConfig      `yaml:"trend"`
	Press      PressConfig      `yaml:"press"`
	UI         UIConfig         `yaml:"ui"`
	Modules    ModulesConfig    `yaml:"modules"`
	DB         DBConfig         `yaml:"db"`
	MQTT       MQTTConfig       `yaml:"mqtt"`
}

type ServerConfig struct {
	ListenAddr string `yaml:"listen_addr"`
}

type PLCConfig struct {
	IP                string `yaml:"ip"`
	DBNum             int    `yaml:"db_num"`
	Rack              int    `yaml:"rack"`
	Slot              int    `yaml:"slot"`
	PollMs            int    `yaml:"poll_ms"`
	ConnectTimeoutSec int    `yaml:"connect_timeout_sec"`
	IdleTimeoutSec    int    `yaml:"idle_timeout_sec"`
	ReconnectDelaySec int    `yaml:"reconnect_delay_sec"`
}

type ThresholdsConfig struct {
	WarningPercent           float64 `yaml:"warning_percent"`
	CriticalPercent          float64 `yaml:"critical_percent"`
	GaugeMaxPercent          float64 `yaml:"gauge_max_percent"`
	ImbalanceWarningPercent  float64 `yaml:"imbalance_warning_percent"`
	ImbalanceCriticalPercent float64 `yaml:"imbalance_critical_percent"`

	LegacyWarningKn  float64 `yaml:"warning_kn,omitempty"`
	LegacyCriticalKn float64 `yaml:"critical_kn,omitempty"`
	LegacyMaxKn      float64 `yaml:"max_kn,omitempty"`
}

type TrendConfig struct {
	Minutes int `yaml:"minutes"`
}

type PressConfig struct {
	MaxTonnage       float64 `yaml:"MAX_TONNAGE"`
	LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"`
}

type UIConfig struct {
	Title      string `yaml:"title"`
	Subtitle   string `yaml:"subtitle"`
	LeftLabel  string `yaml:"left_label"`
	RightLabel string `yaml:"right_label"`
	UnitForce  string `yaml:"unit_force"`
	UnitPct    string `yaml:"unit_percent"`
}

type ModulesConfig struct {
	ShowHeaderControls *bool `yaml:"show_header_controls,omitempty"`
	ShowVerdict        *bool `yaml:"show_verdict,omitempty"`
	ShowSummaryBar     *bool `yaml:"show_summary_bar,omitempty"`
	ShowOverview       *bool `yaml:"show_overview,omitempty"`
	ShowIntelligence   *bool `yaml:"show_intelligence,omitempty"`
	ShowAlarmTimeline  *bool `yaml:"show_alarm_timeline,omitempty"`
	ShowGauges         *bool `yaml:"show_gauges,omitempty"`
	ShowGaugeDigital   *bool `yaml:"show_gauge_digital,omitempty"`
	ShowTrendChart     *bool `yaml:"show_trend_chart,omitempty"`
}

type DBConfig struct {
	Path              string `yaml:"path"`
	BusyTimeoutMs     int    `yaml:"busy_timeout_ms"`
	BatchSize         int    `yaml:"batch_size"`
	FlushIntervalMs   int    `yaml:"flush_interval_ms"`
	RetentionDays     int    `yaml:"retention_days"`
	MaxChartPoints    int    `yaml:"max_chart_points"`
	WriterQueueSize   int    `yaml:"writer_queue_size"`
	AlarmQueueSize    int    `yaml:"alarm_queue_size"`
	CheckpointPages   int    `yaml:"checkpoint_pages"`
	CleanupIntervalHr int    `yaml:"cleanup_interval_hours"`
}

// MQTTConfig holds all MQTT broker and publishing settings.
// Requires restart when changed — not hot-reloadable.
type MQTTConfig struct {
	Enabled           bool   `yaml:"enabled"`
	Broker            string `yaml:"broker"`              // e.g. "tcp://192.168.1.10:1883"
	ClientID          string `yaml:"client_id"`           // unique client identifier
	Username          string `yaml:"username"`            // leave blank if no auth
	Password          string `yaml:"password"`            // leave blank if no auth
	TopicPrefix       string `yaml:"topic_prefix"`        // e.g. "plant1/press3"
	QoS               int    `yaml:"qos"`                 // 0, 1, or 2
	Retain            bool   `yaml:"retain"`              // retain auto-published state msgs
	AutoPublish       bool   `yaml:"auto_publish"`        // publish PLC state on timer
	PublishIntervalMs int    `yaml:"publish_interval_ms"` // how often to auto-publish
	ConnectTimeoutSec int    `yaml:"connect_timeout_sec"`
	ReconnectDelaySec int    `yaml:"reconnect_delay_sec"`
}

// ---------------------------------------------------------------------------
// Config helpers
// ---------------------------------------------------------------------------

func boolPtr(v bool) *bool { return &v }

func boolValue(v *bool, def bool) bool {
	if v == nil {
		return def
	}
	return *v
}

func defaultConfig() Config {
	return Config{
		Server: ServerConfig{ListenAddr: ":8080"},
		PLC: PLCConfig{
			IP:                "192.168.0.1",
			DBNum:             1001,
			Rack:              0,
			Slot:              1,
			PollMs:            500,
			ConnectTimeoutSec: 5,
			IdleTimeoutSec:    5,
			ReconnectDelaySec: 5,
		},
		Thresholds: ThresholdsConfig{
			WarningPercent:           80,
			CriticalPercent:          95,
			GaugeMaxPercent:          130,
			ImbalanceWarningPercent:  10,
			ImbalanceCriticalPercent: 20,
		},
		Trend: TrendConfig{Minutes: 5},
		Press: PressConfig{MaxTonnage: 64},
		UI: UIConfig{
			Title:      "Force Monitor",
			Subtitle:   "Siemens S7-1215C • Piezo peak/stroke input • PLC values in % • kN calculated from MAX_TONNAGE",
			LeftLabel:  "LEVI STEBER",
			RightLabel: "DESNI STEBER",
			UnitForce:  "kN",
			UnitPct:    "%",
		},
		Modules: ModulesConfig{
			ShowHeaderControls: boolPtr(true),
			ShowVerdict:        boolPtr(true),
			ShowSummaryBar:     boolPtr(true),
			ShowOverview:       boolPtr(true),
			ShowIntelligence:   boolPtr(true),
			ShowAlarmTimeline:  boolPtr(true),
			ShowGauges:         boolPtr(true),
			ShowGaugeDigital:   boolPtr(false),
			ShowTrendChart:     boolPtr(true),
		},
		DB: DBConfig{
			Path:              "force_monitor.db",
			BusyTimeoutMs:     5000,
			BatchSize:         32,
			FlushIntervalMs:   1000,
			RetentionDays:     30,
			MaxChartPoints:    2000,
			WriterQueueSize:   4096,
			AlarmQueueSize:    512,
			CheckpointPages:   1000,
			CleanupIntervalHr: 6,
		},
		MQTT: MQTTConfig{
			Enabled:           false,
			Broker:            "tcp://localhost:1883",
			ClientID:          "force_monitor",
			TopicPrefix:       "force_monitor",
			QoS:               1,
			Retain:            false,
			AutoPublish:       true,
			PublishIntervalMs: 1000,
			ConnectTimeoutSec: 10,
			ReconnectDelaySec: 5,
		},
	}
}

func setIfZeroF(dst *float64, def float64) {
	if *dst <= 0 {
		*dst = def
	}
}

// setIfZeroI replaces *dst with def when *dst <= 0.
// Note: this means config values of 0 are treated as "not set".
// For PLC Rack (valid at 0), the default is also 0, so this is a safe no-op.
// For PLC Slot, a value of 0 would be overwritten with default 1.
func setIfZeroI(dst *int, def int) {
	if *dst <= 0 {
		*dst = def
	}
}

func setIfEmpty(dst *string, def string) {
	if strings.TrimSpace(*dst) == "" {
		*dst = def
	}
}

func setIfNilBool(dst **bool, def bool) {
	if *dst == nil {
		v := def
		*dst = &v
	}
}

func normalizeConfig(cfg *Config) {
	def := defaultConfig()

	setIfEmpty(&cfg.Server.ListenAddr, def.Server.ListenAddr)

	setIfEmpty(&cfg.PLC.IP, def.PLC.IP)
	setIfZeroI(&cfg.PLC.DBNum, def.PLC.DBNum)
	// Rack: default is 0 so setIfZeroI is a no-op; kept for symmetry.
	setIfZeroI(&cfg.PLC.Rack, def.PLC.Rack)
	setIfZeroI(&cfg.PLC.Slot, def.PLC.Slot)
	setIfZeroI(&cfg.PLC.PollMs, def.PLC.PollMs)
	setIfZeroI(&cfg.PLC.ConnectTimeoutSec, def.PLC.ConnectTimeoutSec)
	setIfZeroI(&cfg.PLC.IdleTimeoutSec, def.PLC.IdleTimeoutSec)
	setIfZeroI(&cfg.PLC.ReconnectDelaySec, def.PLC.ReconnectDelaySec)

	// Migrate legacy kN fields to percent fields if new fields are absent.
	if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 {
		cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn
	}
	if cfg.Thresholds.CriticalPercent <= 0 && cfg.Thresholds.LegacyCriticalKn > 0 {
		cfg.Thresholds.CriticalPercent = cfg.Thresholds.LegacyCriticalKn
	}
	if cfg.Thresholds.GaugeMaxPercent <= 0 && cfg.Thresholds.LegacyMaxKn > 0 {
		cfg.Thresholds.GaugeMaxPercent = cfg.Thresholds.LegacyMaxKn
	}

	setIfZeroF(&cfg.Thresholds.WarningPercent, def.Thresholds.WarningPercent)
	setIfZeroF(&cfg.Thresholds.CriticalPercent, def.Thresholds.CriticalPercent)
	setIfZeroF(&cfg.Thresholds.GaugeMaxPercent, def.Thresholds.GaugeMaxPercent)
	setIfZeroF(&cfg.Thresholds.ImbalanceWarningPercent, def.Thresholds.ImbalanceWarningPercent)
	setIfZeroF(&cfg.Thresholds.ImbalanceCriticalPercent, def.Thresholds.ImbalanceCriticalPercent)

	if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent {
		cfg.Thresholds.ImbalanceCriticalPercent = cfg.Thresholds.ImbalanceWarningPercent
	}

	setIfZeroI(&cfg.Trend.Minutes, def.Trend.Minutes)

	if cfg.Press.MaxTonnage <= 0 && cfg.Press.LegacyMaxTonnage > 0 {
		cfg.Press.MaxTonnage = cfg.Press.LegacyMaxTonnage
	}
	setIfZeroF(&cfg.Press.MaxTonnage, def.Press.MaxTonnage)

	setIfEmpty(&cfg.UI.Title, def.UI.Title)
	setIfEmpty(&cfg.UI.Subtitle, def.UI.Subtitle)
	setIfEmpty(&cfg.UI.LeftLabel, def.UI.LeftLabel)
	setIfEmpty(&cfg.UI.RightLabel, def.UI.RightLabel)
	setIfEmpty(&cfg.UI.UnitForce, def.UI.UnitForce)
	setIfEmpty(&cfg.UI.UnitPct, def.UI.UnitPct)

	setIfNilBool(&cfg.Modules.ShowHeaderControls, boolValue(def.Modules.ShowHeaderControls, true))
	setIfNilBool(&cfg.Modules.ShowVerdict, boolValue(def.Modules.ShowVerdict, true))
	setIfNilBool(&cfg.Modules.ShowSummaryBar, boolValue(def.Modules.ShowSummaryBar, true))
	setIfNilBool(&cfg.Modules.ShowOverview, boolValue(def.Modules.ShowOverview, true))
	setIfNilBool(&cfg.Modules.ShowIntelligence, boolValue(def.Modules.ShowIntelligence, true))
	setIfNilBool(&cfg.Modules.ShowAlarmTimeline, boolValue(def.Modules.ShowAlarmTimeline, true))
	setIfNilBool(&cfg.Modules.ShowGauges, boolValue(def.Modules.ShowGauges, true))
	setIfNilBool(&cfg.Modules.ShowGaugeDigital, boolValue(def.Modules.ShowGaugeDigital, false))
	setIfNilBool(&cfg.Modules.ShowTrendChart, boolValue(def.Modules.ShowTrendChart, true))

	setIfEmpty(&cfg.DB.Path, def.DB.Path)
	setIfZeroI(&cfg.DB.BusyTimeoutMs, def.DB.BusyTimeoutMs)
	setIfZeroI(&cfg.DB.BatchSize, def.DB.BatchSize)
	setIfZeroI(&cfg.DB.FlushIntervalMs, def.DB.FlushIntervalMs)
	setIfZeroI(&cfg.DB.RetentionDays, def.DB.RetentionDays)
	setIfZeroI(&cfg.DB.MaxChartPoints, def.DB.MaxChartPoints)
	setIfZeroI(&cfg.DB.WriterQueueSize, def.DB.WriterQueueSize)
	setIfZeroI(&cfg.DB.AlarmQueueSize, def.DB.AlarmQueueSize)
	setIfZeroI(&cfg.DB.CheckpointPages, def.DB.CheckpointPages)
	setIfZeroI(&cfg.DB.CleanupIntervalHr, def.DB.CleanupIntervalHr)

	// MQTT normalization (only when enabled to avoid noisy defaults)
	if cfg.MQTT.Enabled {
		setIfEmpty(&cfg.MQTT.Broker, def.MQTT.Broker)
		setIfEmpty(&cfg.MQTT.ClientID, def.MQTT.ClientID)
		setIfEmpty(&cfg.MQTT.TopicPrefix, def.MQTT.TopicPrefix)
		if cfg.MQTT.QoS < 0 || cfg.MQTT.QoS > 2 {
			cfg.MQTT.QoS = def.MQTT.QoS
		}
		setIfZeroI(&cfg.MQTT.PublishIntervalMs, def.MQTT.PublishIntervalMs)
		setIfZeroI(&cfg.MQTT.ConnectTimeoutSec, def.MQTT.ConnectTimeoutSec)
		setIfZeroI(&cfg.MQTT.ReconnectDelaySec, def.MQTT.ReconnectDelaySec)
	}
}

func loadConfigStrict(configPath string) (Config, error) {
	cfg := defaultConfig()

	data, err := os.ReadFile(configPath)
	if err != nil {
		return cfg, fmt.Errorf("failed to read config file: %w", err)
	}

	dec := yaml.NewDecoder(bytes.NewReader(data))
	dec.KnownFields(true)
	if err := dec.Decode(&cfg); err != nil {
		return cfg, fmt.Errorf("failed to parse config file: %w", err)
	}

	normalizeConfig(&cfg)
	return cfg, nil
}

func loadOrCreateConfig(configPath string) (Config, error) {
	cfg := defaultConfig()

	_, err := os.Stat(configPath)
	if errors.Is(err, os.ErrNotExist) {
		data, marshalErr := yaml.Marshal(&cfg)
		if marshalErr != nil {
			return cfg, fmt.Errorf("failed to marshal default config: %w", marshalErr)
		}
		if writeErr := os.WriteFile(configPath, data, 0644); writeErr != nil {
			return cfg, fmt.Errorf("failed to create config file: %w", writeErr)
		}
		log.Printf("config file not found, created default config: %s", configPath)
		return cfg, nil
	}
	if err != nil {
		return cfg, fmt.Errorf("failed to stat config file: %w", err)
	}

	return loadConfigStrict(configPath)
}

func validateConfig(cfg Config) error {
	if cfg.Thresholds.WarningPercent <= 0 {
		return fmt.Errorf("thresholds.warning_percent must be > 0")
	}
	if cfg.Thresholds.CriticalPercent < cfg.Thresholds.WarningPercent {
		return fmt.Errorf("thresholds.critical_percent must be >= thresholds.warning_percent")
	}
	if cfg.Thresholds.GaugeMaxPercent < cfg.Thresholds.CriticalPercent {
		return fmt.Errorf("thresholds.gauge_max_percent must be >= thresholds.critical_percent")
	}
	if cfg.Thresholds.ImbalanceWarningPercent <= 0 {
		return fmt.Errorf("thresholds.imbalance_warning_percent must be > 0")
	}
	if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent {
		return fmt.Errorf("thresholds.imbalance_critical_percent must be >= thresholds.imbalance_warning_percent")
	}
	if cfg.Trend.Minutes <= 0 {
		return fmt.Errorf("trend.minutes must be > 0")
	}
	if cfg.Press.MaxTonnage <= 0 {
		return fmt.Errorf("press.MAX_TONNAGE must be > 0")
	}
	if cfg.MQTT.Enabled && strings.TrimSpace(cfg.MQTT.Broker) == "" {
		return fmt.Errorf("mqtt.broker must be set when mqtt.enabled is true")
	}
	if cfg.MQTT.Enabled && strings.TrimSpace(cfg.MQTT.ClientID) == "" {
		return fmt.Errorf("mqtt.client_id must be set when mqtt.enabled is true")
	}
	return nil
}

// ---------------------------------------------------------------------------
// Domain types
// ---------------------------------------------------------------------------

type Sample struct {
	TS               time.Time
	SilaLPct         float32
	SilaRPct         float32
	SilaLKN          float32
	SilaRKN          float32
	SumPercent       float32
	SumKN            float32
	ImbalancePercent float32
	BiasPercent      float32
}

type AlarmEvent struct {
	TS       time.Time
	Severity string
	Source   string
	Code     string
	State    string
	Message  string
	Value    float64
	Limit    float64
}

type AppState struct {
	sync.RWMutex
	Connected        bool
	SilaL            float32
	SilaR            float32
	SilaLkN          float32
	SilaRkN          float32
	SumPercent       float32
	SumkN            float32
	ImbalancePercent float32
	BiasPercent      float32
	LastUpdate       time.Time
	DroppedSamples   uint64
	DroppedEvents    uint64
}

type APIState struct {
	Connected        bool    `json:"connected"`
	SilaL            float32 `json:"sila_l"`
	SilaR            float32 `json:"sila_r"`
	SilaLkN          float32 `json:"sila_l_kn"`
	SilaRkN          float32 `json:"sila_r_kn"`
	SumPercent       float32 `json:"sum_percent"`
	SumkN            float32 `json:"sum_kn"`
	ImbalancePercent float32 `json:"imbalance_percent"`
	BiasPercent      float32 `json:"bias_percent"`
	LastUpdate       string  `json:"last_update"`
	DroppedSamples   uint64  `json:"dropped_samples"`
	DroppedEvents    uint64  `json:"dropped_events"`
}

type HistoryPoint struct {
	Time  string  `json:"time"`
	SilaL float32 `json:"sila_l"`
	SilaR float32 `json:"sila_r"`
}

type HistoryResponse struct {
	Window string         `json:"window"`
	Points []HistoryPoint `json:"points"`
}

type TrendResponse struct {
	Window             string  `json:"window"`
	AvgPeak5m          float32 `json:"avg_peak_5m"`
	AvgPeak1h          float32 `json:"avg_peak_1h"`
	AvgImbalance5m     float32 `json:"avg_imbalance_5m"`
	AvgImbalance1h     float32 `json:"avg_imbalance_1h"`
	ForceDeltaPct      float32 `json:"force_delta_pct"`
	ImbalanceDeltaPct  float32 `json:"imbalance_delta_pct"`
	ForceDirection     string  `json:"force_direction"`
	ImbalanceDirection string  `json:"imbalance_direction"`
	ProcessStability   string  `json:"process_stability"`
	StabilityReason    string  `json:"stability_reason"`
	ForceStdDev        float32 `json:"force_stddev"`
	ImbalanceStdDev    float32 `json:"imbalance_stddev"`
	SampleCount        int     `json:"sample_count"`
	OldHalfCount       int     `json:"old_half_count"`
	NewHalfCount       int     `json:"new_half_count"`
}

type AlarmEventAPI struct {
	Time     string  `json:"time"`
	Severity string  `json:"severity"`
	Source   string  `json:"source"`
	State    string  `json:"state"`
	Message  string  `json:"message"`
	Value    float64 `json:"value"`
	Limit    float64 `json:"limit"`
}

type AlarmResponse struct {
	Events []AlarmEventAPI `json:"events"`
}

type PageData struct {
	Title                    string
	Subtitle                 string
	LeftLabel                string
	RightLabel               string
	UnitForce                string
	UnitPct                  string
	MaxTonnage               float64
	WarningPercent           float64
	CriticalPercent          float64
	GaugeMaxPercent          float64
	ImbalanceWarningPercent  float64
	ImbalanceCriticalPercent float64
	PollMs                   int
	DefaultWindow            string
	DefaultTrendWindow       string

	ShowHeaderControls bool
	ShowVerdict        bool
	ShowSummaryBar     bool
	ShowOverview       bool
	ShowIntelligence   bool
	ShowAlarmTimeline  bool
	ShowGauges         bool
	ShowGaugeDigital   bool
	ShowTrendChart     bool
	UIRevision         uint64
}

type NumericStats struct {
	Avg   float64
	AvgSq float64
	Min   float64
	Max   float64
	Count int
}

func (s NumericStats) StdDev() float64 {
	if s.Count <= 1 {
		return 0
	}
	v := s.AvgSq - (s.Avg * s.Avg)
	if v < 0 {
		v = 0
	}
	return math.Sqrt(v)
}

type AlarmTracker struct {
	sync.Mutex
	PLCKnown     bool
	PLCConnected bool
	LeftZone     string
	RightZone    string
	ImbZone      string
	LastChange   map[string]time.Time
}

// ---------------------------------------------------------------------------
// MQTT types
// ---------------------------------------------------------------------------

// MQTTReceivedMsg holds a single inbound MQTT message stored in history.
type MQTTReceivedMsg struct {
	Topic    string `json:"topic"`
	Payload  string `json:"payload"`
	Retained bool   `json:"retained"`
	Time     string `json:"time"`
}

// MQTTPublishRequest is the body for POST /api/mqtt/publish.
type MQTTPublishRequest struct {
	Topic   string `json:"topic"`
	Payload string `json:"payload"`
	QoS     int    `json:"qos"`
	Retain  bool   `json:"retain"`
}

// MQTTSubscribeRequest is the body for POST /api/mqtt/subscribe.
type MQTTSubscribeRequest struct {
	Topic string `json:"topic"`
	QoS   int    `json:"qos"`
}

// MQTTStatusResponse is returned by GET /api/mqtt/status.
type MQTTStatusResponse struct {
	Enabled     bool     `json:"enabled"`
	Connected   bool     `json:"connected"`
	Broker      string   `json:"broker"`
	ClientID    string   `json:"client_id"`
	TopicPrefix string   `json:"topic_prefix"`
	Subscribed  []string `json:"subscribed"`
	LastError   string   `json:"last_error,omitempty"`
}

// mqttManager wraps the paho MQTT client and tracks connection state,
// inbound message history, and active subscriptions.
type mqttManager struct {
	mu         sync.RWMutex
	client     mqtt.Client
	connected  bool
	lastErr    string
	broker     string
	clientID   string
	prefix     string
	msgHistory []MQTTReceivedMsg
	msgMax     int
	subs       map[string]byte // topic -> qos currently subscribed
}

// ---------------------------------------------------------------------------
// Package-level singletons
// ---------------------------------------------------------------------------

var (
	cfg          Config
	cfgMu        sync.RWMutex
	state        AppState
	db           *sql.DB
	sampleCh     chan Sample
	alarmCh      chan AlarmEvent
	alarmTracker AlarmTracker
	uiTemplate   = template.Must(template.New("ui").Parse(uiHTML))
	cachedUI     []byte
	uiRevision   uint64 = 1

	// mqttMgr is nil when MQTT is disabled.
	mqttMgr *mqttManager

	// mqttAlarmCh is non-nil when MQTT is enabled; used to avoid goroutine spam.
	mqttAlarmCh chan AlarmEvent
)

// ---------------------------------------------------------------------------
// MQTT manager
// ---------------------------------------------------------------------------

func newMQTTManager(mcfg MQTTConfig) *mqttManager {
	m := &mqttManager{
		broker:   mcfg.Broker,
		clientID: mcfg.ClientID,
		prefix:   mcfg.TopicPrefix,
		msgMax:   500,
		subs:     make(map[string]byte),
	}

	opts := mqtt.NewClientOptions()
	opts.AddBroker(mcfg.Broker)
	opts.SetClientID(mcfg.ClientID)
	if mcfg.Username != "" {
		opts.SetUsername(mcfg.Username)
		opts.SetPassword(mcfg.Password)
	}
	opts.SetAutoReconnect(true)
	opts.SetMaxReconnectInterval(time.Duration(mcfg.ReconnectDelaySec*6) * time.Second)
	opts.SetConnectTimeout(time.Duration(mcfg.ConnectTimeoutSec) * time.Second)
	opts.SetCleanSession(true)
	opts.SetKeepAlive(30 * time.Second)

	opts.SetOnConnectHandler(func(c mqtt.Client) {
		m.mu.Lock()
		m.connected = true
		m.lastErr = ""
		// Snapshot current subs before releasing lock.
		resubTopics := make(map[string]byte, len(m.subs))
		for t, q := range m.subs {
			resubTopics[t] = q
		}
		m.mu.Unlock()

		log.Printf("MQTT connected to %s", mcfg.Broker)

		// Re-subscribe on reconnect.
		for topic, qos := range resubTopics {
			tok := c.Subscribe(topic, qos, m.messageHandler)
			if tok.Wait() && tok.Error() != nil {
				log.Printf("MQTT re-subscribe %s failed: %v", topic, tok.Error())
			}
		}
	})

	opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
		m.mu.Lock()
		m.connected = false
		if err != nil {
			m.lastErr = err.Error()
		}
		m.mu.Unlock()
		log.Printf("MQTT connection lost: %v", err)
	})

	m.client = mqtt.NewClient(opts)
	return m
}

// connect dials the broker; paho handles all subsequent reconnections.
func (m *mqttManager) connect() error {
	tok := m.client.Connect()
	if !tok.WaitTimeout(30 * time.Second) {
		return fmt.Errorf("MQTT connect timeout after 30s")
	}
	if err := tok.Error(); err != nil {
		m.mu.Lock()
		m.lastErr = err.Error()
		m.mu.Unlock()
		return err
	}
	return nil
}

// disconnect cleanly disconnects from the broker.
func (m *mqttManager) disconnect() {
	if m.client != nil {
		m.client.Disconnect(500)
	}
}

// messageHandler is the default callback for all subscriptions.
func (m *mqttManager) messageHandler(_ mqtt.Client, msg mqtt.Message) {
	entry := MQTTReceivedMsg{
		Topic:    msg.Topic(),
		Payload:  string(msg.Payload()),
		Retained: msg.Retained(),
		Time:     time.Now().UTC().Format(time.RFC3339Nano),
	}
	m.mu.Lock()
	m.msgHistory = append(m.msgHistory, entry)
	if len(m.msgHistory) > m.msgMax {
		// Trim oldest half to avoid O(n) shift on every overflow.
		half := m.msgMax / 2
		copy(m.msgHistory, m.msgHistory[half:])
		m.msgHistory = m.msgHistory[:m.msgMax-half]
	}
	m.mu.Unlock()
}

// publish sends a message; returns an error if not connected or publish fails.
func (m *mqttManager) publish(topic, payload string, qos byte, retain bool) error {
	if topic == "" {
		return fmt.Errorf("topic must not be empty")
	}
	if qos > 2 {
		qos = 2
	}
	m.mu.RLock()
	ok := m.connected
	m.mu.RUnlock()
	if !ok {
		return fmt.Errorf("MQTT not connected")
	}
	tok := m.client.Publish(topic, qos, retain, payload)
	if qos > 0 {
		if !tok.WaitTimeout(5 * time.Second) {
			return fmt.Errorf("MQTT publish timeout")
		}
		return tok.Error()
	}
	return nil
}

// subscribe adds a topic subscription; resubscribed automatically on reconnect.
func (m *mqttManager) subscribe(topic string, qos byte) error {
	if topic == "" {
		return fmt.Errorf("topic must not be empty")
	}
	if qos > 2 {
		qos = 2
	}
	m.mu.Lock()
	m.subs[topic] = qos
	m.mu.Unlock()

	m.mu.RLock()
	ok := m.connected
	m.mu.RUnlock()
	if !ok {
		// Will be subscribed upon reconnect via OnConnectHandler.
		return nil
	}

	tok := m.client.Subscribe(topic, qos, m.messageHandler)
	if tok.Wait() && tok.Error() != nil {
		return tok.Error()
	}
	return nil
}

// unsubscribe removes a topic subscription.
func (m *mqttManager) unsubscribe(topic string) error {
	m.mu.Lock()
	delete(m.subs, topic)
	m.mu.Unlock()

	m.mu.RLock()
	ok := m.connected
	m.mu.RUnlock()
	if !ok {
		return nil
	}
	tok := m.client.Unsubscribe(topic)
	if tok.Wait() && tok.Error() != nil {
		return tok.Error()
	}
	return nil
}

// status returns a snapshot of connection state for the API.
func (m *mqttManager) status() MQTTStatusResponse {
	m.mu.RLock()
	defer m.mu.RUnlock()

	subs := make([]string, 0, len(m.subs))
	for t := range m.subs {
		subs = append(subs, t)
	}
	return MQTTStatusResponse{
		Enabled:     true,
		Connected:   m.connected,
		Broker:      m.broker,
		ClientID:    m.clientID,
		TopicPrefix: m.prefix,
		Subscribed:  subs,
		LastError:   m.lastErr,
	}
}

// getMessages returns up to limit of the most recently received messages.
func (m *mqttManager) getMessages(limit int) []MQTTReceivedMsg {
	if limit <= 0 {
		limit = 50
	}
	if limit > 500 {
		limit = 500
	}
	m.mu.RLock()
	defer m.mu.RUnlock()

	total := len(m.msgHistory)
	if total == 0 {
		return []MQTTReceivedMsg{}
	}
	start := 0
	if total > limit {
		start = total - limit
	}
	out := make([]MQTTReceivedMsg, total-start)
	copy(out, m.msgHistory[start:])
	return out
}

// startMQTTPublisher periodically publishes the current PLC state to MQTT.
// Topics published:
//
//	{prefix}/data        – full JSON APIState (same as /api/data)
//	{prefix}/force/left  – left column force in %
//	{prefix}/force/right – right column force in %
//	{prefix}/force/sum_kn – total kN
//	{prefix}/connected   – PLC connection boolean
func startMQTTPublisher(ctx context.Context) {
	if mqttMgr == nil {
		return
	}
	mcfg := getConfigSnapshot().MQTT
	if !mcfg.AutoPublish {
		return
	}

	interval := time.Duration(mcfg.PublishIntervalMs) * time.Millisecond
	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	prefix := mcfg.TopicPrefix
	qos := byte(mcfg.QoS)
	retain := mcfg.Retain

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			s := snapshotState()

			full, err := json.Marshal(s)
			if err == nil {
				_ = mqttMgr.publish(prefix+"/data", string(full), qos, retain)
			}

			_ = mqttMgr.publish(prefix+"/force/left", fmt.Sprintf("%.2f", s.SilaL), qos, retain)
			_ = mqttMgr.publish(prefix+"/force/right", fmt.Sprintf("%.2f", s.SilaR), qos, retain)
			_ = mqttMgr.publish(prefix+"/force/sum_kn", fmt.Sprintf("%.2f", s.SumkN), qos, retain)
			_ = mqttMgr.publish(prefix+"/force/imbalance", fmt.Sprintf("%.2f", s.ImbalancePercent), qos, retain)

			connStr := "false"
			if s.Connected {
				connStr = "true"
			}
			_ = mqttMgr.publish(prefix+"/connected", connStr, qos, retain)
		}
	}
}

// mqttAlarmWorker drains the MQTT alarm channel so we don't spawn a goroutine per event.
func mqttAlarmWorker() {
	for a := range mqttAlarmCh {
		mqttPublishAlarm(a)
	}
}

// mqttPublishAlarm forwards a single alarm event to MQTT non-blocking.
// Called from mqttAlarmWorker; errors are silently discarded to avoid
// blocking the alarm pipeline.
func mqttPublishAlarm(a AlarmEvent) {
	if mqttMgr == nil {
		return
	}
	mcfg := getConfigSnapshot().MQTT
	if !mcfg.Enabled {
		return
	}

	payload, err := json.Marshal(map[string]interface{}{
		"time":     a.TS.UTC().Format(time.RFC3339),
		"severity": a.Severity,
		"source":   a.Source,
		"code":     a.Code,
		"state":    a.State,
		"message":  a.Message,
		"value":    a.Value,
		"limit":    a.Limit,
	})
	if err != nil {
		return
	}
	_ = mqttMgr.publish(mcfg.TopicPrefix+"/alarm", string(payload), byte(mcfg.QoS), false)
}

// ---------------------------------------------------------------------------
// Force calculation
// ---------------------------------------------------------------------------

func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (leftKN, rightKN, sumPercent, sumKN float32) {
	lp := float64(leftPercent)
	rp := float64(rightPercent)

	sumPct := (lp + rp) / 2.0
	left := (lp / 100.0) * (maxTonnage / 2.0)
	right := (rp / 100.0) * (maxTonnage / 2.0)
	total := (sumPct / 100.0) * maxTonnage

	return float32(left), float32(right), float32(sumPct), float32(total)
}

func getConfigSnapshot() Config {
	cfgMu.RLock()
	defer cfgMu.RUnlock()
	return cfg
}

func buildCachedUI(config Config, revision uint64) ([]byte, error) {
	data := PageData{
		Title:                    config.UI.Title,
		Subtitle:                 config.UI.Subtitle,
		LeftLabel:                config.UI.LeftLabel,
		RightLabel:               config.UI.RightLabel,
		UnitForce:                config.UI.UnitForce,
		UnitPct:                  config.UI.UnitPct,
		MaxTonnage:               config.Press.MaxTonnage,
		WarningPercent:           config.Thresholds.WarningPercent,
		CriticalPercent:          config.Thresholds.CriticalPercent,
		GaugeMaxPercent:          config.Thresholds.GaugeMaxPercent,
		ImbalanceWarningPercent:  config.Thresholds.ImbalanceWarningPercent,
		ImbalanceCriticalPercent: config.Thresholds.ImbalanceCriticalPercent,
		PollMs:                   config.PLC.PollMs,
		DefaultWindow:            fmt.Sprintf("%dm", config.Trend.Minutes),
		DefaultTrendWindow:       "15m",

		ShowHeaderControls: boolValue(config.Modules.ShowHeaderControls, true),
		ShowVerdict:        boolValue(config.Modules.ShowVerdict, true),
		ShowSummaryBar:     boolValue(config.Modules.ShowSummaryBar, true),
		ShowOverview:       boolValue(config.Modules.ShowOverview, true),
		ShowIntelligence:   boolValue(config.Modules.ShowIntelligence, true),
		ShowAlarmTimeline:  boolValue(config.Modules.ShowAlarmTimeline, true),
		ShowGauges:         boolValue(config.Modules.ShowGauges, true),
		ShowGaugeDigital:   boolValue(config.Modules.ShowGaugeDigital, false),
		ShowTrendChart:     boolValue(config.Modules.ShowTrendChart, true),
		UIRevision:         revision,
	}

	var buf bytes.Buffer
	if err := uiTemplate.Execute(&buf, data); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

func initCachedUI() {
	config := getConfigSnapshot()
	payload, err := buildCachedUI(config, atomic.LoadUint64(&uiRevision))
	if err != nil {
		log.Fatalf("failed to pre-render UI template: %v", err)
	}
	cfgMu.Lock()
	cachedUI = payload
	cfgMu.Unlock()
}

func hotReloadSectionsLocked(dst *Config, src Config) {
	dst.Thresholds = src.Thresholds
	dst.Trend = src.Trend
	dst.Press = src.Press
	dst.UI = src.UI
	dst.Modules = src.Modules
}

func configSectionChanges(oldCfg, newCfg Config) (hotSections []string, restartSections []string) {
	if !reflect.DeepEqual(oldCfg.Thresholds, newCfg.Thresholds) {
		hotSections = append(hotSections, "thresholds")
	}
	if !reflect.DeepEqual(oldCfg.Trend, newCfg.Trend) {
		hotSections = append(hotSections, "trend")
	}
	if !reflect.DeepEqual(oldCfg.Press, newCfg.Press) {
		hotSections = append(hotSections, "press")
	}
	if !reflect.DeepEqual(oldCfg.UI, newCfg.UI) {
		hotSections = append(hotSections, "ui")
	}
	if !reflect.DeepEqual(oldCfg.Modules, newCfg.Modules) {
		hotSections = append(hotSections, "modules")
	}
	if !reflect.DeepEqual(oldCfg.Server, newCfg.Server) {
		restartSections = append(restartSections, "server")
	}
	if !reflect.DeepEqual(oldCfg.PLC, newCfg.PLC) {
		restartSections = append(restartSections, "plc")
	}
	if !reflect.DeepEqual(oldCfg.DB, newCfg.DB) {
		restartSections = append(restartSections, "db")
	}
	if !reflect.DeepEqual(oldCfg.MQTT, newCfg.MQTT) {
		restartSections = append(restartSections, "mqtt")
	}
	return hotSections, restartSections
}

func reloadConfigSafely(configPath string) {
	newCfg, err := loadConfigStrict(configPath)
	if err != nil {
		log.Printf("config reload rejected: %v", err)
		return
	}
	if err := validateConfig(newCfg); err != nil {
		log.Printf("config reload rejected: %v", err)
		return
	}

	oldCfg := getConfigSnapshot()
	hotSections, restartSections := configSectionChanges(oldCfg, newCfg)

	updatedCfg := oldCfg
	hotReloadSectionsLocked(&updatedCfg, newCfg)

	if len(hotSections) > 0 {
		nextUIRevision := atomic.LoadUint64(&uiRevision) + 1
		payload, err := buildCachedUI(updatedCfg, nextUIRevision)
		if err != nil {
			log.Printf("config reload rejected: failed to rebuild UI: %v", err)
			return
		}
		cfgMu.Lock()
		cfg = updatedCfg
		cachedUI = payload
		atomic.StoreUint64(&uiRevision, nextUIRevision)
		cfgMu.Unlock()
	}

	if len(hotSections) == 0 && len(restartSections) == 0 {
		log.Printf("config reload checked: no effective changes")
		return
	}
	if len(hotSections) > 0 {
		log.Printf("config hot-reloaded safely: %s", strings.Join(hotSections, ", "))
	}
	if len(restartSections) > 0 {
		log.Printf("config changes detected in %s; restart required before they take effect", strings.Join(restartSections, ", "))
	}
}

func startConfigWatcher(ctx context.Context, configPath string) error {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return err
	}

	dir := filepath.Dir(configPath)
	target := filepath.Clean(configPath)
	if err := watcher.Add(dir); err != nil {
		_ = watcher.Close()
		return err
	}

	go func() {
		defer watcher.Close()

		var (
			debounceTimer *time.Timer
			debounceC     <-chan time.Time
		)

		resetDebounce := func() {
			if debounceTimer == nil {
				debounceTimer = time.NewTimer(350 * time.Millisecond)
			} else {
				if !debounceTimer.Stop() {
					select {
					case <-debounceTimer.C:
					default:
					}
				}
				debounceTimer.Reset(350 * time.Millisecond)
			}
			debounceC = debounceTimer.C
		}

		for {
			select {
			case <-ctx.Done():
				if debounceTimer != nil {
					debounceTimer.Stop()
				}
				return
			case event, ok := <-watcher.Events:
				if !ok {
					return
				}
				if filepath.Clean(event.Name) != target {
					continue
				}
				if event.Has(fsnotify.Chmod) {
					continue
				}
				if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) {
					resetDebounce()
				}
			case <-debounceC:
				debounceC = nil
				reloadConfigSafely(configPath)
			case err, ok := <-watcher.Errors:
				if !ok {
					return
				}
				log.Printf("config watcher error: %v", err)
			}
		}
	}()

	return nil
}

// ---------------------------------------------------------------------------
// State helpers
// ---------------------------------------------------------------------------

func snapshotState() APIState {
	state.RLock()
	defer state.RUnlock()

	lastUpdate := ""
	if !state.LastUpdate.IsZero() {
		lastUpdate = state.LastUpdate.Format(time.RFC3339Nano)
	}
	return APIState{
		Connected:        state.Connected,
		SilaL:            state.SilaL,
		SilaR:            state.SilaR,
		SilaLkN:          state.SilaLkN,
		SilaRkN:          state.SilaRkN,
		SumPercent:       state.SumPercent,
		SumkN:            state.SumkN,
		ImbalancePercent: state.ImbalancePercent,
		BiasPercent:      state.BiasPercent,
		LastUpdate:       lastUpdate,
		DroppedSamples:   state.DroppedSamples,
		DroppedEvents:    state.DroppedEvents,
	}
}

func markDisconnected(reason string) {
	state.Lock()
	state.Connected = false
	state.Unlock()
	maybeLogPLCDisconnected(reason)
}

func enqueueSample(s Sample) {
	select {
	case sampleCh <- s:
	default:
		state.Lock()
		state.DroppedSamples++
		state.Unlock()
	}
}

func enqueueAlarm(a AlarmEvent) {
	select {
	case alarmCh <- a:
	default:
		state.Lock()
		state.DroppedEvents++
		state.Unlock()
	}
	// Forward to MQTT non-blocking via single worker goroutine.
	if mqttAlarmCh != nil {
		select {
		case mqttAlarmCh <- a:
		default:
		}
	}
}

// ---------------------------------------------------------------------------
// Database initialisation
// ---------------------------------------------------------------------------

func ensureColumn(database *sql.DB, tableName, columnName, definition string) error {
	rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
	if err != nil {
		return err
	}
	defer rows.Close()

	found := false
	for rows.Next() {
		var cid int
		var name, ctype string
		var notNull int
		var dfltValue sql.NullString
		var pk int
		if err := rows.Scan(&cid, &name, &ctype, &notNull, &dfltValue, &pk); err != nil {
			return err
		}
		if name == columnName {
			found = true
			break
		}
	}
	if err := rows.Err(); err != nil {
		return err
	}
	if found {
		return nil
	}
	_, err = database.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", tableName, columnName, definition))
	return err
}

func initDatabase(dbPath string, dbCfg DBConfig) (*sql.DB, error) {
	dsn := fmt.Sprintf("file:%s?_busy_timeout=%d&_foreign_keys=on", filepath.ToSlash(dbPath), dbCfg.BusyTimeoutMs)
	database, err := sql.Open("sqlite3", dsn)
	if err != nil {
		return nil, fmt.Errorf("open sqlite: %w", err)
	}

	// FIX: allow parallel reads (writes still serialize safely via WAL).
	database.SetMaxOpenConns(4)
	database.SetMaxIdleConns(2)
	database.SetConnMaxLifetime(time.Hour)

	pragmas := []string{
		"PRAGMA journal_mode=WAL;",
		"PRAGMA synchronous=NORMAL;",
		fmt.Sprintf("PRAGMA wal_autocheckpoint=%d;", dbCfg.CheckpointPages),
		fmt.Sprintf("PRAGMA busy_timeout=%d;", dbCfg.BusyTimeoutMs),
		"PRAGMA temp_store=MEMORY;",
	}
	for _, q := range pragmas {
		if _, err := database.Exec(q); err != nil {
			_ = database.Close()
			return nil, fmt.Errorf("sqlite pragma failed (%s): %w", q, err)
		}
	}

	schema := `
CREATE TABLE IF NOT EXISTS samples (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ts DATETIME NOT NULL,
    ts_unix_ns INTEGER NOT NULL DEFAULT 0,
    sila_l_pct REAL NOT NULL,
    sila_r_pct REAL NOT NULL,
    sila_l_kn REAL NOT NULL,
    sila_r_kn REAL NOT NULL,
    sum_pct REAL NOT NULL,
    sum_kn REAL NOT NULL,
    imbalance_pct REAL NOT NULL DEFAULT 0,
    bias_pct REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts);
CREATE INDEX IF NOT EXISTS idx_samples_ts_unix_ns ON samples(ts_unix_ns);

CREATE TABLE IF NOT EXISTS alarm_events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ts DATETIME NOT NULL,
    ts_unix_ns INTEGER NOT NULL DEFAULT 0,
    severity TEXT NOT NULL,
    source TEXT NOT NULL,
    code TEXT NOT NULL,
    state TEXT NOT NULL,
    message TEXT NOT NULL,
    value REAL NOT NULL DEFAULT 0,
    limit_value REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alarm_events_ts_unix_ns ON alarm_events(ts_unix_ns DESC);
`
	if _, err := database.Exec(schema); err != nil {
		_ = database.Close()
		return nil, fmt.Errorf("create schema: %w", err)
	}

	// FIX: covering index for trend queries (sum_pct, imbalance_pct by time).
	if _, err := database.Exec(`CREATE INDEX IF NOT EXISTS idx_samples_trend ON samples(ts_unix_ns, sum_pct, imbalance_pct);`); err != nil {
		_ = database.Close()
		return nil, fmt.Errorf("create trend index: %w", err)
	}

	// Schema migrations: add missing columns to support older databases.
	migrations := []struct{ table, col, def string }{
		{"samples", "ts_unix_ns", "INTEGER NOT NULL DEFAULT 0"},
		{"samples", "imbalance_pct", "REAL NOT NULL DEFAULT 0"},
		{"samples", "bias_pct", "REAL NOT NULL DEFAULT 0"},
		{"alarm_events", "ts_unix_ns", "INTEGER NOT NULL DEFAULT 0"},
	}
	for _, m := range migrations {
		if err := ensureColumn(database, m.table, m.col, m.def); err != nil {
			_ = database.Close()
			return nil, fmt.Errorf("migration (%s.%s): %w", m.table, m.col, err)
		}
	}

	// Ensure the ts_unix_ns index exists (may have been missed pre-migration).
	if _, err := database.Exec(`CREATE INDEX IF NOT EXISTS idx_samples_ts_unix_ns ON samples(ts_unix_ns)`); err != nil {
		_ = database.Close()
		return nil, fmt.Errorf("create ts_unix_ns index: %w", err)
	}

	// Backfill ts_unix_ns for rows that pre-date the column.
	for _, tbl := range []string{"samples", "alarm_events"} {
		q := fmt.Sprintf(`UPDATE %s SET ts_unix_ns = CAST(strftime('%%s', ts) AS INTEGER) * 1000000000 WHERE ts_unix_ns = 0 AND ts IS NOT NULL`, tbl)
		if _, err := database.Exec(q); err != nil {
			log.Printf("warning: ts_unix_ns backfill for %s failed: %v", tbl, err)
		}
	}

	// FIX: update query planner stats so indexes are actually used.
	if _, err := database.Exec("ANALYZE"); err != nil {
		log.Printf("warning: sqlite analyze failed: %v", err)
	}

	return database, nil
}

// ---------------------------------------------------------------------------
// DB writer goroutines
// ---------------------------------------------------------------------------

func startDBWriter(ctx context.Context, database *sql.DB, batchSize, flushMs int) {
	ticker := time.NewTicker(time.Duration(flushMs) * time.Millisecond)
	defer ticker.Stop()

	stmt, err := database.Prepare(`
		INSERT INTO samples (
			ts, ts_unix_ns, sila_l_pct, sila_r_pct, sila_l_kn, sila_r_kn,
			sum_pct, sum_kn, imbalance_pct, bias_pct
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
	`)
	if err != nil {
		log.Printf("db writer prepare failed: %v", err)
		return
	}
	defer stmt.Close()

	batch := make([]Sample, 0, batchSize)
	flushErrCount := 0

	flush := func() {
		if len(batch) == 0 {
			return
		}
		tx, err := database.Begin()
		if err != nil {
			log.Printf("db begin failed: %v", err)
			flushErrCount++
			if flushErrCount >= 3 {
				log.Printf("db writer: dropping batch of %d after %d failures", len(batch), flushErrCount)
				batch = batch[:0]
				flushErrCount = 0
			}
			return
		}
		txStmt := tx.Stmt(stmt)
		defer txStmt.Close()
		ok := true
		for _, s := range batch {
			if _, err := txStmt.Exec(
				s.TS.UTC().Format(time.RFC3339Nano), s.TS.UTC().UnixNano(),
				s.SilaLPct, s.SilaRPct, s.SilaLKN, s.SilaRKN,
				s.SumPercent, s.SumKN, s.ImbalancePercent, s.BiasPercent,
			); err != nil {
				ok = false
				log.Printf("db insert failed: %v", err)
				break
			}
		}
		if !ok {
			_ = tx.Rollback()
			flushErrCount++
			if flushErrCount >= 3 {
				log.Printf("db writer: dropping batch of %d after %d failures", len(batch), flushErrCount)
				batch = batch[:0]
				flushErrCount = 0
			}
			return
		}
		if err := tx.Commit(); err != nil {
			log.Printf("db commit failed: %v", err)
			flushErrCount++
			if flushErrCount >= 3 {
				log.Printf("db writer: dropping batch of %d after %d failures", len(batch), flushErrCount)
				batch = batch[:0]
				flushErrCount = 0
			}
			return
		}
		batch = batch[:0]
		flushErrCount = 0
	}

	for {
		select {
		case <-ctx.Done():
			drained := 0
			for {
				select {
				case s := <-sampleCh:
					batch = append(batch, s)
					drained++
					if drained > 10000 {
						log.Printf("db writer: drain limit reached, dropping remaining")
						flush()
						return
					}
				default:
					flush()
					return
				}
			}
		case s := <-sampleCh:
			batch = append(batch, s)
			if len(batch) >= batchSize {
				flush()
			}
		case <-ticker.C:
			flush()
		}
	}
}

func startAlarmWriter(ctx context.Context, database *sql.DB, batchSize, flushMs int) {
	ticker := time.NewTicker(time.Duration(flushMs) * time.Millisecond)
	defer ticker.Stop()

	stmt, err := database.Prepare(`
		INSERT INTO alarm_events (
			ts, ts_unix_ns, severity, source, code, state, message, value, limit_value
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
	`)
	if err != nil {
		log.Printf("alarm db writer prepare failed: %v", err)
		return
	}
	defer stmt.Close()

	batch := make([]AlarmEvent, 0, batchSize)
	flushErrCount := 0

	flush := func() {
		if len(batch) == 0 {
			return
		}
		tx, err := database.Begin()
		if err != nil {
			log.Printf("alarm db begin failed: %v", err)
			flushErrCount++
			if flushErrCount >= 3 {
				log.Printf("alarm writer: dropping batch of %d after %d failures", len(batch), flushErrCount)
				batch = batch[:0]
				flushErrCount = 0
			}
			return
		}
		txStmt := tx.Stmt(stmt)
		defer txStmt.Close()
		ok := true
		for _, a := range batch {
			if _, err := txStmt.Exec(
				a.TS.UTC().Format(time.RFC3339Nano), a.TS.UTC().UnixNano(),
				a.Severity, a.Source, a.Code, a.State, a.Message, a.Value, a.Limit,
			); err != nil {
				ok = false
				log.Printf("alarm db insert failed: %v", err)
				break
			}
		}
		if !ok {
			_ = tx.Rollback()
			flushErrCount++
			if flushErrCount >= 3 {
				log.Printf("alarm writer: dropping batch of %d after %d failures", len(batch), flushErrCount)
				batch = batch[:0]
				flushErrCount = 0
			}
			return
		}
		if err := tx.Commit(); err != nil {
			log.Printf("alarm db commit failed: %v", err)
			flushErrCount++
			if flushErrCount >= 3 {
				log.Printf("alarm writer: dropping batch of %d after %d failures", len(batch), flushErrCount)
				batch = batch[:0]
				flushErrCount = 0
			}
			return
		}
		batch = batch[:0]
		flushErrCount = 0
	}

	for {
		select {
		case <-ctx.Done():
			drained := 0
			for {
				select {
				case a := <-alarmCh:
					batch = append(batch, a)
					drained++
					if drained > 10000 {
						log.Printf("alarm writer: drain limit reached, dropping remaining")
						flush()
						return
					}
				default:
					flush()
					return
				}
			}
		case a := <-alarmCh:
			batch = append(batch, a)
			if len(batch) >= batchSize {
				flush()
			}
		case <-ticker.C:
			flush()
		}
	}
}

func startDBCleanup(ctx context.Context, database *sql.DB, retentionDays, intervalHr int) {
	if retentionDays <= 0 {
		return
	}

	ticker := time.NewTicker(time.Duration(intervalHr) * time.Hour)
	defer ticker.Stop()

	// Replace the cleanup function in startDBCleanup with this:
	cleanup := func() {
		cutoffNs := time.Now().AddDate(0, 0, -retentionDays).UTC().UnixNano()
		for _, tbl := range []string{"samples", "alarm_events"} {
			for {
				// Select up to 5000 rowids to delete (SQLite supports LIMIT in SELECT)
				rows, err := database.QueryContext(ctx,
					fmt.Sprintf(`SELECT rowid FROM %s WHERE ts_unix_ns > 0 AND ts_unix_ns < ? LIMIT 5000`, tbl),
					cutoffNs,
				)
				if err != nil {
					log.Printf("db cleanup %s select failed: %v", tbl, err)
					break
				}
				var rowids []int64
				for rows.Next() {
					var rid int64
					if err := rows.Scan(&rid); err != nil {
						log.Printf("db cleanup %s scan failed: %v", tbl, err)
						break
					}
					rowids = append(rowids, rid)
				}
				rows.Close()
				if err := rows.Err(); err != nil {
					log.Printf("db cleanup %s rows error: %v", tbl, err)
					break
				}

				if len(rowids) == 0 {
					break // Nothing more to delete
				}

				// Build DELETE IN (...) query
				placeholders := make([]string, len(rowids))
				args := make([]any, len(rowids))
				for i, rid := range rowids {
					placeholders[i] = "?"
					args[i] = rid
				}
				query := fmt.Sprintf(`DELETE FROM %s WHERE rowid IN (%s)`, tbl, strings.Join(placeholders, ","))
				if _, err := database.ExecContext(ctx, query, args...); err != nil {
					log.Printf("db cleanup %s delete failed: %v", tbl, err)
					break
				}
			}
		}
	}

	cleanup()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			cleanup()
		}
	}
}

// ---------------------------------------------------------------------------
// Alarm zone helpers
// ---------------------------------------------------------------------------

func zoneFromValue(value, warn, crit float64) string {
	if value >= crit {
		return "critical"
	}
	if value >= warn {
		return "warning"
	}
	return "ok"
}

func sourceName(source string) string {
	switch source {
	case "force_left":
		return "Left force"
	case "force_right":
		return "Right force"
	case "imbalance":
		return "Imbalance"
	case "plc":
		return "PLC"
	default:
		return source
	}
}

func sourceLimit(source, zone string) float64 {
	config := getConfigSnapshot()
	switch source {
	case "imbalance":
		if zone == "critical" {
			return config.Thresholds.ImbalanceCriticalPercent
		}
		if zone == "warning" {
			return config.Thresholds.ImbalanceWarningPercent
		}
	default:
		if zone == "critical" {
			return config.Thresholds.CriticalPercent
		}
		if zone == "warning" {
			return config.Thresholds.WarningPercent
		}
	}
	return 0
}

func maybeLogZoneChange(source, prev, curr string, value float64) {
	if prev == curr {
		return
	}
	name := sourceName(source)
	now := time.Now()
	if prev == "" && curr == "ok" {
		return
	}

	// FIX: 5-second cooldown per source to prevent oscillation spam.
	alarmTracker.Lock()
	if alarmTracker.LastChange == nil {
		alarmTracker.LastChange = make(map[string]time.Time)
	}
	if last, ok := alarmTracker.LastChange[source]; ok && now.Sub(last) < 5*time.Second {
		alarmTracker.Unlock()
		return
	}
	alarmTracker.LastChange[source] = now
	alarmTracker.Unlock()

	switch curr {
	case "ok":
		enqueueAlarm(AlarmEvent{
			TS:       now,
			Severity: "info",
			Source:   source,
			Code:     source + "_clear",
			State:    "clear",
			Message:  fmt.Sprintf("%s returned to OK", name),
			Value:    value,
			Limit:    0,
		})
	case "warning":
		msg := fmt.Sprintf("%s entered WARNING zone", name)
		if prev == "critical" {
			msg = fmt.Sprintf("%s downgraded from CRITICAL to WARNING", name)
		}
		enqueueAlarm(AlarmEvent{
			TS:       now,
			Severity: "warning",
			Source:   source,
			Code:     source + "_warning",
			State:    "active",
			Message:  msg,
			Value:    value,
			Limit:    sourceLimit(source, "warning"),
		})
	case "critical":
		msg := fmt.Sprintf("%s entered CRITICAL zone", name)
		if prev == "warning" {
			msg = fmt.Sprintf("%s escalated from WARNING to CRITICAL", name)
		}
		enqueueAlarm(AlarmEvent{
			TS:       now,
			Severity: "critical",
			Source:   source,
			Code:     source + "_critical",
			State:    "active",
			Message:  msg,
			Value:    value,
			Limit:    sourceLimit(source, "critical"),
		})
	}
}

func evaluateProcessAlarms(s Sample) {
	// FIX: snapshot thresholds once to avoid repeated locking.
	config := getConfigSnapshot()
	warn := config.Thresholds.WarningPercent
	crit := config.Thresholds.CriticalPercent
	imbWarn := config.Thresholds.ImbalanceWarningPercent
	imbCrit := config.Thresholds.ImbalanceCriticalPercent

	leftZone := zoneFromValue(float64(s.SilaLPct), warn, crit)
	rightZone := zoneFromValue(float64(s.SilaRPct), warn, crit)
	imbZone := zoneFromValue(float64(s.ImbalancePercent), imbWarn, imbCrit)

	alarmTracker.Lock()
	defer alarmTracker.Unlock()
	maybeLogZoneChange("force_left", alarmTracker.LeftZone, leftZone, float64(s.SilaLPct))
	maybeLogZoneChange("force_right", alarmTracker.RightZone, rightZone, float64(s.SilaRPct))
	maybeLogZoneChange("imbalance", alarmTracker.ImbZone, imbZone, float64(s.ImbalancePercent))
	alarmTracker.LeftZone = leftZone
	alarmTracker.RightZone = rightZone
	alarmTracker.ImbZone = imbZone
}

func maybeLogPLCConnected() {
	alarmTracker.Lock()
	defer alarmTracker.Unlock()
	if !alarmTracker.PLCKnown {
		alarmTracker.PLCKnown = true
		alarmTracker.PLCConnected = true
		enqueueAlarm(AlarmEvent{
			TS: time.Now(), Severity: "info", Source: "plc",
			Code: "plc_connected", State: "info",
			Message: "PLC connection established", Value: 1,
		})
		return
	}
	if !alarmTracker.PLCConnected {
		alarmTracker.PLCConnected = true
		enqueueAlarm(AlarmEvent{
			TS: time.Now(), Severity: "info", Source: "plc",
			Code: "plc_restored", State: "info",
			Message: "PLC connection restored", Value: 1,
		})
	}
}

func maybeLogPLCDisconnected(reason string) {
	alarmTracker.Lock()
	defer alarmTracker.Unlock()
	if !alarmTracker.PLCKnown || !alarmTracker.PLCConnected {
		return
	}
	alarmTracker.PLCConnected = false
	alarmTracker.LeftZone = ""
	alarmTracker.RightZone = ""
	alarmTracker.ImbZone = ""
	enqueueAlarm(AlarmEvent{
		TS: time.Now(), Severity: "critical", Source: "plc",
		Code: "plc_disconnected", State: "active",
		Message: "PLC connection lost: " + reason, Value: 0,
	})
}

// ---------------------------------------------------------------------------
// PLC poller
// ---------------------------------------------------------------------------

func startPLCPoller(ctx context.Context) {
	bootCfg := getConfigSnapshot()
	pollInterval := time.Duration(bootCfg.PLC.PollMs) * time.Millisecond
	reconnectDelay := time.Duration(bootCfg.PLC.ReconnectDelaySec) * time.Second

	// FIX: cache frequently accessed config values to avoid lock contention.
	maxTonnage := bootCfg.Press.MaxTonnage
	dbNum := bootCfg.PLC.DBNum

	for {
		select {
		case <-ctx.Done():
			return
		default:
		}

		handler := gos7.NewTCPClientHandler(bootCfg.PLC.IP, bootCfg.PLC.Rack, bootCfg.PLC.Slot)
		handler.Timeout = time.Duration(bootCfg.PLC.ConnectTimeoutSec) * time.Second
		handler.IdleTimeout = time.Duration(bootCfg.PLC.IdleTimeoutSec) * time.Second

		if err := handler.Connect(); err != nil {
			markDisconnected(err.Error())
			log.Printf("PLC connect failed: %v - retrying in %ds...", err, bootCfg.PLC.ReconnectDelaySec)
			select {
			case <-ctx.Done():
				return
			case <-time.After(reconnectDelay):
			}
			continue
		}

		maybeLogPLCConnected()

		client := gos7.NewClient(handler)
		log.Println("PLC connected successfully")

		buf := make([]byte, 8)
		readErrCount := 0

		for {
			select {
			case <-ctx.Done():
				_ = handler.Close()
				return
			default:
			}

			if err := client.AGReadDB(dbNum, 0, 8, buf); err != nil {
				readErrCount++
				if readErrCount < 3 {
					log.Printf("PLC read error (attempt %d/3): %v", readErrCount, err)
					select {
					case <-ctx.Done():
						_ = handler.Close()
						return
					case <-time.After(pollInterval):
					}
					continue
				}
				log.Printf("PLC read error: %v - reconnecting...", err)
				markDisconnected(err.Error())
				_ = handler.Close()
				break
			}
			readErrCount = 0

			var helper gos7.Helper
			silaL := helper.GetRealAt(buf, 0)
			silaR := helper.GetRealAt(buf, 4)

			leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, maxTonnage)
			imbalance := float32(math.Abs(float64(silaL - silaR)))
			bias := silaL - silaR
			now := time.Now()

			state.Lock()
			state.Connected = true
			state.SilaL = silaL
			state.SilaR = silaR
			state.SilaLkN = leftKN
			state.SilaRkN = rightKN
			state.SumPercent = sumPercent
			state.SumkN = sumKN
			state.ImbalancePercent = imbalance
			state.BiasPercent = bias
			state.LastUpdate = now
			state.Unlock()

			sample := Sample{
				TS: now, SilaLPct: silaL, SilaRPct: silaR,
				SilaLKN: leftKN, SilaRKN: rightKN,
				SumPercent: sumPercent, SumKN: sumKN,
				ImbalancePercent: imbalance, BiasPercent: bias,
			}

			evaluateProcessAlarms(sample)
			enqueueSample(sample)

			select {
			case <-ctx.Done():
				_ = handler.Close()
				return
			case <-time.After(pollInterval):
			}
		}
	}
}

// ---------------------------------------------------------------------------
// Query helpers
// ---------------------------------------------------------------------------

func parseWindow(raw string) (time.Duration, string, error) {
	s := strings.TrimSpace(strings.ToLower(raw))
	if s == "" {
		s = fmt.Sprintf("%dm", getConfigSnapshot().Trend.Minutes)
	}
	if strings.HasSuffix(s, "d") {
		n, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
		if err != nil || n <= 0 {
			return 0, "", fmt.Errorf("invalid day window")
		}
		return time.Duration(n) * 24 * time.Hour, s, nil
	}
	d, err := time.ParseDuration(s)
	if err != nil || d <= 0 {
		return 0, "", fmt.Errorf("invalid window")
	}
	return d, s, nil
}

func formatHistoryLabel(t time.Time, window time.Duration) string {
	local := t.Local()
	if window >= 12*time.Hour {
		return local.Format("02.01 15:04")
	}
	return local.Format("15:04:05.000")
}

// FIX: use ts_unix_ns directly instead of parsing RFC3339 strings.
func queryHistory(ctx context.Context, window time.Duration) ([]HistoryPoint, error) {
	cutoffNs := time.Now().Add(-window).UTC().UnixNano()

	rows, err := db.QueryContext(ctx,
		`SELECT ts_unix_ns, sila_l_pct, sila_r_pct FROM samples WHERE ts_unix_ns >= ? ORDER BY ts_unix_ns ASC`,
		cutoffNs)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	points := make([]HistoryPoint, 0, 1024)
	for rows.Next() {
		var tsUnix int64
		var l, r float64
		if err := rows.Scan(&tsUnix, &l, &r); err != nil {
			return nil, err
		}
		t := time.Unix(0, tsUnix).Local()
		points = append(points, HistoryPoint{
			Time:  formatHistoryLabel(t, window),
			SilaL: float32(l),
			SilaR: float32(r),
		})
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	maxPts := getConfigSnapshot().DB.MaxChartPoints
	if len(points) <= maxPts {
		return points, nil
	}
	return downsamplePoints(points, maxPts), nil
}

// FIX: remove unnecessary map allocation; indices are monotonic.
func downsamplePoints(points []HistoryPoint, max int) []HistoryPoint {
	if len(points) <= max || max < 3 {
		return points
	}

	out := make([]HistoryPoint, max)
	step := float64(len(points)-1) / float64(max-1)
	for i := 0; i < max; i++ {
		idx := int(float64(i) * step)
		if idx >= len(points) {
			idx = len(points) - 1
		}
		out[i] = points[idx]
	}
	out[max-1] = points[len(points)-1]
	return out
}

func validField(field string) (string, error) {
	switch field {
	case "sum_pct":
		return "sum_pct", nil
	case "imbalance_pct":
		return "imbalance_pct", nil
	default:
		return "", fmt.Errorf("invalid field")
	}
}

// FIX: accept context so queries cancel when HTTP client disconnects.
func queryNumericStats(ctx context.Context, field string, fromNs, toNs int64) (NumericStats, error) {
	safeField, err := validField(field)
	if err != nil {
		return NumericStats{}, err
	}
	query := fmt.Sprintf(`
		SELECT
			COALESCE(AVG(%[1]s), 0),
			COALESCE(AVG(%[1]s * %[1]s), 0),
			COALESCE(MIN(%[1]s), 0),
			COALESCE(MAX(%[1]s), 0),
			COUNT(*)
		FROM samples
		WHERE ts_unix_ns >= ? AND ts_unix_ns < ?
	`, safeField)

	var stats NumericStats
	err = db.QueryRowContext(ctx, query, fromNs, toNs).Scan(&stats.Avg, &stats.AvgSq, &stats.Min, &stats.Max, &stats.Count)
	if err != nil {
		return NumericStats{}, err
	}
	return stats, nil
}

// ---------------------------------------------------------------------------
// Trend / stability classification
// ---------------------------------------------------------------------------

func classifyDirection(delta float64, oldCount, newCount int, stableThreshold float64, posLabel, negLabel string) string {
	if oldCount < 3 || newCount < 3 {
		return "insufficient_data"
	}
	if math.Abs(delta) < stableThreshold {
		return "stable"
	}
	if delta > 0 {
		return posLabel
	}
	return negLabel
}

func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sampleCount int) (string, string) {
	if sampleCount < 8 {
		return "insufficient_data", "Too few samples in selected trend window"
	}
	config := getConfigSnapshot()

	if forceStd >= 6.0 || math.Abs(forceDelta) >= 8.0 || avgImb5m >= config.Thresholds.ImbalanceCriticalPercent || imbStd >= 4.0 {
		if avgImb5m >= config.Thresholds.ImbalanceCriticalPercent {
			return "unstable", "High average imbalance in last 5 minutes"
		}
		if math.Abs(forceDelta) >= 8.0 {
			return "unstable", "Average peak force is drifting fast"
		}
		if forceStd >= 6.0 {
			return "unstable", "Force variation is too high"
		}
		return "unstable", "Imbalance variation is too high"
	}
	if forceStd >= 3.0 || math.Abs(forceDelta) >= 3.0 || avgImb5m >= config.Thresholds.ImbalanceWarningPercent || imbStd >= 2.0 {
		if avgImb5m >= config.Thresholds.ImbalanceWarningPercent {
			return "caution", "Imbalance is trending above warning region"
		}
		if math.Abs(forceDelta) >= 3.0 {
			return "caution", "Average force is drifting"
		}
		if forceStd >= 3.0 {
			return "caution", "Force is less repeatable than normal"
		}
		return "caution", "Imbalance repeatability is degrading"
	}
	return "stable", "Process variation is low"
}

// FIX: 1-second response cache so overlapping UI polls don't hammer the DB.
var (
	trendCache     atomic.Value // stores *trendCacheEntry
	trendCacheTime int64        // atomic
)

type trendCacheEntry struct {
	Window time.Duration
	Resp   TrendResponse
}

// FIX: accept context and use cache.
func buildTrendResponse(ctx context.Context, window time.Duration, label string) (TrendResponse, error) {
	now := time.Now().UnixMilli()
	if cached, ok := trendCache.Load().(*trendCacheEntry); ok {
		if cached.Window == window && now-atomic.LoadInt64(&trendCacheTime) < 1000 {
			return cached.Resp, nil
		}
	}

	nowNs := time.Now().UTC().UnixNano()
	windowNs := window.Nanoseconds()
	startNs := nowNs - windowNs
	midNs := startNs + (windowNs / 2)

	force5m, err := queryNumericStats(ctx, "sum_pct", nowNs-(5*time.Minute).Nanoseconds(), nowNs)
	if err != nil {
		return TrendResponse{}, err
	}
	force1h, err := queryNumericStats(ctx, "sum_pct", nowNs-(1*time.Hour).Nanoseconds(), nowNs)
	if err != nil {
		return TrendResponse{}, err
	}
	imb5m, err := queryNumericStats(ctx, "imbalance_pct", nowNs-(5*time.Minute).Nanoseconds(), nowNs)
	if err != nil {
		return TrendResponse{}, err
	}
	imb1h, err := queryNumericStats(ctx, "imbalance_pct", nowNs-(1*time.Hour).Nanoseconds(), nowNs)
	if err != nil {
		return TrendResponse{}, err
	}
	forceOld, err := queryNumericStats(ctx, "sum_pct", startNs, midNs)
	if err != nil {
		return TrendResponse{}, err
	}
	forceNew, err := queryNumericStats(ctx, "sum_pct", midNs, nowNs)
	if err != nil {
		return TrendResponse{}, err
	}
	imbOld, err := queryNumericStats(ctx, "imbalance_pct", startNs, midNs)
	if err != nil {
		return TrendResponse{}, err
	}
	imbNew, err := queryNumericStats(ctx, "imbalance_pct", midNs, nowNs)
	if err != nil {
		return TrendResponse{}, err
	}

	forceDelta := forceNew.Avg - forceOld.Avg
	imbDelta := imbNew.Avg - imbOld.Avg

	forceDirection := classifyDirection(forceDelta, forceOld.Count, forceNew.Count, 1.0, "rising", "falling")
	imbDirection := classifyDirection(imbDelta, imbOld.Count, imbNew.Count, 0.5, "worsening", "improving")

	fullWindowForce, err := queryNumericStats(ctx, "sum_pct", startNs, nowNs)
	if err != nil {
		return TrendResponse{}, err
	}
	fullWindowImb, err := queryNumericStats(ctx, "imbalance_pct", startNs, nowNs)
	if err != nil {
		return TrendResponse{}, err
	}

	stability, reason := classifyProcessStability(
		fullWindowForce.StdDev(), fullWindowImb.StdDev(),
		forceDelta, imb5m.Avg, fullWindowForce.Count,
	)

	resp := TrendResponse{
		Window:             label,
		AvgPeak5m:          float32(force5m.Avg),
		AvgPeak1h:          float32(force1h.Avg),
		AvgImbalance5m:     float32(imb5m.Avg),
		AvgImbalance1h:     float32(imb1h.Avg),
		ForceDeltaPct:      float32(forceDelta),
		ImbalanceDeltaPct:  float32(imbDelta),
		ForceDirection:     forceDirection,
		ImbalanceDirection: imbDirection,
		ProcessStability:   stability,
		StabilityReason:    reason,
		ForceStdDev:        float32(fullWindowForce.StdDev()),
		ImbalanceStdDev:    float32(fullWindowImb.StdDev()),
		SampleCount:        fullWindowForce.Count,
		OldHalfCount:       forceOld.Count,
		NewHalfCount:       forceNew.Count,
	}

	trendCache.Store(&trendCacheEntry{Window: window, Resp: resp})
	atomic.StoreInt64(&trendCacheTime, now)
	return resp, nil
}

// FIX: use ts_unix_ns directly and accept context.
func queryAlarmEvents(ctx context.Context, limit int) ([]AlarmEventAPI, error) {
	if limit <= 0 {
		limit = 20
	}
	if limit > 100 {
		limit = 100
	}
	rows, err := db.QueryContext(ctx, `
		SELECT ts_unix_ns, severity, source, state, message, value, limit_value
		FROM alarm_events
		ORDER BY ts_unix_ns DESC
		LIMIT ?
	`, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	events := make([]AlarmEventAPI, 0, limit)
	for rows.Next() {
		var tsUnix int64
		var severity, source, state, message string
		var value, limitValue float64
		if err := rows.Scan(&tsUnix, &severity, &source, &state, &message, &value, &limitValue); err != nil {
			return nil, err
		}
		displayTime := time.Unix(0, tsUnix).Local().Format("02.01.2006 15:04:05")
		events = append(events, AlarmEventAPI{
			Time: displayTime, Severity: severity, Source: source,
			State: state, Message: message, Value: value, Limit: limitValue,
		})
	}
	return events, rows.Err()
}

// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------

// writeJSON writes v as JSON with correct headers and CORS.
func writeJSON(w http.ResponseWriter, status int, v any) {
	h := w.Header()
	h.Set("Content-Type", "application/json")
	h.Set("Cache-Control", "no-store")
	h.Set("Access-Control-Allow-Origin", "*")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

// allowMethod checks r.Method and handles CORS preflight.
// Returns false if the caller should stop processing.
func allowMethod(w http.ResponseWriter, r *http.Request, method string) bool {
	if r.Method == http.MethodOptions {
		h := w.Header()
		h.Set("Access-Control-Allow-Origin", "*")
		h.Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
		h.Set("Access-Control-Allow-Headers", "Content-Type")
		w.WriteHeader(http.StatusNoContent)
		return false
	}
	if r.Method != method {
		http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
		return false
	}
	return true
}

// ---------------------------------------------------------------------------
// HTTP handlers — core
// ---------------------------------------------------------------------------

func apiData(w http.ResponseWriter, r *http.Request) {
	if !allowMethod(w, r, http.MethodGet) {
		return
	}
	writeJSON(w, http.StatusOK, snapshotState())
}

func apiUIRevision(w http.ResponseWriter, r *http.Request) {
	if !allowMethod(w, r, http.MethodGet) {
		return
	}
	writeJSON(w, http.StatusOK, map[string]uint64{"revision": atomic.LoadUint64(&uiRevision)})
}

func apiHistory(w http.ResponseWriter, r *http.Request) {
	if !allowMethod(w, r, http.MethodGet) {
		return
	}
	window, label, err := parseWindow(r.URL.Query().Get("window"))
	if err != nil {
		http.Error(w, `{"error":"invalid window"}`, http.StatusBadRequest)
		return
	}
	points, err := queryHistory(r.Context(), window)
	if err != nil {
		log.Printf("history query failed: %v", err)
		http.Error(w, `{"error":"history query failed"}`, http.StatusInternalServerError)
		return
	}
	writeJSON(w, http.StatusOK, HistoryResponse{Window: label, Points: points})
}

func apiTrend(w http.ResponseWriter, r *http.Request) {
	if !allowMethod(w, r, http.MethodGet) {
		return
	}
	window, label, err := parseWindow(r.URL.Query().Get("window"))
	if err != nil {
		http.Error(w, `{"error":"invalid trend window"}`, http.StatusBadRequest)
		return
	}
	resp, err := buildTrendResponse(r.Context(), window, label)
	if err != nil {
		log.Printf("trend query failed: %v", err)
		http.Error(w, `{"error":"trend query failed"}`, http.StatusInternalServerError)
		return
	}
	writeJSON(w, http.StatusOK, resp)
}

func apiAlarms(w http.ResponseWriter, r *http.Request) {
	if !allowMethod(w, r, http.MethodGet) {
		return
	}
	limit := 20
	if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
		if n, err := strconv.Atoi(raw); err == nil && n > 0 {
			limit = n
		}
	}
	events, err := queryAlarmEvents(r.Context(), limit)
	if err != nil {
		log.Printf("alarm query failed: %v", err)
		http.Error(w, `{"error":"alarm query failed"}`, http.StatusInternalServerError)
		return
	}
	writeJSON(w, http.StatusOK, AlarmResponse{Events: events})
}

func serveUI(w http.ResponseWriter, r *http.Request) {
	cfgMu.RLock()
	payload := cachedUI
	cfgMu.RUnlock()

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-cache, max-age=0")
	_, _ = w.Write(payload)
}

// ---------------------------------------------------------------------------
// HTTP handlers — MQTT REST API
// ---------------------------------------------------------------------------

// GET /api/mqtt/status
// Returns MQTT connection state, broker info, and active subscriptions.
func apiMQTTStatus(w http.ResponseWriter, r *http.Request) {
	if !allowMethod(w, r, http.MethodGet) {
		return
	}
	if mqttMgr == nil {
		writeJSON(w, http.StatusOK, MQTTStatusResponse{
			Enabled:    false,
			Connected:  false,
			Broker:     "",
			Subscribed: []string{},
		})
		return
	}
	writeJSON(w, http.StatusOK, mqttMgr.status())
}

// POST /api/mqtt/publish
// Body: {"topic":"plant/press/cmd","payload":"reset","qos":1,"retain":false}
// Publishes an arbitrary message to the MQTT broker.
func apiMQTTPublish(w http.ResponseWriter, r *http.Request) {
	if !allowMethod(w, r, http.MethodPost) {
		return
	}
	if mqttMgr == nil {
		http.Error(w, `{"error":"MQTT not enabled"}`, http.StatusServiceUnavailable)
		return
	}

	var req MQTTPublishRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, `{"error":"invalid JSON body"}`, http.StatusBadRequest)
		return
	}
	if strings.TrimSpace(req.Topic) == "" {
		http.Error(w, `{"error":"topic required"}`, http.StatusBadRequest)
		return
	}
	if req.QoS < 0 || req.QoS > 2 {
		http.Error(w, `{"error":"qos must be 0, 1, or 2"}`, http.StatusBadRequest)
		return
	}

	if err := mqttMgr.publish(req.Topic, req.Payload, byte(req.QoS), req.Retain); err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "published", "topic": req.Topic})
}

// GET /api/mqtt/messages?limit=50
// Returns the most recently received MQTT messages (across all subscribed topics).
func apiMQTTMessages(w http.ResponseWriter, r *http.Request) {
	if !allowMethod(w, r, http.MethodGet) {
		return
	}
	if mqttMgr == nil {
		writeJSON(w, http.StatusOK, map[string]any{"messages": []MQTTReceivedMsg{}, "enabled": false})
		return
	}
	limit := 50
	if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
		if n, err := strconv.Atoi(raw); err == nil && n > 0 {
			limit = n
		}
	}
	msgs := mqttMgr.getMessages(limit)
	writeJSON(w, http.StatusOK, map[string]any{"messages": msgs, "count": len(msgs)})
}

// POST /api/mqtt/subscribe
// Body: {"topic":"plant/press/#","qos":1}
// Subscribes to a topic filter. Messages are stored in the ring buffer
// and accessible via GET /api/mqtt/messages.
func apiMQTTSubscribe(w http.ResponseWriter, r *http.Request) {
	if !allowMethod(w, r, http.MethodPost) {
		return
	}
	if mqttMgr == nil {
		http.Error(w, `{"error":"MQTT not enabled"}`, http.StatusServiceUnavailable)
		return
	}

	var req MQTTSubscribeRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, `{"error":"invalid JSON body"}`, http.StatusBadRequest)
		return
	}
	if strings.TrimSpace(req.Topic) == "" {
		http.Error(w, `{"error":"topic required"}`, http.StatusBadRequest)
		return
	}
	if req.QoS < 0 || req.QoS > 2 {
		http.Error(w, `{"error":"qos must be 0, 1, or 2"}`, http.StatusBadRequest)
		return
	}

	if err := mqttMgr.subscribe(req.Topic, byte(req.QoS)); err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "subscribed", "topic": req.Topic})
}

// DELETE /api/mqtt/subscribe
// Body: {"topic":"plant/press/#"}
// Unsubscribes from a topic filter.
func apiMQTTUnsubscribe(w http.ResponseWriter, r *http.Request) {
	if !allowMethod(w, r, http.MethodDelete) {
		return
	}
	if mqttMgr == nil {
		http.Error(w, `{"error":"MQTT not enabled"}`, http.StatusServiceUnavailable)
		return
	}

	var req MQTTSubscribeRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, `{"error":"invalid JSON body"}`, http.StatusBadRequest)
		return
	}
	if strings.TrimSpace(req.Topic) == "" {
		http.Error(w, `{"error":"topic required"}`, http.StatusBadRequest)
		return
	}

	if err := mqttMgr.unsubscribe(req.Topic); err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "unsubscribed", "topic": req.Topic})
}

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------

func main() {
	wd, err := os.Getwd()
	if err != nil {
		log.Fatalf("failed to get working directory: %v", err)
	}

	configPath := filepath.Join(wd, "config.yaml")
	cfg, err = loadOrCreateConfig(configPath)
	if err != nil {
		log.Fatalf("failed to load config: %v", err)
	}
	if err := validateConfig(cfg); err != nil {
		log.Fatalf("invalid config: %v", err)
	}

	dbPath := cfg.DB.Path
	if !filepath.IsAbs(dbPath) {
		dbPath = filepath.Join(wd, dbPath)
	}

	// Pass DB config explicitly so initDatabase doesn't touch the global.
	db, err = initDatabase(dbPath, cfg.DB)
	if err != nil {
		log.Fatalf("failed to init database: %v", err)
	}
	defer db.Close()

	sampleCh = make(chan Sample, cfg.DB.WriterQueueSize)
	alarmCh = make(chan AlarmEvent, cfg.DB.AlarmQueueSize)

	initCachedUI()

	log.Printf("S7-1200 Force Monitor v%s", version)
	log.Printf("Config:  %s", configPath)
	log.Printf("DB:      %s", dbPath)
	log.Printf("PLC:     ip=%s db=%d rack=%d slot=%d poll=%dms",
		cfg.PLC.IP, cfg.PLC.DBNum, cfg.PLC.Rack, cfg.PLC.Slot, cfg.PLC.PollMs)
	log.Printf("Press:   MAX_TONNAGE=%.2f %s", cfg.Press.MaxTonnage, cfg.UI.UnitForce)

	// Initialise MQTT if enabled.
	if cfg.MQTT.Enabled {
		mqttMgr = newMQTTManager(cfg.MQTT)
		mqttAlarmCh = make(chan AlarmEvent, 256)
		go mqttAlarmWorker()
		if err := mqttMgr.connect(); err != nil {
			// Non-fatal: paho will reconnect automatically.
			log.Printf("MQTT initial connect failed (will retry): %v", err)
		} else {
			log.Printf("MQTT: connected to %s (prefix=%s)", cfg.MQTT.Broker, cfg.MQTT.TopicPrefix)
		}
	} else {
		log.Printf("MQTT: disabled (set mqtt.enabled: true in config to enable)")
	}

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

	if err := startConfigWatcher(ctx, configPath); err != nil {
		log.Printf("config watch disabled: %v", err)
	} else {
		log.Printf("Config watcher enabled for %s", configPath)
	}

	// Snapshot DB config values once (DB is not hot-reloadable).
	dbCfg := cfg.DB

	var wg sync.WaitGroup
	wg.Add(4)
	go func() { defer wg.Done(); startDBWriter(ctx, db, dbCfg.BatchSize, dbCfg.FlushIntervalMs) }()
	go func() { defer wg.Done(); startAlarmWriter(ctx, db, dbCfg.BatchSize, dbCfg.FlushIntervalMs) }()
	go func() { defer wg.Done(); startDBCleanup(ctx, db, dbCfg.RetentionDays, dbCfg.CleanupIntervalHr) }()
	go func() { defer wg.Done(); startPLCPoller(ctx) }()

	if cfg.MQTT.Enabled {
		wg.Add(1)
		go func() { defer wg.Done(); startMQTTPublisher(ctx) }()
	}

	staticRoot, err := fs.Sub(staticFiles, "static")
	if err != nil {
		log.Fatalf("failed to mount embedded static files: %v", err)
	}

	mux := http.NewServeMux()
	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
	mux.HandleFunc("/", serveUI)

	// Core data API
	mux.HandleFunc("/api/data", apiData)
	mux.HandleFunc("/api/ui-revision", apiUIRevision)
	mux.HandleFunc("/api/history", apiHistory)
	mux.HandleFunc("/api/trend", apiTrend)
	mux.HandleFunc("/api/alarms", apiAlarms)

	// MQTT REST API
	//
	//  GET  /api/mqtt/status             → connection status, broker, subscriptions
	//  POST /api/mqtt/publish            → publish message to any topic
	//  GET  /api/mqtt/messages[?limit=N] → last N received messages
	//  POST /api/mqtt/subscribe          → subscribe to topic filter
	//  DELETE /api/mqtt/subscribe        → unsubscribe from topic filter
	mux.HandleFunc("/api/mqtt/status", apiMQTTStatus)
	mux.HandleFunc("/api/mqtt/publish", apiMQTTPublish)
	mux.HandleFunc("/api/mqtt/messages", apiMQTTMessages)
	mux.HandleFunc("/api/mqtt/subscribe", apiMQTTSubscribe)
	mux.HandleFunc("/api/mqtt/unsubscribe", apiMQTTUnsubscribe)

	srv := &http.Server{
		Addr:         cfg.Server.ListenAddr,
		Handler:      mux,
		ReadTimeout:  15 * time.Second,
		WriteTimeout: 15 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	log.Printf("Listening on http://localhost%s", cfg.Server.ListenAddr)
	log.Printf("MQTT API: GET /api/mqtt/status | POST /api/mqtt/publish | GET /api/mqtt/messages")

	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("HTTP server error: %v", err)
		}
	}()

	<-ctx.Done()
	log.Println("Shutting down — flushing DB writers...")

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("HTTP server shutdown error: %v", err)
	}

	if mqttMgr != nil {
		mqttMgr.disconnect()
		log.Println("MQTT disconnected")
	}

	wg.Wait()
	log.Println("Shutdown complete")
}

const uiHTML = `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{.Title}}</title>
    <script src="/static/tailwind.min.js"></script>
    <script src="/static/chart.umd.min.js"></script>
    <style>
        :root {
            --bg1: #050816;
            --bg2: #0b1224;
            --panel: rgba(255,255,255,0.055);
            --body-text: #f4f4f5;
            --button-bg: rgba(255,255,255,0.05);
            --button-border: rgba(255,255,255,0.10);
            --button-text: #e4e4e7;
        }

        * { box-sizing: border-box; }
        html, body { min-height: 100%; }

        body {
            font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
            background:
                radial-gradient(circle at 10% 10%, rgba(34,211,238,0.12), transparent 18%),
                radial-gradient(circle at 90% 10%, rgba(168,85,247,0.14), transparent 18%),
                linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
            color: var(--body-text);
            transition: background 180ms ease, color 180ms ease;
        }

        body[data-theme="light"] {
            --bg1: #eef4ff;
            --bg2: #f8fafc;
            --panel: rgba(255,255,255,0.80);
            --body-text: #0f172a;
            --button-bg: rgba(255,255,255,0.88);
            --button-border: rgba(15,23,42,0.10);
            --button-text: #0f172a;
            background:
                radial-gradient(circle at 10% 10%, rgba(14,165,233,0.10), transparent 20%),
                radial-gradient(circle at 90% 10%, rgba(168,85,247,0.10), transparent 18%),
                linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%);
        }

        .glass {
            background: var(--panel);
            backdrop-filter: blur(14px);
            -webkit-backdrop-filter: blur(14px);
        }

        body[data-theme="light"] .glass,
        body[data-theme="light"] .summary-card,
        body[data-theme="light"] .intel-card,
        body[data-theme="light"] .verdict-card {
            border-color: rgba(15,23,42,0.10) !important;
            box-shadow: 0 10px 28px rgba(15,23,42,0.06);
        }

        body[data-theme="light"] .bg-zinc-900\/60,
        body[data-theme="light"] .bg-white\/5 {
            background: rgba(255,255,255,0.86) !important;
        }

        body[data-theme="light"] .border-zinc-800,
        body[data-theme="light"] .border-white\/10 {
            border-color: rgba(15,23,42,0.10) !important;
        }

        body[data-theme="light"] .text-zinc-100,
        body[data-theme="light"] .text-zinc-200 { color: #0f172a !important; }
        body[data-theme="light"] .text-zinc-300 { color: #1e293b !important; }
        body[data-theme="light"] .text-zinc-400,
        body[data-theme="light"] .text-zinc-500 { color: #475569 !important; }
        body[data-theme="light"] .text-emerald-300 { color: #059669 !important; }
        body[data-theme="light"] .text-emerald-400 { color: #10b981 !important; }
        body[data-theme="light"] .text-sky-100,
        body[data-theme="light"] .text-sky-200 { color: #0369a1 !important; }
        body[data-theme="light"] .text-violet-100,
        body[data-theme="light"] .text-violet-200 { color: #7c3aed !important; }
        body[data-theme="light"] .text-amber-200 { color: #b45309 !important; }
        body[data-theme="light"] .text-sky-400 { color: #0284c7 !important; }
        body[data-theme="light"] .text-violet-400 { color: #7c3aed !important; }
        body[data-theme="light"] .text-red-400 { color: #dc2626 !important; }
        body[data-theme="light"] .text-yellow-400 { color: #b45309 !important; }

        .control-btn {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            min-height: 44px;
            padding: 10px 14px;
            border-radius: 16px;
            border: 1px solid var(--button-border);
            background: var(--button-bg);
            color: var(--button-text);
            font-weight: 600;
            transition: 160ms ease;
            backdrop-filter: blur(10px);
            -webkit-backdrop-filter: blur(10px);
        }

        .control-btn:hover {
            transform: translateY(-1px);
            box-shadow: 0 8px 20px rgba(0,0,0,0.10);
        }

        .soft-glow-green { box-shadow: 0 0 0 1px rgba(34,197,94,0.28), 0 0 38px rgba(34,197,94,0.08); }
        .soft-glow-yellow { box-shadow: 0 0 0 1px rgba(234,179,8,0.28), 0 0 38px rgba(234,179,8,0.08); }
        .soft-glow-red { box-shadow: 0 0 0 1px rgba(239,68,68,0.28), 0 0 38px rgba(239,68,68,0.08); }

        .gauge-header-row {
            display: flex;
            align-items: flex-start;
            justify-content: space-between;
            gap: 24px;
            margin-bottom: 10px;
        }

        .gauge-head {
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 16px;
            text-align: center;
            margin-bottom: 10px;
        }

        .gauge-head.with-digital {
            justify-content: flex-start;
            text-align: left;
            margin-bottom: 0;
        }

        .gauge-head-copy {
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
        }

        .gauge-head-copy.with-digital { align-items: flex-start; }

        .gauge-digital { text-align: right; flex-shrink: 0; }

        .gauge-container {
            position: relative;
            width: 100%;
            max-width: 720px;
            margin: 0 auto;
        }

        .gauge-container.no-digital { height: clamp(430px, 48vw, 560px); }
        .gauge-container.with-digital { height: clamp(360px, 42vw, 500px); }

        .gauge-canvas { width: 100%; height: 100%; display: block; }

        .window-btn.active, .trend-window-btn.active {
            border-color: rgba(34,211,238,0.9);
            color: white;
            background: rgba(34,211,238,0.14);
            box-shadow: 0 0 0 1px rgba(34,211,238,0.18) inset;
        }

        body[data-theme="light"] .window-btn.active,
        body[data-theme="light"] .trend-window-btn.active {
            color: #0f172a;
            background: rgba(14,165,233,0.12);
        }

        .chart-wrap { width: min(92vw, 1800px); margin: 0 auto; }

        .summary-card, .intel-card, .verdict-card {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 16px;
            border-radius: 20px;
            padding: 18px 20px;
            border: 1px solid rgba(255,255,255,0.09);
            background: rgba(255,255,255,0.04);
            transition: 180ms ease;
        }

        .intel-card { min-height: 126px; align-items: flex-start; }

        .summary-card.ok, .intel-card.ok, .verdict-card.ok {
            border-color: rgba(34,197,94,0.35);
            box-shadow: 0 0 0 1px rgba(34,197,94,0.08) inset, 0 0 26px rgba(34,197,94,0.06);
        }
        .summary-card.warning, .intel-card.warning, .verdict-card.warning {
            border-color: rgba(234,179,8,0.35);
            box-shadow: 0 0 0 1px rgba(234,179,8,0.08) inset, 0 0 26px rgba(234,179,8,0.06);
        }
        .summary-card.critical, .intel-card.critical, .verdict-card.critical {
            border-color: rgba(239,68,68,0.35);
            box-shadow: 0 0 0 1px rgba(239,68,68,0.08) inset, 0 0 26px rgba(239,68,68,0.06);
        }
        .summary-card.neutral, .intel-card.neutral, .verdict-card.neutral {
            border-color: rgba(113,113,122,0.35);
            box-shadow: 0 0 0 1px rgba(113,113,122,0.08) inset;
        }

        .summary-dot { width: 14px; height: 14px; border-radius: 999px; }
        .summary-dot.ok { background: #10b981; box-shadow: 0 0 14px rgba(16,185,129,0.55); }
        .summary-dot.warning { background: #f59e0b; box-shadow: 0 0 14px rgba(245,158,11,0.55); }
        .summary-dot.critical { background: #ef4444; box-shadow: 0 0 14px rgba(239,68,68,0.55); }
        .summary-dot.neutral { background: #71717a; box-shadow: 0 0 12px rgba(113,113,122,0.35); }

        .summary-status.ok { color: #34d399; }
        .summary-status.warning { color: #facc15; }
        .summary-status.critical { color: #f87171; }
        .summary-status.neutral { color: #a1a1aa; }

        .intel-value {
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
            font-size: 2rem;
            font-weight: 700;
            line-height: 1;
            color: #f4f4f5;
        }

        body[data-theme="light"] .intel-value { color: #0f172a; }

        .intel-sub { font-size: 0.83rem; color: #a1a1aa; margin-top: 10px; line-height: 1.35; }

        .intel-kpi {
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
            font-size: 1.1rem;
            font-weight: 700;
        }

        .dir-up { color: #facc15; }
        .dir-down { color: #34d399; }
        .dir-flat { color: #a1a1aa; }
        .dir-bad { color: #f87171; }
        .mini-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }

        .severity-pill {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-width: 86px;
            padding: 4px 10px;
            border-radius: 999px;
            font-size: 12px;
            font-weight: 700;
            letter-spacing: 0.04em;
        }

        .severity-pill.info { background: rgba(59,130,246,0.12); color: #93c5fd; border: 1px solid rgba(59,130,246,0.22); }
        .severity-pill.warning { background: rgba(245,158,11,0.12); color: #fde68a; border: 1px solid rgba(245,158,11,0.22); }
        .severity-pill.critical { background: rgba(239,68,68,0.12); color: #fca5a5; border: 1px solid rgba(239,68,68,0.22); }
        body[data-theme="light"] .severity-pill.info { color: #1d4ed8; }
        body[data-theme="light"] .severity-pill.warning { color: #b45309; }
        body[data-theme="light"] .severity-pill.critical { color: #dc2626; }

        .alarm-table tbody tr:hover { background: rgba(255,255,255,0.03); }
        body[data-theme="light"] .alarm-table tbody tr:hover { background: rgba(15,23,42,0.03); }

        .limit-line-note { font-size: 0.8rem; color: #a1a1aa; }

        .process-offline {
            opacity: 0.35;
            filter: grayscale(1) blur(1.5px);
            pointer-events: none;
            user-select: none;
            transition: opacity 180ms ease, filter 180ms ease;
        }

        .process-online {
            opacity: 1;
            filter: none;
            pointer-events: auto;
            user-select: auto;
            transition: opacity 180ms ease, filter 180ms ease;
        }
    </style>
</head>
<body data-theme="dark">
    <div class="w-[92vw] max-w-[1800px] mx-auto p-4 md:p-8 min-h-screen">
        <div id="alarm-banner" class="hidden mb-6 bg-red-600/90 border border-red-500 text-white px-8 py-4 rounded-2xl flex items-center justify-between text-lg font-medium">
            <div class="flex items-center gap-3">
                <span class="text-2xl">&#9888;&#65039;</span>
                <span id="alarm-text">CRITICAL ALARM ACTIVE</span>
            </div>
        </div>

        <div class="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between mb-8">
            <div>
                <h1 class="text-4xl md:text-5xl xl:text-6xl font-semibold tracking-tighter bg-gradient-to-r from-sky-300 to-violet-300 bg-clip-text text-transparent">{{.Title}}</h1>
                <p class="text-zinc-400 mt-2 text-base md:text-lg">{{.Subtitle}}</p>
                <p class="text-zinc-500 mt-1 text-sm font-mono">MAX_TONNAGE = {{printf "%.1f" .MaxTonnage}} {{.UnitForce}}</p>
            </div>

            <div class="flex flex-col gap-4 xl:items-end">
                {{if .ShowHeaderControls}}
                <div class="flex flex-wrap gap-3 justify-end">
                    <button id="theme-toggle" class="control-btn" type="button">Light theme</button>
                    <button id="fullscreen-toggle" class="control-btn" type="button">Enter fullscreen</button>
                </div>
                {{end}}

                <div class="glass border border-white/10 px-6 py-4 rounded-3xl flex flex-col md:flex-row md:items-center gap-4 md:gap-8 w-fit">
                    <div class="flex items-center gap-3">
                        <div id="dot" class="w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20"></div>
                        <span id="status-text" class="font-semibold text-lg text-red-400">Disconnected</span>
                    </div>
                    <div class="hidden md:block h-8 w-px bg-zinc-700"></div>
                    <div id="last-update" class="font-mono text-zinc-400 text-sm">Last update: --:--:--.---</div>
                    <div class="hidden md:block h-8 w-px bg-zinc-700"></div>
                    <div class="font-mono text-zinc-500 text-sm">Dropped S: <span id="dropped-samples">0</span> | E: <span id="dropped-events">0</span></div>
                </div>
            </div>
        </div>

        <div id="process-content">
        {{if .ShowVerdict}}
        <div id="verdict-card" class="verdict-card neutral mb-8">
            <div>
                <div class="text-xs uppercase tracking-[0.24em] text-zinc-500">Machine verdict</div>
                <div id="verdict-status" class="text-3xl md:text-4xl font-bold mt-2 text-zinc-200">NO DATA</div>
            </div>
            <div id="verdict-reason" class="text-right text-zinc-300 text-base md:text-lg">Waiting for PLC data</div>
        </div>
        {{end}}

        {{if .ShowSummaryBar}}
        <div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-8">
            <div id="summary-force-card" class="summary-card neutral">
                <div class="flex items-center gap-3">
                    <div id="summary-force-dot" class="summary-dot neutral"></div>
                    <div>
                        <div class="text-xs uppercase tracking-[0.22em] text-zinc-500">FORCE</div>
                        <div id="summary-force-text" class="summary-status neutral font-semibold mt-1">NO DATA</div>
                    </div>
                </div>
                <div id="summary-force-value" class="font-mono text-zinc-200 text-lg">--</div>
            </div>

            <div id="summary-imbalance-card" class="summary-card neutral">
                <div class="flex items-center gap-3">
                    <div id="summary-imbalance-dot" class="summary-dot neutral"></div>
                    <div>
                        <div class="text-xs uppercase tracking-[0.22em] text-zinc-500">IMBALANCE</div>
                        <div id="summary-imbalance-text" class="summary-status neutral font-semibold mt-1">NO DATA</div>
                    </div>
                </div>
                <div id="summary-imbalance-value" class="font-mono text-zinc-200 text-lg">--</div>
            </div>

            <div id="summary-plc-card" class="summary-card neutral">
                <div class="flex items-center gap-3">
                    <div id="summary-plc-dot" class="summary-dot neutral"></div>
                    <div>
                        <div class="text-xs uppercase tracking-[0.22em] text-zinc-500">PLC</div>
                        <div id="summary-plc-text" class="summary-status neutral font-semibold mt-1">OFFLINE</div>
                    </div>
                </div>
                <div id="summary-plc-value" class="font-mono text-zinc-200 text-lg">Disconnected</div>
            </div>
        </div>
        {{end}}

        {{if .ShowOverview}}
        <div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
            <div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
                <div>
                    <div class="text-zinc-400 text-sm uppercase tracking-[0.25em]">TOTAL PEAK FORCE</div>
                    <div class="mt-2 flex items-end gap-4">
                        <div class="text-5xl md:text-6xl font-mono font-bold text-emerald-300 leading-none" id="sum-kn">0.0</div>
                        <div class="text-2xl text-emerald-400 mb-1">{{.UnitForce}}</div>
                    </div>
                </div>

                <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 min-w-[320px]">
                    <div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
                        <div class="text-zinc-500 text-xs uppercase tracking-widest">TOTAL %</div>
                        <div class="text-3xl font-mono font-bold text-sky-200 mt-1"><span id="sum-percent">0.0</span> {{.UnitPct}}</div>
                    </div>
                    <div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
                        <div class="text-zinc-500 text-xs uppercase tracking-widest">IMBALANCE</div>
                        <div class="text-3xl font-mono font-bold text-amber-200 mt-1"><span id="imbalance-pct">0.0</span> {{.UnitPct}}</div>
                        <div class="text-xs text-zinc-500 mt-2 font-mono">abs(L - R)</div>
                    </div>
                    <div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
                        <div class="text-zinc-500 text-xs uppercase tracking-widest">BIAS</div>
                        <div class="text-3xl font-mono font-bold text-violet-200 mt-1"><span id="bias-pct">0.0</span> {{.UnitPct}}</div>
                        <div class="text-xs text-zinc-500 mt-2 font-mono">L - R</div>
                    </div>
                    <div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
                        <div class="text-zinc-500 text-xs uppercase tracking-widest">LIMITS</div>
                        <div class="text-sm font-mono text-zinc-300 mt-2">Force W {{printf "%.0f" .WarningPercent}} / C {{printf "%.0f" .CriticalPercent}}</div>
                        <div class="text-xs text-zinc-500 mt-2 font-mono">Imb W {{printf "%.0f" .ImbalanceWarningPercent}} / C {{printf "%.0f" .ImbalanceCriticalPercent}}</div>
                    </div>
                </div>
            </div>
        </div>
        {{end}}

        {{if .ShowIntelligence}}
        <div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
            <div class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
                <div>
                    <h2 class="text-2xl md:text-3xl font-semibold">Drift / Deterioration Intelligence</h2>
                    <div class="text-zinc-400 mt-1 text-sm md:text-base">Averages, drift direction, imbalance deterioration and process stability</div>
                </div>
                <div class="flex flex-wrap items-center gap-2">
                    <button class="trend-window-btn active px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="5m">5m</button>
                    <button class="trend-window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="15m">15m</button>
                    <button class="trend-window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="30m">30m</button>
                    <button class="trend-window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="1h">1h</button>
                    <div class="flex items-center gap-2 ml-1">
                        <input id="custom-trend-window" type="text" placeholder="e.g. 90m or 2h" class="px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-100 w-36 outline-none">
                        <button id="apply-trend-window" class="px-3 py-2 rounded-xl border border-sky-400/40 bg-sky-400/10 text-sky-200 text-sm font-medium">Apply</button>
                    </div>
                </div>
            </div>
            <div class="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-5 gap-4">
                <div id="intel-avg5-card" class="intel-card neutral"><div class="w-full">
                    <div class="text-xs uppercase tracking-[0.22em] text-zinc-500">AVG PEAK 5 MIN</div>
                    <div id="intel-avg5-value" class="intel-value mt-3">--</div>
                    <div id="intel-avg5-sub" class="intel-sub">No data</div>
                </div></div>
                <div id="intel-avg1h-card" class="intel-card neutral"><div class="w-full">
                    <div class="text-xs uppercase tracking-[0.22em] text-zinc-500">AVG PEAK 1 HOUR</div>
                    <div id="intel-avg1h-value" class="intel-value mt-3">--</div>
                    <div id="intel-avg1h-sub" class="intel-sub">No data</div>
                </div></div>
                <div id="intel-force-card" class="intel-card neutral"><div class="w-full">
                    <div class="text-xs uppercase tracking-[0.22em] text-zinc-500">FORCE TREND</div>
                    <div id="intel-force-value" class="intel-value mt-3">--</div>
                    <div id="intel-force-sub" class="intel-sub">No data</div>
                </div></div>
                <div id="intel-imb-card" class="intel-card neutral"><div class="w-full">
                    <div class="text-xs uppercase tracking-[0.22em] text-zinc-500">IMBALANCE TREND</div>
                    <div id="intel-imb-value" class="intel-value mt-3">--</div>
                    <div id="intel-imb-sub" class="intel-sub">No data</div>
                </div></div>
                <div id="intel-stability-card" class="intel-card neutral"><div class="w-full">
                    <div class="text-xs uppercase tracking-[0.22em] text-zinc-500">PROCESS STABILITY</div>
                    <div id="intel-stability-value" class="intel-value mt-3">--</div>
                    <div id="intel-stability-sub" class="intel-sub">No data</div>
                </div></div>
            </div>
        </div>
        {{end}}

        {{if .ShowAlarmTimeline}}
        <div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
            <div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-5">
                <div>
                    <h2 class="text-2xl md:text-3xl font-semibold">Event / Alarm Timeline</h2>
                    <div class="text-zinc-400 mt-1 text-sm md:text-base">Recent transitions show exactly when the process began drifting, overloading, losing balance, or losing PLC communication</div>
                </div>
                <div class="limit-line-note">Newest events first • clear events included</div>
            </div>
            <div class="overflow-x-auto">
                <table class="alarm-table w-full text-sm">
                    <thead>
                        <tr class="text-left text-zinc-500 border-b border-white/10">
                            <th class="py-3 pr-4">Time</th>
                            <th class="py-3 pr-4">Severity</th>
                            <th class="py-3 pr-4">Source</th>
                            <th class="py-3 pr-4">Event</th>
                            <th class="py-3 pr-4 text-right">Value</th>
                            <th class="py-3 text-right">Limit</th>
                        </tr>
                    </thead>
                    <tbody id="alarm-table-body" class="text-zinc-200">
                        <tr><td colspan="6" class="py-6 text-center text-zinc-500">No events yet</td></tr>
                    </tbody>
                </table>
            </div>
        </div>
        {{end}}

        {{if .ShowGauges}}
        <div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-8">
            <div id="card-l" class="glass border border-white/10 rounded-3xl p-5 md:p-6 xl:p-8 transition-all duration-300">
                {{if .ShowGaugeDigital}}
                <div class="gauge-header-row">
                    <div class="gauge-head with-digital">
                        <div id="led-l" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
                        <div class="gauge-head-copy with-digital">
                            <h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.LeftLabel}}</h2>
                            <div id="state-l" class="text-sm text-zinc-400 mt-1">NORMAL</div>
                        </div>
                    </div>
                    <div id="digital-l" class="gauge-digital">
                        <div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-sky-100 leading-none">0.0</div>
                        <div class="text-xl text-sky-400 mt-1">{{.UnitPct}}</div>
                        <div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
                    </div>
                </div>
                {{else}}
                <div class="gauge-head">
                    <div id="led-l" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
                    <div class="gauge-head-copy">
                        <h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.LeftLabel}}</h2>
                        <div id="state-l" class="text-sm text-zinc-400 mt-1">NORMAL</div>
                    </div>
                </div>
                {{end}}
                <div class="gauge-container {{if .ShowGaugeDigital}}with-digital{{else}}no-digital{{end}}">
                    <canvas id="gaugeL" class="gauge-canvas"></canvas>
                </div>
            </div>

            <div id="card-r" class="glass border border-white/10 rounded-3xl p-5 md:p-6 xl:p-8 transition-all duration-300">
                {{if .ShowGaugeDigital}}
                <div class="gauge-header-row">
                    <div class="gauge-head with-digital">
                        <div id="led-r" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
                        <div class="gauge-head-copy with-digital">
                            <h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.RightLabel}}</h2>
                            <div id="state-r" class="text-sm text-zinc-400 mt-1">NORMAL</div>
                        </div>
                    </div>
                    <div id="digital-r" class="gauge-digital">
                        <div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-violet-100 leading-none">0.0</div>
                        <div class="text-xl text-violet-400 mt-1">{{.UnitPct}}</div>
                        <div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
                    </div>
                </div>
                {{else}}
                <div class="gauge-head">
                    <div id="led-r" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
                    <div class="gauge-head-copy">
                        <h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.RightLabel}}</h2>
                        <div id="state-r" class="text-sm text-zinc-400 mt-1">NORMAL</div>
                    </div>
                </div>
                {{end}}
                <div class="gauge-container {{if .ShowGaugeDigital}}with-digital{{else}}no-digital{{end}}">
                    <canvas id="gaugeR" class="gauge-canvas"></canvas>
                </div>
            </div>
        </div>
        {{end}}

        {{if .ShowTrendChart}}
        <div class="chart-wrap">
            <div class="glass border border-white/10 rounded-3xl p-5 md:p-7">
                <div class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
                    <div>
                        <h2 class="text-2xl md:text-3xl font-semibold">Peak Trend</h2>
                        <div class="text-zinc-400 mt-1 text-sm md:text-base">Piezo peak/stroke history from SQLite with visible warning and critical limits</div>
                    </div>
                    <div class="flex flex-wrap items-center gap-2">
                        <button class="window-btn active px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="30s">30s</button>
                        <button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="1m">1m</button>
                        <button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="5m">5m</button>
                        <button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="15m">15m</button>
                        <button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="1h">1h</button>
                        <button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="8h">8h</button>
                        <button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="24h">24h</button>
                        <div class="flex items-center gap-2 ml-1">
                            <input id="custom-window" type="text" placeholder="e.g. 90m or 2h" class="px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-100 w-36 outline-none">
                            <button id="apply-window" class="px-3 py-2 rounded-xl border border-sky-400/40 bg-sky-400/10 text-sky-200 text-sm font-medium">Apply</button>
                        </div>
                    </div>
                </div>
                <div class="h-[52vh] min-h-[420px] max-h-[760px]">
                    <canvas id="lineChart"></canvas>
                </div>
            </div>
        </div>
        {{end}}
        </div><!-- #process-content -->
    </div>

    <script>
        const WARNING_PERCENT = {{.WarningPercent}};
        const CRITICAL_PERCENT = {{.CriticalPercent}};
        const GAUGE_MAX_PERCENT = {{.GaugeMaxPercent}};
        const IMBALANCE_WARNING_PERCENT = {{.ImbalanceWarningPercent}};
        const IMBALANCE_CRITICAL_PERCENT = {{.ImbalanceCriticalPercent}};
        const UNIT_FORCE = '{{.UnitForce}}';
        const UNIT_PCT = '{{.UnitPct}}';
        const POLL_MS = {{.PollMs}};
        const DEFAULT_WINDOW = '{{.DefaultWindow}}';
        const DEFAULT_TREND_WINDOW = '{{.DefaultTrendWindow}}';
        const STALE_MS = Math.max(POLL_MS * 4, 2500);

        const SHOW_HEADER_CONTROLS = {{if .ShowHeaderControls}}true{{else}}false{{end}};
        const SHOW_VERDICT = {{if .ShowVerdict}}true{{else}}false{{end}};
        const SHOW_SUMMARY_BAR = {{if .ShowSummaryBar}}true{{else}}false{{end}};
        const SHOW_OVERVIEW = {{if .ShowOverview}}true{{else}}false{{end}};
        const SHOW_INTELLIGENCE = {{if .ShowIntelligence}}true{{else}}false{{end}};
        const SHOW_ALARM_TIMELINE = {{if .ShowAlarmTimeline}}true{{else}}false{{end}};
        const SHOW_GAUGES = {{if .ShowGauges}}true{{else}}false{{end}};
        const SHOW_GAUGE_DIGITAL = {{if .ShowGaugeDigital}}true{{else}}false{{end}};
        const SHOW_TREND_CHART = {{if .ShowTrendChart}}true{{else}}false{{end}};
        const CURRENT_UI_REVISION = {{.UIRevision}};

        const START_ANGLE = Math.PI * 0.75;
        const END_ANGLE   = Math.PI * 2.25;

        let lineChart = null;
        let latestData = null;
        let currentWindow = DEFAULT_WINDOW;
        let currentTrendWindow = DEFAULT_TREND_WINDOW;
        let currentTheme = 'dark';
        let activeUIRevision = CURRENT_UI_REVISION;
        let historyBusy = false;
        let trendBusy = false;
        let alarmsBusy = false;

        function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
        function lerp(a, b, t) { return a + (b - a) * t; }
        function isLightTheme() { return currentTheme === 'light'; }

        function escapeHtml(value) {
            return String(value === undefined || value === null ? '' : value)
                .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
                .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
        }

        function setTextById(id, text) {
            const el = document.getElementById(id);
            if (el) el.textContent = text;
        }

        function colorMix(c1, c2, t) {
            return { r: Math.round(lerp(c1.r, c2.r, t)), g: Math.round(lerp(c1.g, c2.g, t)), b: Math.round(lerp(c1.b, c2.b, t)) };
        }

        function colorToCss(c, a) {
            a = a === undefined ? 1 : a;
            return 'rgba(' + c.r + ',' + c.g + ',' + c.b + ',' + a + ')';
        }

        function polar(cx, cy, r, a) { return { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r }; }

        function valueToAngle(value) {
            const ratio = clamp((Number(value) || 0) / GAUGE_MAX_PERCENT, 0, 1);
            return START_ANGLE + ratio * (END_ANGLE - START_ANGLE);
        }

        function prepCanvas(canvas) {
            const dpr = window.devicePixelRatio || 1;
            const rect = canvas.getBoundingClientRect();
            const w = Math.max(1, Math.floor(rect.width));
            const h = Math.max(1, Math.floor(rect.height));
            canvas.width = Math.max(1, Math.floor(w * dpr));
            canvas.height = Math.max(1, Math.floor(h * dpr));
            const ctx = canvas.getContext('2d');
            ctx.setTransform(1, 0, 0, 1, 0, 0);
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
            return { ctx, w, h };
        }

        function gaugeBandColor(v) {
            const green  = { r: 34,  g: 197, b: 94  };
            const yellow = { r: 234, g: 179, b: 8   };
            const red    = { r: 239, g: 68,  b: 68  };
            if (v <= WARNING_PERCENT) {
                const t = WARNING_PERCENT <= 0 ? 0 : v / WARNING_PERCENT;
                return colorMix(green, yellow, t * 0.15);
            }
            if (v <= CRITICAL_PERCENT) {
                const span = Math.max(1, CRITICAL_PERCENT - WARNING_PERCENT);
                return colorMix(green, yellow, (v - WARNING_PERCENT) / span);
            }
            const span = Math.max(1, GAUGE_MAX_PERCENT - CRITICAL_PERCENT);
            return colorMix(yellow, red, (v - CRITICAL_PERCENT) / span);
        }

        function drawArc(ctx, cx, cy, r, a1, a2, stroke, width, shadowBlur) {
            shadowBlur = shadowBlur || 0;
            ctx.save();
            ctx.beginPath();
            ctx.arc(cx, cy, r, a1, a2, false);
            ctx.strokeStyle = stroke;
            ctx.lineWidth = width;
            ctx.lineCap = 'butt';
            if (shadowBlur > 0) { ctx.shadowColor = stroke; ctx.shadowBlur = shadowBlur; }
            ctx.stroke();
            ctx.restore();
        }

        function drawColoredBand(ctx, cx, cy, r, width) {
            const segments = 180;
            for (let i = 0; i < segments; i++) {
                const v1 = (i / segments) * GAUGE_MAX_PERCENT;
                const v2 = ((i + 1) / segments) * GAUGE_MAX_PERCENT;
                const c = gaugeBandColor((v1 + v2) / 2);
                drawArc(ctx, cx, cy, r, valueToAngle(v1), valueToAngle(v2), colorToCss(c, 0.95), width, 0);
            }
        }

        function drawGauge(canvasId, percentValue, knValue, sideAccent) {
            if (!SHOW_GAUGES) return;
            const canvas = document.getElementById(canvasId);
            if (!canvas) return;
            const prep = prepCanvas(canvas);
            const ctx = prep.ctx, w = prep.w, h = prep.h;
            const light = isLightTheme();
            const cx = w / 2;
            const radius = Math.min(w * 0.35, h * 0.42);
            const cy = h * 0.58;
            const trackWidth = Math.max(20, radius * 0.17);
            const value = clamp(Number(percentValue) || 0, 0, GAUGE_MAX_PERCENT);
            const valueAngle = valueToAngle(value);

            ctx.save();
            ctx.beginPath();
            ctx.arc(cx, cy, radius + 24, 0, Math.PI * 2);
            ctx.fillStyle = light ? 'rgba(15,23,42,0.04)' : 'rgba(255,255,255,0.015)';
            ctx.shadowColor = light ? 'rgba(15,23,42,0.12)' : 'rgba(0,0,0,0.45)';
            ctx.shadowBlur = 32;
            ctx.fill();
            ctx.restore();

            drawArc(ctx, cx, cy, radius, START_ANGLE, END_ANGLE, light ? 'rgba(15,23,42,0.08)' : 'rgba(255,255,255,0.06)', trackWidth + 10, 0);
            drawColoredBand(ctx, cx, cy, radius, trackWidth);
            drawArc(ctx, cx, cy, radius, valueAngle, END_ANGLE, light ? 'rgba(255,255,255,0.72)' : 'rgba(9,9,11,0.60)', trackWidth - 1, 0);
            drawArc(ctx, cx, cy, radius, START_ANGLE, valueAngle, light ? 'rgba(15,23,42,0.05)' : 'rgba(255,255,255,0.04)', trackWidth - 1, 10);

            for (let v = 0; v <= GAUGE_MAX_PERCENT + 0.0001; v += 5) {
                const a = valueToAngle(v);
                const isMajor = Math.abs(v % 10) < 0.0001;
                const isThreshold = Math.abs(v - WARNING_PERCENT) < 0.0001 || Math.abs(v - CRITICAL_PERCENT) < 0.0001;
                const r1 = isThreshold ? radius * 0.66 : isMajor ? radius * 0.72 : radius * 0.80;
                const p1 = polar(cx, cy, r1, a);
                const p2 = polar(cx, cy, radius * 0.97, a);
                ctx.beginPath();
                ctx.moveTo(p1.x, p1.y);
                ctx.lineTo(p2.x, p2.y);
                if (isThreshold) {
                    ctx.strokeStyle = light ? '#0f172a' : '#ffffff';
                    ctx.lineWidth = 3.2;
                } else if (isMajor) {
                    ctx.strokeStyle = light ? 'rgba(15,23,42,0.80)' : 'rgba(255,255,255,0.86)';
                    ctx.lineWidth = 2.2;
                } else {
                    ctx.strokeStyle = light ? 'rgba(71,85,105,0.65)' : 'rgba(161,161,170,0.74)';
                    ctx.lineWidth = 1.1;
                }
                ctx.stroke();
            }

            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillStyle = light ? 'rgba(15,23,42,0.88)' : 'rgba(244,244,245,0.96)';
            ctx.font = '700 18px system-ui, sans-serif';
            for (const v of [0, 20, 40, 60, 80, 100, 120, 130]) {
                const p = polar(cx, cy, radius * 1.13, valueToAngle(v));
                ctx.fillText(String(v), p.x, p.y);
            }

            const tip   = polar(cx, cy, radius * 0.86, valueAngle);
            const left  = polar(cx, cy, 8, valueAngle + Math.PI / 2);
            const right = polar(cx, cy, 8, valueAngle - Math.PI / 2);
            const tail  = polar(cx, cy, radius * 0.20, valueAngle + Math.PI);
            ctx.save();
            ctx.beginPath();
            ctx.moveTo(left.x, left.y);
            ctx.lineTo(tip.x, tip.y);
            ctx.lineTo(right.x, right.y);
            ctx.lineTo(tail.x, tail.y);
            ctx.closePath();
            ctx.fillStyle = light ? '#0f172a' : '#ffffff';
            ctx.shadowColor = light ? 'rgba(15,23,42,0.12)' : 'rgba(255,255,255,0.18)';
            ctx.shadowBlur = 10;
            ctx.fill();
            ctx.restore();

            ctx.beginPath();
            ctx.arc(cx, cy, 14, 0, Math.PI * 2);
            ctx.fillStyle = light ? '#ffffff' : '#101114';
            ctx.fill();
            ctx.lineWidth = 3;
            ctx.strokeStyle = sideAccent;
            ctx.stroke();

            ctx.beginPath();
            ctx.arc(cx, cy, 4.5, 0, Math.PI * 2);
            ctx.fillStyle = light ? '#0f172a' : '#ffffff';
            ctx.fill();

            const centerPlateRadius = (radius * 0.72) - 18;
            ctx.beginPath();
            ctx.arc(cx, cy + 8, centerPlateRadius, 0, Math.PI * 2);
            ctx.fillStyle = light ? 'rgba(255,255,255,0.98)' : 'rgba(9,9,11,0.90)';
            ctx.fill();
            ctx.lineWidth = 1.2;
            ctx.strokeStyle = light ? 'rgba(15,23,42,0.12)' : 'rgba(255,255,255,0.10)';
            ctx.stroke();

            let valueFontPx = 58;
            if (value >= 100) valueFontPx = 50;
            if (w < 420) valueFontPx -= 6;

            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillStyle = light ? '#0f172a' : '#ffffff';
            ctx.font = '700 ' + valueFontPx + 'px system-ui, sans-serif';
            ctx.fillText(value.toFixed(1), cx, cy - 6);
            ctx.fillStyle = sideAccent;
            ctx.font = '700 19px system-ui, sans-serif';
            ctx.fillText(UNIT_PCT, cx, cy + 30);
            ctx.fillStyle = light ? '#334155' : '#a1a1aa';
            ctx.font = '600 17px system-ui, sans-serif';
            ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 58);
        }

        // FIX: update digital display HTML elements (only visible when show_gauge_digital: true)
        function updateDigitalDisplay(side, percent, kn) {
            if (!SHOW_GAUGE_DIGITAL) return;
            const el = document.getElementById('digital-' + side);
            if (!el) return;
            const pctEl = el.querySelector('.percent');
            const knEl  = el.querySelector('.kn');
            if (pctEl) pctEl.textContent = percent.toFixed(1);
            if (knEl)  knEl.textContent  = kn.toFixed(1) + ' ' + UNIT_FORCE;
        }

        function getZone(v) {
            if (v >= CRITICAL_PERCENT)  return 'critical';
            if (v >= WARNING_PERCENT)   return 'warning';
            return 'ok';
        }

        function getImbalanceZone(v) {
            if (v >= IMBALANCE_CRITICAL_PERCENT) return 'critical';
            if (v >= IMBALANCE_WARNING_PERCENT)  return 'warning';
            return 'ok';
        }

        function setProcessVisualState(connected) {
            const el = document.getElementById('process-content');
            if (!el) return;
            el.classList.toggle('process-offline', !connected);
            el.classList.toggle('process-online', connected);
        }

        function setConnectionIndicator(connected, stale) {
            const dot  = document.getElementById('dot');
            const text = document.getElementById('status-text');
            if (!dot || !text) return;
            if (!connected) {
                dot.className  = 'w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20';
                text.textContent = 'Disconnected';
                text.className = 'font-semibold text-lg text-red-400';
                return;
            }
            if (stale) {
                dot.className  = 'w-4 h-4 rounded-full bg-yellow-400 ring-4 ring-yellow-400/20';
                text.textContent = 'Stale';
                text.className = 'font-semibold text-lg text-yellow-300';
                return;
            }
            dot.className  = 'w-4 h-4 rounded-full bg-emerald-400 ring-4 ring-emerald-400/20';
            text.textContent = 'Connected';
            text.className = 'font-semibold text-lg text-emerald-400';
        }

        function applyChannelState(side, percentValue) {
            if (!SHOW_GAUGES) return;
            const zone = getZone(percentValue);
            const card  = document.getElementById('card-' + side);
            const led   = document.getElementById('led-' + side);
            const stTxt = document.getElementById('state-' + side);
            if (!card || !led || !stTxt) return;
            card.classList.remove('soft-glow-green', 'soft-glow-yellow', 'soft-glow-red');
            if (zone === 'critical') {
                led.className = 'w-6 h-6 bg-red-500 rounded-full shadow-lg shadow-red-600/50';
                stTxt.textContent = 'CRITICAL';
                stTxt.className = 'text-sm text-red-400 mt-1 font-semibold';
                card.classList.add('soft-glow-red');
            } else if (zone === 'warning') {
                led.className = 'w-6 h-6 bg-yellow-400 rounded-full shadow-lg shadow-yellow-500/50';
                stTxt.textContent = 'WARNING';
                stTxt.className = 'text-sm text-yellow-400 mt-1 font-semibold';
                card.classList.add('soft-glow-yellow');
            } else {
                led.className = 'w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40';
                stTxt.textContent = 'NORMAL';
                stTxt.className = 'text-sm text-emerald-400 mt-1 font-semibold';
                card.classList.add('soft-glow-green');
            }
        }

        function formatLastUpdate(isoString) {
            if (!isoString) return 'Last update: --:--:--.---';
            const d = new Date(isoString);
            if (isNaN(d.getTime())) return 'Last update: --:--:--.---';
            const hh = String(d.getHours()).padStart(2, '0');
            const mm = String(d.getMinutes()).padStart(2, '0');
            const ss = String(d.getSeconds()).padStart(2, '0');
            const ms = String(d.getMilliseconds()).padStart(3, '0');
            return 'Last update: ' + hh + ':' + mm + ':' + ss + '.' + ms;
        }

        function setSummaryCard(kind, zone, text, value) {
            if (!SHOW_SUMMARY_BAR) return;
            const card   = document.getElementById('summary-' + kind + '-card');
            const dot    = document.getElementById('summary-' + kind + '-dot');
            const status = document.getElementById('summary-' + kind + '-text');
            const val    = document.getElementById('summary-' + kind + '-value');
            if (!card || !dot || !status || !val) return;
            card.className   = 'summary-card ' + zone;
            dot.className    = 'summary-dot '  + zone;
            status.className = 'summary-status ' + zone + ' font-semibold mt-1';
            status.textContent = text;
            val.textContent    = value;
        }

        function setVerdict(zone, statusText, reasonText) {
            if (!SHOW_VERDICT) return;
            const card   = document.getElementById('verdict-card');
            const status = document.getElementById('verdict-status');
            const reason = document.getElementById('verdict-reason');
            if (!card || !status || !reason) return;
            card.className     = 'verdict-card ' + zone;
            status.textContent = statusText;
            reason.textContent = reasonText;
        }

        function updateSummaryBar(connected, stale, leftPct, rightPct, imbalance) {
            if (!SHOW_SUMMARY_BAR) return;
            if (!connected) {
                setSummaryCard('force',     'neutral',  'NO DATA', '--');
                setSummaryCard('imbalance', 'neutral',  'NO DATA', '--');
                setSummaryCard('plc',       'critical', 'OFFLINE', 'Disconnected');
                return;
            }
            const maxForce    = Math.max(leftPct, rightPct);
            const forceZone   = getZone(maxForce);
            const dominantSide = leftPct >= rightPct ? 'L' : 'R';
            const forceText   = forceZone === 'ok' ? 'OK' : forceZone === 'warning' ? 'WARNING' : 'CRITICAL';
            setSummaryCard('force', forceZone, forceText, 'Max ' + maxForce.toFixed(1) + UNIT_PCT + ' (' + dominantSide + ')');
            const imbZone  = getImbalanceZone(imbalance);
            const imbText  = imbZone === 'ok' ? 'OK' : imbZone === 'warning' ? 'WARNING' : 'CRITICAL';
            setSummaryCard('imbalance', imbZone, imbText, imbalance.toFixed(1) + UNIT_PCT);
            if (stale) {
                setSummaryCard('plc', 'warning', 'STALE', 'No fresh data');
            } else {
                setSummaryCard('plc', 'ok', 'OK', 'Online');
            }
        }

        function updateMachineVerdict(connected, stale, leftPct, rightPct, imbalance) {
            if (!SHOW_VERDICT) return;
            if (!connected) { setVerdict('critical', 'OFFLINE', 'No PLC communication'); return; }
            if (stale)      { setVerdict('warning', 'STALE DATA', 'PLC connected but no fresh values received'); return; }
            const leftCrit = leftPct >= CRITICAL_PERCENT;
            const rightCrit = rightPct >= CRITICAL_PERCENT;
            const imbCrit = imbalance >= IMBALANCE_CRITICAL_PERCENT;
            if (leftCrit || rightCrit || imbCrit) {
                const reasons = [];
                if (leftCrit)  reasons.push('left force critical');
                if (rightCrit) reasons.push('right force critical');
                if (imbCrit)   reasons.push('imbalance critical');
                setVerdict('critical', 'CRITICAL', reasons.join(' • '));
                return;
            }
            const leftWarn = leftPct >= WARNING_PERCENT;
            const rightWarn = rightPct >= WARNING_PERCENT;
            const imbWarn = imbalance >= IMBALANCE_WARNING_PERCENT;
            if (leftWarn || rightWarn || imbWarn) {
                const reasons = [];
                if (leftWarn)  reasons.push('left force warning');
                if (rightWarn) reasons.push('right force warning');
                if (imbWarn)   reasons.push('imbalance warning');
                setVerdict('warning', 'WARNING', reasons.join(' • '));
                return;
            }
            setVerdict('ok', 'OK', 'Production stable within configured force and imbalance limits');
        }

        function updateAlarmBanner(leftPct, rightPct, imbalancePct, connected, stale) {
            const banner = document.getElementById('alarm-banner');
            const text   = document.getElementById('alarm-text');
            if (!banner || !text) return;
            if (!connected) { text.textContent = 'CRITICAL ALARM ACTIVE • PLC OFFLINE'; banner.classList.remove('hidden'); return; }
            if (stale) { text.textContent = 'WARNING • PLC DATA STALE'; banner.classList.remove('hidden'); return; }
            const lc = leftPct >= CRITICAL_PERCENT;
            const rc = rightPct >= CRITICAL_PERCENT;
            const ic = imbalancePct >= IMBALANCE_CRITICAL_PERCENT;
            if (!lc && !rc && !ic) { banner.classList.add('hidden'); return; }
            const parts = [];
            if (lc && rc) parts.push('FORCE LEFT + RIGHT');
            else if (lc) parts.push('FORCE LEFT');
            else if (rc) parts.push('FORCE RIGHT');
            if (ic) parts.push('IMBALANCE');
            text.textContent = 'CRITICAL ALARM ACTIVE • ' + parts.join(' • ');
            banner.classList.remove('hidden');
        }

        function redrawGauges() {
            if (!SHOW_GAUGES || !latestData) return;
            const lPct = Number(latestData.sila_l)    || 0;
            const rPct = Number(latestData.sila_r)    || 0;
            const lKN  = Number(latestData.sila_l_kn) || 0;
            const rKN  = Number(latestData.sila_r_kn) || 0;
            drawGauge('gaugeL', lPct, lKN, '#22d3ee');
            drawGauge('gaugeR', rPct, rKN, '#c084fc');
        }

        function directionLabel(d) {
            const map = { rising:'↑ rising', falling:'↓ falling', worsening:'↑ worsening', improving:'↓ improving', stable:'→ stable' };
            return map[d] || 'No data';
        }

        function directionClass(d) {
            const map = { rising:'dir-up', falling:'dir-down', worsening:'dir-bad', improving:'dir-down', stable:'dir-flat' };
            return map[d] || 'dir-flat';
        }

        function trendZoneForForce(dir, delta) {
            if (dir === 'insufficient_data') return 'neutral';
            if (dir === 'stable') return 'ok';
            if (dir === 'rising') return Math.abs(delta) >= 8 ? 'critical' : 'warning';
            return 'ok';
        }

        function trendZoneForImbalance(dir, delta) {
            if (dir === 'insufficient_data') return 'neutral';
            if (dir === 'stable') return 'ok';
            if (dir === 'worsening') return Math.abs(delta) >= 4 ? 'critical' : 'warning';
            return 'ok';
        }

        function stabilityZone(s) {
            return { stable:'ok', caution:'warning', unstable:'critical' }[s] || 'neutral';
        }

        function setIntelCard(idPrefix, zone, valueText, subText) {
            if (!SHOW_INTELLIGENCE) return;
            const card  = document.getElementById(idPrefix + '-card');
            const value = document.getElementById(idPrefix + '-value');
            const sub   = document.getElementById(idPrefix + '-sub');
            if (!card || !value || !sub) return;
            card.className = 'intel-card ' + zone;
            value.innerHTML = valueText;
            sub.innerHTML   = subText;
        }

        function formatSource(source) {
            const map = { force_left:'LEFT', force_right:'RIGHT', imbalance:'IMBALANCE', plc:'PLC' };
            return map[source] || String(source || '').toUpperCase();
        }

        function formatValue(value) {
            const n = Number(value);
            if (!isFinite(n)) return '--';
            return n.toFixed(1) + UNIT_PCT;
        }

        function applyTheme(theme) {
            currentTheme = theme === 'light' ? 'light' : 'dark';
            document.body.setAttribute('data-theme', currentTheme);
            try { localStorage.setItem('force-monitor-theme', currentTheme); } catch (e) {}
            updateThemeButton();
            updateChartTheme();
            redrawGauges();
        }

        function initTheme() {
            let theme = 'dark';
            try {
                const stored = localStorage.getItem('force-monitor-theme');
                if (stored === 'light' || stored === 'dark') {
                    theme = stored;
                } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
                    theme = 'light';
                }
            } catch (e) {}
            applyTheme(theme);
        }

        function toggleTheme() { applyTheme(isLightTheme() ? 'dark' : 'light'); }

        function updateThemeButton() {
            const btn = document.getElementById('theme-toggle');
            if (btn) btn.textContent = isLightTheme() ? 'Dark theme' : 'Light theme';
        }

        function updateFullscreenButton() {
            const btn = document.getElementById('fullscreen-toggle');
            if (btn) btn.textContent = document.fullscreenElement ? 'Exit fullscreen' : 'Enter fullscreen';
        }

        async function toggleFullscreen() {
            try {
                if (!document.fullscreenElement) {
                    await document.documentElement.requestFullscreen();
                } else {
                    await document.exitFullscreen();
                }
            } catch (err) {
                console.warn('Fullscreen error:', err);
            } finally {
                updateFullscreenButton();
            }
        }

        function updateChartTheme() {
            if (!SHOW_TREND_CHART || !lineChart) return;
            const light = isLightTheme();
            const grid = light ? 'rgba(15,23,42,0.10)' : 'rgba(255,255,255,0.06)';
            const tick = light ? '#334155' : '#a1a1aa';
            const legend = light ? '#0f172a' : '#f4f4f5';
            const tooltipBg = light ? 'rgba(255,255,255,0.98)' : 'rgba(9,9,11,0.96)';
            const tooltipText = light ? '#0f172a' : '#f4f4f5';
            lineChart.options.scales.x.grid.color = grid;
            lineChart.options.scales.x.ticks.color = tick;
            lineChart.options.scales.y.grid.color = grid;
            lineChart.options.scales.y.ticks.color = tick;
            lineChart.options.plugins.legend.labels.color = legend;
            lineChart.options.plugins.tooltip.backgroundColor = tooltipBg;
            lineChart.options.plugins.tooltip.titleColor = tooltipText;
            lineChart.options.plugins.tooltip.bodyColor = tooltipText;
            lineChart.update('none');
        }

        async function checkUIRevision() {
            try {
                const res = await fetch('/api/ui-revision', { cache: 'no-store' });
                if (!res.ok) return;
                const d = await res.json();
                const revision = Number(d.revision) || 0;
                if (revision > activeUIRevision) { window.location.reload(); return; }
                activeUIRevision = revision;
            } catch (err) { console.warn('UI revision check error:', err); }
        }

        async function fetchLiveData() {
            try {
                const res = await fetch('/api/data', { cache: 'no-store' });
                if (!res.ok) throw new Error('HTTP ' + res.status);
                const d = await res.json();
                latestData = d;

                const connected   = !!d.connected;
                const leftPercent = Number(d.sila_l)          || 0;
                const rightPercent= Number(d.sila_r)          || 0;
                const sumPercent  = Number(d.sum_percent)      || 0;
                const sumKN       = Number(d.sum_kn)           || 0;
                const imbalance   = Number(d.imbalance_percent)|| 0;
                const bias        = Number(d.bias_percent)     || 0;
                const leftKN      = Number(d.sila_l_kn)        || 0;
                const rightKN     = Number(d.sila_r_kn)        || 0;

                let stale = false;
                if (connected && d.last_update) {
                    const lastTs = new Date(d.last_update).getTime();
                    if (!isNaN(lastTs)) stale = (Date.now() - lastTs) > STALE_MS;
                }

                setConnectionIndicator(connected, stale);
                setProcessVisualState(connected && !stale);

                if (SHOW_OVERVIEW) {
                    setTextById('sum-percent', sumPercent.toFixed(1));
                    setTextById('sum-kn',      sumKN.toFixed(1));
                    setTextById('imbalance-pct', imbalance.toFixed(1));
                    setTextById('bias-pct',    bias.toFixed(1));
                }

                setTextById('last-update',      formatLastUpdate(d.last_update));
                setTextById('dropped-samples',  String(d.dropped_samples || 0));
                setTextById('dropped-events',   String(d.dropped_events  || 0));

                applyChannelState('l', leftPercent);
                applyChannelState('r', rightPercent);

                // FIX: update digital HTML displays (was missing in original)
                updateDigitalDisplay('l', leftPercent, leftKN);
                updateDigitalDisplay('r', rightPercent, rightKN);

                updateSummaryBar(connected, stale, leftPercent, rightPercent, imbalance);
                updateMachineVerdict(connected, stale, leftPercent, rightPercent, imbalance);
                updateAlarmBanner(leftPercent, rightPercent, imbalance, connected, stale);
                redrawGauges();
            } catch (err) {
                console.warn('Live fetch error:', err);
                latestData = null;
                setConnectionIndicator(false, false);
                setProcessVisualState(false);
                updateSummaryBar(false, false, 0, 0, 0);
                updateMachineVerdict(false, false, 0, 0, 0);
                updateAlarmBanner(0, 0, 0, false, false);
                // FIX: reset digital displays on error
                updateDigitalDisplay('l', 0, 0);
                updateDigitalDisplay('r', 0, 0);
            }
        }

        async function fetchHistory() {
            if (!SHOW_TREND_CHART || !lineChart || historyBusy) return;
            historyBusy = true;
            try {
                const res = await fetch('/api/history?window=' + encodeURIComponent(currentWindow), { cache: 'no-store' });
                if (!res.ok) throw new Error('HTTP ' + res.status);
                const d   = await res.json();
                const pts = Array.isArray(d.points) ? d.points : [];
                const labels  = pts.map(p => p.time);
                const dataL   = pts.map(p => p.sila_l);
                const dataR   = pts.map(p => p.sila_r);
                const warnLine = labels.map(() => WARNING_PERCENT);
                const critLine = labels.map(() => CRITICAL_PERCENT);
                lineChart.data.labels = labels;
                lineChart.data.datasets[0].data = dataL;
                lineChart.data.datasets[1].data = dataR;
                lineChart.data.datasets[2].data = warnLine;
                lineChart.data.datasets[3].data = critLine;
                lineChart.update('none');
            } catch (err) {
                console.warn('History fetch error:', err);
            } finally {
                historyBusy = false;
            }
        }

        async function fetchTrend() {
            if (!SHOW_INTELLIGENCE || trendBusy) return;
            trendBusy = true;
            try {
                const res = await fetch('/api/trend?window=' + encodeURIComponent(currentTrendWindow), { cache: 'no-store' });
                if (!res.ok) throw new Error('HTTP ' + res.status);
                const d = await res.json();
                const avgPeak5m   = Number(d.avg_peak_5m)         || 0;
                const avgPeak1h   = Number(d.avg_peak_1h)         || 0;
                const avgImb5m    = Number(d.avg_imbalance_5m)    || 0;
                const avgImb1h    = Number(d.avg_imbalance_1h)    || 0;
                const forceDelta  = Number(d.force_delta_pct)     || 0;
                const imbDelta    = Number(d.imbalance_delta_pct) || 0;
                const forceDir    = d.force_direction      || 'insufficient_data';
                const imbDir      = d.imbalance_direction  || 'insufficient_data';
                const stability   = d.process_stability    || 'insufficient_data';
                const stReason    = d.stability_reason     || 'No data';
                const forceStd    = Number(d.force_stddev)        || 0;
                const imbStd      = Number(d.imbalance_stddev)    || 0;
                const sampleCount = Number(d.sample_count)        || 0;
                const windowLabel = d.window || currentTrendWindow;

                setIntelCard('intel-avg5', getZone(avgPeak5m),
                    avgPeak5m.toFixed(1) + UNIT_PCT,
                    'Avg imbalance 5m: <span class="intel-kpi">' + avgImb5m.toFixed(1) + UNIT_PCT + '</span>');

                setIntelCard('intel-avg1h', getZone(avgPeak1h),
                    avgPeak1h.toFixed(1) + UNIT_PCT,
                    'Avg imbalance 1h: <span class="intel-kpi">' + avgImb1h.toFixed(1) + UNIT_PCT + '</span>');

                setIntelCard('intel-force', trendZoneForForce(forceDir, forceDelta),
                    (forceDelta >= 0 ? '+' : '') + forceDelta.toFixed(1) + UNIT_PCT,
                    '<span class="' + directionClass(forceDir) + ' font-semibold">' + directionLabel(forceDir) + '</span><br>Δ avg force over ' + windowLabel + ' • σ ' + forceStd.toFixed(2));

                setIntelCard('intel-imb', trendZoneForImbalance(imbDir, imbDelta),
                    (imbDelta >= 0 ? '+' : '') + imbDelta.toFixed(1) + UNIT_PCT,
                    '<span class="' + directionClass(imbDir) + ' font-semibold">' + directionLabel(imbDir) + '</span><br>Δ avg imbalance over ' + windowLabel + ' • σ ' + imbStd.toFixed(2));

                setIntelCard('intel-stability', stabilityZone(stability),
                    String(stability).toUpperCase(),
                    stReason + '<br><span class="mini-mono">Samples: ' + sampleCount + ' • Window: ' + windowLabel + '</span>');
            } catch (err) {
                console.warn('Trend fetch error:', err);
                ['intel-avg5','intel-avg1h','intel-force','intel-imb','intel-stability']
                    .forEach(id => setIntelCard(id, 'neutral', '--', 'No data'));
            } finally {
                trendBusy = false;
            }
        }

        async function fetchAlarms() {
            if (!SHOW_ALARM_TIMELINE || alarmsBusy) return;
            alarmsBusy = true;
            try {
                const res = await fetch('/api/alarms?limit=20', { cache: 'no-store' });
                if (!res.ok) throw new Error('HTTP ' + res.status);
                const d      = await res.json();
                const events = Array.isArray(d.events) ? d.events : [];
                const body   = document.getElementById('alarm-table-body');
                if (!body) return;
                if (events.length === 0) {
                    body.innerHTML = '<tr><td colspan="6" class="py-6 text-center text-zinc-500">No events yet</td></tr>';
                    return;
                }
                let html = '';
                for (const ev of events) {
                    const sev = String(ev.severity || 'info');
                    const val = ev.source === 'plc' ? '--' : formatValue(ev.value);
                    const lim = ev.limit > 0 ? formatValue(ev.limit) : '--';
                    html += '<tr class="border-b border-white/5">';
                    html += '<td class="py-3 pr-4 font-mono text-zinc-300 whitespace-nowrap">' + escapeHtml(ev.time || '--') + '</td>';
                    html += '<td class="py-3 pr-4"><span class="severity-pill ' + escapeHtml(sev) + '">' + escapeHtml(sev.toUpperCase()) + '</span></td>';
                    html += '<td class="py-3 pr-4 font-semibold text-zinc-200">' + escapeHtml(formatSource(ev.source)) + '</td>';
                    html += '<td class="py-3 pr-4 text-zinc-300">' + escapeHtml(ev.message || '--') + '</td>';
                    html += '<td class="py-3 pr-4 text-right font-mono text-zinc-200">' + escapeHtml(val) + '</td>';
                    html += '<td class="py-3 text-right font-mono text-zinc-400">' + escapeHtml(lim) + '</td>';
                    html += '</tr>';
                }
                body.innerHTML = html;
            } catch (err) {
                console.warn('Alarm fetch error:', err);
            } finally {
                alarmsBusy = false;
            }
        }

        function setActiveWindowButton(value) {
            document.querySelectorAll('.window-btn').forEach(btn =>
                btn.classList.toggle('active', btn.dataset.window === value));
        }

        function setActiveTrendWindowButton(value) {
            document.querySelectorAll('.trend-window-btn').forEach(btn =>
                btn.classList.toggle('active', btn.dataset.window === value));
        }

        function useWindow(value) {
            if (!SHOW_TREND_CHART) return;
            currentWindow = value;
            setActiveWindowButton(value);
            fetchHistory();
        }

        function useTrendWindow(value) {
            if (!SHOW_INTELLIGENCE) return;
            currentTrendWindow = value;
            setActiveTrendWindowButton(value);
            fetchTrend();
        }

        window.onload = () => {
            initTheme();
            setActiveWindowButton(DEFAULT_WINDOW);
            setActiveTrendWindowButton(DEFAULT_TREND_WINDOW);

            if (SHOW_HEADER_CONTROLS) {
                const themeBtn = document.getElementById('theme-toggle');
                const fsBtn    = document.getElementById('fullscreen-toggle');
                if (themeBtn) themeBtn.addEventListener('click', toggleTheme);
                if (fsBtn)    fsBtn.addEventListener('click', toggleFullscreen);
            }

            document.querySelectorAll('.window-btn').forEach(btn =>
                btn.addEventListener('click', () => useWindow(btn.dataset.window)));

            document.querySelectorAll('.trend-window-btn').forEach(btn =>
                btn.addEventListener('click', () => useTrendWindow(btn.dataset.window)));

            const applyWindowBtn = document.getElementById('apply-window');
            if (applyWindowBtn) {
                applyWindowBtn.addEventListener('click', () => {
                    const input = document.getElementById('custom-window');
                    const val = input ? input.value.trim() : '';
                    if (!val) return;
                    currentWindow = val;
                    document.querySelectorAll('.window-btn').forEach(btn => btn.classList.remove('active'));
                    fetchHistory();
                });
            }

            const applyTrendBtn = document.getElementById('apply-trend-window');
            if (applyTrendBtn) {
                applyTrendBtn.addEventListener('click', () => {
                    const input = document.getElementById('custom-trend-window');
                    const val = input ? input.value.trim() : '';
                    if (!val) return;
                    currentTrendWindow = val;
                    document.querySelectorAll('.trend-window-btn').forEach(btn => btn.classList.remove('active'));
                    fetchTrend();
                });
            }

            document.addEventListener('fullscreenchange', updateFullscreenButton);
            updateFullscreenButton();

            if (SHOW_TREND_CHART) {
                const chartCanvas = document.getElementById('lineChart');
                if (chartCanvas) {
                    lineChart = new Chart(chartCanvas, {
                        type: 'line',
                        data: {
                            labels: [],
                            datasets: [
                                { label: 'Levi peak %',   borderColor: '#22d3ee', backgroundColor: 'rgba(34,211,238,0.10)', borderWidth: 3, tension: 0.22, pointRadius: 0, data: [] },
                                { label: 'Desni peak %',  borderColor: '#c084fc', backgroundColor: 'rgba(192,132,252,0.10)', borderWidth: 3, tension: 0.22, pointRadius: 0, data: [] },
                                { label: 'Warning limit', borderColor: 'rgba(245,158,11,0.95)', borderWidth: 2, pointRadius: 0, borderDash: [8, 6], tension: 0, data: [] },
                                { label: 'Critical limit',borderColor: 'rgba(239,68,68,0.95)',  borderWidth: 2, pointRadius: 0, borderDash: [8, 6], tension: 0, data: [] }
                            ]
                        },
                        options: {
                            responsive: true,
                            maintainAspectRatio: false,
                            interaction: { mode: 'index', intersect: false },
                            animation: false,
                            scales: {
                                x: { grid: { color: 'rgba(255,255,255,0.06)' }, ticks: { color: '#a1a1aa', maxTicksLimit: 18 } },
                                y: { min: 0, max: GAUGE_MAX_PERCENT, grid: { color: 'rgba(255,255,255,0.06)' }, ticks: { color: '#a1a1aa', stepSize: 10 } }
                            },
                            plugins: {
                                legend: { position: 'top', labels: { color: '#f4f4f5' } },
                                tooltip: { backgroundColor: 'rgba(9,9,11,0.96)', titleColor: '#f4f4f5', bodyColor: '#f4f4f5' }
                            }
                        }
                    });
                    updateChartTheme();
                }
            }

            setProcessVisualState(false);

            fetchLiveData();
            fetchHistory();
            fetchTrend();
            fetchAlarms();
            checkUIRevision();

            setInterval(fetchLiveData,  POLL_MS);
            setInterval(checkUIRevision, 1200);
            if (SHOW_TREND_CHART)    setInterval(fetchHistory, Math.max(1500, POLL_MS * 3));
            if (SHOW_INTELLIGENCE)   setInterval(fetchTrend,   Math.max(2500, POLL_MS * 5));
            if (SHOW_ALARM_TIMELINE) setInterval(fetchAlarms,  2500);

            window.addEventListener('resize', redrawGauges);
        };
    </script>
</body>
</html>`
