#!/usr/bin/env python3
"""Monitor final de investigación, solo lectura. No coloca órdenes."""
from __future__ import annotations
import argparse,csv,json,signal,threading,time
from datetime import datetime,timezone,timedelta
from pathlib import Path
import requests
EX=('bitunix','blofin','binance'); WINDOWS=(0,1,5,10,20,30,60); STOP=False; H={'User-Agent':'listing-monitor/6.0'}
def iso():return datetime.now(timezone.utc).isoformat()
def val(x):
 try:return float(x)
 except:return None
def get(s,u,p=None):
 r=s.get(u,params=p,headers=H,timeout=8);r.raise_for_status();return r.json()
def arr(x):
 if isinstance(x,list):return [q for q in x if isinstance(q,dict)]
 if isinstance(x,dict):
  for k in ('data','result','symbols','instruments'):
   if isinstance(x.get(k),list):return [q for q in x[k] if isinstance(q,dict)]
 return []
def active(x):return str(x).upper() in {'OPEN','LIVE','TRADING','1','TRUE'}
def key(e,z):return e+':'+z.upper().replace('-','')
def root(z):return z.upper().replace('-','')[:-4]
def blofin(z):
 z=z.upper().replace('-','');return z[:-4]+'-USDT' if z.endswith('USDT') else z
def discover(e,s):
 if e=='binance':
  p=get(s,'https://fapi.binance.com/fapi/v1/exchangeInfo');return [{'exchange':e,'symbol':x['symbol'],'status':x.get('status'),'active':active(x.get('status'))} for x in p.get('symbols',[]) if x.get('contractType')=='PERPETUAL' and x.get('quoteAsset')=='USDT']
 if e=='blofin':
  p=get(s,'https://openapi.blofin.com/api/v1/market/instruments',{'instType':'SWAP'});o=[]
  for x in arr(p):
   z=str(x.get('instId','')).upper();st=x.get('state',x.get('status'))
   if z and 'USDT' in z:o.append({'exchange':e,'symbol':z,'status':st,'active':active(st)})
  return o
 p=get(s,'https://fapi.bitunix.com/api/v1/futures/market/trading_pairs');o=[]
 for x in arr(p):
  z=str(x.get('symbol','')).upper();st=x.get('symbolStatus',x.get('status',x.get('state')))
  if z.endswith('USDT'):o.append({'exchange':e,'symbol':z,'status':st,'active':active(st)})
 return o
def book(e,z,s):
 if e=='binance':b=get(s,'https://fapi.binance.com/fapi/v1/depth',{'symbol':z,'limit':5})
 elif e=='blofin':
  q=get(s,'https://openapi.blofin.com/api/v1/market/books',{'instId':blofin(z),'sz':5});a=arr(q);b=a[0] if a else {}
 else:
  q=get(s,'https://fapi.bitunix.com/api/v1/futures/market/depth',{'symbol':z,'limit':5});b=q.get('data',q) if isinstance(q,dict) else {}
 bids,asks=b.get('bids',[]),b.get('asks',[]);bid,bq=(val(bids[0][0]),val(bids[0][1])) if bids else (None,None);ask,aq=(val(asks[0][0]),val(asks[0][1])) if asks else (None,None);mid=(bid+ask)/2 if bid is not None and ask is not None else None
 if mid is None:return None
 return {'available':True,'mid':mid,'bid':bid,'ask':ask,'spread_pct':(ask-bid)/mid*100,'imbalance_pct':((bq-aq)/(bq+aq)*100 if bq is not None and aq is not None and bq+aq else None),'error':''}
def append(path,fields,items):
 if not items:return
 path.parent.mkdir(parents=True,exist_ok=True);new=not path.exists()
 with path.open('a',newline='',encoding='utf-8') as h:
  w=csv.DictWriter(h,fieldnames=fields)
  if new:w.writeheader()
  w.writerows({k:x.get(k) for k in fields} for x in items)
def replace_queue(path,q):
 fields=['queued_at_utc','exchange','symbol','status','active','monitor_until_utc','reason'];path.parent.mkdir(parents=True,exist_ok=True)
 with path.open('w',newline='',encoding='utf-8') as h:
  w=csv.DictWriter(h,fieldnames=fields);w.writeheader()
  for x in q.values():w.writerow({k:x.get(k) for k in fields})
def impact(ev,observed,ss,out,done):
 ident=(ev['exchange'],ev['symbol'],ev['detected_at_utc'])
 if ident in done:return
 begin=time.monotonic();data={e:{} for e in observed}
 for t in WINDOWS:
  while time.monotonic()-begin<t and not STOP:time.sleep(.05)
  for e in observed:
   try:
    z=ev['symbol'] if e!='blofin' else blofin(ev['symbol']);q=book(e,z,ss[e])
    if q:data[e][str(t)]=q
   except:pass
 if not STOP:
  fields=['detected_at_utc','source_exchange','source_symbol','observed_exchange','observed_symbol','samples_json']
  append(out/'listing_impact_compact.csv',fields,[{'detected_at_utc':ev['detected_at_utc'],'source_exchange':ev['exchange'],'source_symbol':ev['symbol'],'observed_exchange':e,'observed_symbol':ev['symbol'] if e!='blofin' else blofin(ev['symbol']),'samples_json':json.dumps(v,separators=(',',':'))} for e,v in data.items() if v])
 done.add(ident)
