package ws_test

// protocol_contract_test.go — locks docs/protocol-schema.json and the
// generated ws/message_types.go together so the two cannot silently drift.
//
// message_types.go is documented as "Code generated by scripts/genprotocol
// from docs/protocol-schema.json; DO NOT EDIT" and CI runs
// `make protocol-verify`, but that only re-runs the generator and diffs its
// output — it says nothing about message-type constants that exist in the ws
// package outside the generated file (e.g. a handler defining its own
// MsgTypeFoo instead of adding it to the schema). This test instead parses
// both the schema and every non-test .go file in this package for `MsgType*`
// string constants and asserts they agree in both directions:
//
//   - every wire value the schema lists has a matching Go constant with the
//     schema's stated name and value ("schema -> code"), and
//   - every MsgType* constant declared anywhere in the ws package appears in
//     the schema ("code -> schema").
//
// The exception list below is empty and should stay that way. Its one
// historical entry, MsgTypeChatCommand, predated the schema/genprotocol
// pipeline (plugin slash commands were judged internal wiring); the 2026-08-04
// remediation moved the whole plugin command family (chat_command,
// command_reply, plugin_broadcast) into the schema, closing DC-01. If an
// undocumented constant ever appears, this test fails and names it — that is
// the drift this test exists to catch, not something to silently allowlist.

import (
	"encoding/json"
	"go/ast"
	"go/parser"
	"go/token"
	"os"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"testing"
)

var knownUndocumentedConstants = map[string]string{}

// protocolSchemaEntry mirrors one element of the client_to_server /
// server_to_client arrays in docs/protocol-schema.json.
type protocolSchemaEntry struct {
	Wire string `json:"wire"`
	Go   string `json:"go"`
	TS   string `json:"ts"`
}

type protocolSchema struct {
	Version        int                   `json:"version"`
	ClientToServer []protocolSchemaEntry `json:"client_to_server"`
	ServerToClient []protocolSchemaEntry `json:"server_to_client"`
}

// loadProtocolSchema locates and parses docs/protocol-schema.json relative to
// this test file (ws/ -> Server/ -> repo root -> docs/), so the test does not
// depend on the working directory `go test` happens to be invoked from.
func loadProtocolSchema(t *testing.T) protocolSchema {
	t.Helper()
	_, thisFile, _, ok := runtime.Caller(0)
	if !ok {
		t.Fatal("runtime.Caller failed to resolve test file path")
	}
	wsDir := filepath.Dir(thisFile)
	schemaPath := filepath.Join(wsDir, "..", "..", "docs", "protocol-schema.json")

	raw, err := os.ReadFile(schemaPath)
	if err != nil {
		t.Fatalf("reading protocol schema at %s: %v", schemaPath, err)
	}

	var schema protocolSchema
	if err := json.Unmarshal(raw, &schema); err != nil {
		t.Fatalf("parsing protocol schema: %v", err)
	}
	return schema
}

// loadGoMsgTypeConstants statically parses every non-test .go file in the ws
// package directory and returns a map of MsgType* constant name -> its wire
// string value, for top-level `const ( Name = "value" )` declarations.
func loadGoMsgTypeConstants(t *testing.T) map[string]string {
	t.Helper()
	_, thisFile, _, ok := runtime.Caller(0)
	if !ok {
		t.Fatal("runtime.Caller failed to resolve test file path")
	}
	wsDir := filepath.Dir(thisFile)

	entries, err := os.ReadDir(wsDir)
	if err != nil {
		t.Fatalf("reading ws package directory %s: %v", wsDir, err)
	}

	out := make(map[string]string)
	fset := token.NewFileSet()
	for _, e := range entries {
		name := e.Name()
		if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
			continue
		}

		file, err := parser.ParseFile(fset, filepath.Join(wsDir, name), nil, 0)
		if err != nil {
			t.Fatalf("parsing %s: %v", name, err)
		}

		for _, decl := range file.Decls {
			gen, isGen := decl.(*ast.GenDecl)
			if !isGen || gen.Tok != token.CONST {
				continue
			}
			for _, spec := range gen.Specs {
				vspec, isVal := spec.(*ast.ValueSpec)
				if !isVal {
					continue
				}
				for i, ident := range vspec.Names {
					if !strings.HasPrefix(ident.Name, "MsgType") {
						continue
					}
					if i >= len(vspec.Values) {
						continue // no explicit value on this line (e.g. iota-style)
					}
					lit, isLit := vspec.Values[i].(*ast.BasicLit)
					if !isLit || lit.Kind != token.STRING {
						continue
					}
					val, err := strconv.Unquote(lit.Value)
					if err != nil {
						t.Fatalf("unquoting %s = %s in %s: %v", ident.Name, lit.Value, name, err)
					}
					if existing, dup := out[ident.Name]; dup && existing != val {
						t.Fatalf("constant %s declared twice with different values (%q vs %q) across ws package files",
							ident.Name, existing, val)
					}
					out[ident.Name] = val
				}
			}
		}
	}
	return out
}

