"""One hybrid search used by the CLI, preview API and Forge MCP."""
import argparse,hashlib,json,os,sys
from pathlib import Path
import numpy as np
sys.path.insert(0,str(Path(__file__).resolve().parent))
from lookup import Library
import embeddings as e
ROOT=Path(__file__).resolve().parents[1]

def key(a):return hashlib.sha256((a['id']+'@'+a['release']).encode()).hexdigest()
def allowed(a,target):
    r=a.get('reuse',{})
    return not target or bool(r.get(target,a.get('game_use',False)))

class Search:
    def __init__(self,root=ROOT):
        self.root=Path(root);self.lex=Library(self.root/'index.json');self.assets=self.lex.assets
        self.byid={a['id']:a for a in self.assets}
        self.vectors={}
        for a in self.assets:
            p=self.root/'vectors'/e.VECTOR_SET/(key(a)+'.npz')
            if p.exists():
                with np.load(p) as z:self.vectors[a['id']]={k:e.unit(z[k]) for k in z.files}
    def variants(self,aid):
        """The asset, where it comes from (parent chain) and every custom variant made from it. Needs no embeddings."""
        a=self.byid.get(aid)
        if not a:raise ValueError('unknown asset id: '+aid)
        chain=[];cur=a
        while cur.get('parent') and cur['parent']['id'] in self.byid and len(chain)<10:
            cur=self.byid[cur['parent']['id']];chain.append(cur['id'])
        kids=[dict(self.byid[i],**{'class':i.split('/')[0]},glb_path=str(self.root/'store/blobs'/(self.byid[i]['glb']+'.glb'))) for i in a.get('variants',[]) if i in self.byid]
        return {'asset':a['id'],'parent':a.get('parent'),'ancestors':chain,'results':kids,'total':len(kids),'gaps':[],'mode':'variants'}
    def group_variants(self,top):
        """Under each parent in the result list: its variants. A variant that is listed together with its parent is marked."""
        ids={h['id'] for h in top}
        for h in top:
            h['variants_detail']=[{'id':i,'name':self.byid[i]['name'],'kind':(self.byid[i].get('variant') or {}).get('kind'),'note':(self.byid[i].get('variant') or {}).get('note'),'status':self.byid[i]['status'],'reuse':self.byid[i].get('reuse')} for i in h.get('variants',[]) if i in self.byid]
            if h.get('parent') and h['parent']['id'] in ids:h['grouped_under']=h['parent']['id']
    def search(self,query='',image=None,like=None,kind=None,asset_class=None,game_use=True,reuse='omg',min_size=None,max_size=None,limit=5,lexical_only=False,variants_of=None):
        if variants_of:return self.variants(variants_of)
        path=asset_class or kind
        lex=self.lex.search(query,path=asset_class,game_use=game_use,limit=len(self.assets),reuse=reuse)
        path=lex['path'] or path
        if kind and path and not (path==kind or path.startswith(kind+'.')):
            return {'results':[],'gaps':[path],'reason':'class outside requested kind'}
        if lex['gaps']:return {'results':[],'gaps':lex['gaps'],'models':{'multimodal':e.MM,'description':e.TEXT}}
        if path and not any(a['id'].split('/')[0]==path or a['id'].split('/')[0].startswith(path+'.') for a in self.assets):return {'results':[],'gaps':[path]}
        scores={a['id']:a['score'] for a in lex['hits']};maxlex=max(scores.values(),default=1) or 1
        if not lexical_only and not self.vectors:raise ValueError('No active vectors. Run tools/enrich_library.py, or use --lexical-only.')
        qi=qt=qd=None
        if not lexical_only:
            if image:qi=e.multimodal(image=image)['imageEmbedding']
            elif like:
                if like not in self.byid:raise ValueError('unknown asset id: '+like)
                v=self.vectors.get(like)
                if not v:raise ValueError('asset not embedded: '+like)
                qi=v['image'];qd=v['description']
            elif query:
                qt=e.multimodal(text=query)['textEmbedding'];qd=e.text_vector(query,query=True)
        hits=[]
        for a in self.assets:
            if a['id']==like or not allowed(a,reuse):continue
            if not self.lex._filter(a,path,None,('approved','legacy','candidate'),game_use,None,None,None,reuse):continue
            dims=[a['size'][k] for k in ['w','l','h']]
            if min_size and any(v<lo for v,lo in zip(dims,min_size) if lo is not None):continue
            if max_size and any(v>hi for v,hi in zip(dims,max_size) if hi is not None):continue
            bm=scores.get(a['id'],0)/maxlex if query else 0;v=self.vectors.get(a['id']);semantic=0
            if v:
                if qi is not None:semantic=float(qi@v['image'])*.75+float(qi@v['text'])*.25 if image else float(qi@v['image'])
                elif qt is not None:semantic=float(qt@v['image'])*.65+float(qt@v['text'])*.35
                if qd is not None:semantic=.7*semantic+.3*float(qd@v['description'])
            if lexical_only and query and a['id'] not in scores:continue
            score=.8*semantic+.2*bm if (qi is not None or qt is not None) else bm
            hits.append(dict(a,score=round(score,5),**{'class':a['id'].split('/')[0]},thumbnail=str(self.root/a.get('thumbnail',f"site/out/measure/thumbs/{a['glb']}.jpg")),glb_path=str(self.root/'store/blobs'/(a['glb']+'.glb'))))
        hits.sort(key=lambda a:(-a['score'],a['id']))
        top=hits[:max(1,min(100,limit))]
        self.group_variants(top)
        return {'results':top,'total':len(hits),'gaps':[],'path':path,'models':{'multimodal':e.MM,'description':e.TEXT},'coverage':{'embedded':len(self.vectors),'assets':len(self.assets)},'mode':'lexical' if lexical_only else 'hybrid'}