def health(out,ss):
 fields=['observed_at_utc','exchange','symbol','available','mid','spread_pct','imbalance_pct','error']
 for e in EX:
  z='BTCUSDT' if e!='blofin' else 'BTC-USDT'
  try:append(out/'market_health.csv',fields,[{'observed_at_utc':iso(),'exchange':e,'symbol':z,**book(e,z,ss[e])}])
  except Exception as x:append(out/'market_health.csv',fields,[{'observed_at_utc':iso(),'exchange':e,'symbol':z,'available':False,'error':str(x)}])
def main():
 global STOP
 p=argparse.ArgumentParser();p.add_argument('--out',default='integrated_data_final');p.add_argument('--run-seconds',type=int,default=0);p.add_argument('--discovery-interval',type=int,default=300);p.add_argument('--pending-seconds',type=int,default=900);p.add_argument('--pending-check',type=int,default=10);p.add_argument('--health-interval',type=int,default=10);a=p.parse_args()
 out=Path(a.out);ss={e:requests.Session() for e in EX};state={};queue={};done=set();baseline=True;start=time.monotonic();lastd=lastp=lasth=0
 def stop(*_):
  global STOP
  STOP=True
 signal.signal(signal.SIGINT,stop);signal.signal(signal.SIGTERM,stop);ef=['detected_at_utc','event','exchange','symbol','canonical_symbol','active','status']
 while not STOP and (not a.run_seconds or time.monotonic()-start<a.run_seconds):
  t=time.monotonic()
  if t-lastd>=a.discovery_interval:
   lastd=t;allr=[]
   for e in EX:
    try:allr+=discover(e,ss[e]);print(e,'contratos',sum(x['exchange']==e for x in allr))
    except Exception as x:print(e,'ERROR',x)
   current={key(x['exchange'],x['symbol']):x for x in allr}
   if baseline:
    state=current.copy();until=datetime.now(timezone.utc)+timedelta(seconds=a.pending_seconds)
    for k,x in current.items():
     if not x['active']:queue[k]={'queued_at_utc':iso(),'exchange':x['exchange'],'symbol':x['symbol'],'status':x.get('status'),'active':False,'monitor_until_utc':until.isoformat(),'reason':'INITIAL_INACTIVE'}
    replace_queue(out/'monitor_queue.csv',queue);baseline=False;print('Línea base creada; cola persistida:',len(queue))
   else:
    events=[];stamp=iso()
    for k,x in current.items():
     old=state.get(k);x={**x,'canonical_symbol':k,'detected_at_utc':stamp};state[k]=x
     if old is None:x['event']='NEW_TOKEN';events.append(x)
     elif old['active']!=x['active']:x['event']='REACTIVATED' if x['active'] else 'INACTIVE';events.append(x)
     if not x['active']:
      until=datetime.now(timezone.utc)+timedelta(seconds=a.pending_seconds);queue[k]={'queued_at_utc':stamp,'exchange':x['exchange'],'symbol':x['symbol'],'status':x.get('status'),'active':False,'monitor_until_utc':until.isoformat(),'reason':'STATUS_INACTIVE'}
    replace_queue(out/'monitor_queue.csv',queue);append(out/'discovery_events_compact.csv',ef,events)
    for x in events:
     if x.get('active') and x['event'] in ('NEW_TOKEN','REACTIVATED'):
      valid={e for e in EX if any(r['exchange']==e and r['active'] and root(r['symbol'])==root(x['symbol']) for r in allr)}
      if valid:threading.Thread(target=impact,args=(x,valid,ss,out,done),daemon=True).start()
  if t-lastp>=a.pending_check:
   lastp=t
   for k,x in list(queue.items()):
    try:
     expiry=datetime.fromisoformat(x['monitor_until_utc']).timestamp()
     if time.time()>expiry:queue.pop(k,None);continue
     fresh=next((z for z in discover(x['exchange'],ss[x['exchange']]) if z['symbol']==x['symbol']),None)
     if fresh and fresh['active']:
      ev={**fresh,'event':'REACTIVATED','detected_at_utc':iso(),'canonical_symbol':k};queue.pop(k,None);state[k]=ev;append(out/'discovery_events_compact.csv',ef,[ev]);threading.Thread(target=impact,args=(ev,{ev['exchange']},ss,out,done),daemon=True).start()
    except:pass
   replace_queue(out/'monitor_queue.csv',queue)
  if t-lasth>=a.health_interval:lasth=t;health(out,ss)
  time.sleep(1)
 STOP=True;print('Cierre seguro. Datos guardados en',out)
if __name__=='__main__':main()
