"""Hermes LMP V5 runtime. Python 3.8+. No credentials stored here.
Safe by default: dry-run unless HERMES_LMP_LIVE=1.
"""
import os
import json
import time
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import urlsplit
import httpx
import pytz
from hermes_lmp_matcher import asociar, identificar
from hermes_lmp_guard import validar_asociaciones
from hermes_lmp_inventory import obtener_inventario
from hermes_lmp_playlist import extraer_playlist
from hermes_lmp_coordinator import planificar
from hermes_lmp_v5_state import StateStore

TZ = pytz.timezone('America/Tijuana')
STATE = StateStore(str(Path(__file__).with_name('hermes_lmp_v5_live_state.json')))
LIVE = os.environ.get('HERMES_LMP_LIVE') == '1'


def _getstatus(host, auth, ids):
    response = httpx.post(host + '/api/action', headers={
        'Authorization': auth, 'Content-Type': 'application/json',
        'Origin': host, 'Referer': host + '/config/lmp'},
        json={'Action': 'getstatus', 'ProviderId': 'lmp',
              'Streams': [{'Id': ','.join(ids)}]}, timeout=15)
    response.raise_for_status()
    data = response.json()
    providers = [p for p in data.get('Providers', []) if p.get('Id') == 'lmp']
    if len(providers) != 1:
        raise RuntimeError('Proveedor LMP ambiguo')
    found = {c['Id']: c for c in providers[0].get('Streams', []) if c.get('Id') in ids}
    if set(found) != set(ids):
        raise RuntimeError('Inventario y getstatus no coinciden')
    return found


def _validated_playlist(host, auth, inventory):
    response = httpx.get(host + '/playlist.m3u', headers={'Authorization': auth}, timeout=20)
    response.raise_for_status()
    channels = [c for c in extraer_playlist(response.text)
                if c['name'].strip().lower().startswith('[lmp]')]
    result = {}
    for cid, name in inventory.items():
        teams = identificar(name)
        matches = [c for c in channels if len(teams) == 2 and identificar(c['name']) == teams]
        if len(matches) != 1:
            raise RuntimeError('M3U ambigua para ' + cid)
        url = matches[0]['url']
        if urlsplit(url).scheme not in ('http', 'https'):
            raise RuntimeError('URL invalida')
        result[cid] = url
    if len(set(result.values())) != len(result):
        raise RuntimeError('URLs duplicadas')
    return result


def _refresh_provider(host, auth):
    headers = {'Authorization': auth, 'Content-Type': 'application/json',
               'Origin': host, 'Referer': host + '/config/lmp?type=live'}
    for action in ('channelsupdaterequest', 'channelsupdateapply'):
        response = httpx.post(host + '/api/action', headers=headers,
                              json={'Action': action, 'ProviderId': 'lmp'}, timeout=15)
        response.raise_for_status()
        if action == 'channelsupdaterequest':
            time.sleep(5)
    time.sleep(5)


def _key(game):
    away, home = identificar(game.get('awayTeamShort', '')), identificar(game.get('homeTeamShort', ''))
    if len(away) != 1 or len(home) != 1 or len(away | home) != 2:
        return None
    return str(game.get('localFullDate', '')) + '|' + '|'.join(sorted(away | home))


def _apply_observation(data, prepared, status_map, now):
    games = data['games']
    for game in prepared:
        key = game['_hermes_key']
        code = game.get('gameStatus')
        rec = games.setdefault(key, {'status': 'unknown', 'first_final_seen': None,
                                     'final_confirmations': 0, 'last_final_seen': None,
                                     'start_attempts': [], 'prepared': False,
                                     'stop_confirmed': False})
        rec['channel_id'] = game['_hermes_channel']
        rec['last_seen'] = now.isoformat()
        if code == 2:
            if rec.get('status') != 'final':
                rec['first_final_seen'] = now.isoformat()
                rec['final_confirmations'] = 0
            rec['status'] = 'final'
            rec['final_confirmations'] = rec.get('final_confirmations', 0) + 1
            rec['last_final_seen'] = now.isoformat()
        elif code == 1:
            rec['status'] = 'live'
            rec['final_confirmations'] = 0
            rec['first_final_seen'] = None
        elif code == 0 and rec.get('status') != 'final':
            rec['status'] = 'scheduled'
        # Unknown status never grants stop.
    return True




def _strict_snapshot():
    """Never silently replace damaged persistent state with an empty state."""
    if not STATE.path.exists():
        return {'games': {}}
    data = json.loads(STATE.path.read_text(encoding='utf-8'))
    if not isinstance(data, dict) or not isinstance(data.get('games'), dict):
        raise ValueError('Estado V5 invalido')
    return data


