package main

import (
	"bufio"
	"bytes"
	"crypto/rand"
	"crypto/sha1"
	"crypto/sha256"
	"crypto/tls"
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"sync"
	"time"
)

const (
	agentVersion          = "1.5.0"
	protocolVersion       = 1
	websocketGUID         = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
	heartbeatInterval     = 8 * time.Second
	overlayPollInterval   = 400 * time.Millisecond
	overlayResendInterval = 2 * time.Second
)

type Config struct {
	ServerURL         string `json:"server_url"`
	FallbackServerURL string `json:"fallback_server_url,omitempty"`
	VmixURL           string `json:"vmix_url"`
	DeviceID          string `json:"device_id"`
	DeviceSecret      string `json:"device_secret"`
	DeviceName        string `json:"device_name"`
	StartMinimized    bool   `json:"start_minimized"`
	RunWithWindows    bool   `json:"run_with_windows"`
}

type VmixStatus struct {
	Connected bool   `json:"connected"`
	Version   string `json:"version,omitempty"`
	URL       string `json:"url"`
	Error     string `json:"error,omitempty"`
	Response  string `json:"response,omitempty"`
}

type VmixFieldInventory struct {
	Name  string `json:"name"`
	Type  string `json:"type"`
	Index string `json:"index,omitempty"`
}

type VmixInputInventory struct {
	Key    string               `json:"key,omitempty"`
	Number string               `json:"number,omitempty"`
	Title  string               `json:"title"`
	Type   string               `json:"type,omitempty"`
	Fields []VmixFieldInventory `json:"fields"`
}

type VmixInventory struct {
	Fingerprint string               `json:"fingerprint"`
	Inputs      []VmixInputInventory `json:"inputs"`
	InputCount  int                  `json:"input_count"`
	FieldCount  int                  `json:"field_count"`
	VmixVersion string               `json:"vmix_version,omitempty"`
}

type vmixAPIXML struct {
	Version  string           `xml:"version"`
	Inputs   []vmixInputXML   `xml:"inputs>input"`
	Overlays []vmixOverlayXML `xml:"overlays>overlay"`
}

type vmixOverlayXML struct {
	Number string `xml:"number,attr"`
	Key    string `xml:"key,attr"`
	Value  string `xml:",chardata"`
}

type VmixOverlayTally struct {
	Input       string `json:"input"`
	InputNumber string `json:"input_number,omitempty"`
	InputKey    string `json:"input_key,omitempty"`
	InputTitle  string `json:"input_title,omitempty"`
}

type vmixInputXML struct {
	Key      string         `xml:"key,attr"`
	Number   string         `xml:"number,attr"`
	Type     string         `xml:"type,attr"`
	Title    string         `xml:"title,attr"`
	Children []vmixFieldXML `xml:",any"`
}

type vmixFieldXML struct {
	XMLName xml.Name
	Index   string `xml:"index,attr"`
	Name    string `xml:"name,attr"`
}

type AppState struct {
	ServerConnected   bool
	ServerConnecting  bool
	ServerError       string
	Vmix              VmixStatus
	Paired            bool
	PairedLogin       string
	Active            bool
	AssignmentID      string
	MatchID           string
	MappingName       string
	MappingVersion    string
	LastCommand       string
	LastCommandOK     bool
	LastCommandAt     time.Time
	LastCommandDetail string
	LastServerAt      time.Time
	ConnectedServer   string
}

type Agent struct {
	mu          sync.RWMutex
	cfg         Config
	cfgPath     string
	state       AppState
	logs        []string
	logFilePath string
	fileLog     chan string
	currentWS   *wsConn
	inventory   VmixInventory
	stop        chan struct{}
	reconnect   chan struct{}
}

type wsConn struct {
	conn net.Conn
	r    *bufio.Reader
	mu   sync.Mutex
}

func NewAgent(cfgPath string, cfg Config) *Agent {
	return &Agent{
		cfgPath:     cfgPath,
		cfg:         cfg,
		stop:        make(chan struct{}),
		reconnect:   make(chan struct{}, 1),
		logs:        make([]string, 0, 150),
		logFilePath: filepath.Join(filepath.Dir(cfgPath), "agent.log"),
		fileLog:     make(chan string, 256),
		state:       AppState{Vmix: VmixStatus{URL: cfg.VmixURL}},
	}
}

func (a *Agent) Start() {
	go a.fileLogLoop()
	a.logf("Agent %s started", agentVersion)
	a.logf("Device: %s (%s)", a.cfg.DeviceName, short(a.cfg.DeviceID))
	go a.vmixMonitor()
	go a.vmixOverlayMonitor()
	go a.connectionLoop()
}

func (a *Agent) Stop() {
	select {
	case <-a.stop:
		return
	default:
		close(a.stop)
	}
	a.mu.Lock()
	ws := a.currentWS
	a.currentWS = nil
	a.mu.Unlock()
	// Never perform socket I/O while holding the Agent mutex. A slow or stuck
	// network close must not block GUI reads such as Config()/Snapshot().
	if ws != nil {
		_ = ws.Close()
	}
}

func (a *Agent) Config() Config {
	a.mu.RLock()
	defer a.mu.RUnlock()
	return a.cfg
}

