"""The fallback embedder: a fixed, deterministic 1056-number vector for any short phrase.

This is what a participant's phone uses when the real sentence model (MiniLM, loaded in the browser by
web/embed.js) cannot be downloaded. It is a port-for-port twin of ``hashEmbed`` in web/embed.js, so the
two give identical vectors; tests/parity.py checks that.

The vector has three blocks:

* 512 "word" dimensions: every word is hashed (FNV-1a) to one of 512 slots with a +/- sign and added
  with a weight that depends on how common it is. The word list lives in web/vocab.js, roughly ordered
  from most to least common: function words (the first 100) count 0.1, everyday words 0.45, topical
  words (after the "|" marker) 1.2, and words not in the list at all 1.5, because a rare word says the
  most about a phrase.
* 512 "character trigram" dimensions: every run of three characters inside a word (padded with spaces)
  is hashed the same way. This catches spelling variants and word stems ("climb", "climbing").
* 32 "topic" dimensions from a small hand-written table (also in vocab.js): a point for every word of
  a topic the phrase contains. This is the only meaning the fallback knows.

Each block is scaled to unit length, the blocks are weighted (1, 0.5, 0.9), and the whole vector is scaled
to unit length, so cosine similarity is a dot product.
"""
from __future__ import annotations

import json
import re
from pathlib import Path

import numpy as np

D_WORD = 512
D_TRI = 512
STOP_RANK = 100                       # words ranked below this are function words
W_STOP, W_COMMON, W_TOPICAL, W_OOV = 0.1, 0.45, 1.2, 1.5
BLOCK_WEIGHTS = (1.0, 0.5, 0.9)       # words, trigrams, topics
EMBEDDER_ID = "hash1"                 # stored with every entry so the map never mixes embedders

_VOCAB_FILE = Path(__file__).resolve().parent.parent / "web" / "vocab.js"


def load_vocab(path: Path = _VOCAB_FILE) -> tuple[dict[str, int], int, dict[str, list[str]]]:
    """(word -> rank, rank of the "|" marker, topic -> words), read from web/vocab.js."""
    text = path.read_text(encoding="utf-8")
    words: list[str] = []
    for chunk in re.findall(r'ROOMMAP_VOCAB\s*\+?=\s*"([^"]*)"', text):
        words.extend(chunk.split())
    rank: dict[str, int] = {}
    for w in words:                      # duplicates keep their first (most common) position
        if w not in rank:
            rank[w] = len(rank)
    marker = rank.pop("|")
    topics = json.loads(re.search(r"ROOMMAP_TOPICS\s*=\s*(\{.*?\});", text, re.S).group(1))
    return rank, marker, {k: v.split() for k, v in topics.items()}


VOCAB, MARKER, TOPICS = load_vocab()
TOPIC_NAMES = list(TOPICS)
D_TOPIC = len(TOPIC_NAMES)
DIM = D_WORD + D_TRI + D_TOPIC
WORD_TOPICS: dict[str, list[int]] = {}
for _t, _ws in enumerate(TOPICS.values()):
    for _w in _ws:
        WORD_TOPICS.setdefault(_w, []).append(_t)
STOPWORDS = frozenset(w for w, r in VOCAB.items() if r < STOP_RANK)


def tokenize(text: str) -> list[str]:
    """Lower-case words made of letters, digits and apostrophes. Same rule as the JavaScript."""
    return [t for t in re.sub(r"[^a-z0-9']+", " ", text.lower()).split(" ") if t]


def fnv1a(s: str) -> int:
    """32-bit FNV-1a over the UTF-8 bytes of s."""
    h = 0x811C9DC5
    for b in s.encode("utf-8"):
        h ^= b
        h = (h * 0x01000193) & 0xFFFFFFFF
    return h


def word_weight(word: str) -> float:
    r = VOCAB.get(word)
    if r is None:
        return W_OOV
    if r < STOP_RANK:
        return W_STOP
    return W_COMMON if r < MARKER else W_TOPICAL


def _unit(v: np.ndarray) -> np.ndarray:
    n = float(np.linalg.norm(v))
    return v / n if n > 0 else v


def embed(text: str) -> np.ndarray:
    """The unit vector for one phrase (all zeros for an empty phrase)."""
    words = np.zeros(D_WORD)
    tris = np.zeros(D_TRI)
    topics = np.zeros(D_TOPIC)
    for tok in tokenize(text):
        h = fnv1a(tok)
        words[h % D_WORD] += word_weight(tok) * (1.0 if (h >> 9) & 1 else -1.0)
        s = " " + tok + " "
        for i in range(len(s) - 2):
            h = fnv1a(s[i:i + 3])
            tris[h % D_TRI] += 1.0 if (h >> 9) & 1 else -1.0
        for t in WORD_TOPICS.get(tok, ()):
            topics[t] += 1.0
    v = np.concatenate([BLOCK_WEIGHTS[0] * _unit(words), BLOCK_WEIGHTS[1] * _unit(tris), BLOCK_WEIGHTS[2] * _unit(topics)])
    return _unit(v)


def embed_all(texts: list[str]) -> np.ndarray:
    return np.stack([embed(t) for t in texts]) if texts else np.zeros((0, DIM))