// TestProtocolSchema_MatchesGeneratedGoConstants is the "schema -> code"
// direction: every wire constant docs/protocol-schema.json lists (in either
// direction of traffic) must have a same-named Go constant in the ws package
// carrying exactly the schema's wire string.
func TestProtocolSchema_MatchesGeneratedGoConstants(t *testing.T) {
	schema := loadProtocolSchema(t)
	goConsts := loadGoMsgTypeConstants(t)

	check := func(direction string, entries []protocolSchemaEntry) {
		for _, e := range entries {
			got, ok := goConsts[e.Go]
			if !ok {
				t.Errorf("%s: schema entry wire=%q go=%q has no matching Go constant in ws package",
					direction, e.Wire, e.Go)
				continue
			}
			if got != e.Wire {
				t.Errorf("%s: ws.%s = %q, want %q per protocol-schema.json", direction, e.Go, got, e.Wire)
			}
		}
	}

	if len(schema.ClientToServer) == 0 {
		t.Fatal("protocol-schema.json client_to_server is empty — schema failed to load")
	}
	if len(schema.ServerToClient) == 0 {
		t.Fatal("protocol-schema.json server_to_client is empty — schema failed to load")
	}

	check("client_to_server", schema.ClientToServer)
	check("server_to_client", schema.ServerToClient)
}

// TestProtocolSchema_NoUndocumentedGoConstants is the "code -> schema"
// direction: every MsgType* constant declared in the ws package must appear
// in docs/protocol-schema.json, except the documented exceptions in
// knownUndocumentedConstants (see its doc comment). This is what catches a
// handler minting its own wire constant instead of adding it to the schema.
func TestProtocolSchema_NoUndocumentedGoConstants(t *testing.T) {
	schema := loadProtocolSchema(t)
	goConsts := loadGoMsgTypeConstants(t)

	documented := make(map[string]string, len(schema.ClientToServer)+len(schema.ServerToClient))
	for _, e := range schema.ClientToServer {
		documented[e.Go] = e.Wire
	}
	for _, e := range schema.ServerToClient {
		documented[e.Go] = e.Wire
	}

	for name, wire := range goConsts {
		if _, inSchema := documented[name]; inSchema {
			continue
		}
		exceptWire, isException := knownUndocumentedConstants[name]
		if !isException {
			t.Errorf("ws.%s = %q is not in docs/protocol-schema.json and is not a documented exception "+
				"(knownUndocumentedConstants) — add it to the schema or the exception list", name, wire)
			continue
		}
		if exceptWire != wire {
			t.Errorf("documented exception ws.%s has wire value %q, but knownUndocumentedConstants says %q — update the exception list",
				name, wire, exceptWire)
		}
	}

	// Guard against the exception list growing silently: it must contain
	// exactly the constants we can currently account for as intentionally
	// undocumented, and nothing that has since been added to the schema.
	for name := range knownUndocumentedConstants {
		if _, stillMissing := goConsts[name]; !stillMissing {
			t.Errorf("knownUndocumentedConstants lists %q but no such Go constant exists anymore — remove it from the exception list", name)
			continue
		}
		if _, nowDocumented := documented[name]; nowDocumented {
			t.Errorf("knownUndocumentedConstants lists %q but it is now in protocol-schema.json — remove it from the exception list", name)
		}
	}
}