func (a *Agent) UpdateConfig(cfg Config) error {
	a.mu.Lock()
	// Device identity is intentionally immutable from the GUI.
	cfg.DeviceID = a.cfg.DeviceID
	cfg.DeviceSecret = a.cfg.DeviceSecret
	if strings.TrimSpace(cfg.DeviceName) == "" {
		cfg.DeviceName = hostname()
	}
	cfg.ServerURL = normalizeServerBase(cfg.ServerURL)
	cfg.FallbackServerURL = normalizeServerBase(cfg.FallbackServerURL)
	cfg.VmixURL = strings.TrimSpace(cfg.VmixURL)
	a.cfg = cfg
	a.mu.Unlock()
	if err := saveConfig(a.cfgPath, cfg); err != nil {
		return err
	}
	a.logf("Settings saved")
	select {
	case a.reconnect <- struct{}{}:
	default:
	}
	a.mu.RLock()
	ws := a.currentWS
	a.mu.RUnlock()
	// Closing a WebSocket can occasionally take longer than expected on a bad
	// network. Do it outside the mutex so the Windows UI never waits on it.
	if ws != nil {
		_ = ws.Close()
	}
	return nil
}

func (a *Agent) Snapshot() AppState {
	a.mu.RLock()
	defer a.mu.RUnlock()
	return a.state
}

func (a *Agent) Logs() string {
	a.mu.RLock()
	defer a.mu.RUnlock()
	return strings.Join(a.logs, "\r\n")
}

func (a *Agent) logf(format string, args ...any) {
	line := fmt.Sprintf("[%s] %s", stamp(), fmt.Sprintf(format, args...))
	a.mu.Lock()
	a.logs = append(a.logs, line)
	if len(a.logs) > 150 {
		a.logs = append([]string(nil), a.logs[len(a.logs)-150:]...)
	}
	a.mu.Unlock()
	// File logging is best-effort and never allowed to block the Agent. This is
	// especially useful when the GUI itself becomes unresponsive: agent.log can
	// still show the last network/vMix event before the problem.
	select {
	case a.fileLog <- line:
	default:
	}
}

func (a *Agent) fileLogLoop() {
	path := a.logFilePath
	if stat, err := os.Stat(path); err == nil && stat.Size() > 2*1024*1024 {
		_ = os.Remove(path + ".1")
		_ = os.Rename(path, path+".1")
	}
	f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
	if err != nil {
		return
	}
	defer f.Close()
	for {
		select {
		case <-a.stop:
			return
		case line := <-a.fileLog:
			_, _ = io.WriteString(f, time.Now().Format("2006-01-02 ")+line+"\r\n")
		}
	}
}

func (a *Agent) vmixMonitor() {
	ticker := time.NewTicker(3 * time.Second)
	defer ticker.Stop()
	for {
		a.mu.RLock()
		vmixURL := a.cfg.VmixURL
		a.mu.RUnlock()
		status, inventory := scanVmix(vmixURL)
		a.mu.Lock()
		previous := a.state.Vmix.Connected
		previousErr := a.state.Vmix.Error
		previousFingerprint := a.inventory.Fingerprint
		a.state.Vmix = status
		a.inventory = inventory
		ws := a.currentWS
		a.mu.Unlock()
		if status.Connected && inventory.Fingerprint != "" && inventory.Fingerprint != previousFingerprint && ws != nil {
			if err := ws.WriteJSON(map[string]any{"type": "vmix.inventory", "device_id": a.cfg.DeviceID, "inventory": inventory, "vmix": status}); err == nil {
				a.logf("vMix structure sent: %d Inputs / %d fields", inventory.InputCount, inventory.FieldCount)
			}
		}
		if status.Connected && !previous {
			a.logf("vMix connected%s", suffixVersion(status.Version))
		}
		if !status.Connected && (previous || status.Error != previousErr) {
			a.logf("vMix not found: %s", status.Error)
		}
		select {
		case <-a.stop:
			return
		case <-ticker.C:
		}
	}
}

func (a *Agent) vmixOverlayMonitor() {
	ticker := time.NewTicker(overlayPollInterval)
	defer ticker.Stop()
	lastHash := ""
	var lastWS *wsConn
	lastSentAt := time.Time{}
	for {
		a.mu.RLock()
		vmixURL := a.cfg.VmixURL
		ws := a.currentWS
		a.mu.RUnlock()

		overlays, connected := scanVmixOverlayTally(vmixURL)
		encoded, _ := json.Marshal(overlays)
		stateHash := fmt.Sprintf("%t:%s", connected, string(encoded))
		force := ws != nil && ws != lastWS
		periodic := ws != nil && (lastSentAt.IsZero() || time.Since(lastSentAt) >= overlayResendInterval)
		changed := stateHash != lastHash
		if ws != nil && (force || periodic || changed) {
			payload := map[string]any{
				"type":           "vmix.overlay_state",
				"device_id":      a.cfg.DeviceID,
				"vmix_connected": connected,
				"overlays":       overlays,
				"observed_at":    time.Now().UTC().Format(time.RFC3339Nano),
			}
			if err := ws.WriteJSON(payload); err == nil {
				lastHash = stateHash
				lastWS = ws
				lastSentAt = time.Now()
			}
		}
		if ws == nil {
			lastWS = nil
		}

		select {
		case <-a.stop:
			return
		case <-ticker.C:
		}
	}
}

