#!/usr/bin/env python3
from __future__ import annotations
import argparse, os, sqlite3, subprocess
from pathlib import Path
from datetime import datetime, timezone
from dbutil import setting_bool, log_event

DB=os.environ.get('FIYAT_RADAR_DB','/var/lib/fiyat-radar/fiyat-radar.sqlite')
BASE=Path('/var/lib/fiyat-radar')
APP=Path(os.environ.get('FIYAT_RADAR_APP','/opt/fiyat-radar-app'))

def connect():
    c=sqlite3.connect(DB,timeout=20);c.row_factory=sqlite3.Row
    c.execute('PRAGMA foreign_keys=ON');c.execute('PRAGMA busy_timeout=30000');c.execute('PRAGMA synchronous=NORMAL')
    return c

def level_for(minutes:float)->int:
    if minutes < 10:return 1
    if minutes < 30:return 2
    if minutes < 120:return 3
    return 4

def interval_minutes(level:int)->int:
    return {1:2,2:3,3:5,4:8}.get(level,8)

def queue_bootstrap(c,pid,priority,note):
    # v0.3.10: aynı başarısız ürün için yüzlerce error satırı üretme. En son işi
    # yeniden kullan; backoff süresi dolmadıysa yeni pending üretme.
    row=c.execute("SELECT id,priority,status,started_at,next_attempt_at,COALESCE(attempts,0) attempts FROM product_bootstrap_requests WHERE product_id=? ORDER BY id DESC LIMIT 1",(pid,)).fetchone()
    if row:
        status=str(row['status'] or '')
        live=(status=='running' and row['started_at'] is not None and c.execute("SELECT ? >= datetime('now','-4 minutes')",(row['started_at'],)).fetchone()[0])
        if live or status=='pending':
            c.execute("UPDATE product_bootstrap_requests SET priority=MAX(priority,?),note=? WHERE id=?",(priority,note,int(row['id'])))
            return int(row['id'])
        if row['next_attempt_at'] and c.execute("SELECT ? > CURRENT_TIMESTAMP",(row['next_attempt_at'],)).fetchone()[0]:
            return int(row['id'])
        c.execute("UPDATE product_bootstrap_requests SET status='pending',priority=MAX(priority,?),requested_by='readiness-rescue',requested_at=CURRENT_TIMESTAMP,started_at=NULL,finished_at=NULL,note=? WHERE id=?",(priority,note,int(row['id'])))
        return int(row['id'])
    cur=c.execute("INSERT INTO product_bootstrap_requests(product_id,status,priority,requested_by,note,attempts) VALUES(?,'pending',?,'readiness-rescue',?,0)",(pid,priority,note))
    return int(cur.lastrowid)

def queue_image(c,pid,priority,note):
    c.execute("""INSERT INTO image_fetch_requests(product_id,status,priority,attempts,requested_at,next_attempt_at,note)
                 VALUES(?,'pending',?,0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)
                 ON CONFLICT(product_id) DO UPDATE SET
                   status=CASE WHEN image_fetch_requests.status='running' THEN 'running' ELSE 'pending' END,
                   priority=MAX(image_fetch_requests.priority,excluded.priority),
                   next_attempt_at=CASE WHEN image_fetch_requests.status='running' THEN image_fetch_requests.next_attempt_at ELSE CURRENT_TIMESTAMP END,
                   note=excluded.note""",(pid,priority,note))

def queue_manual(c,pid,note):
    if c.execute("SELECT 1 FROM manual_scan_requests WHERE product_id=? AND status IN ('pending','running') LIMIT 1",(pid,)).fetchone():return
    c.execute("INSERT INTO manual_scan_requests(product_id,status,requested_by,note) VALUES(?,'pending','readiness-rescue',?)",(pid,note))

def due_counts(c):
    return {
        'bootstrap':int(c.execute("SELECT COUNT(*) FROM product_bootstrap_requests WHERE status='pending'").fetchone()[0]),
        'image':int(c.execute("SELECT COUNT(*) FROM image_fetch_requests WHERE status IN ('pending','retry') AND (next_attempt_at IS NULL OR next_attempt_at<=CURRENT_TIMESTAMP)").fetchone()[0]),
        'manual':int(c.execute("SELECT COUNT(*) FROM manual_scan_requests WHERE status='pending'").fetchone()[0]),
    }