def _reconcile_pending(host, auth, statuses, now, log):
    """Resolve uncertain commands from observed O11 state, not from HTTP outcome."""
    snapshot = _strict_snapshot()['games']
    for key, rec in snapshot.items():
        cid = rec.get('channel_id')
        channel = statuses.get(cid)
        if channel is None:
            continue
        running = channel.get('Running')
        attempts = rec.get('start_attempts', [])
        if attempts and attempts[-1].get('status') in ('reserved', 'sent'):
            last = attempts[-1]
            try:
                age = (now - datetime.fromisoformat(last['time'])).total_seconds()
            except (ValueError, TypeError, KeyError):
                raise ValueError('Intento pendiente sin fecha valida: ' + key)
            if running is True:
                def confirm(d, k=key, number=last['attempt']):
                    a = d['games'][k]['start_attempts'][-1]
                    if a.get('attempt') == number and a.get('status') in ('reserved', 'sent'):
                        a['status'] = 'confirmed'
                STATE.actualizar(confirm)
                log('HERMES V5 RECUPERADO START ' + key)
            elif running is False and age >= 90:
                # Keep the attempt in history. Next slot may retry; never resend this slot.
                def fail(d, k=key, number=last['attempt']):
                    a = d['games'][k]['start_attempts'][-1]
                    if a.get('attempt') == number and a.get('status') in ('reserved', 'sent'):
                        a['status'] = 'unconfirmed'
                STATE.actualizar(fail)
                log('HERMES V5 START NO CONFIRMADO ' + key)
        stop = rec.get('stop_operation') or {}
        if stop.get('status') in ('reserved', 'sent'):
            try:
                age = (now - datetime.fromisoformat(stop['time'])).total_seconds()
            except (ValueError, TypeError, KeyError):
                raise ValueError('STOP pendiente sin fecha valida: ' + key)
            if running is False:
                def stop_ok(d, k=key):
                    r = d['games'][k]
                    if (r.get('stop_operation') or {}).get('status') in ('reserved', 'sent'):
                        r['stop_operation']['status'] = 'confirmed'
                        r['stop_confirmed'] = True
                STATE.actualizar(stop_ok)
                log('HERMES V5 RECUPERADO STOP ' + key)
            elif running is True and age >= 90:
                def stop_fail(d, k=key):
                    r = d['games'][k]
                    if (r.get('stop_operation') or {}).get('status') in ('reserved', 'sent'):
                        r['stop_operation']['status'] = 'unconfirmed'
                STATE.actualizar(stop_fail)
                log('HERMES V5 STOP NO CONFIRMADO ' + key)