func scanVmixOverlayTally(base string) (map[string]VmixOverlayTally, bool) {
	result := map[string]VmixOverlayTally{}
	target := strings.TrimSpace(base)
	if target == "" {
		return result, false
	}
	resp, err := vmixHTTPClient.Get(target)
	if err != nil {
		return result, false
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return result, false
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024*1024))
	if err != nil {
		return result, false
	}
	var api vmixAPIXML
	if err := xml.Unmarshal(body, &api); err != nil {
		return result, true
	}
	byNumber := map[string]vmixInputXML{}
	byKey := map[string]vmixInputXML{}
	for _, input := range api.Inputs {
		number := strings.TrimSpace(input.Number)
		key := strings.TrimSpace(input.Key)
		if number != "" {
			byNumber[number] = input
		}
		if key != "" {
			byKey[strings.ToLower(key)] = input
		}
	}
	for _, overlay := range api.Overlays {
		layer := strings.TrimSpace(overlay.Number)
		if layer != "1" && layer != "2" && layer != "3" && layer != "4" {
			continue
		}
		raw := strings.TrimSpace(overlay.Value)
		rawKey := strings.TrimSpace(overlay.Key)
		if raw == "" && rawKey == "" {
			continue
		}
		var matched vmixInputXML
		found := false
		if raw != "" {
			if item, ok := byNumber[raw]; ok {
				matched, found = item, true
			} else if item, ok := byKey[strings.ToLower(raw)]; ok {
				matched, found = item, true
			}
		}
		if !found && rawKey != "" {
			if item, ok := byKey[strings.ToLower(rawKey)]; ok {
				matched, found = item, true
			}
		}
		entry := VmixOverlayTally{Input: raw}
		if entry.Input == "" {
			entry.Input = rawKey
		}
		if found {
			entry.InputNumber = strings.TrimSpace(matched.Number)
			entry.InputKey = strings.TrimSpace(matched.Key)
			entry.InputTitle = strings.TrimSpace(matched.Title)
			if entry.Input == "" {
				entry.Input = entry.InputNumber
			}
		} else {
			entry.InputKey = rawKey
			if _, ok := byNumber[raw]; ok || raw != "" {
				entry.InputNumber = raw
			}
		}
		result[layer] = entry
	}
	return result, true
}

func suffixVersion(v string) string {
	if v == "" {
		return ""
	}
	return " · " + v
}

func (a *Agent) connectionLoop() {
	backoff := time.Second
outer:
	for {
		select {
		case <-a.stop:
			return
		default:
		}
		a.mu.RLock()
		cfg := a.cfg
		a.mu.RUnlock()
		candidates := serverCandidates(cfg)
		if len(candidates) == 0 {
			candidates = []string{"http://127.0.0.1:8000"}
		}
		var lastErr error
		for _, serverBase := range candidates {
			a.mu.Lock()
			a.state.ServerConnecting = true
			a.state.ServerError = ""
			a.mu.Unlock()
			connectionStarted := time.Now()
			err := a.runConnection(serverBase)
			if time.Since(connectionStarted) >= 5*time.Second {
				backoff = time.Second
			}
			a.mu.Lock()
			a.state.ServerConnected = false
			a.state.ServerConnecting = false
			a.state.ConnectedServer = ""
			if err != nil {
				a.state.ServerError = err.Error()
			}
			a.currentWS = nil
			a.mu.Unlock()
			if err == nil {
				lastErr = nil
				break
			}
			lastErr = err
			a.logf("Server unavailable (%s): %v", serverBase, err)
			select {
			case <-a.stop:
				return
			case <-a.reconnect:
				backoff = time.Second
				continue outer
			default:
			}
		}
		if lastErr != nil {
			a.logf("All configured Hockey servers are offline")
		}
		timer := time.NewTimer(backoff)
		select {
		case <-a.stop:
			timer.Stop()
			return
		case <-a.reconnect:
			timer.Stop()
			backoff = time.Second
			continue
		case <-timer.C:
		}
		if backoff < 15*time.Second {
			backoff *= 2
		}
		if backoff > 15*time.Second {
			backoff = 15 * time.Second
		}
	}
}