def _spawn_dispatcher(name:str):
    script=APP/'bin'/name
    if not script.exists():return False
    try:
        subprocess.Popen([str(script)],cwd=str(APP),stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True,close_fds=True)
        return True
    except Exception:
        return False

def kick_queues(counts):
    # .path eventleri coalesce olabilir. Trigger'a ek olarak dispatcher'ı doğrudan başlat.
    mapping=(('bootstrap','bootstrap.trigger','dispatch-bootstrap'),('image','image.trigger','dispatch-image'),('manual','manual.trigger','dispatch-manual'))
    kicked=[]
    for key,trig,dispatcher in mapping:
        if int(counts.get(key,0))<=0:continue
        try:(BASE/trig).touch()
        except OSError:pass
        if _spawn_dispatcher(dispatcher):kicked.append(key)
    return kicked


def stall_watch(c):
    """v0.3.15: Katalogda eksik fiyat varken uzun süre hiçbir YENİ fiyat gözlemi
    oluşmadıysa aynı başarısız backoff döngüsünde sessizce saatler geçirmesin.
    En fazla 5 zor ürünü 15 dakikada bir derin/sitemap kuyruğuna yükseltir.
    """
    try:
        mins_row=c.execute("SELECT CAST((julianday('now')-julianday(MAX(observed_at)))*1440 AS INTEGER) FROM price_observations").fetchone()
        idle=int(mins_row[0]) if mins_row and mins_row[0] is not None else 999999
        threshold_row=c.execute("SELECT value FROM settings WHERE key='bootstrap_stall_watch_minutes'").fetchone()
        threshold=max(5,int(threshold_row[0] if threshold_row else 15))
        missing=int(c.execute("""SELECT COUNT(*) FROM products p WHERE p.active=1 AND NOT EXISTS(
          SELECT 1 FROM product_sources ps WHERE ps.product_id=p.id AND ps.active=1 AND ps.condition_status='verified'
          AND ps.detected_condition=p.condition_type AND ps.last_price IS NOT NULL)""").fetchone()[0])
        if missing<=0 or idle<threshold:return 0,idle
        last=c.execute("SELECT value FROM settings WHERE key='bootstrap_last_stall_kick_at'").fetchone()
        if last and last[0] and c.execute("SELECT datetime(?) > datetime('now',?)",(last[0],f'-{threshold} minutes')).fetchone()[0]:
            return 0,idle
        rows=c.execute("""SELECT p.id FROM products p LEFT JOIN readiness_rescue rr ON rr.product_id=p.id
          WHERE p.active=1 AND NOT EXISTS(SELECT 1 FROM product_sources ps WHERE ps.product_id=p.id AND ps.active=1
            AND ps.condition_status='verified' AND ps.detected_condition=p.condition_type AND ps.last_price IS NOT NULL)
          AND NOT EXISTS(SELECT 1 FROM product_bootstrap_requests b WHERE b.product_id=p.id AND b.status='running')
          ORDER BY COALESCE(rr.escalation_level,1) DESC,COALESCE(rr.first_missing_at,p.created_at) ASC,p.id DESC LIMIT 5""").fetchall()
        n=0
        for r in rows:
            pid=int(r['id']);bid=queue_bootstrap(c,pid,520,f'STALL kurtarma: {idle} dk yeni fiyat yok · sitemap + exact deep keşif')
            c.execute("""UPDATE product_bootstrap_requests SET status='pending',priority=MAX(priority,520),next_attempt_at=CURRENT_TIMESTAMP,
                         started_at=NULL,lease_until=NULL,heartbeat_at=NULL,note=? WHERE id=? AND status<>'running'""",
                      (f'STALL kurtarma: {idle} dk yeni fiyat yok · sitemap + exact deep keşif',bid))
            n+=1
        c.execute("INSERT INTO settings(key,value,updated_at) VALUES('bootstrap_last_stall_kick_at',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP")
        if n:log_event(c,'readiness.stall','warning',f'{idle} dk yeni fiyat yok; {n} zor ürün derin keşfe yükseltildi',run_type='readiness-rescue',details={'idle_minutes':idle,'products':n,'priority':520})
        return n,idle
    except Exception as e:
        try:log_event(c,'readiness.stall','error',f'Stall watchdog hatası: {e}',run_type='readiness-rescue')
        except Exception:pass
        return 0,-1

