package lobby

import (
	"fmt"
	"strings"
	"unicode"
	"unicode/utf8"

	confusables "github.com/disciplinedware/go-confusables"
	"golang.org/x/text/cases"
	"golang.org/x/text/language"
)

// raceNameMaxRuneLen bounds the display length of a race-name. Must
// match the documented user-facing limit; the value is mirrored as an
// `if len(...)` check rather than enforced at the storage boundary so
// migrations stay simple.
const raceNameMaxRuneLen = 32

// CanonicalKey is the platform-wide race-name uniqueness key produced by
// `Policy.Canonical`. Two display names that yield the same CanonicalKey
// are considered the "same" race name for ownership purposes regardless
// of casing or visually-confusable substitutions.
type CanonicalKey string

// String returns the canonical key as its underlying string.
func (k CanonicalKey) String() string { return string(k) }

// IsZero reports whether the key carries no usable value.
func (k CanonicalKey) IsZero() bool { return strings.TrimSpace(string(k)) == "" }

// confusableSkeletoner is satisfied by the default
// `disciplinedware/go-confusables` runtime; tests substitute a
// deterministic stub via `WithSkeletoner`.
type confusableSkeletoner interface {
	Skeleton(string) string
}

// Policy holds the canonicalisation pipeline used by the Race Name
// Directory. The pipeline is `case-fold → anti-fraud digit-letter
// replace → confusable skeleton`. Each step is idempotent.
type Policy struct {
	caseFolder cases.Caser
	skeletoner confusableSkeletoner
}

// antiFraudReplacer collapses the documented ASCII digit-to-letter
// pairs so `P1lot` and `Pilot` canonicalise to the same key. The set
// is intentionally small — adding entries broadens the equivalence
// classes platform-wide and is a deliberate policy decision.
var antiFraudReplacer = strings.NewReplacer(
	"1", "i",
	"0", "o",
	"8", "b",
)

// NewPolicy returns the default race-name canonicalisation policy.
// Returns an error when the `disciplinedware/go-confusables` default
// skeletoner cannot be obtained — should never happen in practice but
// the constructor surfaces it explicitly so tests can assert on
// failure.
func NewPolicy() (*Policy, error) {
	p := &Policy{
		caseFolder: cases.Fold(cases.Compact),
		skeletoner: confusables.Default(),
	}
	if p.skeletoner == nil {
		return nil, fmt.Errorf("lobby: build race-name policy: confusables.Default() returned nil")
	}
	return p, nil
}

// WithSkeletoner overrides the underlying TR39 confusable skeletoner.
// Tests use this to substitute a deterministic stub; production wiring
// uses the default obtained from `NewPolicy`.
func (p *Policy) WithSkeletoner(s confusableSkeletoner) *Policy {
	if p == nil {
		return nil
	}
	if s == nil {
		return p
	}
	out := *p
	out.skeletoner = s
	return &out
}

// Canonical returns the canonical key for raceName. The function trims
// surrounding whitespace, applies Unicode case-folding, runs the
// anti-fraud replacer, and then computes the TR39 confusable skeleton.
// Returns ErrInvalidInput when raceName is empty after trimming or the
// resulting key is empty.
//
// `language.Und` is passed to the case folder because case-folding for
// race names is intentionally locale-independent — two players from
// different locales must agree on which names collide.
func (p *Policy) Canonical(raceName string) (CanonicalKey, error) {
	if p == nil || p.skeletoner == nil {
		return "", fmt.Errorf("%w: lobby policy not initialised", ErrInvalidInput)
	}
	trimmed := strings.TrimSpace(raceName)
	if trimmed == "" {
		return "", fmt.Errorf("%w: race name must not be empty", ErrInvalidInput)
	}
	if utf8.RuneCountInString(trimmed) > raceNameMaxRuneLen {
		return "", fmt.Errorf("%w: race name exceeds %d characters", ErrInvalidInput, raceNameMaxRuneLen)
	}
	folded := p.caseFolder.String(trimmed)
	mapped := antiFraudReplacer.Replace(folded)
	skeleton := p.skeletoner.Skeleton(mapped)
	if strings.TrimSpace(skeleton) == "" {
		return "", fmt.Errorf("%w: race name canonical key is empty", ErrInvalidInput)
	}
	return CanonicalKey(skeleton), nil
}

// ValidateDisplayName enforces the structural invariants on the
// caller-supplied display form: non-empty, ≤ raceNameMaxRuneLen runes,
// no control characters. Returns the trimmed form on success.
func ValidateDisplayName(raceName string) (string, error) {
	trimmed := strings.TrimSpace(raceName)
	if trimmed == "" {
		return "", fmt.Errorf("%w: race name must not be empty", ErrInvalidInput)
	}
	if utf8.RuneCountInString(trimmed) > raceNameMaxRuneLen {
		return "", fmt.Errorf("%w: race name exceeds %d characters", ErrInvalidInput, raceNameMaxRuneLen)
	}
	for _, r := range trimmed {
		if unicode.IsControl(r) {
			return "", fmt.Errorf("%w: race name must not contain control characters", ErrInvalidInput)
		}
	}
	return trimmed, nil
}

// languageForFolder is the static language tag passed to cases.Fold; it
// remains untyped at construction time and is resolved lazily inside
// `cases.Fold(...)`. Kept here so tests can reference it explicitly.
var languageForFolder = language.Und