func (a *Agent) runConnection(serverBase string) error {
	a.mu.RLock()
	cfg := a.cfg
	a.mu.RUnlock()
	target, err := websocketURL(serverBase)
	if err != nil {
		return err
	}
	a.logf("Connecting to %s", target)
	ws, err := dialWebSocket(target, 7*time.Second)
	if err != nil {
		return err
	}
	defer ws.Close()
	a.mu.Lock()
	a.currentWS = ws
	a.state.ServerConnected = true
	a.state.ServerConnecting = false
	a.state.ServerError = ""
	a.state.LastServerAt = time.Now()
	a.state.ConnectedServer = serverBase
	a.mu.Unlock()
	a.logf("Server connected: %s", serverBase)

	vmix, inventory := scanVmix(cfg.VmixURL)
	a.mu.Lock()
	a.state.Vmix = vmix
	a.inventory = inventory
	a.mu.Unlock()
	hello := map[string]any{
		"type": "hello", "protocol": protocolVersion,
		"device_id": cfg.DeviceID, "device_secret": cfg.DeviceSecret,
		"device_name": cfg.DeviceName, "hostname": hostname(),
		"agent_version": agentVersion, "platform": runtime.GOOS + "/" + runtime.GOARCH,
		"vmix": vmix,
	}
	if err := ws.WriteJSON(hello); err != nil {
		return err
	}
	if inventory.Fingerprint != "" {
		if err := ws.WriteJSON(map[string]any{"type": "vmix.inventory", "device_id": cfg.DeviceID, "inventory": inventory, "vmix": vmix}); err != nil {
			return err
		}
	}

	// Heartbeats are written from their own goroutine. The old implementation
	// used a 1.2 s read timeout merely to get a chance to send heartbeats. If a
	// WebSocket frame arrived slowly, that timeout could fire after part of the
	// frame had already been consumed, desynchronising the stream. From then on
	// the agent could randomly disconnect or appear to hang. A dedicated writer
	// removes that failure mode completely.
	heartbeatStop := make(chan struct{})
	heartbeatErr := make(chan error, 1)
	defer close(heartbeatStop)

	sendHeartbeat := func() error {
		a.mu.RLock()
		currentVmix := a.state.Vmix
		assignmentID, matchID := a.state.AssignmentID, a.state.MatchID
		a.mu.RUnlock()
		return ws.WriteJSON(map[string]any{
			"type": "heartbeat", "device_id": cfg.DeviceID,
			"assignment_id": assignmentID, "match_id": matchID,
			"vmix": currentVmix, "error": currentVmix.Error,
		})
	}

	// Send one immediately so the server sees fresh status without waiting.
	if err := sendHeartbeat(); err != nil {
		return err
	}
	go func() {
		ticker := time.NewTicker(heartbeatInterval)
		defer ticker.Stop()
		for {
			select {
			case <-heartbeatStop:
				return
			case <-a.stop:
				return
			case <-ticker.C:
				if err := sendHeartbeat(); err != nil {
					select {
					case heartbeatErr <- err:
					default:
					}
					_ = ws.Close() // unblock the reader immediately
					return
				}
			}
		}
	}()

	// The server answers every heartbeat. If no complete server frame is
	// received for 30 seconds, treat the TCP connection as stale and reconnect.
	// A timeout always discards this socket; we never continue after partially
	// consuming a frame.
	for {
		_ = ws.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
		payload, err := ws.ReadText()
		if err != nil {
			select {
			case hbErr := <-heartbeatErr:
				return fmt.Errorf("heartbeat write failed: %w", hbErr)
			default:
			}
			if ne, ok := err.(net.Error); ok && ne.Timeout() {
				return errors.New("server watchdog timeout: no data for 30s")
			}
			select {
			case <-a.stop:
				return nil
			default:
			}
			return err
		}
		var msg map[string]any
		if json.Unmarshal(payload, &msg) != nil {
			continue
		}
		a.mu.Lock()
		a.state.LastServerAt = time.Now()
		a.mu.Unlock()
		if err := a.handleMessage(ws, msg); err != nil {
			a.logf("Message error: %v", err)
		}
	}
}

func (a *Agent) handleMessage(ws *wsConn, msg map[string]any) error {
	typ := stringValue(msg["type"])
	switch typ {
	case "hello.ok":
		a.logf("Device registered on server")
		if dev, ok := msg["device"].(map[string]any); ok {
			a.mu.Lock()
			a.state.Paired = boolValue(dev["paired"])
			a.state.PairedLogin = stringValue(dev["owner"])
			a.state.Active = boolValue(dev["active_for_account"])
			a.state.MatchID = stringValue(dev["current_match_id"])
			a.state.AssignmentID = stringValue(dev["assignment_id"])
			a.mu.Unlock()
		}
	case "hello.error":
		return errors.New(stringValue(msg["detail"]))
	case "pairing.confirmed":
		account, _ := msg["account"].(map[string]any)
		login := stringValue(account["login"])
		active := boolValue(msg["active_for_account"])
		a.mu.Lock()
		a.state.Paired = true
		a.state.PairedLogin = login
		a.state.Active = active
		a.mu.Unlock()
		a.logf("Paired to account: %s", login)
	case "pairing.revoked":
		a.mu.Lock()
		a.state.Paired = false
		a.state.PairedLogin = ""
		a.state.Active = false
		a.state.AssignmentID = ""
		a.state.MatchID = ""
		a.mu.Unlock()
		a.logf("Pairing revoked")
	case "device.activated":
		a.mu.Lock()
		a.state.Active = true
		a.mu.Unlock()
		a.logf("Device activated for broadcast")
	case "device.deactivated":
		a.mu.Lock()
		a.state.Active = false
		a.state.AssignmentID = ""
		a.state.MatchID = ""
		a.mu.Unlock()
		a.logf("Device moved to standby")
	case "match.assign":
		assignmentID := stringValue(msg["assignment_id"])
		matchID := stringValue(msg["game_id"])
		a.mu.Lock()
		old := a.state.MatchID
		a.state.AssignmentID = assignmentID
		a.state.MatchID = matchID
		a.mu.Unlock()
		if old != "" && old != matchID {
			a.logf("Match switched: %s → %s", old, matchID)
		} else {
			a.logf("Match assigned: %s", matchID)
		}
		a.mu.RLock()
		vmix := a.state.Vmix
		a.mu.RUnlock()
		return ws.WriteJSON(map[string]any{"type": "match.accepted", "device_id": a.cfg.DeviceID, "assignment_id": assignmentID, "match_id": matchID, "vmix": vmix})
	case "vmix.probe":
		a.mu.RLock()
		vmix := a.state.Vmix
		a.mu.RUnlock()
		return ws.WriteJSON(map[string]any{"type": "vmix.status", "request_id": stringValue(msg["request_id"]), "vmix": vmix, "error": vmix.Error})
	case "vmix.command":
		return a.handleVmixCommand(ws, msg)
	case "vmix.batch":
		return a.handleVmixBatch(ws, msg)
	case "heartbeat.ack":
		return nil
	case "mapping.assigned":
		a.mu.Lock()
		a.state.MappingName = stringValue(msg["name"])
		a.state.MappingVersion = stringValue(msg["version"])
		a.mu.Unlock()
		a.logf("Mapping assigned: %s", stringValue(msg["name"]))
	case "mapping.missing":
		a.mu.Lock()
		a.state.MappingName = ""
		a.state.MappingVersion = ""
		a.mu.Unlock()
		a.logf("Mapping not configured for current vMix project")
	case "server.error":
		a.logf("Server error: %s", stringValue(msg["detail"]))
	}
	return nil
}

