package main

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

const (
	agentVersion      = "1.1.0"
	protocolVersion   = 1
	websocketGUID     = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
	heartbeatInterval = 8 * time.Second
)

type Config struct {
	ServerURL    string `json:"server_url"`
	VmixURL      string `json:"vmix_url"`
	DeviceID     string `json:"device_id"`
	DeviceSecret string `json:"device_secret"`
	DeviceName   string `json:"device_name"`
}

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 Agent struct {
	cfg          Config
	mu           sync.Mutex
	assignmentID string
	matchID      string
	pairedLogin  string
	active       bool
}

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

func main() {
	cfgPath, err := configPath()
	if err != nil {
		fatal(err)
	}
	cfg, err := loadConfig(cfgPath)
	if err != nil {
		fatal(err)
	}

	server := flag.String("server", "", "Hockey server URL")
	vmix := flag.String("vmix", "", "vMix API URL")
	name := flag.String("name", "", "Device display name")
	resetID := flag.Bool("reset-device", false, "Generate a new Device ID and secret")
	flag.Parse()
	changed := false
	if *server != "" {
		cfg.ServerURL = strings.TrimRight(strings.TrimSpace(*server), "/")
		changed = true
	}
	if *vmix != "" {
		cfg.VmixURL = strings.TrimSpace(*vmix)
		changed = true
	}
	if *name != "" {
		cfg.DeviceName = strings.TrimSpace(*name)
		changed = true
	}
	if *resetID {
		cfg.DeviceID = newUUID()
		cfg.DeviceSecret = randomToken(40)
		changed = true
	}
	if changed {
		if err := saveConfig(cfgPath, cfg); err != nil {
			fatal(err)
		}
	}

	fmt.Println("============================================================")
	fmt.Println(" HOCKEY vMix AGENT")
	fmt.Printf(" Version:   %s | Protocol: %d\n", agentVersion, protocolVersion)
	fmt.Printf(" Device ID: %s\n", cfg.DeviceID)
	fmt.Printf(" Name:      %s\n", cfg.DeviceName)
	fmt.Printf(" Server:    %s\n", cfg.ServerURL)
	fmt.Printf(" vMix API:  %s\n", cfg.VmixURL)
	fmt.Printf(" Config:    %s\n", cfgPath)
	fmt.Println("============================================================")
	fmt.Println("После подключения откройте web-интерфейс → vMix Agent → Прикрепить.")
	fmt.Println()

	agent := &Agent{cfg: cfg}
	backoff := time.Second
	for {
		if err := agent.runConnection(); err != nil {
			fmt.Printf("[%s] SERVER OFFLINE: %v\n", stamp(), err)
		}
		time.Sleep(backoff)
		if backoff < 15*time.Second {
			backoff *= 2
		}
		if backoff > 15*time.Second {
			backoff = 15 * time.Second
		}
	}
}

func (a *Agent) runConnection() error {
	target, err := websocketURL(a.cfg.ServerURL)
	if err != nil {
		return err
	}
	fmt.Printf("[%s] Connecting %s ...\n", stamp(), target)
	ws, err := dialWebSocket(target, 7*time.Second)
	if err != nil {
		return err
	}
	defer ws.Close()
	fmt.Printf("[%s] SERVER ONLINE\n", stamp())

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

	lastHeartbeat := time.Time{}
	for {
		if time.Since(lastHeartbeat) >= heartbeatInterval {
			vmix := probeVmix(a.cfg.VmixURL, nil)
			a.printVmix(vmix)
			a.mu.Lock()
			assignmentID, matchID := a.assignmentID, a.matchID
			a.mu.Unlock()
			_ = ws.WriteJSON(map[string]any{
				"type":          "heartbeat",
				"device_id":     a.cfg.DeviceID,
				"assignment_id": assignmentID,
				"match_id":      matchID,
				"vmix":          vmix,
				"error":         vmix.Error,
			})
			lastHeartbeat = time.Now()
		}

		_ = ws.conn.SetReadDeadline(time.Now().Add(1200 * time.Millisecond))
		payload, err := ws.ReadText()
		if err != nil {
			if ne, ok := err.(net.Error); ok && ne.Timeout() {
				continue
			}
			return err
		}
		var msg map[string]any
		if err := json.Unmarshal(payload, &msg); err != nil {
			continue
		}
		if err := a.handleMessage(ws, msg); err != nil {
			fmt.Printf("[%s] MESSAGE ERROR: %v\n", stamp(), err)
		}
	}
}