def tick(epg, panel, stream_database, *, log=print):
    """One synchronous scheduler tick; does not start a second scheduler."""
    now = datetime.now(TZ)
    host, auth = panel['host'].rstrip('/'), panel['auth']
    games = (epg['response'].get('games', []) + epg['response'].get('prevGames', []))
    inventory = obtener_inventario(host, auth)
    statuses = _getstatus(host, auth, list(inventory))
    matches = asociar(games, inventory)
    valid = {}
    for day in (now.date(), (now - timedelta(days=1)).date()):
        valid.update(validar_asociaciones(games, matches, inventory, day.isoformat()))
    prepared = []
    for game in games:
        key = _key(game)
        if key in valid:
            g = dict(game)
            g['_hermes_key'], g['_hermes_channel'] = key, valid[key]
            prepared.append(g)
    if not prepared:
        log('HERMES V5: sin asociaciones validas; sin acciones')
        return
    STATE.actualizar(lambda d: _apply_observation(d, prepared, statuses, now))
    _reconcile_pending(host, auth, statuses, now, log)
    snapshot = _strict_snapshot()['games']
    # Refresh at most once per tick even if multiple games share kickoff.
    refreshed_this_tick = False
    for game in prepared:
        key, cid = game['_hermes_key'], game['_hermes_channel']
        start = datetime.fromisoformat(game['localFullDate'].replace('Z', '+00:00'))
        if start.tzinfo is None:
            start = TZ.localize(start, is_dst=None)
        if not start - timedelta(minutes=10) <= now <= start + timedelta(minutes=20):
            continue
        rec = snapshot[key]
        if rec.get('prepared') or rec.get('status') == 'final':
            continue
        if LIVE and not refreshed_this_tick:
            _refresh_provider(host, auth)
            inventory = obtener_inventario(host, auth)
            statuses = _getstatus(host, auth, list(inventory))
            refreshed_this_tick = True
        links = _validated_playlist(host, auth, inventory)
        # A new provider refresh can change IDs: re-resolve, never use stale cid.
        current = asociar(games, inventory)
        if current.get(key) not in links:
            raise RuntimeError('Canal cambiado o ausente tras refresh: ' + key)
        cid = current[key]
        if LIVE:
            for slug, db in stream_database['lmp'].items():
                if slug in identificar(inventory[cid]):
                    db['regional'] = links[cid]
        if LIVE:
            valid[key] = cid
            game['_hermes_channel'] = cid
            STATE.actualizar(lambda d, k=key, c=cid: d['games'][k].update(
                {'prepared': True, 'channel_id': c, 'prepared_at': now.isoformat()}))
        log('HERMES V5: preparacion validada ' + key + (' LIVE' if LIVE else ' DRY'))
    snapshot = _strict_snapshot()['games']
    states = {}
    for game in prepared:
        key = game['_hermes_key']
        rec = snapshot[key]
        cid = rec.get('channel_id', game['_hermes_channel'])
        channel = statuses.get(cid, {})
        states[key] = dict(rec, running=channel.get('Running'), ondemand=channel.get('OnDemand'))
    actions = planificar(prepared, valid, states, now)
    for action in actions:
        key, cid = action['game'], action['channel']
        rec = snapshot[key]
        if cid != rec.get('channel_id') or not rec.get('prepared'):
            continue
        if action['action'] == 'start':
            attempts = rec.get('start_attempts', [])
            if attempts and attempts[-1].get('status') in ('reserved', 'sent'):
                continue
        if action['action'] == 'stop' and rec.get('stop_confirmed'):
            continue
        if action['action'] == 'stop':
            if rec.get('final_confirmations', 0) < 2:
                continue
            last = rec.get('last_final_seen')
            if not last or (now - datetime.fromisoformat(last)).total_seconds() > 300:
                continue
        if not LIVE:
            log('HERMES V5 DRY ' + action['action'] + ' ' + key)
            continue
        from hermes_lmp_actions import O11Actions
        attempt = action.get('attempt')
        if attempt:
            def reserve(d):
                r = d['games'][key]
                if len(r.get('start_attempts', [])) != attempt - 1:
                    return False
                r['start_attempts'].append({'attempt': attempt, 'status': 'reserved',
                                            'time': now.isoformat()})
                return True
            if not STATE.actualizar(reserve):
                continue
        else:
            def reserve_stop(d):
                r = d['games'][key]
                if r.get('stop_confirmed'):
                    return False
                op = r.get('stop_operation') or {}
                if op.get('status') in ('reserved', 'sent', 'confirmed'):
                    return False
                r['stop_operation'] = {'status': 'reserved', 'time': now.isoformat()}
                return True
            if not STATE.actualizar(reserve_stop):
                continue
        # An uncertain HTTP result is intentionally left reserved for reconciliation.
        O11Actions(host, auth, dry_run=False).ejecutar(action['action'], cid)
        verified = _getstatus(host, auth, [cid])[cid].get('Running')
        desired = action['action'] == 'start'
        if verified is not desired:
            raise RuntimeError('O11 no confirmo ' + action['action'] + ' ' + cid)
        def confirm(d):
            r = d['games'][key]
            if attempt:
                a = r['start_attempts'][-1]
                if a.get('attempt') == attempt:
                    a['status'] = 'confirmed'
            else:
                r['stop_operation']['status'] = 'confirmed'
                r['stop_confirmed'] = True
        STATE.actualizar(confirm)
        log('HERMES V5 CONFIRMADO ' + action['action'] + ' ' + key)


def access_allowed(slug, *, now=None):
    """Fail closed for LMP only when live mode is enabled."""
    if not LIVE:
        return True
    now = now or datetime.now(TZ)
    try:
        data = _strict_snapshot()
        for key, game in data['games'].items():
            if slug not in key.split('|')[1:]:
                continue
            if game.get('stop_confirmed') or not game.get('prepared'):
                continue
            start = datetime.fromisoformat(key.split('|')[0])
            if start.tzinfo is None:
                start = TZ.localize(start, is_dst=None)
            if now < start - timedelta(minutes=10):
                continue
            if now <= start + timedelta(hours=6):
                return True
            # Long games may continue; require fresh, explicit live EPG observation.
            if game.get('status') == 'live':
                seen = game.get('last_seen')
                if seen:
                    observed = datetime.fromisoformat(seen)
                    if observed.tzinfo is not None and 0 <= (now - observed).total_seconds() <= 300:
                        return True
    except Exception:
        return False
    return False