def run(limit=30):
    c=connect()
    if not setting_bool(c,'readiness_rescue_enabled',True):c.close();return 0
    # Eski/pasif ürünlerin readiness kayıtları pending sayacını kirletmesin.
    c.execute("""UPDATE readiness_rescue SET status='inactive',updated_at=CURRENT_TIMESTAMP
                 WHERE status='pending' AND NOT EXISTS(SELECT 1 FROM products p WHERE p.id=readiness_rescue.product_id AND p.active=1)""")
    # systemd kill/reboot sonrası bootstrap 20 dk kilitli kalmasın. 4 dk üstü running yeniden kuyruğa dönsün.
    c.execute("""UPDATE product_bootstrap_requests SET status='pending',started_at=NULL,
                 note='v0.3.10 watchdog: sahipsiz/yarım bootstrap yeniden kuyruğa alındı'
                 WHERE status='running' AND (started_at IS NULL OR started_at < datetime('now','-4 minutes'))""")
    # Görsel worker: started_at NULL eski kayıtlar da sahipsizdir. v0.3.9 bunları
    # atladığı için image_running sayısı sonsuza dek büyüyebiliyordu.
    c.execute("""UPDATE image_fetch_requests SET status='retry',started_at=NULL,next_attempt_at=CURRENT_TIMESTAMP,
                 note='v0.3.10 watchdog: sahipsiz/yarım görsel işi yeniden kuyruğa alındı'
                 WHERE status='running' AND (started_at IS NULL OR started_at < datetime('now','-4 minutes'))""")
    c.commit()
    rows=c.execute("""SELECT p.id,p.name,p.created_at,p.image_path,COALESCE(p.image_locked,0) image_locked,
      EXISTS(SELECT 1 FROM product_sources ps WHERE ps.product_id=p.id AND ps.active=1 AND ps.condition_status='verified' AND ps.detected_condition=p.condition_type AND ps.last_price IS NOT NULL) has_price,
      EXISTS(SELECT 1 FROM product_sources ps WHERE ps.product_id=p.id AND ps.active=1) has_source,
      rr.first_missing_at,rr.last_attempt_at,COALESCE(rr.attempts,0) attempts
      FROM products p LEFT JOIN readiness_rescue rr ON rr.product_id=p.id
      WHERE p.active=1 AND (
        NOT EXISTS(SELECT 1 FROM product_sources ps WHERE ps.product_id=p.id AND ps.active=1 AND ps.condition_status='verified' AND ps.detected_condition=p.condition_type AND ps.last_price IS NOT NULL)
        OR p.image_path IS NULL OR p.image_path='' OR COALESCE(p.image_locked,0)=0)
      ORDER BY CASE WHEN p.id >= (SELECT COALESCE(MAX(id)-80,0) FROM products) THEN 0 ELSE 1 END,
               COALESCE(rr.escalation_level,1) DESC,COALESCE(rr.first_missing_at,p.created_at) ASC,p.id DESC LIMIT ?""",(max(1,limit),)).fetchall()
    now=datetime.now(timezone.utc);processed=0;max_level=0
    for r in rows:
        first=r['first_missing_at'] or r['created_at'] or now.strftime('%Y-%m-%d %H:%M:%S')
        try:first_dt=datetime.fromisoformat(str(first).replace('Z','+00:00'));first_dt=first_dt if first_dt.tzinfo else first_dt.replace(tzinfo=timezone.utc)
        except Exception:first_dt=now
        age=max(0,(now-first_dt).total_seconds()/60);level=level_for(age)
        # Eğer alt kuyrukta iş hâlâ aktifse yeniden duplicate üretme; fakat dispatcher watchdog yine aşağıda dürtülecek.
        active_work=c.execute("""SELECT
          EXISTS(SELECT 1 FROM product_bootstrap_requests WHERE product_id=? AND status IN ('pending','running')) b,
          EXISTS(SELECT 1 FROM image_fetch_requests WHERE (product_id=? AND status IN ('pending','running')) OR (product_id=? AND status='retry' AND (next_attempt_at IS NULL OR next_attempt_at<=CURRENT_TIMESTAMP))) i""",(int(r['id']),int(r['id']),int(r['id']))).fetchone()
        if r['last_attempt_at'] and not (active_work and (active_work['b'] or active_work['i'])):
            try:last=datetime.fromisoformat(str(r['last_attempt_at']).replace('Z','+00:00'));last=last if last.tzinfo else last.replace(tzinfo=timezone.utc)
            except Exception:last=None
            if last and (now-last).total_seconds() < interval_minutes(level)*60:continue
        missing_price=not bool(r['has_price']);missing_image=not(bool(r['image_path']) and bool(r['image_locked']))
        priority={1:220,2:280,3:340,4:420}[level]
        reasons=[]
        if missing_price:reasons.append('fiyat yok')
        if missing_image:reasons.append('görsel yok')
        note=f'Hazırlık kurtarma L{level}: '+', '.join(reasons)+f' · bekleme {int(age)} dk'
        if missing_price:queue_bootstrap(c,int(r['id']),priority,note)
        if missing_image:queue_image(c,int(r['id']),priority,note)
        if missing_price and r['has_source']:
            c.execute("UPDATE product_sources SET next_check_at=CURRENT_TIMESTAMP WHERE product_id=? AND active=1",(int(r['id']),))
            queue_manual(c,int(r['id']),note)
        c.execute("""INSERT INTO readiness_rescue(product_id,first_missing_at,last_attempt_at,attempts,escalation_level,missing_price,missing_image,status,last_note,updated_at)
          VALUES(?,?,CURRENT_TIMESTAMP,1,?,?,?,'pending',?,CURRENT_TIMESTAMP)
          ON CONFLICT(product_id) DO UPDATE SET last_attempt_at=CURRENT_TIMESTAMP,attempts=readiness_rescue.attempts+1,escalation_level=excluded.escalation_level,missing_price=excluded.missing_price,missing_image=excluded.missing_image,status='pending',last_note=excluded.last_note,updated_at=CURRENT_TIMESTAMP""",
          (int(r['id']),first,level,int(missing_price),int(missing_image),note))
        log_event(c,'readiness.rescue','warning' if level>=3 else 'info',note,run_type='readiness-rescue',product_id=int(r['id']),details={'level':level,'age_minutes':int(age),'missing_price':missing_price,'missing_image':missing_image,'priority':priority})
        processed+=1;max_level=max(max_level,level)
    c.execute("""UPDATE readiness_rescue SET status='ready',missing_price=0,missing_image=0,updated_at=CURRENT_TIMESTAMP
      WHERE status<>'ready' AND EXISTS(SELECT 1 FROM products p WHERE p.id=readiness_rescue.product_id AND p.active=1 AND p.image_path IS NOT NULL AND p.image_path<>'' AND COALESCE(p.image_locked,0)=1)
      AND EXISTS(SELECT 1 FROM product_sources ps JOIN products p ON p.id=ps.product_id WHERE ps.product_id=readiness_rescue.product_id AND ps.active=1 AND ps.condition_status='verified' AND ps.detected_condition=p.condition_type AND ps.last_price IS NOT NULL)""")
    stall_kicked,price_idle_minutes=stall_watch(c)
    counts=due_counts(c)
    c.commit();c.close()
    kicked=kick_queues(counts)
    if max_level>=3:
        for trig in ('discovery.trigger','variant-ai.trigger'):
            try:(BASE/trig).touch()
            except OSError:pass
    print(f"Readiness rescue: {processed} ürün işlendi | stall={stall_kicked} idle={price_idle_minutes}dk | due bootstrap={counts['bootstrap']} image={counts['image']} manual={counts['manual']} | kick={','.join(kicked) or '-'}")
    return processed

if __name__=='__main__':
    ap=argparse.ArgumentParser();ap.add_argument('--limit',type=int,default=30);a=ap.parse_args();run(a.limit)