func (a *Agent) handleVmixCommand(ws *wsConn, msg map[string]any) error {
	requestID := stringValue(msg["request_id"])
	incomingAssignment := stringValue(msg["assignment_id"])
	incomingMatch := stringValue(msg["match_id"])
	a.mu.RLock()
	currentAssignment, currentMatch := a.state.AssignmentID, a.state.MatchID
	vmixURL := a.cfg.VmixURL
	a.mu.RUnlock()
	if incomingAssignment != "" && incomingAssignment != currentAssignment {
		return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": false, "reason": "assignment_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch})
	}
	if incomingMatch != "" && incomingMatch != currentMatch {
		return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": false, "reason": "match_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch})
	}
	rawCommand, _ := msg["command"].(map[string]any)
	function := stringValue(rawCommand["Function"])
	if function == "" {
		function = stringValue(rawCommand["function"])
	}
	if function == "" {
		return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": false, "reason": "missing_function"})
	}
	params := url.Values{}
	params.Set("Function", function)
	for key, value := range rawCommand {
		if strings.EqualFold(key, "function") || value == nil {
			continue
		}
		text := stringValue(value)
		if strings.EqualFold(key, "Value") || text != "" {
			params.Set(key, text)
		}
	}
	detail := function
	if input := params.Get("Input"); input != "" {
		detail += " · " + input
	}
	if field := params.Get("SelectedName"); field != "" {
		detail += " · " + field
	}
	a.logf("vMix command: %s", detail)
	vmix := probeVmix(vmixURL, params)
	a.mu.Lock()
	a.state.Vmix = vmix
	a.state.LastCommand = detail
	a.state.LastCommandOK = vmix.Connected
	a.state.LastCommandAt = time.Now()
	a.state.LastCommandDetail = vmix.Error
	a.mu.Unlock()
	if vmix.Connected {
		a.logf("Command OK: %s", function)
	} else {
		a.logf("Command failed: %s", vmix.Error)
	}
	return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": vmix.Connected, "reason": vmix.Error, "assignment_id": currentAssignment, "match_id": currentMatch, "vmix": vmix, "error": vmix.Error})
}

func (a *Agent) handleVmixBatch(ws *wsConn, msg map[string]any) error {
	requestID := stringValue(msg["request_id"])
	incomingAssignment := stringValue(msg["assignment_id"])
	incomingMatch := stringValue(msg["match_id"])
	a.mu.RLock()
	currentAssignment, currentMatch := a.state.AssignmentID, a.state.MatchID
	vmixURL := a.cfg.VmixURL
	a.mu.RUnlock()
	if incomingAssignment != "" && incomingAssignment != currentAssignment {
		return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": false, "reason": "assignment_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}})
	}
	if incomingMatch != "" && incomingMatch != currentMatch {
		return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": false, "reason": "match_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}})
	}
	rawCommands, _ := msg["commands"].([]any)
	if len(rawCommands) == 0 {
		return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": true, "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}, "applied": 0})
	}
	if len(rawCommands) > 500 {
		return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": false, "reason": "too_many_commands", "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}})
	}

	type batchResult struct {
		OK       bool
		Reason   string
		Detail   string
		Function string
		Vmix     VmixStatus
	}
	results := make([]batchResult, len(rawCommands))
	jobs := make(chan int)
	workers := 6
	if len(rawCommands) < workers {
		workers = len(rawCommands)
	}
	var wg sync.WaitGroup
	wg.Add(workers)
	for w := 0; w < workers; w++ {
		go func() {
			defer wg.Done()
			for index := range jobs {
				rawCommand, _ := rawCommands[index].(map[string]any)
				function := stringValue(rawCommand["Function"])
				if function == "" {
					function = stringValue(rawCommand["function"])
				}
				if function == "" {
					results[index] = batchResult{OK: false, Reason: "missing_function"}
					continue
				}
				params := url.Values{}
				params.Set("Function", function)
				for key, value := range rawCommand {
					if strings.EqualFold(key, "function") || value == nil {
						continue
					}
					text := stringValue(value)
					if strings.EqualFold(key, "Value") || text != "" {
						params.Set(key, text)
					}
				}
				detail := function
				if input := params.Get("Input"); input != "" {
					detail += " · " + input
				}
				if field := params.Get("SelectedName"); field != "" {
					detail += " · " + field
				}
				vmix := probeVmix(vmixURL, params)
				results[index] = batchResult{OK: vmix.Connected, Reason: vmix.Error, Detail: detail, Function: function, Vmix: vmix}
			}
		}()
	}
	for index := range rawCommands {
		jobs <- index
	}
	close(jobs)
	wg.Wait()

	ackResults := make([]map[string]any, len(results))
	applied := 0
	failed := 0
	lastDetail := ""
	lastStatus := VmixStatus{URL: vmixURL}
	for index, item := range results {
		ackResults[index] = map[string]any{"ok": item.OK, "reason": item.Reason}
		if item.OK {
			applied++
		} else {
			failed++
		}
		if item.Detail != "" {
			lastDetail = item.Detail
			lastStatus = item.Vmix
		}
	}
	a.mu.Lock()
	if lastDetail != "" {
		a.state.Vmix = lastStatus
		a.state.LastCommand = fmt.Sprintf("Batch %d · %s", len(results), lastDetail)
		a.state.LastCommandOK = failed == 0
		a.state.LastCommandAt = time.Now()
		if failed > 0 {
			a.state.LastCommandDetail = fmt.Sprintf("%d команд с ошибкой", failed)
		} else {
			a.state.LastCommandDetail = ""
		}
	}
	a.mu.Unlock()
	a.logf("vMix batch: %d commands · OK %d · errors %d", len(results), applied, failed)
	return ws.WriteJSON(map[string]any{
		"type": "command.batch.ack", "request_id": requestID, "ok": failed == 0,
		"assignment_id": currentAssignment, "match_id": currentMatch,
		"applied": applied, "failed": failed, "results": ackResults,
	})
}