func (a *Agent) handleMessage(ws *wsConn, msg map[string]any) error {
	typ := stringValue(msg["type"])
	switch typ {
	case "hello.ok":
		fmt.Printf("[%s] Device registered on server\n", stamp())
	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.pairedLogin = login
		a.active = active
		a.mu.Unlock()
		suffix := ""
		if active {
			suffix = " (ACTIVE)"
		}
		fmt.Printf("[%s] PAIRED: %s%s\n", stamp(), login, suffix)
	case "pairing.revoked":
		a.mu.Lock()
		a.pairedLogin = ""
		a.active = false
		a.assignmentID = ""
		a.matchID = ""
		a.mu.Unlock()
		fmt.Printf("[%s] Pairing revoked\n", stamp())
	case "device.activated":
		a.mu.Lock()
		a.active = true
		a.mu.Unlock()
		fmt.Printf("[%s] Device activated for broadcast\n", stamp())
	case "device.deactivated":
		a.mu.Lock()
		a.active = false
		a.assignmentID = ""
		a.matchID = ""
		a.mu.Unlock()
		fmt.Printf("[%s] Device deactivated; match assignment cleared\n", stamp())
	case "match.assign":
		assignmentID := stringValue(msg["assignment_id"])
		matchID := stringValue(msg["game_id"])
		a.mu.Lock()
		a.assignmentID = assignmentID
		a.matchID = matchID
		a.mu.Unlock()
		fmt.Printf("[%s] MATCH ASSIGNED: %s | assignment %s\n", stamp(), matchID, short(assignmentID))
		vmix := probeVmix(a.cfg.VmixURL, nil)
		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":
		vmix := probeVmix(a.cfg.VmixURL, nil)
		a.printVmix(vmix)
		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 "heartbeat.ack":
		return nil
	case "server.error":
		fmt.Printf("[%s] SERVER ERROR: %s\n", stamp(), 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.Lock()
	currentAssignment, currentMatch := a.assignmentID, a.matchID
	a.mu.Unlock()
	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 text != "" {
			params.Set(key, text)
		}
	}
	fmt.Printf("[%s] vMix COMMAND: %s | Input=%s | SelectedName=%s\n", stamp(), function, params.Get("Input"), params.Get("SelectedName"))
	vmix := probeVmix(a.cfg.VmixURL, params)
	a.printVmix(vmix)
	if vmix.Connected {
		fmt.Printf("[%s] vMix COMMAND OK: %s | response=%s\n", stamp(), function, short(vmix.Response))
	}
	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) printVmix(status VmixStatus) {
	if status.Connected {
		suffix := ""
		if status.Version != "" {
			suffix = " " + status.Version
		}
		fmt.Printf("[%s] vMix CONNECTED%s\n", stamp(), suffix)
	} else if status.Error != "" {
		fmt.Printf("[%s] vMix NO CONNECTION: %s\n", stamp(), status.Error)
	}
}

func probeVmix(base string, params url.Values) VmixStatus {
	result := VmixStatus{URL: base}
	target := strings.TrimSpace(base)
	if params != nil && len(params) > 0 {
		sep := "?"
		if strings.Contains(target, "?") {
			sep = "&"
		}
		target += sep + params.Encode()
	}
	client := &http.Client{Timeout: 3 * time.Second}
	resp, err := client.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 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
	}

	key := randomBase64(16)
	path := u.EscapedPath()
	if path == "" {
		path = "/"
	}
	if u.RawQuery != "" {
		path += "?" + u.RawQuery
	}
	hostHeader := u.Host
	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, hostHeader, 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")
	}
	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()
	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"
	}
	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 }
func fatal(err error)      { fmt.Fprintln(os.Stderr, "FATAL:", err); os.Exit(1) }