def main():
    p=argparse.ArgumentParser();p.add_argument('query',nargs='?',default='');p.add_argument('--root',default=os.environ.get('FORGE_ASSET_LIBRARY',str(ROOT)));p.add_argument('--image');p.add_argument('--like');p.add_argument('--kind');p.add_argument('--class',dest='asset_class');p.add_argument('--reuse',choices=['omg','omu','any'],default='omg');p.add_argument('--all',action='store_true',help='include assets not cleared for games');p.add_argument('--min-size',nargs=3,type=float);p.add_argument('--max-size',nargs=3,type=float);p.add_argument('--limit',type=int,default=5);p.add_argument('--json',action='store_true');p.add_argument('--lexical-only',action='store_true');p.add_argument('--variants-of',metavar='ID',help='list the custom variants made from this asset (and what it derives from)');p.add_argument('--uses');p.add_argument('--used-by');a=p.parse_args()
    if a.uses or a.used_by:  # usage ledger, both ways (tools/asset_usage.py)
        sys.path.insert(0,str(ROOT/'tools'));import asset_usage;sys.exit(asset_usage.main(['--uses',a.uses]+(['--json'] if a.json else []) if a.uses else ['--used-by',a.used_by]+(['--json'] if a.json else [])))
    r=Search(a.root).search(a.query,image=a.image,like=a.like,kind=a.kind,asset_class=a.asset_class,game_use=not a.all,reuse=None if a.reuse=='any' else a.reuse,min_size=a.min_size,max_size=a.max_size,limit=a.limit,lexical_only=a.lexical_only,variants_of=a.variants_of)
    if a.json:print(json.dumps(r,indent=1))
    else:
        if r.get('mode')=='variants':print(f"{r['asset']}  derives from: {r['parent']['id'] if r['parent'] else '-'}  variants: {r['total']}")
        shown=set()
        for h in r['results']:
            if h['id'] in shown:continue
            tag=f"  [{h['variant']['kind']} of {h['parent']['id']}]" if h.get('parent') else ''
            print(f"{h.get('score',0):.3f}  {h['id']}  {h['name']}{tag}  {h['glb_path']}"+(f"  used by {h['used_by']['count']}" if h.get('used_by') else ''));shown.add(h['id'])
            for v in h.get('variants_detail',[]):
                if any(x['id']==v['id'] for x in r['results']):continue
                print(f"         +- {v['kind']}: {v['id']}  {v['name']}")
            for x in r['results']:
                if x.get('grouped_under')==h['id']:print(f"         +- {x['variant']['kind']}: {x['id']}  {x['name']}  (also matched {x['score']:.3f})");shown.add(x['id'])
        if r['gaps']:print('GAP: '+', '.join(r['gaps']))
if __name__=='__main__':main()