func scanVmix(base string) (VmixStatus, VmixInventory) {
	status := VmixStatus{URL: base}
	inventory := VmixInventory{Inputs: []VmixInputInventory{}}
	target := strings.TrimSpace(base)
	if target == "" {
		status.Error = "vMix URL is empty"
		return status, inventory
	}
	client := &http.Client{Timeout: 4 * time.Second}
	resp, err := client.Get(target)
	if err != nil {
		status.Error = err.Error()
		return status, inventory
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		status.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
		return status, inventory
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024*1024))
	if err != nil {
		status.Error = err.Error()
		return status, inventory
	}
	status.Connected = true
	var api vmixAPIXML
	if err := xml.Unmarshal(body, &api); err != nil {
		status.Error = "vMix XML parse: " + err.Error()
		status.Connected = true
		return status, inventory
	}
	status.Version = strings.TrimSpace(api.Version)
	inventory.VmixVersion = status.Version
	fingerprintLines := make([]string, 0, len(api.Inputs))
	for _, raw := range api.Inputs {
		item := VmixInputInventory{
			Key: strings.TrimSpace(raw.Key), Number: strings.TrimSpace(raw.Number),
			Title: strings.TrimSpace(raw.Title), Type: strings.TrimSpace(raw.Type),
			Fields: []VmixFieldInventory{},
		}
		fieldLines := make([]string, 0, len(raw.Children))
		for _, child := range raw.Children {
			name := strings.TrimSpace(child.Name)
			if name == "" {
				continue
			}
			fieldType := strings.ToLower(strings.TrimSpace(child.XMLName.Local))
			if fieldType == "" {
				fieldType = "field"
			}
			item.Fields = append(item.Fields, VmixFieldInventory{Name: name, Type: fieldType, Index: strings.TrimSpace(child.Index)})
			fieldLines = append(fieldLines, fieldType+":"+name)
		}
		sort.Slice(item.Fields, func(i, j int) bool {
			if item.Fields[i].Type == item.Fields[j].Type {
				return item.Fields[i].Name < item.Fields[j].Name
			}
			return item.Fields[i].Type < item.Fields[j].Type
		})
		sort.Strings(fieldLines)
		inventory.FieldCount += len(item.Fields)
		inventory.Inputs = append(inventory.Inputs, item)
		fingerprintLines = append(fingerprintLines, item.Type+"|"+item.Title+"|"+strings.Join(fieldLines, ","))
	}
	sort.Slice(inventory.Inputs, func(i, j int) bool {
		if inventory.Inputs[i].Title == inventory.Inputs[j].Title {
			return inventory.Inputs[i].Number < inventory.Inputs[j].Number
		}
		return inventory.Inputs[i].Title < inventory.Inputs[j].Title
	})
	sort.Strings(fingerprintLines)
	inventory.InputCount = len(inventory.Inputs)
	hash := sha256.Sum256([]byte(strings.Join(fingerprintLines, "\n")))
	inventory.Fingerprint = fmt.Sprintf("%x", hash[:])
	return status, inventory
}

var vmixHTTPClient = &http.Client{
	Timeout: 3 * time.Second,
	Transport: &http.Transport{
		MaxIdleConns:        32,
		MaxIdleConnsPerHost: 16,
		IdleConnTimeout:     90 * time.Second,
	},
}

