"""Find assets in index.json with plain code: class path + filters, alias table, BM25 word ranking.

    from lookup import Library
    lib = Library('index.json')
    lib.search('yellow cab', style='toy', game_use=True, limit=5)
    lib.search(path='vehicle.service', max_tris=5000)
The same algorithm lives in browse/lookup.mjs for the browse page.
"""
import json, math, re, time

STOP = set('a an and the of with for to in on at by from my our some big small little old new one two three'.split())


def tokens(text):
    out = []
    for t in re.split(r'[^a-z0-9]+', (text or '').lower()):
        if not t or t in STOP:
            continue
        if len(t) > 3 and t.endswith('es') and not t.endswith('ses'):
            t = t[:-2] if t[:-2].endswith(('ch', 'sh', 'x')) else t[:-1]
        elif len(t) > 3 and t.endswith('s') and not t.endswith('ss'):
            t = t[:-1]
        out.append(t)
    return out


class Library:
    def __init__(self, index_path_or_dict):
        d = index_path_or_dict if isinstance(index_path_or_dict, dict) else json.load(open(index_path_or_dict))
        self.index = d
        self.assets = d['assets']
        self.aliases = sorted(d['aliases'].items(), key=lambda kv: -len(kv[0]))
        self.docs = []
        df = {}
        for a in self.assets:
            cls = a['id'].split('/')[0]
            words = tokens(' '.join([a['name'], a['summary'], ' '.join(a['tags']), cls.replace('.', ' ').replace('-', ' ')]))
            tf = {}
            for w in words:
                tf[w] = tf.get(w, 0) + 1
            self.docs.append((tf, len(words)))
            for w in tf:
                df[w] = df.get(w, 0) + 1
        # class words from the tree ("tank" -> vehicle.military-land.tank) and which classes hold assets
        self.class_words = {}
        for kind, node in d['tree'].items():
            for grp, g in node['groups'].items():
                for cls in g['classes']:
                    for w in tokens(cls.replace('-', ' ')) if '-' in cls else [cls]:
                        self.class_words.setdefault(w, []).append(f'{kind}.{grp}.{cls}')
        self.filled = {a['id'].split('/')[0] for a in self.assets}
        n = len(self.assets)
        self.idf = {w: math.log(1 + (n - c + .5) / (c + .5)) for w, c in df.items()}
        self.avg = sum(l for _, l in self.docs) / max(1, n)

    def resolve_alias(self, q):
        """Longest alias phrase found in q -> (class prefix, q without it)."""
        low = ' ' + re.sub(r'[^a-z0-9 -]+', ' ', q.lower()) + ' '
        ltok = ' ' + ' '.join(tokens(q)) + ' '  # singular words: "casks" meets the alias "cask"
        for phrase, path in self.aliases:
            pt = ' '.join(tokens(phrase))
            if f' {phrase} ' in low or (pt and f' {pt} ' in ltok):
                return path, low.replace(f' {phrase} ', ' ')
        return None, q

    def _filter(self, a, path, style, status, game_use, max_tris, rigged, fits, reuse):
        cls = a['id'].split('/')[0]
        if path and not (cls == path or cls.startswith(path + '.')):
            return False
        if style and a['style'] != style:
            return False
        if status and a['status'] not in status:
            return False
        if reuse and not (a.get('reuse') or {}).get(reuse, a.get('game_use', False)):
            return False
        if game_use and not a['game_use']:
            return False
        if max_tris and a['tris'] > max_tris:
            return False
        if rigged is not None and bool(a['rig']) != rigged:
            return False
        if fits:
            w, l, h = fits
            s = a['size']
            if (w and s['w'] > w) or (l and s['l'] > l) or (h and s['h'] > h):
                return False
        return True

    def search(self, q='', path=None, style=None, status=('approved', 'legacy'), game_use=True, max_tris=None, rigged=None,
               fits=None, limit=10, reuse="omg"):
        t0 = time.perf_counter()
        alias_path = None
        if q and not path:
            alias_path, rest = self.resolve_alias(q)
            if alias_path:
                path = alias_path
        qt = tokens(q)
        # a word that names a class: search only that class; an EMPTY class is a library gap, never a name lookalike
        gaps = []
        if qt and not path:
            named = [c for w in qt for c in self.class_words.get(w, [])]
            full = [c for c in named if c in self.filled]
            if full:
                path = full[0] if len(set(full)) == 1 else None
            elif named:
                gaps = sorted(set(named))
                return {'path': None, 'alias': None, 'ms': round((time.perf_counter() - t0) * 1000, 2), 'hits': [],
                        'total': 0, 'gaps': gaps}
        hits = []
        for i, a in enumerate(self.assets):
            if not self._filter(a, path, style, status, game_use, max_tris, rigged, fits, reuse):
                continue
            tf, ln = self.docs[i]
            score = 0.0
            for w in qt:
                f = tf.get(w, 0)
                if f:
                    score += self.idf.get(w, 0) * f * 2.2 / (f + 1.2 * (.25 + .75 * ln / self.avg))
            if qt and not score and not alias_path:
                continue
            if a['status'] == 'approved':
                score += .01  # authored-to-standard beats legacy on ties
            hits.append((score, a))
        hits.sort(key=lambda x: -x[0])
        return {'path': path, 'alias': alias_path, 'ms': round((time.perf_counter() - t0) * 1000, 2),
                'hits': [dict(a, score=round(s, 3)) for s, a in hits[:limit]], 'total': len(hits), 'gaps': gaps}

    def tree(self):
        return self.index['tree']