func probeVmix(base string, params url.Values) VmixStatus {
	result := VmixStatus{URL: base}
	target := strings.TrimSpace(base)
	if target == "" {
		result.Error = "vMix URL is empty"
		return result
	}
	if params != nil && len(params) > 0 {
		sep := "?"
		if strings.Contains(target, "?") {
			sep = "&"
		}
		target += sep + params.Encode()
	}
	resp, err := vmixHTTPClient.Get(target)
	if err != nil {
		result.Error = err.Error()
		return result
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
		return result
	}
	body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
	result.Connected = true
	text := string(body)
	if params != nil && len(params) > 0 {
		responseText := strings.TrimSpace(text)
		if len(responseText) > 1000 {
			responseText = responseText[:1000]
		}
		result.Response = responseText
	}
	if i := strings.Index(text, "<version>"); i >= 0 {
		rest := text[i+len("<version>"):]
		if j := strings.Index(rest, "</version>"); j >= 0 {
			result.Version = strings.TrimSpace(rest[:j])
		}
	}
	return result
}

func normalizeServerBase(raw string) string {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return ""
	}
	if !strings.Contains(raw, "://") {
		raw = "http://" + raw
	}
	return strings.TrimRight(raw, "/")
}

// serverCandidates makes deployment independent from localhost.
// Priority:
//  1. HOCKEY_SERVER_URL environment variable
//  2. server_url.txt next to agent.exe (handy for server migration/deployment)
//  3. primary URL saved in agent_config.json
//  4. optional fallback URL saved in agent_config.json
func serverCandidates(cfg Config) []string {
	values := make([]string, 0, 4)
	if env := normalizeServerBase(os.Getenv("HOCKEY_SERVER_URL")); env != "" {
		values = append(values, env)
	}
	if exe, err := os.Executable(); err == nil {
		path := filepath.Join(filepath.Dir(exe), "server_url.txt")
		if data, err := os.ReadFile(path); err == nil {
			if v := normalizeServerBase(strings.TrimSpace(string(data))); v != "" {
				values = append(values, v)
			}
		}
	}
	values = append(values, normalizeServerBase(cfg.ServerURL), normalizeServerBase(cfg.FallbackServerURL))
	seen := map[string]bool{}
	out := make([]string, 0, len(values))
	for _, value := range values {
		if value == "" || seen[value] {
			continue
		}
		seen[value] = true
		out = append(out, value)
	}
	return out
}

func websocketURL(server string) (string, error) {
	raw := strings.TrimRight(strings.TrimSpace(server), "/")
	if raw == "" {
		return "", errors.New("server_url is empty")
	}
	u, err := url.Parse(raw)
	if err != nil {
		return "", err
	}
	switch strings.ToLower(u.Scheme) {
	case "http":
		u.Scheme = "ws"
	case "https":
		u.Scheme = "wss"
	case "ws", "wss":
	default:
		if !strings.Contains(raw, "://") {
			return websocketURL("http://" + raw)
		}
		return "", fmt.Errorf("unsupported server scheme: %s", u.Scheme)
	}
	u.Path = strings.TrimRight(u.Path, "/") + "/ws/hockey-agent"
	u.RawQuery = ""
	return u.String(), nil
}

func dialWebSocket(target string, timeout time.Duration) (*wsConn, error) {
	u, err := url.Parse(target)
	if err != nil {
		return nil, err
	}
	host := u.Hostname()
	port := u.Port()
	if port == "" {
		if u.Scheme == "wss" {
			port = "443"
		} else {
			port = "80"
		}
	}
	address := net.JoinHostPort(host, port)
	d := net.Dialer{Timeout: timeout}
	var conn net.Conn
	if u.Scheme == "wss" {
		conn, err = tls.DialWithDialer(&d, "tcp", address, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
	} else {
		conn, err = d.Dial("tcp", address)
	}
	if err != nil {
		return nil, err
	}
	// Bound the entire HTTP Upgrade handshake. Without this, a peer that
	// accepts TCP but never completes the response can stall a connection attempt.
	_ = conn.SetDeadline(time.Now().Add(timeout))
	key := randomBase64(16)
	path := u.EscapedPath()
	if path == "" {
		path = "/"
	}
	if u.RawQuery != "" {
		path += "?" + u.RawQuery
	}
	req := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\nUser-Agent: Hockey-vMix-Agent/%s\r\n\r\n", path, u.Host, key, agentVersion)
	if _, err := io.WriteString(conn, req); err != nil {
		conn.Close()
		return nil, err
	}
	reader := bufio.NewReader(conn)
	statusLine, err := reader.ReadString('\n')
	if err != nil {
		conn.Close()
		return nil, err
	}
	if !strings.Contains(statusLine, " 101 ") {
		conn.Close()
		return nil, fmt.Errorf("websocket upgrade failed: %s", strings.TrimSpace(statusLine))
	}
	headers := map[string]string{}
	for {
		line, err := reader.ReadString('\n')
		if err != nil {
			conn.Close()
			return nil, err
		}
		line = strings.TrimRight(line, "\r\n")
		if line == "" {
			break
		}
		if i := strings.Index(line, ":"); i > 0 {
			headers[strings.ToLower(strings.TrimSpace(line[:i]))] = strings.TrimSpace(line[i+1:])
		}
	}
	sum := sha1.Sum([]byte(key + websocketGUID))
	expected := base64.StdEncoding.EncodeToString(sum[:])
	if headers["sec-websocket-accept"] != expected {
		conn.Close()
		return nil, errors.New("invalid Sec-WebSocket-Accept")
	}
	_ = conn.SetDeadline(time.Time{})
	return &wsConn{conn: conn, r: reader}, nil
}
func (w *wsConn) Close() error { return w.conn.Close() }
func (w *wsConn) WriteJSON(value any) error {
	payload, err := json.Marshal(value)
	if err != nil {
		return err
	}
	return w.writeFrame(0x1, payload)
}
func (w *wsConn) writeFrame(opcode byte, payload []byte) error {
	w.mu.Lock()
	defer w.mu.Unlock()
	// Never let a dead network block the WebSocket writer forever.
	_ = w.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
	defer w.conn.SetWriteDeadline(time.Time{})
	var header bytes.Buffer
	header.WriteByte(0x80 | opcode)
	n := len(payload)
	switch {
	case n < 126:
		header.WriteByte(0x80 | byte(n))
	case n <= 65535:
		header.WriteByte(0x80 | 126)
		_ = binary.Write(&header, binary.BigEndian, uint16(n))
	default:
		header.WriteByte(0x80 | 127)
		_ = binary.Write(&header, binary.BigEndian, uint64(n))
	}
	mask := make([]byte, 4)
	if _, err := rand.Read(mask); err != nil {
		return err
	}
	header.Write(mask)
	masked := make([]byte, n)
	for i := range payload {
		masked[i] = payload[i] ^ mask[i%4]
	}
	if _, err := w.conn.Write(header.Bytes()); err != nil {
		return err
	}
	_, err := w.conn.Write(masked)
	return err
}
func (w *wsConn) ReadText() ([]byte, error) {
	for {
		first, err := w.r.ReadByte()
		if err != nil {
			return nil, err
		}
		second, err := w.r.ReadByte()
		if err != nil {
			return nil, err
		}
		fin := first&0x80 != 0
		opcode := first & 0x0F
		masked := second&0x80 != 0
		length := uint64(second & 0x7F)
		if length == 126 {
			var n uint16
			if err := binary.Read(w.r, binary.BigEndian, &n); err != nil {
				return nil, err
			}
			length = uint64(n)
		} else if length == 127 {
			if err := binary.Read(w.r, binary.BigEndian, &length); err != nil {
				return nil, err
			}
		}
		if length > 16*1024*1024 {
			return nil, errors.New("websocket frame too large")
		}
		var mask [4]byte
		if masked {
			if _, err := io.ReadFull(w.r, mask[:]); err != nil {
				return nil, err
			}
		}
		payload := make([]byte, int(length))
		if _, err := io.ReadFull(w.r, payload); err != nil {
			return nil, err
		}
		if masked {
			for i := range payload {
				payload[i] ^= mask[i%4]
			}
		}
		switch opcode {
		case 0x1:
			if !fin {
				return nil, errors.New("fragmented text frames are not supported")
			}
			return payload, nil
		case 0x8:
			return nil, io.EOF
		case 0x9:
			_ = w.writeFrame(0xA, payload)
		case 0xA:
			continue
		default:
			continue
		}
	}
}

func configPath() (string, error) {
	exe, err := os.Executable()
	if err != nil {
		return "", err
	}
	return filepath.Join(filepath.Dir(exe), "agent_config.json"), nil
}
func loadConfig(path string) (Config, error) {
	cfg := Config{ServerURL: "http://127.0.0.1:8000", VmixURL: "http://127.0.0.1:8088/api/", DeviceID: newUUID(), DeviceSecret: randomToken(40), DeviceName: hostname()}
	if data, err := os.ReadFile(path); err == nil {
		_ = json.Unmarshal(data, &cfg)
	}
	if cfg.DeviceID == "" {
		cfg.DeviceID = newUUID()
	}
	if cfg.DeviceSecret == "" {
		cfg.DeviceSecret = randomToken(40)
	}
	if cfg.DeviceName == "" {
		cfg.DeviceName = hostname()
	}
	if cfg.ServerURL == "" {
		cfg.ServerURL = "http://127.0.0.1:8000"
	}
	cfg.ServerURL = normalizeServerBase(cfg.ServerURL)
	cfg.FallbackServerURL = normalizeServerBase(cfg.FallbackServerURL)
	if cfg.VmixURL == "" {
		cfg.VmixURL = "http://127.0.0.1:8088/api/"
	}
	if err := saveConfig(path, cfg); err != nil {
		return cfg, err
	}
	return cfg, nil
}
func saveConfig(path string, cfg Config) error {
	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0600)
}
func newUUID() string {
	b := make([]byte, 16)
	_, _ = rand.Read(b)
	b[6] = (b[6] & 0x0f) | 0x40
	b[8] = (b[8] & 0x3f) | 0x80
	return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
func randomBase64(n int) string {
	b := make([]byte, n)
	_, _ = rand.Read(b)
	return base64.StdEncoding.EncodeToString(b)
}
func randomToken(n int) string {
	b := make([]byte, n)
	_, _ = rand.Read(b)
	return base64.RawURLEncoding.EncodeToString(b)
}
func hostname() string {
	h, _ := os.Hostname()
	if h == "" {
		return "HOCKEY-GFX"
	}
	return h
}
func stamp() string { return time.Now().Format("15:04:05") }
func short(v string) string {
	if len(v) > 12 {
		return v[:12] + "..."
	}
	return v
}
func stringValue(v any) string {
	if v == nil {
		return ""
	}
	if s, ok := v.(string); ok {
		return s
	}
	return fmt.Sprint(v)
}
func boolValue(v any) bool { b, _ := v.(bool); return b }
