import os, sys, re, io, base64, uuid, json, secrets, string, subprocess, signal, glob, time as _time, hashlib, shutil, hmac, html, unicodedata, logging
from datetime import datetime, timedelta, timezone

# Setup logging
# app.log é o diário COMPLETO da aplicação: não só erros. Tudo que acontece
# numa reprodução (token emitido, escolha HLS/TS, handoff para a VPS, falha de
# origem, estado do MSE no navegador) fica gravado aqui. É o único jeito de
# descobrir por que a imagem travou sem depender do console do navegador.
_logger = logging.getLogger('troco_play')
_logger.setLevel(logging.INFO)
_logger.propagate = False


def _keep_last_bytes(path, keep_bytes, max_bytes):
    """Mantem um arquivo de log pequeno e legivel.

    Se o arquivo passar de ``max_bytes``, reescreve-o guardando apenas os
    ultimos ``keep_bytes`` (as entradas mais recentes). Usado tanto no
    app.log no boot quanto nos logs de background (append puro, sem
    rotacao propria) antes de cada import, para que nunca fiquem enormes
    e impossiveis de ler no cPanel.
    """
    try:
        if not os.path.isfile(path):
            return
        size = os.path.getsize(path)
        if size <= max_bytes:
            return
        with open(path, 'rb') as f:
            f.seek(size - keep_bytes)
            data = f.read()
        # Corta a primeira linha, que pode estar incompleta.
        nl = data.find(b'\n')
        if nl != -1:
            data = data[nl + 1:]
        with open(path, 'wb') as f:
            f.write(data)
    except Exception:
        # Nunca pode derrubar o boot/import por causa de limpeza de log.
        pass


if not _logger.handlers:
    try:
        from logging.handlers import TimedRotatingFileHandler
        _base_dir = os.path.dirname(os.path.abspath(__file__))
        log_file = os.path.join(_base_dir, 'app.log')
        # Garante que o app.log atual comece legivel: se acumulou megas,
        # deixa so as ultimas ~1MB (entradas recentes) antes de abrir.
        _keep_last_bytes(log_file, keep_bytes=1 * 1024 * 1024,
                         max_bytes=2 * 1024 * 1024)
        # Remove backups numericos (app.log.1..N) deixados pelo antigo
        # RotatingFileHandler por tamanho — a nova politica e manter 1 dia.
        # O regex \d+ garante que NAO apague os backups diarios novos
        # (app.log.2026-10-16), que contem '-' e nao sao so digitos.
        for _stale in glob.glob(os.path.join(_base_dir, 'app.log.*')):
            if re.fullmatch(r'app\.log\.\d+', os.path.basename(_stale)):
                try:
                    os.remove(_stale)
                except Exception:
                    pass
        # Rotacao diaria a meia-noite, guardando ontem (backupCount=1):
        # mantem ~1 dia de historico, como pedido.
        fh = TimedRotatingFileHandler(log_file, when='midnight',
                                      backupCount=1, encoding='utf-8')
        fh.setLevel(logging.INFO)
        ch = logging.StreamHandler()
        ch.setLevel(logging.INFO)
        formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
        fh.setFormatter(formatter)
        ch.setFormatter(formatter)
        _logger.addHandler(fh)
        _logger.addHandler(ch)
    except Exception as _e:
        # Antes isto era 'pass' silencioso: se o arquivo nao pode ser aberto,
        # o app inteiro ficava sem log nenhum e ninguem percebia. Agora cai
        # para stderr e deixa o motivo visivel.
        _fallback = logging.StreamHandler()
        _fallback.setLevel(logging.INFO)
        _fallback.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s'))
        _logger.addHandler(_fallback)
        sys.stderr.write(f'[aviso] app.log indisponivel ({_e}); logs vao para stderr\n')
        sys.stderr.flush()

# Separate error log file (ERROR and above only)
try:
    from logging.handlers import TimedRotatingFileHandler as _ERRHandler
    _err_base = os.path.dirname(os.path.abspath(__file__))
    error_log_file = os.path.join(_err_base, 'erro.log')
    eh = _ERRHandler(error_log_file, when='midnight', backupCount=7, encoding='utf-8')
    eh.setLevel(logging.ERROR)
    eh.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S'))
    _logger.addHandler(eh)
except Exception as _err_e:
    try:
        sys.stderr.write(f'[aviso] erro.log indisponivel ({_err_e})\n')
    except Exception:
        pass

# Logs de background (bg_m3u.log, bg_epg.log): sao apenas append, sem
# rotacao propria. No boot, zera qualquer um que nao foi tocado em mais
# de 1 dia, para nunca acumularem meses de historico.
try:
    _bg_log_dir = os.path.dirname(os.path.abspath(__file__))
    _now_ts = _time.time()
    for _bg in ('bg_m3u.log', 'bg_epg.log'):
        _bg_path = os.path.join(_bg_log_dir, _bg)
        if os.path.isfile(_bg_path) and (_now_ts - os.path.getmtime(_bg_path)) > 86400:
            open(_bg_path, 'w').close()
except Exception:
    pass

def log_info(msg):
    _logger.info(msg)

def log_error(msg):
    _logger.error(msg)

try:
    from dotenv import load_dotenv
    load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env'))
except ImportError:
    pass
from sqlalchemy import or_, and_, func, text
from functools import wraps
from urllib.parse import urljoin, urlparse, quote as urlquote
import urllib.parse
import csv
import threading
import time
import smtplib
from email.message import EmailMessage
import xml.etree.ElementTree as ET
from difflib import SequenceMatcher

import requests
from flask import (
    Flask, render_template, request, redirect, url_for,
    flash, jsonify, session, send_file, Response, stream_with_context, abort, g
)
from flask_sqlalchemy import SQLAlchemy
from flask_login import (
    LoginManager, UserMixin, login_user, logout_user, login_required, current_user
)
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.exceptions import HTTPException

# ─── Optional security packages ─────────────────────────────────────
try:
    import bleach
    HAS_BLEACH = True
except ModuleNotFoundError:
    HAS_BLEACH = False

try:
    from flask_wtf.csrf import CSRFProtect, generate_csrf
    HAS_CSRF = True
except ModuleNotFoundError:
    HAS_CSRF = False

try:
    from flask_limiter import Limiter
    from flask_limiter.util import get_remote_address
    HAS_LIMITER = True
except ModuleNotFoundError:
    HAS_LIMITER = False
from apscheduler.schedulers.background import BackgroundScheduler
from qrcode import make as make_qr
from io import BytesIO, StringIO

# ─── Config ────────────────────────────────────────────────────────────────
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', '').strip() or secrets.token_hex(32)

# ─── Diário de requisições (sempre, não só em erro) ───────────────────────
# O heartbeat fica de fora: ele roda a cada segundos e enterraria o que
# importa. Todo o resto entra com status, duração, tamanho e Range.
_LOG_SKIP = re.compile(
    # /static/ precisa do prefixo inteiro: com '$' depois de 'static' o
    # /static/logo.png passava e enchia o diario de ruido.
    r'^/static/'
    r'|^/(favicon\.ico|apple-touch-icon[^/]*\.png|manifest\.json|robots\.txt)$'
    r'|/api/(device_heartbeat|consume_time|watch_heartbeat)$'
)

@app.before_request
def _log_request_start():
    g._t0 = _time.time()


# ─── Navegacao instantanea: renderizacao parcial ────────────────────────────
# A mesma rota pedida com ?spa=1 devolve apenas o conteudo dos blocos da pagina
# (sem <head>, menu e rodape). O navegador entao troca so o conteudo, como o
# app da Netflix faz, e o clique abre na hora — em TODAS as paginas, inclusive o
# painel admin.
#
# Em vez de uma lista de rotas "permitidas" (que ficava para tras do site), o
# padrao e partial e apenas o que NUNCA pode ser parcial fica de fora: API,
# arquivos, login/pagamento (fluxo com redirect e POST, que quer a pagina
# inteira) e o que nao e HTML. Qualquer pagina que ainda herde a casca inteira
# devolve o shell, o cliente percebe (X-SPA ausente / id="main-content" no
# corpo) e faz a carga normal — cair para o jeito antigo e sempre a saida.
_SPA_NUNCA = ('/api/', '/cron/', '/proxy', '/static/', '/download/', '/instalar',
              '/.well-known', '/favicon', '/robots.txt', '/manifest',
              '/login', '/register', '/esqueci-senha', '/redefinir-senha',
              '/tv/login', '/admin/login', '/pagamento/', '/pagamento-concluido',
              '/payment/', '/logout')


def _spa_pode_ser_parcial(caminho):
    caminho = caminho or ''
    return not any(caminho.startswith(p) for p in _SPA_NUNCA)


@app.before_request
def _spa_flag_parcial():
    if request.method != 'GET' or request.args.get('spa') != '1':
        return
    if _spa_pode_ser_parcial(request.path):
        g.spa_parcial = True


@app.context_processor
def inject_spa_base():
    # os templates herdam daqui: em modo parcial o "shell" vira um arquivo sem
    # carcaca, e o resto do template continua o mesmo.
    return {'spa_base': '_spa.html' if getattr(g, 'spa_parcial', False) else None}


@app.after_request
def _spa_header_parcial(response):
    if not getattr(g, 'spa_parcial', False) or response.status_code != 200:
        return response
    tipo = response.headers.get('Content-Type', '') or ''
    if 'text/html' not in tipo:
        return response
    response.headers['X-SPA'] = 'partial'
    response.headers['Cache-Control'] = 'no-store'
    return response

@app.after_request
def _log_request_end(response):
    try:
        dt = _time.time() - getattr(g, '_t0', _time.time())
    except Exception:
        dt = 0.0
    path = request.path or ''
    if _LOG_SKIP.search(path):
        return response
    try:
        n = int(response.headers.get('Content-Length') or 0)
    except Exception:
        n = 0
    rng = request.headers.get('Range') or request.args.get('Range') or ''
    extra = f' range={rng[:40]}' if rng else ''
    try:
        uid = current_user.id if current_user.is_authenticated else 0
    except Exception:
        uid = 0
    log_info(f'HTTP {request.method} {path} -> {response.status_code} '
             f'{dt * 1000:.0f}ms uid={uid} bytes={n}{extra}')
    return response

# Em hospedagem cPanel/Passenger, o processo web não deve manter scheduler,
# workers ou conversores de mídia persistentes. As tarefas recorrentes são
# executadas pelos Cron Jobs do próprio cPanel.
CPANEL_MODE = os.environ.get('CPANEL_MODE', '').strip().lower() in {'1', 'true', 'yes', 'on'}
SCHEDULER_ENABLED = not CPANEL_MODE and not bool(os.environ.get('FLASK_SKIP_SCHEDULER'))
FFMPEG_BINARY = os.environ.get('FFMPEG_BINARY', '').strip() or shutil.which('ffmpeg')
FFMPEG_ENABLED = os.environ.get('FFMPEG_ENABLED', 'false').strip().lower() in {'1', 'true', 'yes', 'on'} and bool(FFMPEG_BINARY)
HEALTH_MAX_WORKERS = max(1, min(int(os.environ.get('HEALTH_MAX_WORKERS', '5')), 20))

# Contas M3U/Xtream vencidas ou sem acesso nao podem ser usadas na
# reproducao, no sync do catalogo nem no health check. O status de cada
# conta fica em cache por M3U_SOURCE_HEALTH_TTL segundos (player_api.php
# e consultado, no maximo, uma vez por minuto por conta).
M3U_SOURCE_HEALTH_TTL = max(30, int(os.environ.get('M3U_SOURCE_HEALTH_TTL', '60') or 60))
M3U_SOURCE_HEALTH_ENABLED = os.environ.get('M3U_SOURCE_HEALTH_ENABLED', 'true').strip().lower() in {'1', 'true', 'yes', 'on'}
M3U_SOURCE_CHECK_TIMEOUT = (5, 12)

# Banco de dados: use DATABASE_URL para MySQL no cPanel.
# Exemplo: mysql+pymysql://usuario:senha@localhost/troco_play?charset=utf8mb4
DATABASE_URL = os.environ.get('DATABASE_URL', '').strip()
if DATABASE_URL:
    app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL
else:
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///troco_play.db?timeout=60'

app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
if DATABASE_URL and not DATABASE_URL.startswith('sqlite'):
    app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
        'pool_size': 5,
        'max_overflow': 5,
        'pool_timeout': 60,
        'pool_recycle': 280,
        'pool_pre_ping': True,
        # Sem timeouts de socket o MySQL pode segurar um request (e uma conexao
        # do pool) indefinidamente em "Lock wait timeout exceeded".
        'connect_args': {
            'connect_timeout': 10,
            'read_timeout': 60,
            'write_timeout': 60,
        },
    }
else:
    app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
        'pool_size': 5,
        'max_overflow': 5,
        'pool_timeout': 60,
        'pool_recycle': 300,
        'connect_args': {'check_same_thread': False},
    }
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)

# Security hardening
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024  # 5 MB
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
if os.environ.get('HTTPS_ENABLED') == 'true':
    app.config['SESSION_COOKIE_SECURE'] = True
app.config['WTF_CSRF_ENABLED'] = True
app.config['WTF_CSRF_TIME_LIMIT'] = 3600  # 1 hour
app.config['WTF_CSRF_SSL_STRICT'] = False

db = SQLAlchemy(app)

# Enable WAL mode + busy timeout for SQLite concurrent access
from sqlalchemy.engine import Engine as _Engine
from sqlalchemy import event as _sa_event
@_sa_event.listens_for(_Engine, 'connect')
def _set_sqlite_pragma(dbapi_connection, connection_record):
    if 'pysqlite' in dbapi_connection.__class__.__module__ or 'sqlite3' in dbapi_connection.__class__.__module__:
        cursor = dbapi_connection.cursor()
        cursor.execute('PRAGMA journal_mode=WAL')
        cursor.execute('PRAGMA busy_timeout=5000')
        cursor.close()

if HAS_CSRF:
    csrf = CSRFProtect(app)
else:
    class _DummyCSRF:
        def exempt(self, f):
            return f
    csrf = _DummyCSRF()

    @app.context_processor
    def inject_fallback_csrf():
        import secrets as _secrets
        def _fallback_token():
            session.setdefault('_csrf_token', _secrets.token_hex(32))
            return session['_csrf_token']
        return dict(csrf_token=_fallback_token)

def _rate_limit_key():
    """IP real do cliente para o controle de flood.

    Atrás de proxy/CDN (cPanel+Passenger, VPS, Cloudflare) o ``remote_addr``
    costuma ser o do proxy: nesse caso TODOS os usuários dividem o mesmo
    contador e um único cliente ativo derruba os demais com 429. O
    ``X-Forwarded-For`` traz o IP de origem — usamos o primeiro da cadeia.
    """
    try:
        xff = request.headers.get('X-Forwarded-For', '')
        if xff:
            first = xff.split(',')[0].strip()
            if first:
                return first
        real = request.headers.get('X-Real-IP', '').strip()
        if real:
            return real
    except Exception:
        pass
    return get_remote_address()


if HAS_LIMITER:
    limiter = Limiter(
        _rate_limit_key,
        app=app,
        # O limite genérico existe SÓ para barrar flood/abuso. Ele não pode
        # contar o tráfego normal de quem está assistindo: o player conversa
        # com /api/epg, /api/device_heartbeat, /api/consume_time,
        # /api/watch_heartbeat e /api/player_diag a cada poucos segundos
        # (>1400 requisições/hora). Com "1000 per hour" o usuário começava a
        # receber 429 em TUDO — inclusive nas páginas — depois de ~40 min de
        # reprodução, e o site parecia quebrado só para ele, de forma isolada.
        # Os endpoints de telemetria/polling também estão isentos abaixo; o
        # teto aqui é só uma rede de segurança contra flood.
        default_limits=['200000 per day', '40000 per hour'],
        storage_uri='memory://',
        enabled=os.environ.get('RATE_LIMIT_ENABLED', 'true').lower() == 'true',
    )
else:
    class _DummyLimiter:
        def limit(self, *a, **kw):
            def deco(f):
                return f
            return deco
        def exempt(self, f):
            return f
    limiter = _DummyLimiter()


# ══════════════════════════════════════════════════════════════════════
# Cache de API (Redis com fallback em memoria)
# ──────────────────────────────────────────────────────────────────────
# Objetivo: cortar ~70% das consultas ao banco nas rotas de leitura
# frequentes (EPG, notificacoes, dashboard, progresso, logo).
#
# No cPanel compartilhado normalmente NAO ha servidor Redis. Por isso
# usamos Redis SOMENTE se REDIS_URL estiver definido (VPS dedicada);
# caso contrario caimos num cache TTL em memoria, por processo. Mesmo
# sem Redis isso reduz bastante as queries porque o polling do player
# repete as mesmas chaves a cada poucos segundos.
# ══════════════════════════════════════════════════════════════════════
_REDIS_CLIENT = None
_REDIS_CHECKED = False
_MEM_CACHE = {}       # key -> (value, expires_at)
_MEM_CACHE_MAX = 5000


def _get_redis():
    """Retorna o client Redis ou None. Conecta uma unica vez (lazy)."""
    global _REDIS_CLIENT, _REDIS_CHECKED
    if _REDIS_CHECKED:
        return _REDIS_CLIENT
    _REDIS_CHECKED = True
    url = (os.environ.get('REDIS_URL') or '').strip()
    if not url:
        return None
    try:
        import redis as _redis_mod
        _REDIS_CLIENT = _redis_mod.Redis.from_url(
            url, socket_timeout=2, socket_connect_timeout=2,
            decode_responses=False,
        )
        _REDIS_CLIENT.ping()
        log_info('[Cache] Redis conectado (REDIS_URL presente).')
    except Exception as _e:
        _REDIS_CLIENT = None
        log_info(f'[Cache] Redis indisponivel ({_e}); usando cache em memoria.')
    return _REDIS_CLIENT


def cache_get(key):
    """Busca um valor no cache. Devolve None se ausente/expirado."""
    r = _get_redis()
    if r is not None:
        try:
            raw = r.get(key)
            if raw is None:
                return None
            return json.loads(raw)
        except Exception:
            return None
    ent = _MEM_CACHE.get(key)
    if not ent:
        return None
    val, exp = ent
    if _time.time() > exp:
        _MEM_CACHE.pop(key, None)
        return None
    return val


def cache_set(key, value, ttl):
    """Grava um valor com TTL (segundos). Falha nunca derruba a rota."""
    r = _get_redis()
    if r is not None:
        try:
            r.setex(key, int(ttl), json.dumps(value, default=str))
            return
        except Exception:
            pass
    # Fallback em memoria com limite de tamanho (evita crescimento solto).
    try:
        if len(_MEM_CACHE) >= _MEM_CACHE_MAX:
            now = _time.time()
            expiradas = [k for k, (_v, e) in _MEM_CACHE.items() if e <= now]
            for k in expiradas:
                _MEM_CACHE.pop(k, None)
            # ainda cheio? remove os ~10% mais antigos
            if len(_MEM_CACHE) >= _MEM_CACHE_MAX:
                ordenado = sorted(_MEM_CACHE.items(), key=lambda kv: kv[1][1])
                for k, _v in ordenado[: max(1, _MEM_CACHE_MAX // 10)]:
                    _MEM_CACHE.pop(k, None)
        _MEM_CACHE[key] = (value, _time.time() + int(ttl))
    except Exception:
        pass


def cached_api(ttl, key_extra=None):
    """Decorator de cache para rotas GET que devolvem JSON.

    A chave inclui endpoint, view args, query string e (quando logado) o
    user id — assim /api/epg/12 e /api/epg/15 nao colidem, e dados por
    usuario (notificacoes, dashboard) nunca vazam entre contas.
    Só guarda respostas 200 com JSON valido; qualquer falha cai direto na
    rota original, entao o cache nunca quebra o comportamento.
    """
    def deco(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            if request.method != 'GET':
                return f(*args, **kwargs)
            try:
                uid = current_user.get_id() if current_user.is_authenticated else 'anon'
            except Exception:
                uid = 'anon'
            parts = [f.__name__, request.path, uid]
            if request.query_string:
                parts.append(request.query_string.decode('utf-8', 'replace'))
            for k in sorted(kwargs.keys()):
                parts.append(f'{k}={kwargs[k]}')
            if key_extra:
                try:
                    parts.append(str(key_extra() if callable(key_extra) else key_extra))
                except Exception:
                    pass
            # Prefixo legivel (endpoint:uid) permite invalidar por escrita.
            ckey = f'apicache:{f.__name__}:{uid}:' + hashlib.md5('|'.join(parts).encode('utf-8')).hexdigest()

            hit = cache_get(ckey)
            if hit is not None:
                resp = Response(json.dumps(hit), mimetype='application/json')
                resp.headers['X-Cache'] = 'HIT'
                return resp

            out = f(*args, **kwargs)
            try:
                # jsonify devolve um Response; extrai o payload para cachear.
                status = getattr(out, 'status_code', 200)
                if status == 200:
                    payload = out.get_json() if hasattr(out, 'get_json') else None
                    if payload is not None:
                        cache_set(ckey, payload, ttl)
                        if hasattr(out, 'headers'):
                            out.headers['X-Cache'] = 'MISS'
            except Exception:
                pass
            return out
        return wrapper
    return deco


def cache_invalidate(endpoint_name, uid='*'):
    """Descarta cache de uma rota (para um usuario, ou todos se uid='*')."""
    r = _get_redis()
    prefix = f'apicache:{endpoint_name}:{uid}'
    if r is not None:
        try:
            for k in r.scan_iter(match=prefix + '*', count=200):
                r.delete(k)
        except Exception:
            pass
        return
    pat = re.compile(r'^' + re.escape(prefix))
    for k in [k for k in _MEM_CACHE.keys() if pat.match(k)]:
        _MEM_CACHE.pop(k, None)


_APP_START_TS = _time.time()  # usado no /health para reportar uptime


# ══════════════════════════════════════════════════════════════════════
# Rate limiting por IP para /api/ e /proxy (item 3.1)
# ──────────────────────────────────────────────────────────────────────
# Token bucket por IP. Objetivo: barrar flood/DDoS sem penalizar o uso
# normal. Por isso os endpoints que legitimamente disparam em rajada
# ficam DE FORA do teto por segundo:
#   • /api/logo e /api/epg/* — a pagina de canais renderiza dezenas de
#     cards e cada um busca logo+EPG de uma vez (rajada de 50+).
#   • telemetria do player (heartbeats, consume_time, diag) — polling
#     constante, ja isentado no limitador global.
# Fora esses, valem 10 req/s (api) e 50 req/s (proxy) por IP.
# Desligavel com RATE_LIMIT_ENABLED=false.
# ══════════════════════════════════════════════════════════════════════
_RATE_IP_ENABLED = os.environ.get('RATE_LIMIT_ENABLED', 'true').lower() == 'true'
_RATE_IP_STATE = {}          # ip -> [tokens, last_ts]
_RATE_IP_STATE_MAX = 5000

# Endpoints isentos do teto por segundo (ja sao @limiter.exempt tambem).
_RATE_EXEMPT_ENDPOINTS = {
    'api_logo_proxy', 'api_epg', 'api_epg_progress', 'api_canais_status',
    'player_diag', 'watch_heartbeat', 'watch_stop', 'consume_time',
    'device_heartbeat', 'device_stream_start', 'device_stream_stop',
    'check_device', 'api_watched_ids', 'continue_watching',
    'api_m3u_progress', 'online_viewers', 'check_favorite',
}


def _rate_allow(ip, rate_per_s, burst):
    """Token bucket simples por IP. True = deixa passar."""
    now = _time.time()
    st = _RATE_IP_STATE.get(ip)
    if st is None:
        if len(_RATE_IP_STATE) >= _RATE_IP_STATE_MAX:
            # descarta os mais antigos para nao crescer sem limite
            corte = now - 120
            for k in [k for k, v in _RATE_IP_STATE.items() if v[1] < corte][:1000]:
                _RATE_IP_STATE.pop(k, None)
            if len(_RATE_IP_STATE) >= _RATE_IP_STATE_MAX:
                _RATE_IP_STATE.clear()
        st = [float(burst), now]
        _RATE_IP_STATE[ip] = st
    tokens, last = st
    tokens = min(float(burst), tokens + (now - last) * rate_per_s)
    st[1] = now
    if tokens >= 1.0:
        st[0] = tokens - 1.0
        return True
    st[0] = tokens
    return False


@app.before_request
def _rate_ip_guard():
    """Aplica o teto por IP so em /api/ e /proxy (fora os isentos)."""
    if not _RATE_IP_ENABLED:
        return None
    try:
        path = request.path or ''
        if not (path.startswith('/api/') or path == '/proxy'):
            return None
        ep = request.endpoint or ''
        if ep in _RATE_EXEMPT_ENDPOINTS:
            return None
        ip = _rate_limit_key()
        if path == '/proxy':
            if _rate_allow('proxy:' + ip, 50.0, 100):
                return None
            retry = 5
        else:
            if _rate_allow('api:' + ip, 10.0, 20):
                return None
            retry = 5
        resp = jsonify({'error': 'Muitas requisicoes. Tente novamente em instantes.',
                        'ok': False, 'rate_limited': True, 'retry_after': retry})
        resp.status_code = 429
        resp.headers['Retry-After'] = str(retry)
        return resp
    except Exception:
        # Nunca derruba a requisicao por causa do proprio rate limit.
        return None


login_manager = LoginManager(app)
login_manager.login_view = 'login'
login_manager.login_message = 'Faça login para continuar.'

@login_manager.unauthorized_handler
def _unauthorized_handler():
    """Redireciona rotas /tv/* para /tv/login em vez de /login."""
    ep = request.endpoint or ''
    if ep.startswith('tv_') or request.path.startswith('/tv/'):
        from flask import redirect as _redir, url_for as _url_for
        return _redir(_url_for('tv_login'))
    from flask import redirect as _redir, url_for as _url_for
    return _redir(_url_for('login'))

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
M3U_PATH = os.path.join(BASE_DIR, 'canais.txt')
SETTINGS_PATH = os.path.join(BASE_DIR, 'instance', 'settings.json')

def _safe_iso(dt):
    """Chama .isoformat() em datetime ou retorna a string como esta."""
    if dt is None:
        return None
    if isinstance(dt, str):
        return dt
    try:
        return dt.isoformat()
    except Exception:
        return str(dt)

def _safe_iso_or_empty(dt):
    """Igual _safe_iso mas retorna '' ao inves de None."""
    return _safe_iso(dt) or ''
BACKUP_DIR = os.path.join(BASE_DIR, 'backup')
C7_API = os.environ.get('C7_API', 'https://api.carteirado7.com/v2')
C7_API_KEY = os.environ.get('C7_API_KEY', '').strip()
C7_API_SECRET = os.environ.get('C7_API_SECRET', '').strip()
SUPPORT_WHATSAPP = os.environ.get('SUPPORT_WHATSAPP', '5584981518164')
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', '').strip().lower()
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '').strip()

FREE_DAILY_HOURS_DEFAULT = 4
PLAN_MONTHLY_PRICE = 10
EPG_URL = os.environ.get('EPG_URL', '').strip()

BRASILIA_TZ = timezone(timedelta(hours=-3))



def _naive_as_utc(dt_value):
    """Assume datetimes 'naive' do banco como UTC (convencao de armazenamento).

    Todas as colunas DateTime sao gravadas como UTC (wall-clock via
    datetime.now(timezone.utc) ou conversao explicita). Na leitura o MySQL
    devolve valor naive => tratar como UTC.
    """
    if dt_value is None:
        return None
    if isinstance(dt_value, str):
        try:
            dt_value = datetime.strptime(dt_value[:19], '%Y-%m-%d %H:%M:%S')
        except Exception:
            return None
    if dt_value.tzinfo is None:
        return dt_value.replace(tzinfo=timezone.utc)
    return dt_value.astimezone(timezone.utc)


def to_brasilia(dt_value):
    """Converte datetime para Brasilia (UTC-3). Naive no banco = UTC."""
    utc = _naive_as_utc(dt_value)
    return utc.astimezone(BRASILIA_TZ) if utc is not None else None


def brasilia_iso(dt_value):
    """Serializa timestamps para APIs sempre com o offset oficial de Brasília."""
    if not dt_value:
        return ''
    try:
        return to_brasilia(dt_value).isoformat()
    except Exception:
        return str(dt_value)

def get_user_timezone():
    """Retorna o offset de timezone do usuario logado, ou Brasilia (UTC-3) como padrao."""
    try:
        if current_user.is_authenticated and hasattr(current_user, 'timezone_offset') and current_user.timezone_offset is not None:
            return timezone(timedelta(hours=current_user.timezone_offset))
    except Exception:
        pass
    return BRASILIA_TZ


def to_user_tz(dt_value):
    """Converte horario armazenado (UTC) para o timezone configurado pelo usuario.

    Usuario padrao = UTC-3 (Brasilia); se ele configurar outro offset em
    /conta (timezone_offset), a conversao segue o fuso escolhido.
    """
    utc = _naive_as_utc(dt_value)
    if utc is None:
        return None
    try:
        return utc.astimezone(get_user_timezone())
    except Exception:
        return utc.astimezone(BRASILIA_TZ)


@app.template_filter('btime')
def btime_filter(utc_dt, fmt='%H:%M'):
    if not utc_dt or (isinstance(utc_dt, str) and not utc_dt.strip()):
        return '-'
    try:
        br = to_user_tz(utc_dt)
        return br.strftime(fmt) if br else '-'
    except Exception:
        return '-'

@app.template_filter('bdate')
def bdate_filter(utc_dt, fmt='%d/%m/%Y'):
    if not utc_dt or (isinstance(utc_dt, str) and not utc_dt.strip()):
        return '-'
    try:
        br = to_user_tz(utc_dt)
        return br.strftime(fmt) if br else '-'
    except Exception:
        return '-'

@app.template_filter('bdatetime')
def bdatetime_filter(utc_dt, fmt='%d/%m %H:%M'):
    if not utc_dt or (isinstance(utc_dt, str) and not utc_dt.strip()):
        return '-'
    try:
        br = to_user_tz(utc_dt)
        return br.strftime(fmt) if br else '-'
    except Exception:
        return '-'


def _tz_label(tz=None):
    """Rotulo do fuso (ex.: UTC-3, UTC+0, UTC-3:30)."""
    if tz is None:
        tz = get_user_timezone()
    try:
        off_h = tz.utcoffset(None).total_seconds() / 3600
        if float(off_h).is_integer():
            return f'UTC{off_h:+.0f}'
        return f'UTC{int(off_h):+d}:30'
    except Exception:
        return 'UTC-3'


def _notification_message(n):
    """Mensagem exibida da notificacao, adaptada ao fuso ATUAL do usuario.

    Para notificacoes de login, a mensagem armazenada já inclui o contexto
    (IP, cidade/UF, pais, dispositivo, navegador), entao retornamos o texto
    completo. O timestamp gravado em created_at (UTC) pode ser reexibido
    no fuso configurado do usuario quando necessario.
    """
    try:
        if getattr(n, 'title', '') == 'Novo login na sua conta' and getattr(n, 'created_at', None):
            stored = (n.message or '').strip()
            if stored:
                return stored
            dt = to_user_tz(n.created_at)
            if dt:
                return (f'Login realizado em {dt.strftime("%d/%m/%Y %H:%M")} '
                        f'({_tz_label()}). Se não foi você, altere sua senha imediatamente.')
    except Exception:
        pass
    return n.message or ''


@app.context_processor
def _inject_notification_helpers():
    def notif_msg(n):
        return _notification_message(n)
    return {'notif_msg': notif_msg}


@app.context_processor
def _inject_video_helpers():
    """Disponibiliza helpers para templates consumirem videos institucionais."""
    def videos_for(location):
        return get_active_videos(location)
    return {'videos_for': videos_for, 'site_video_locations': VIDEO_LOCATIONS}


def _as_brasilia_wall(dt_value):
    """Campos gravados ja em horario de Brasilia (paid_until/PlanHistory)."""
    if dt_value is None:
        return None
    if isinstance(dt_value, str):
        try:
            dt_value = datetime.strptime(dt_value[:19], '%Y-%m-%d %H:%M:%S')
        except Exception:
            return None
    if dt_value.tzinfo is None:
        return dt_value
    return dt_value.astimezone(BRASILIA_TZ).replace(tzinfo=None)


@app.template_filter('brtime')
def brtime_filter(dt_value, fmt='%H:%M'):
    """Mostra horario ja gravado em parede de Brasilia (sem deslocar)."""
    br = _as_brasilia_wall(dt_value)
    return br.strftime(fmt) if br else '-'


@app.template_filter('brdate')
def brdate_filter(dt_value, fmt='%d/%m/%Y'):
    """Mostra data ja gravada em parede de Brasilia (sem deslocar)."""
    br = _as_brasilia_wall(dt_value)
    return br.strftime(fmt) if br else '-'


@app.template_filter('brdatetime')
def brdatetime_filter(dt_value, fmt='%d/%m %H:%M'):
    """Mostra data/hora ja gravados em parede de Brasilia (sem deslocar)."""
    br = _as_brasilia_wall(dt_value)
    return br.strftime(fmt) if br else '-'


@app.template_filter('from_json')
def from_json_filter(s):
    if not s:
        return []
    try:
        return json.loads(s) if isinstance(s, str) else s
    except (json.JSONDecodeError, TypeError):
        return []


@app.template_filter('urldecode')
def urldecode_filter(s):
    """Decodifica URL encoding (%20, %C3%A3, etc) em nomes."""
    if not s or '%' not in str(s):
        return s or ''
    return urllib.parse.unquote(str(s))


def _logo_proxy_href(url):
    """Monta a URL do proxy de logo usando base64url.

    O formato antigo (/api/logo?url=http%3A//...) e bloqueado por alguns
    WAFs do cPanel (regra anti-RFI), que respondem 404 antes de chegar ao
    Flask. Codificando em base64url o parametro nao contem "http", "//"
    nem ":" — passa por qualquer filtro e nao quebra o cache do browser.
    """
    if not url:
        return ''
    url_str = str(url).strip()
    if not url_str.startswith(('http://', 'https://')):
        if url_str and url_str not in ('', 'logo:1', 'none', 'null'):
            try:
                _logger.warning(f'proxy_logo: URL invalida ignorada: {url_str[:100]}')
            except Exception:
                pass
        return ''
    enc = base64.urlsafe_b64encode(url_str.encode('utf-8')).decode('ascii')
    # v=3 muda a chave de cache do browser: a versao antiga do proxy guardava
    # um pixel VERDE de fallback com max-age de ate 1 dia, e quem abriu o site
    # naquele periodo via card verde ate o cache expirar.
    return f'/api/logo?v=3&u={enc}'


@app.template_filter('proxy_logo')
def proxy_logo_filter(url):
    """Return logo URL through proxy to avoid CORS/certificate issues."""
    return _logo_proxy_href(url)


@app.template_global('BRASILIA_TZ')
def get_brasilia_tz():
    return BRASILIA_TZ

app.jinja_env.globals['datetime'] = datetime

@app.template_global('proxy_logo')
def proxy_logo_global(url):
    """Global template function for logo."""
    return _logo_proxy_href(url)


DEFAULT_SETTINGS = {
    'm3u_url': 'https://raw.githubusercontent.com/ManoLimah/Manoteste/refs/heads/main/ManoTV.m3u',
    'm3u_urls': [],
    'epg_url': 'https://althierestm.github.io/Lista-RadarTV/epg.xml',
    'maintenance_enabled': False,
    'maintenance_message': 'Sistema em manutencao. Volte em breve.',
    'maintenance_start': '',
    'maintenance_end': '',
    'maintenance_sections': [],  # vazio=global; ['canais','filmes','series','tv_canais','tv_filmes','tv_series','app_mobile','app_tv']
    'maintenance_category': 'all',  # all | canais | filmes | series | esportes | site | app
    'maintenance_pages': [],  # vazio=todas; rotas/page keys
    'maintenance_platforms': ['site', 'app_mobile', 'app_tv', 'celular'],
    'maintenance_show_login': True,  # mesmo visitantes podem logar pra checar status
    'maintenance_immediate': True,  # ignora agendamento e aplica agora
    'free_daily_hours': 4,
    'app_version': '1.14',
    'app_update_required': False,
    'app_update_url': '',
    'app_update_changelog': '',
    'apk_size': '6.0 MB',
    'app_release_date': '2026-10-25',
    'payment_whatsapp': '5584981518164',
    'support_whatsapp': '5584981518164',
    'payment_method': 'whatsapp',
    'cakto_client_id': '',
    'cakto_client_secret': '',
    'cakto_webhook_secret': '',
    'cakto_offer_ids': '{}',
    'adult_categories': [],
}

_settings_cache = None
_settings_cache_ts = 0

# Metadados do APK EMPacotADO em static/trocoplay.apk. A migracao de boot
# aplica estes valores ao settings.json UMA vez por versionName (antes o
# boot reforcava tamanho/data antigos por cima do que o admin editava).
APK_META = {
    'app_version': '1.14',
    'apk_size': '6.0 MB',
    'app_release_date': '2026-10-25',
    'app_update_changelog': (
        'Versao 1.14:\n'
        '- Notificacoes agora chegam na barra do celular (app Android)\n'
        '- PiP funcionando no app: o botao PiP abre o video flutuante nativo\n'
        '- Pagina App: instalacao do app para PC e iPhone com popup do navegador'
    ),
}

# ─── In-Memory Query Cache ────────────────────────────────────────
_query_cache = {}  # key -> (value, timestamp)
def cached_query(key, ttl_seconds, fn):
    """Cacheia resultado de funcao por ttl_seconds."""
    now = _time.time()
    entry = _query_cache.get(key)
    if entry and now - entry[1] < ttl_seconds:
        return entry[0]
    result = fn()
    _query_cache[key] = (result, now)
    return result
def invalidate_cache(prefix=''):
    """Remove todas as chaves que comecam com prefix."""
    keys_to_del = [k for k in _query_cache if k.startswith(prefix)]
    for k in keys_to_del:
        del _query_cache[k]


_swrc_trava = threading.Lock()
_swrc_carregando = set()


def cached_swrc(key, ttl_seconds, fn):
    """Entrega o valor guardado na hora e refaz em segundo plano quando vence.

    O painel admin refaz as mesmas contas a cada minuto e cada uma delas varre
    centenas de milhares de linhas. Servindo o numero velho enquanto o calculo
    roda longe do pedido, a pagina abre no ato e o trabalho pesado sai do
    caminho de quem esta assistindo."""
    now = _time.time()
    entry = _query_cache.get(key)
    if entry and now - entry[1] < ttl_seconds:
        return entry[0]
    if entry:
        with _swrc_trava:
            precisa = key not in _swrc_carregando
            if precisa:
                _swrc_carregando.add(key)
        if precisa:
            def _fundo():
                try:
                    with app.app_context():
                        _query_cache[key] = (fn(), _time.time())
                except Exception as exc:
                    log_error('renovar cache %s falhou: %s' % (key, exc))
                finally:
                    with _swrc_trava:
                        _swrc_carregando.discard(key)
            threading.Thread(target=_fundo, daemon=True).start()
        return entry[0]
    resultado = fn()
    _query_cache[key] = (resultado, now)
    return resultado

def load_settings():
    global _settings_cache, _settings_cache_ts
    now = _time.time()
    if _settings_cache is not None and now - _settings_cache_ts < 5:
        return _settings_cache
    if os.path.exists(SETTINGS_PATH):
        with open(SETTINGS_PATH, 'r') as f:
            data = json.load(f)
            for k, v in DEFAULT_SETTINGS.items():
                if k not in data:
                    data[k] = v
            _settings_cache = data
            _settings_cache_ts = now
            return data
    _settings_cache = DEFAULT_SETTINGS.copy()
    _settings_cache_ts = now
    return _settings_cache

def save_settings(data):
    global _settings_cache, _settings_cache_ts
    invalidate_cache('m3u_cred_sources')
    os.makedirs(os.path.dirname(SETTINGS_PATH), exist_ok=True)
    with open(SETTINGS_PATH, 'w') as f:
        json.dump(data, f, indent=2)
    _settings_cache = data
    _settings_cache_ts = _time.time()


_cakto_token_cache = {'token': None, 'expires_at': 0}


def validate_cpf(cpf):
    cpf = ''.join(c for c in str(cpf) if c.isdigit())
    if len(cpf) != 11:
        return False
    if cpf in ('00000000000', '11111111111', '22222222222', '33333333333', '44444444444',
               '55555555555', '66666666666', '77777777777', '88888888888', '99999999999'):
        return False
    s = 0
    for i in range(9):
        s += int(cpf[i]) * (10 - i)
    r = 11 - (s % 11)
    if r in (10, 11):
        r = 0
    if int(cpf[9]) != r:
        return False
    s = 0
    for i in range(10):
        s += int(cpf[i]) * (11 - i)
    r = 11 - (s % 11)
    if r in (10, 11):
        r = 0
    return int(cpf[10]) == r


def _get_cakto_token():
    now = _time.time()
    if _cakto_token_cache['token'] and now < _cakto_token_cache['expires_at']:
        return _cakto_token_cache['token']
    settings = load_settings()
    cid = settings.get('cakto_client_id', '')
    csecret = settings.get('cakto_client_secret', '')
    if not cid or not csecret:
        return None
    try:
        r = requests.post(
            'https://api.cakto.com.br/public_api/token/',
            data={'client_id': cid, 'client_secret': csecret},
            headers={'Content-Type': 'application/x-www-form-urlencoded'},
            timeout=10,
        )
        if r.status_code == 200:
            data = r.json()
            token = data.get('access_token')
            expires_in = int(data.get('expires_in', 3600))
            if token:
                _cakto_token_cache['token'] = token
                _cakto_token_cache['expires_at'] = now + expires_in - 60
                return token
    except Exception as e:
        print(f'[Cakto] Token error: {e}')
    return None


def get_m3u_sources(settings=None, include_disabled=False):
    """Retorna URLs M3U únicas; a primeira fornece o catálogo unificado.
    
    Se include_disabled=False (padrão), filtra as fontes marcadas como desabilitadas.
    """
    settings = settings or load_settings()
    raw = settings.get('m3u_urls') or []
    if isinstance(raw, str):
        raw = raw.replace(';', '\\n').splitlines()
    sources = [str(value).strip() for value in raw if str(value).strip()]
    if not sources and settings.get('m3u_url'):
        sources = [str(settings['m3u_url']).strip()]
    unique = []
    for source in sources:
        if source not in unique:
            unique.append(source)
    
    if not include_disabled:
        disabled = settings.get('m3u_disabled', [])
        if disabled:
            unique = [s for i, s in enumerate(unique) if i not in disabled]
    
    return unique


def _m3u_source_health(source, force=False, ignore_capacity=False):
    """Diz se uma credencial M3U/Xtream ainda pode ser usada.

    Devolve ``{'usable', 'reason', 'info', 'active_cons', 'max_connections',
    'slots_free'}``. A consulta ao ``player_api.php`` fica em cache por
    ``M3U_SOURCE_HEALTH_TTL`` segundos para nao martelar o provedor a cada
    play ou a cada tentativa de sync.

    Ficam de fora: conta sem acesso (auth != 1), vencida, com status
    expirado/banido, sem resposta 404/401 ou com conexoes lotadas
    (``active_cons >= max_connections``).

    Conta sem usuario/senha (lista estatica) e considerada utilizavel: nao
    ha o que verificar pelo player_api.php e bloquear seria pior.
    """
    if not M3U_SOURCE_HEALTH_ENABLED:
        return {'usable': True, 'reason': '', 'info': None}
    try:
        info = _fetch_xtream_account_info(source, force=force)
    except Exception as exc:
        # Falha de rede nao deve derrubar a conta: deixa passar.
        return {'usable': True, 'reason': '', 'info': None,
                'erro': f'{type(exc).__name__}: {str(exc)[:120]}'}
    if not info or not info.get('fonte'):
        return {'usable': True, 'reason': '', 'info': info}

    creds = _parse_m3u_credentials(source)
    if not creds:
        # Lista sem credenciais (ex.: M3U publica): nao da para checar.
        return {'usable': True, 'reason': '', 'info': info}

    base = {
        'info': info,
        'active_cons': info.get('active_cons'),
        'max_connections': info.get('max_connections'),
        'slots_free': None,
    }
    if not info.get('ok'):
        http_status = info.get('http_status')
        if http_status in (401, 403, 404, 410):
            # 404/401 no player_api = usuario inexistente ou sem acesso.
            return dict(base, usable=False,
                        reason=f'Conta sem acesso no provedor (HTTP {http_status}).')
        # Nao respondeu agora. Pode ser instabilidade do provedor: nao
        # descarta so por isso (o failover do /proxy resolve na hora).
        return dict(base, usable=True, reason='', instavel=True)

    reason = ''
    status = (info.get('status') or '').strip().lower()
    if info.get('auth') != 1:
        reason = 'Sem acesso: o provedor recusou o usuario/senha (auth != 1).'
    elif status in ('expired', 'banned', 'disabled'):
        reason = f'Conta {status} no provedor.'
    elif not reason and info.get('vencido'):
        reason = 'Conta vencida (exp_date no passado).'

    active_cons = info.get('active_cons')
    max_connections = info.get('max_connections')
    slots_free = None
    if isinstance(active_cons, int) and isinstance(max_connections, int) and max_connections > 0:
        slots_free = max(0, max_connections - active_cons)
        base['slots_free'] = slots_free
    if not reason and not ignore_capacity and slots_free == 0:
        reason = 'Conta no limite de conexoes (active_cons = max_connections).'
    return dict(base, usable=not reason, reason=reason, cheia=(slots_free == 0))


def _m3u_source_health_map(sources=None, force=False, ignore_capacity=False):
    """Health de todas as credenciais, por indice (mesma ordem do catalogo)."""
    sources = sources if sources is not None else get_m3u_sources()
    out = {}
    if not sources:
        return out
    if len(sources) == 1:
        out[0] = _m3u_source_health(sources[0], force=force, ignore_capacity=ignore_capacity)
        return out
    try:
        from concurrent.futures import ThreadPoolExecutor
        workers = min(8, len(sources))
        with ThreadPoolExecutor(max_workers=workers) as pool:
            results = list(pool.map(
                lambda s: _m3u_source_health(s, force=force, ignore_capacity=ignore_capacity),
                sources,
            ))
        for index, res in enumerate(results):
            out[index] = res
    except Exception:
        for index, source in enumerate(sources):
            out[index] = _m3u_source_health(source, force=force, ignore_capacity=ignore_capacity)
    return out


def _m3u_source_capacity_limit(health_entry):
    """Quantas sessoes locais a credencial aceita (limite do provedor)."""
    entry = health_entry or {}
    max_connections = entry.get('max_connections')
    if isinstance(max_connections, int) and max_connections > 0:
        return max_connections
    return 1


def _m3u_source_local_usage(sources, health_map=None):
    """Reservas locais ativas por credencial (o que o site ja esta usando).

    Conta so as sessoes com heartbeat recente: sessao morta nao ocupa vaga.
    """
    usage = {}
    try:
        cutoff = datetime.now(timezone.utc) - timedelta(seconds=STREAM_SESSION_IDLE_SECONDS)
        rows = UserStreaming.query.filter(UserStreaming.last_heartbeat >= cutoff).all()
        for row in rows:
            if row.source_index is None:
                continue
            usage[int(row.source_index)] = usage.get(int(row.source_index), 0) + 1
    except Exception:
        pass
    return usage


def _m3u_usable_source_indexes(sources=None, ignore_capacity=False, force=False):
    """Indices das credenciais que podem ser usadas agora.

    Devolve ``(usaveis, contas_cheias, health)``. Uma conta entra em
    ``contas_cheias`` quando o provedor ja esta com todas as conexoes
    ocupadas — o chamador decide se ainda aceita como ultimo recurso.
    """
    sources = sources if sources is not None else get_m3u_sources()
    health = _m3u_source_health_map(sources, ignore_capacity=False, force=force)
    usable, cheias = [], []
    for index in range(len(sources)):
        entry = health.get(index, {})
        if entry.get('usable', True):
            usable.append(index)
        elif entry.get('cheia'):
            cheias.append(index)
    return usable, cheias, health


def _build_m3u_credential_sources(settings=None):
    """Extrai username/password das listas para rotear streams pelo proxy."""
    result = []
    for source in get_m3u_sources(settings, include_disabled=False):
        try:
            parsed = urlparse(source)
            params = urllib.parse.parse_qs(parsed.query)
            username = (params.get('username') or [''])[0].strip()
            password = (params.get('password') or [''])[0].strip()
            if username and password and parsed.netloc:
                result.append({'username': username, 'password': password, 'scheme': parsed.scheme, 'netloc': parsed.netloc, 'hostname': (parsed.hostname or '').lower()})
        except Exception:
            continue
    return result


def get_m3u_credential_sources(settings=None):
    """Credenciais M3U. Sem settings explicitos usa cache de 60s.

    Sem cache isso era recalculado em TODO request do /proxy — e um canal HLS
    gera dezenas de requests por minuto (playlist + cada segmento).
    """
    if settings is not None:
        return _build_m3u_credential_sources(settings)
    return cached_query('m3u_cred_sources', 60, _build_m3u_credential_sources)


def _extract_xtream_id_from_url(stream_url):
    """Extrai o stream_id do Xtream API a partir da URL M3U."""
    try:
        m = re.search(r'/(\d+)\.\w+$', stream_url)
        if m:
            return m.group(1)
    except Exception:
        pass
    return None


def _get_xtream_api_url(stream_url):
    """Constrói a base URL da API Xtream a partir da URL do stream.
    Ex: http://dnsone.space:80/series/user/pass/1303069.mp4 -> http://dnsone.space/player_api.php?username=user&password=pass
    """
    try:
        parsed = urllib.parse.urlparse(stream_url)
        path = parsed.path or ''
        parts = [p for p in path.split('/') if p]
        # Formato: /series/username/password/id.mp4 ou /movie/username/password/id.mp4 ou /live/username/password/id.m3u8
        if len(parts) >= 3 and parts[0] in ('series', 'movie', 'live'):
            username, password = parts[1], parts[2]
        elif len(parts) >= 2:
            username, password = parts[0], parts[1]
        else:
            return None
        base = f"{parsed.scheme}://{parsed.hostname}"
        if parsed.port and parsed.port not in (80, 443):
            base += f":{parsed.port}"
        return f"{base}/player_api.php?username={username}&password={password}"
    except Exception:
        pass
    return None


import threading

_XTREAM_CACHE = {}
_CACHE_TTL = 3600

def _get_cached(key):
    import time
    if key in _XTREAM_CACHE:
        entry = _XTREAM_CACHE[key]
        if time.time() - entry['ts'] < entry.get('ttl', _CACHE_TTL):
            return entry['data']
    return None

def _set_cached(key, data, ttl=None):
    import time
    _XTREAM_CACHE[key] = {'data': data, 'ts': time.time(), 'ttl': ttl or _CACHE_TTL}

def _background_xtream_update(series_name, stream_url, app_context):
    """Atualiza dados da série no banco em background."""
    with app_context:
        try:
            info = _fetch_xtream_series_info(series_name, stream_url)
            if info and info.get('plot'):
                Canal.query.filter(
                    Canal.tipo == 'series', Canal.is_hidden == False,
                    Canal.series_name == series_name
                ).update({'description': info['plot'][:1990]}, synchronize_session=False)
                db.session.commit()
        except Exception as e:
            print(f'[Xtream BG] Erro ao atualizar {series_name}: {e}', flush=True)

def _background_xtream_movie_update(movie_id, stream_url, app_context):
    """Atualiza dados do filme no banco em background."""
    with app_context:
        try:
            info = _fetch_xtream_vod_info(stream_url)
            if info and info.get('plot'):
                Canal.query.filter_by(id=movie_id).update({
                    'description': info['plot'][:1990],
                    'logo': info.get('movie_image', '') or Canal.logo
                }, synchronize_session=False)
                db.session.commit()
        except Exception as e:
            print(f'[Xtream BG] Erro ao atualizar filme {movie_id}: {e}', flush=True)


_XTREAM_BG_FILAS = set()      # ids de filme ja escalados nesta processo


def _peek_xtream_vod_info(stream_url):
    """Metadados ja conhecidos (cache do processo): nunca toca a rede."""
    sid = _extract_xtream_id_from_url(stream_url or '')
    return _get_cached('xt_vod_%s' % sid) if sid else None


def _xtream_movie_em_background(movie_id, stream_url):
    """Rebusca os metadados do filme sem atrasar a pagina que o usuario abriu.

    Esperar o provedor aqui transforma o clique em tela branca (um host morto
    custa ~30 s). O thread grava no banco e no cache, entao a proxima abertura
    ja abre completa e na hora."""
    if not stream_url or movie_id in _XTREAM_BG_FILAS:
        return
    if _peek_xtream_vod_info(stream_url) is not None:
        return                      # ja sabemos: nem que seja "o provedor nao tem"
    _XTREAM_BG_FILAS.add(movie_id)

    def _rodar():
        try:
            _background_xtream_movie_update(movie_id, stream_url, app.app_context())
        finally:
            _XTREAM_BG_FILAS.discard(movie_id)
    threading.Thread(target=_rodar, daemon=True).start()


def _fetch_xtream_vod_info(stream_url, stream_id=None):
    """Busca info completa de um filme via API Xtream (plot, cast, genre, movie_image, rating, etc)."""
    if not stream_id:
        stream_id = _extract_xtream_id_from_url(stream_url)
    api_url = _get_xtream_api_url(stream_url)
    if not api_url or not stream_id:
        return {}
    cache_key = f'xt_vod_{stream_id}'
    cached = _get_cached(cache_key)
    if cached is not None:
        return cached
    try:
        import urllib.request
        req_url = f"{api_url}&action=get_vod_info&vod_id={stream_id}"
        req = urllib.request.Request(req_url, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read().decode('utf-8'))
            info = data.get('info', {})
            movie_data = data.get('movie_data', {})
            result = {
                'plot': info.get('plot', ''),
                'cast': info.get('cast', ''),
                'director': info.get('director', ''),
                'genre': info.get('genre', ''),
                'releaseDate': info.get('releasedate', ''),
                'rating': info.get('rating', ''),
                'duration': info.get('duration', ''),
                'movie_image': info.get('movie_image', ''),
                'backdrop_path': movie_data.get('backdrop_path', ''),
                'tmdb_id': info.get('tmdb_id', ''),
                'youtube_trailer': info.get('youtube_trailer', ''),
            }
            _set_cached(cache_key, result)
            return result
    except Exception as e:
        print(f'[Xtream] Erro ao buscar vod_info para {stream_id}: {e}', flush=True)
        # provedor mudo nao deve ser rebuscado a cada abertura de pagina
        _set_cached(cache_key, {}, 300)
        return {}


def _fetch_xtream_vod_catalog(api_url):
    """Busca catalogo VOD completo da API Xtream para enriquecimento de posters."""
    if not api_url:
        return {}
    try:
        import urllib.request
        req_url = f"{api_url}&action=get_vod_streams"
        req = urllib.request.Request(req_url, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = json.loads(resp.read().decode('utf-8'))
            streams = data if isinstance(data, list) else []
            return {str(s.get('stream_id', '')): s for s in streams if s.get('stream_id')}
    except Exception as e:
        print(f'[Xtream] Erro ao buscar vod_streams catalog: {e}', flush=True)
        return {}


def _match_series_entry(series_list, series_name):
    """Acha a serie na API Xtream mesmo quando o nome do M3U difere do nome
    do catalogo (ex: M3U 'One Piece [L] 2025' x API 'One Piece [L] 2025 (2025)').

    Tenta, em ordem: igualdade exata; igualdade ignorando ano/"[L]"/acentos;
    inicio do nome da API com o do M3U; e por fim o melhor prefixo em comum
    (>= 6 caracteres) — evitando casar series diferentes.
    """
    if not series_list or not series_name:
        return None
    def _norm_full(value):
        """Normaliza preservando o ano (para não confundir One Piece 1999 x 2025)."""
        value = str(value or '').lower()
        value = re.sub(r'\[[^\]]*\]', ' ', value)
        value = re.sub(r'\((\d{4})\)', r' \1 ', value)
        value = re.sub(r'[^a-z0-9]+', ' ', unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode())
        return re.sub(r'\s+', ' ', value).strip()
    def _norm_bare(value):
        return re.sub(r'\b(19|20)\d{2}\b', ' ', _norm_full(value)).strip()
    raw_target = str(series_name).lower().strip()
    for s in series_list:
        if str(s.get('name', '')).lower().strip() == raw_target:
            return s
    target_full = _norm_full(series_name)
    target_bare = _norm_bare(series_name)
    # 1) igualdade preservando o ano
    for s in series_list:
        if target_full and _norm_full(s.get('name', '')) == target_full:
            return s
    def _year(value):
        m = re.search(r'\b(19|20)\d{2}\b', str(value or ''))
        return m.group(0) if m else ''
    target_year = _year(series_name)
    def _pick(pred):
        matches = [s for s in series_list if pred(s)]
        if not matches:
            return None
        # Prefere o mesmo ano para nao trocar One Piece 1999 por 2025.
        if target_year:
            same_year = [s for s in matches if _year(s.get('name', '')) == target_year]
            if same_year:
                matches = same_year
        matches.sort(key=lambda s: len(_norm_full(s.get('name', ''))))
        return matches[0]
    # 2) igualdade ignorando o ano
    if target_bare:
        found = _pick(lambda s: _norm_bare(s.get('name', '')) == target_bare)
        if found:
            return found
    # 3) nome da API começa com o procurado (com ano, depois sem)
    for target in (target_full, target_bare):
        if target:
            found = _pick(lambda s, t=target: _norm_full(s.get('name', '')).startswith(t))
            if found:
                return found
    # 4) procurado começa com o nome da API
    for target in (target_full, target_bare):
        if target:
            found = _pick(lambda s, t=target: t.startswith(_norm_full(s.get('name', ''))) and len(_norm_full(s.get('name', ''))) >= 6)
            if found:
                return found
    # 5) substring
    for target in (target_full, target_bare):
        if target:
            found = _pick(lambda s, t=target: t in _norm_full(s.get('name', '')))
            if found:
                return found
    # 6) maior prefixo em comum (>= 6 caracteres)
    best, best_score = None, 0
    for s in series_list:
        norm = _norm_bare(s.get('name', ''))
        score = 0
        for a, b in zip(target_bare, norm):
            if a != b:
                break
            score += 1
        if score > best_score:
            best, best_score = s, score
    if best is not None and best_score >= 6:
        return best
    return None


def _fetch_xtream_series_info(series_name, stream_url=None):
    """Busca info completa de series via API Xtream (plot, cover, genre, rating, episodes, etc)."""
    api_url = _get_xtream_api_url(stream_url or '')
    if not api_url:
        return {}
    cache_key = f'xt_series_{series_name.lower().strip()}'
    cached = _get_cached(cache_key)
    if cached is not None:
        return cached
    try:
        import urllib.request
        req_url = f"{api_url}&action=get_series"
        req = urllib.request.Request(req_url, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=20) as resp:
            data = json.loads(resp.read().decode('utf-8'))
            series_list = data if isinstance(data, list) else data.get('series', [])
            matched = _match_series_entry(series_list, series_name)
            if matched and matched.get('series_id'):
                detail_url = f"{api_url}&action=get_series_info&series_id={matched['series_id']}"
                detail_req = urllib.request.Request(detail_url, headers={'User-Agent': 'Mozilla/5.0'})
                with urllib.request.urlopen(detail_req, timeout=15) as dresp:
                    detail_data = json.loads(dresp.read().decode('utf-8'))
                    info = detail_data.get('info', {})
                    episodes_data = detail_data.get('episodes', {})
                    # Provedores variam: o plot pode vir no catalogo (get_series)
                    # e vir vazio no detalhe (get_series_info). Usa o que tiver.
                    result = {
                        'series_id': matched['series_id'],
                        'name': matched.get('name', ''),
                        'cover': matched.get('cover', '') or info.get('cover', ''),
                        'plot': info.get('plot', '') or matched.get('plot', ''),
                        'genre': info.get('genre', '') or matched.get('genre', ''),
                        'rating': info.get('rating', '') or matched.get('rating', ''),
                        'releaseDate': info.get('releaseDate', '') or info.get('release_date', '') or matched.get('releaseDate', '') or matched.get('year', ''),
                        'cast': info.get('cast', '') or info.get('actors', ''),
                        'director': info.get('director', ''),
                        'backdrop_path': info.get('backdrop_path', ''),
                        'episodes': episodes_data,
                    }
                    _set_cached(cache_key, result)
                    return result
            return {}
    except Exception as e:
        print(f'[Xtream] Erro ao buscar series para {series_name}: {e}', flush=True)
    return {}


def _fetch_xtream_series_catalog(api_url):
    """Busca catalogo de series completo da API Xtream para enriquecimento de capas."""
    if not api_url:
        return {}
    try:
        import urllib.request
        req_url = f"{api_url}&action=get_series"
        req = urllib.request.Request(req_url, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = json.loads(resp.read().decode('utf-8'))
            series_list = data if isinstance(data, list) else data.get('series', [])
            return {str(s.get('series_id', '')): s for s in series_list if s.get('series_id')}
    except Exception as e:
        print(f'[Xtream] Erro ao buscar series catalog: {e}', flush=True)
        return {}


def _norm_serie_api(value):
    """Nome de serie como chave de comparacao: minusculo, sem acento, sem
    "[...]" e sem o "(2025)" do catalogo."""
    value = str(value or '').lower()
    value = re.sub(r'\[[^\]]*\]', ' ', value)
    value = re.sub(r'\((\d{4})\)', r' \1 ', value)
    value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode()
    value = re.sub(r'[^a-z0-9]+', ' ', value)
    return re.sub(r'\s+', ' ', value).strip()


def _sem_ano(valor):
    return re.sub(r'\b(19|20)\d{2}\b', ' ', str(valor or '')).strip()


def _ano_de(valor):
    m = re.search(r'\b(19|20)\d{2}\b', str(valor or ''))
    return m.group(0) if m else ''


def _varrer_catalogo_series(api_url, precisas=None, max_bytes=600 * 1024 * 1024,
                            timeout=240):
    """Le ``action=get_series`` em streaming.

    Devolve ``{nome_normalizado: (nome, cover, sinopse, series_id)}`` de todas
    as series do provedor. O catalogo de uma fonte grande passa de 100 mil
    series: ``json.loads`` do corpo inteiro custava centenas de MB no worker do
    cPanel, e era por isso que o enriquecimento parava em 500 series. Aqui o
    buffer guarda apenas o objeto em leitura e a sinopse (o campo longo) so e
    guardada para as series que o site tem.
    """
    import urllib.request
    req = urllib.request.Request('%s&action=get_series' % api_url,
                                 headers={'User-Agent': 'Mozilla/5.0'})
    dec = json.JSONDecoder()
    achadas = {}
    texto = ''
    pos = 0
    lidos = 0
    lista = None               # None ate ler o primeiro caractere util
    # a sinopse (campo longo) so importa para as series do site; como o nome pode
    # bater pela versao sem ano ('Friends' x 'Friends (1994)'), o teste tambem
    # ignora o ano.
    precisas_bare = {_sem_ano(n) for n in precisas} if precisas is not None else None

    def _absorver(corpo, chave):
        if isinstance(corpo, list):
            for sub in corpo:
                _absorver(sub, chave)
            return
        if not isinstance(corpo, dict):
            return
        nome = str(corpo.get('name') or '').strip()
        norm = _norm_serie_api(nome)
        if not norm:
            return
        cover = str(corpo.get('cover') or '')
        quer_plot = precisas_bare is None or norm in precisas or _sem_ano(norm) in precisas_bare
        atual = achadas.get(norm)
        if atual is None or (not atual[1] and cover):
            achadas[norm] = (
                nome, cover,
                str(corpo.get('plot') or '')[:1990] if quer_plot else '',
                str(corpo.get('series_id') or chave))

    with urllib.request.urlopen(req, timeout=timeout) as resp:
        while True:
            pedaco = resp.read(1 << 20)
            fim = not pedaco
            if not fim:
                lidos += len(pedaco)
                if lidos > max_bytes:
                    raise ValueError('catalogo de series maior que o limite seguro')
                texto += pedaco.decode('utf-8', 'replace')
                if lista is None:
                    ini = min((i for i in (texto.find('{'), texto.find('[')) if i >= 0),
                              default=-1)
                    if ini < 0:
                        continue
                    lista = texto[ini] == '['
                    texto = texto[ini + 1:]
                    pos = 0
            while True:
                while pos < len(texto) and texto[pos] in ' \n\t\r,':
                    pos += 1
                if pos >= len(texto):
                    break
                if texto[pos] == (']' if lista else '}'):
                    return achadas
                try:
                    if lista:
                        corpo, pos = dec.raw_decode(texto, pos)
                    else:
                        chave, pos = dec.raw_decode(texto, pos)
                        pos = texto.index(':', pos) + 1
                        while pos < len(texto) and texto[pos] in ' \n\t\r':
                            pos += 1          # raw_decode nao tolera espaco antes do valor
                        corpo, pos = dec.raw_decode(texto, pos)
                        _absorver(corpo, chave)
                        continue
                except (ValueError, IndexError):
                    break                 # objeto ainda nao chegou inteiro
                _absorver(corpo, '')
            if fim:
                return achadas
            if pos > (1 << 20):
                texto = texto[pos:]
                pos = 0
    return achadas


def _casar_serie_no_catalogo(chave_norm, achadas, por_ano, ordenados):
    """Acha a serie no catalogo mesmo quando o M3U escreve o nome diferente
    ('Friends (1994)' x 'Friends', 'One Piece [L] 2025' x 'One Piece 2025')."""
    import bisect
    alvo = achadas.get(chave_norm)
    if alvo is not None:
        return chave_norm
    bare = _sem_ano(chave_norm)
    ano = _ano_de(chave_norm)
    candidatos = list(por_ano.get(bare, ()))
    if candidatos:
        if ano:
            com_ano = [c for c in candidatos if _ano_de(c) == ano]
            if com_ano:
                candidatos = com_ano
        # sem ano no nome do M3U: fica com a versao mais recente do provedor
        return max(candidatos, key=lambda n: (_ano_de(n), len(n)))
    # nome do catalogo comeca com o nosso
    inicio = bisect.bisect_left(ordenados, chave_norm)
    melhor = ''
    for norm in ordenados[inicio:inicio + 200]:
        if not norm.startswith(chave_norm):
            break
        if ano and _ano_de(norm) and _ano_de(norm) != ano:
            continue
        if not melhor:
            melhor = norm
    if melhor:
        return melhor
    # nosso nome comeca com o do catalogo
    melhor, melhor_len = '', 0
    corte = bisect.bisect_left(ordenados, chave_norm[:6])
    for norm in ordenados[corte:corte + 400]:
        if len(norm) < 6 or not chave_norm.startswith(norm):
            continue
        if ano and _ano_de(norm) and _ano_de(norm) != ano:
            continue
        if len(norm) > melhor_len:
            melhor, melhor_len = norm, len(norm)
    return melhor or None


def _xtream_json(api_base, extra='', timeout=20, max_bytes=16 * 1024 * 1024):
    """GET na player_api e parseia a resposta, com teto de bytes.

    O teto existe porque alguns provedores ignoram o filtro e devolvem o catalogo
    inteiro (dezenas de MB); um json.loads disso no worker do cPanel mata o
    processo antes de gravar qualquer capa.
    """
    import urllib.request
    req = urllib.request.Request('%s%s' % (api_base, extra),
                                 headers={'User-Agent': 'Mozilla/5.0'})
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        corpo = b''
        while True:
            pedaco = resp.read(1 << 20)
            if not pedaco:
                break
            corpo += pedaco
            if len(corpo) > max_bytes:
                raise ValueError('resposta maior que o limite seguro')
    return json.loads(corpo.decode('utf-8', 'replace'))


_xtream_cats_cache = {}        # base -> ([(cid, norm, nome)], ate quando)


def _xtream_categorias_series(api_base, timeout=20):
    """[(category_id, nome_norm, nome)] do provedor, ou [] quando nao ha."""
    guardado = _xtream_cats_cache.get(api_base)
    if guardado and guardado[1] > _time.time():
        return guardado[0]
    try:
        dados = _xtream_json(api_base, '&action=get_series_categories',
                             timeout=timeout, max_bytes=4 * 1024 * 1024)
    except Exception:
        return []
    saida = []
    for item in dados if isinstance(dados, list) else []:
        cid = str((item or {}).get('category_id') or '').strip()
        nome = str((item or {}).get('category_name') or '').strip()
        if cid and nome:
            saida.append((cid, _norm_serie_api(nome), nome))
    if saida:
        _xtream_cats_cache[api_base] = (saida, _time.time() + 900)
    return saida


def _api_bases_para_capas(fontes=None, limite=6):
    """Credenciais Xtream que realmente servem a player_api.

    Antes disto o sweep usava somente get_m3u_sources(): quando a fonte do painel
    e uma lista .m3u hospedada fora do provedor (github, por exemplo) nao ha
    credencial ali, a varredura baixava um 404 e serie_capa ficava VAZIA para
    sempre -- que e exatamente "a capa continua uma cena de episodio". As
    credenciais que importam sao as que estao gravadas em cada stream_url.
    """
    bases, hosts = [], set()

    def _acrescentar(base):
        if not base:
            return
        host = (urlparse(base).netloc or '').lower()
        if not host or host in hosts:
            return
        hosts.add(host)
        bases.append(base)

    for fonte in (fontes if fontes is not None else get_m3u_sources()):
        creds = _parse_m3u_credentials(fonte)
        if not creds:
            continue
        _acrescentar('%s://%s/player_api.php?username=%s&password=%s' % (
            creds['scheme'], creds['netloc'], creds['username'], creds['password']))
    if len(bases) < limite:
        rows = db.session.execute(text(
            "SELECT stream_url, COUNT(*) n FROM canal WHERE tipo='series' "
            "AND stream_url LIKE 'http%' GROUP BY stream_url ORDER BY n DESC LIMIT 300")
        ).fetchall()
        for (url, _n) in rows:
            _acrescentar(_get_xtream_api_url(url or ''))
            if len(bases) >= limite:
                break
    if len(bases) < limite:
        for (url,) in db.session.execute(text(
                "SELECT stream_url FROM canal WHERE tipo IN ('movie','live') "
                "AND stream_url LIKE 'http%' GROUP BY stream_url LIMIT 200")).fetchall():
            _acrescentar(_get_xtream_api_url(url or ''))
            if len(bases) >= limite:
                break
    return bases


def _gravar_capas(resolvidas, por_norm, gravar_canal=True):
    """Escreve em serie_capa (e no catalogo) o que a varredura achou.

    ``por_norm`` mapeia o nome normalizado para TODOS os nomes que o site usa
    para aquela serie: o M3U pode ter 'Friends (1994)' e 'Friends 1994' na mesma
    fonte, e gravar so um dos dois deixaria o outro em skeleton ate a proxima
    visita (que rebusca o provedor inteira).
    """
    existentes = {n: (c or '', p or '') for n, c, p in db.session.query(
        SeriesCover.name, SeriesCover.cover, SeriesCover.plot).all()}
    agora = datetime.now(timezone.utc)
    novos, mudados = [], []
    for norm, (nome, cover, plot, sid) in resolvidas.items():
        cover = (cover or '')[:990]
        for chave in por_norm.get(norm) or ():
            if chave not in existentes:
                if not cover:
                    continue    # ausencia ainda nao registrada: nao cria linha vazia
                novos.append({'n': chave, 'c': cover, 'p': plot, 's': str(sid or '')[:55], 'u': agora})
            elif existentes[chave] != (cover, plot):
                mudados.append({'n': chave, 'c': cover, 'p': plot, 's': str(sid or '')[:55], 'u': agora})
            existentes[chave] = (cover, plot)
    for i in range(0, len(novos), 400):
        try:
            db.session.execute(
                text("INSERT OR IGNORE INTO serie_capa (name, cover, series_id, plot, updated_at) "
                     "VALUES (:n, :c, :s, :p, :u)"), novos[i:i + 400])
        except Exception as exc:
            db.session.rollback()
            print('[Capas] INSERT falhou: %s' % str(exc)[:200], flush=True)
    for i in range(0, len(mudados), 400):
        try:
            db.session.execute(
                text("UPDATE serie_capa SET cover = :c, plot = :p, series_id = :s, "
                     "updated_at = :u WHERE name = :n"), mudados[i:i + 400])
        except Exception as exc:
            db.session.rollback()
            print('[Capas] UPDATE falhou: %s' % str(exc)[:200], flush=True)
    db.session.commit()

    capas_tabela = {}
    for item in novos + mudados:
        capas_tabela[item['n']] = (item['c'], item['p'])
    sinopses = 0
    if gravar_canal and capas_tabela:
        # O logo do M3U e por episodio; aqui a capa da serie volta a ser a
        # oficial em cada linha, e a sinopse so entra onde ainda nao ha texto.
        pares = [{'n': n, 'c': v[0]} for n, v in capas_tabela.items() if v[0]]
        for i in range(0, len(pares), 400):
            try:
                db.session.execute(
                    text("UPDATE canal SET logo = :c WHERE tipo = 'series' "
                         "AND is_hidden = 0 AND series_name = :n "
                         "AND (logo IS NULL OR logo <> :c)"),
                    pares[i:i + 400])
            except Exception as exc:
                db.session.rollback()
                print('[Capas] UPDATE de logo falhou: %s' % str(exc)[:200], flush=True)
        com_plot = [{'n': n, 'p': v[1]} for n, v in capas_tabela.items() if v[1]]
        for i in range(0, len(com_plot), 400):
            try:
                db.session.execute(
                    text("UPDATE canal SET description = :p WHERE tipo = 'series' "
                         "AND is_hidden = 0 AND series_name = :n "
                         "AND (description IS NULL OR description = '')"),
                    com_plot[i:i + 400])
                sinopses += len(com_plot[i:i + 400])
            except Exception as exc:
                db.session.rollback()
                print('[Capas] UPDATE de sinopse falhou: %s' % str(exc)[:200], flush=True)
        db.session.commit()
    invalidate_cache('series:')
    return {'novos': len(novos), 'mudados': len(mudados), 'sinopses': sinopses}


def atualizar_capas_oficiais_series(progress=None, fontes=None, gravar_canal=True):
    """Grava em ``serie_capa`` a capa oficial que o provedor Xtream publica.

    O catalogo e lido CATEGORIA POR CATEGORIA (``get_series_categories`` +
    ``get_series&category_id=N``): cada pedido tem algumas centenas de KB, entao
    cabe na RAM do cPanel, o job pode ser interrompido e retomado, e a cobertura
    sao TODAS as series do provedor — nao existe mais teto de 500. Provedor sem
    categorias cai na leitura unica de ``get_series`` (com teto de bytes).
    """
    def _anda(msg, pct=None):
        if progress:
            try:
                progress(msg, pct)
            except Exception:
                pass

    chaves = [k for (k,) in db.session.execute(text(
        "SELECT DISTINCT COALESCE(NULLIF(series_name,''), name) FROM canal "
        "WHERE tipo='series' AND is_hidden = 0")).fetchall() if k]
    if not chaves:
        return {'series': 0, 'sinopses': 0, 'fontes': 0, 'cobertas': 0, 'total_series': 0}
    por_norm = {}
    for k in chaves:
        por_norm.setdefault(_norm_serie_api(k), []).append(k)
    bases = _api_bases_para_capas(fontes)
    if not bases:
        return {'series': 0, 'sinopses': 0, 'fontes': 0, 'cobertas': 0,
                'total_series': len(chaves),
                'erro': 'nenhuma credencial xtream no catalogo'}

    _anda('Lendo as categorias de series do provedor...')
    resolvidas = {}
    falhas, fontes_ok, categorias_lidas = 0, 0, 0
    for base in bases:
        if len(resolvidas) >= len(por_norm):
            break
        categorias = _xtream_categorias_series(base)
        if not categorias:
            # sem categorias: uma leitura do get_series inteiro
            try:
                achadas = _varrer_catalogo_series(base, precisas=set(por_norm))
            except Exception as exc:
                falhas += 1
                print('[Capas] Catalogo indisponivel: %s' % str(exc)[:200], flush=True)
                continue
            if not achadas:
                falhas += 1
                continue
            fontes_ok += 1
            por_ano = {}
            for norm in achadas:
                por_ano.setdefault(_sem_ano(norm), []).append(norm)
            ordenados = sorted(achadas)
            for norm in por_norm:
                if norm in resolvidas:
                    continue
                casado = _casar_serie_no_catalogo(norm, achadas, por_ano, ordenados)
                if casado:
                    nome, cover, plot, sid = achadas[casado]
                    if cover or plot:
                        resolvidas[norm] = (nome, cover, plot, sid)
            print('[Capas] %s series lidas do provedor; %s do site casadas.'
                  % (len(achadas), len(resolvidas)), flush=True)
            continue
        fontes_ok += 1
        for cid, _norm_cat, nome_cat in categorias:
            if len(resolvidas) >= len(por_norm):
                break
            try:
                achadas = _varrer_catalogo_series(
                    '%s&category_id=%s' % (base, cid), precisas=set(por_norm),
                    max_bytes=80 * 1024 * 1024, timeout=90)
            except Exception as exc:
                falhas += 1
                print('[Capas] Categoria %s falhou: %s'
                      % (nome_cat[:40], str(exc)[:160]), flush=True)
                continue
            categorias_lidas += 1
            if not achadas:
                continue
            por_ano = {}
            for norm in achadas:
                por_ano.setdefault(_sem_ano(norm), []).append(norm)
            ordenados = sorted(achadas)
            for norm in por_norm:
                if norm in resolvidas:
                    continue
                casado = _casar_serie_no_catalogo(norm, achadas, por_ano, ordenados)
                if casado:
                    nome, cover, plot, sid = achadas[casado]
                    if cover or plot:
                        resolvidas[norm] = (nome, cover, plot, sid)
            _anda('Capas: %s de %s series do site resolvidas (categoria %s)'
                  % (len(resolvidas), len(por_norm), nome_cat[:24]),
                  10 + int(60 * len(resolvidas) / max(1, len(por_norm))))

    # O que a varredura por categoria nao casou vai para o pedido sob demanda:
    # ou o nome do M3U difere do do provedor, ou a serie nem esta no catalogo
    # dele. Registrar tambem a AUSENCIA e o que tira a pagina do skeleton sem
    # jamais voltar para a cena do episodio.
    _anda('Procurando as series que faltam...')
    agora = datetime.now(timezone.utc)
    nomes_ausentes = []
    for norm in [n for n in por_norm if n not in resolvidas]:
        nomes = por_norm[norm]
        linha = db.session.execute(text(
            "SELECT stream_url, group_title FROM canal WHERE tipo='series' "
            "AND COALESCE(NULLIF(series_name,''), name) = :n LIMIT 1"),
            {'n': nomes[0]}).fetchone()
        cover, plot = _garantir_capa_oficial(nomes[0],
                                             linha[0] if linha else '',
                                             linha[1] if linha else '')
        if cover or plot:
            # _garantir_capa_oficial gravou pelo nome exato; as outras grafias
            # do mesmo titulo vao junto no INSERT de baixo
            resolvidas[norm] = (nomes[0], cover, plot, '')
        elif _capa_estado(nomes[0]) == 'sem':
            # so registra ausencia se o provedor RESPONDEU e nao tem a serie;
            # leitura falha deixa 'desconhecido' (skeleton), nunca cena
            nomes_ausentes.extend(nomes)
    for i in range(0, len(nomes_ausentes), 400):
        try:
            db.session.execute(text(
                "INSERT OR IGNORE INTO serie_capa (name, cover, series_id, plot, updated_at) "
                "VALUES (:n, '', '', '', :u)"),
                [{'n': n, 'u': agora} for n in nomes_ausentes[i:i + 400]])
        except Exception as exc:
            db.session.rollback()
            print('[Capas] registro de ausencia falhou: %s' % str(exc)[:160], flush=True)
    db.session.commit()

    if not resolvidas:
        return {'series': 0, 'sinopses': 0, 'fontes': fontes_ok,
                'cobertas': 0, 'total_series': len(chaves),
                'categorias': categorias_lidas, 'falhas': falhas,
                'erro': 'nenhuma serie casada com o catalogo do provedor'}

    _anda('Gravando as capas oficiais...')
    gravado = _gravar_capas(resolvidas, por_norm, gravar_canal)

    print('[Capas] %s capas novas, %s atualizadas, %s series atendidas de %s '
          '(%s categorias lidas, %s falhas).'
          % (gravado['novos'], gravado['mudados'], len(resolvidas), len(por_norm),
             categorias_lidas, falhas), flush=True)
    return {'series': gravado['novos'] + gravado['mudados'],
            'sinopses': gravado['sinopses'], 'fontes': fontes_ok,
            'cobertas': sum(len(por_norm[n]) for n in resolvidas),
            'total_series': len(chaves),
            'categorias': categorias_lidas, 'falhas': falhas}


def _capa_oficial_serie(nome):
    """(capa, sinopse) oficiais de uma serie, ou ('', '') quando o provedor nao a tem."""
    if not nome:
        return '', ''
    try:
        linha = db.session.query(SeriesCover.cover, SeriesCover.plot).filter_by(name=nome).first()
    except Exception:
        db.session.rollback()
        return '', ''
    return (linha[0] or '', linha[1] or '') if linha else ('', '')


# ─── Capa oficial sob demanda ──────────────────────────────────────────────
# A varredura noturna enche serie_capa, mas entre o reimport e a próxima tarefa
# (e para serie que o provedor so responde no pedido sob demanda) a pagina de
# serie ainda mostrava a cena de um episodio. O resolve abaixo busca so a serie
# aberta, numa categoria de algumas centenas de KB, e grava o resultado — com a
# ausencia tambem gravada, para nao reapontar a API a cada visita.
_CAPA_PROCURADA_TTL_DIAS = 30
_capa_locks = {}
_capa_locks_guard = threading.Lock()
_capa_busca_muda = {}          # host -> ate quando considerar o provedor mudo
_capa_sem_sucesso = {}         # nome -> ate quando nao reaprocurar (busca parcial)
_capa_categoria_cache = {}     # (base, category_id) -> (achadas, ate quando)


def _serie_capa_linha(nome):
    try:
        return db.session.query(SeriesCover).filter_by(name=nome).first()
    except Exception:
        db.session.rollback()
        return None


def _capa_estado(nome):
    """'oficial' | 'sem' (provedor nao tem) | 'desconhecido' (nunca procurado)."""
    linha = _serie_capa_linha(nome)
    if linha is None:
        return 'desconhecido'
    if (linha.cover or '').strip():
        return 'oficial'
    try:
        atualizado = linha.updated_at
        if atualizado and atualizado.tzinfo is None:
            atualizado = atualizado.replace(tzinfo=timezone.utc)
        if atualizado and (datetime.now(timezone.utc) - atualizado).days >= _CAPA_PROCURADA_TTL_DIAS:
            return 'desconhecido'
    except Exception:
        pass
    return 'sem'


def _registrar_capa(nome, cover, plot, series_id):
    """Insert/update de uma linha de serie_capa (cover='' = procurado, nao ha)."""
    cover = (cover or '')[:990]
    agora = datetime.now(timezone.utc)
    try:
        linha = _serie_capa_linha(nome)
        if linha is None:
            db.session.add(SeriesCover(name=nome, cover=cover, plot=plot or '',
                                       series_id=str(series_id or '')[:55],
                                       updated_at=agora))
        else:
            linha.cover = cover
            linha.plot = (plot or '')[:1990]
            linha.series_id = str(series_id or '')[:55]
            linha.updated_at = agora
        db.session.commit()
    except Exception as exc:
        db.session.rollback()
        print('[Capas] registro de %s falhou: %s' % (str(nome)[:40], str(exc)[:160]), flush=True)
    invalidate_cache('series:')


def _ler_achadas_da_categoria(base, categoria_id, alvos):
    """Lê (e guarda em RAM por 15 min) o get_series de uma categoria.

    Uma pagina de lista pede ate 40 series; sem esta cache cada uma baixaria a
    categoria de novo. O dicionario guarda o nome normalizado de TODAS as series
    da categoria (a sinopse longa so vem para as que o site tem), entao serve
    para qualquer alvo.
    """
    chave = (base, categoria_id or '')
    agora = _time.time()
    guardado = _capa_categoria_cache.get(chave)
    if guardado and guardado[1] > agora:
        achadas = guardado[0]
    else:
        alvo_url = '%s&category_id=%s' % (base, categoria_id) if categoria_id else base
        try:
            achadas = _varrer_catalogo_series(alvo_url, precisas=set(alvos or ()),
                                              max_bytes=60 * 1024 * 1024, timeout=25)
        except Exception as exc:
            _capa_busca_muda[(urlparse(base).netloc or '').lower()] = _time.time() + 900
            print('[Capas] sob demanda em %s: %s'
                  % ((categoria_id or 'catalogo')[:12], str(exc)[:140]), flush=True)
            return {}
        if achadas:
            _capa_categoria_cache[chave] = (achadas, agora + 900)
            if len(_capa_categoria_cache) > 8:
                for k in sorted(_capa_categoria_cache, key=lambda x: _capa_categoria_cache[x][1])[:-8]:
                    _capa_categoria_cache.pop(k, None)
    return achadas or {}


def _garantir_capa_oficial(nome, stream_url='', grupo=''):
    """Capa oficial de uma serie, buscando no provedor se ainda nao se sabe.

    Devolve (cover, plot). Nunca sobe um pedido de dezenas de MB: usa a
    categoria da propria serie (get_series&category_id) e, se o provedor nao tem
    categorias, um get_series com teto de bytes e timeout curto.
    """
    nome = str(nome or '').strip()
    if not nome:
        return '', ''
    estado = _capa_estado(nome)
    if estado == 'oficial':
        linha = _serie_capa_linha(nome)
        return linha.cover or '', linha.plot or ''
    if estado == 'sem':
        return '', ''
    if _capa_sem_sucesso.get(nome, 0) > _time.time():
        return '', ''

    chave = _norm_serie_api(nome)
    base = _get_xtream_api_url(stream_url or '') or ''
    if not base:
        bases = _api_bases_para_capas()
        base = bases[0] if bases else ''
    host = (urlparse(base).netloc or '').lower() if base else ''
    if not host:
        return '', ''
    agora = _time.time()
    if _capa_busca_muda.get(host, 0) > agora:
        return '', ''            # provedor mudo: nao segura ninguem esperando de novo

    with _capa_locks_guard:
        cadeado = _capa_locks.setdefault(nome, threading.Lock())
    if not cadeado.acquire(timeout=8):
        linha = _serie_capa_linha(nome)          # outro pedido esta resolvendo a mesma serie
        return (linha.cover or '', linha.plot or '') if linha else ('', '')
    try:
        if _capa_estado(nome) != 'desconhecido':
            linha = _serie_capa_linha(nome)
            return (linha.cover or '', linha.plot or '')
        achadas = {}
        categoria_id = ''
        if grupo:
            norm_grupo = _norm_serie_api(grupo)
            for cid, norm_cat, _nome_cat in _xtream_categorias_series(base, timeout=8):
                if norm_cat == norm_grupo or norm_grupo and (
                        norm_grupo in norm_cat or norm_cat in norm_grupo):
                    categoria_id = cid
                    break
        alvos = {chave, _sem_ano(chave)}

        def _procurar(achadas):
            if not achadas:
                return None
            por_ano = {}
            for norm in achadas:
                por_ano.setdefault(_sem_ano(norm), []).append(norm)
            return _casar_serie_no_catalogo(chave, achadas, por_ano, sorted(achadas))

        respondeu = False
        achadas = _ler_achadas_da_categoria(base, categoria_id, alvos) if categoria_id else {}
        if achadas:
            respondeu = True
            casado = _procurar(achadas)
        else:
            casado = None
        if not casado:
            # nao estava na categoria do grupo (o M3U descreve o grupo errado, ou o
            # provedor classificou em outro lugar): procura no catalogo inteiro, que
            # a cache de 15 min entrega de graca para as proximas series da pagina.
            completos = _ler_achadas_da_categoria(base, '', alvos)
            if completos:
                respondeu = True
                casado = _procurar(completos)
                if casado:
                    achadas = completos
        if casado:
            nome_api, cover, plot, sid = achadas[casado]
            _registrar_capa(nome, cover, plot, sid)
            if cover:
                try:
                    db.session.execute(text(
                        "UPDATE canal SET logo = :c WHERE tipo='series' "
                        "AND is_hidden=0 AND (series_name = :n OR name = :n) "
                        "AND (logo IS NULL OR logo <> :c)"),
                        {'c': cover[:990], 'n': nome})
                    db.session.commit()
                except Exception:
                    db.session.rollback()
                invalidate_cache('series:')
            return cover or '', plot or ''
        if respondeu:
            # o provedor respondeu e a serie nao esta em lugar nenhum: ausencia
            # real, gravada para nao rebuscar a cada abertura de pagina
            _registrar_capa(nome, '', '', '')
        else:
            _capa_sem_sucesso[nome] = _time.time() + 3600   # leitura falhou: tenta depois
        return '', ''
    finally:
        cadeado.release()


def _enrich_xtream_progress(phase, pct, msg):
    """Escreve progresso do enrichment para arquivo JSON."""
    try:
        pf = os.path.join(BASE_DIR, 'progress_xtream.json')
        data = {'running': True, 'phase': phase, 'message': msg, 'percent': int(pct), 'timestamp': datetime.now().isoformat()}
        with open(pf, 'w') as f:
            json.dump(data, f)
    except Exception:
        pass


def _enrich_xtream_finished(success=True, msg=''):
    """Marca enriquecimento como finalizado."""
    try:
        pf = os.path.join(BASE_DIR, 'progress_xtream.json')
        data = {'running': False, 'success': success, 'message': msg, 'percent': 100 if success else 0, 'timestamp': datetime.now().isoformat()}
        with open(pf, 'w') as f:
            json.dump(data, f)
    except Exception:
        pass


def _enrich_movie(movie, api_base_url):
    """Busca e atualiza metadados de um filme via API Xtream."""
    stream_id = _extract_xtream_id_from_url(movie.stream_url)
    if not stream_id:
        return False
    # Usa a API do proprio item quando possivel (varias fontes M3U no painel).
    api_base_url = _get_xtream_api_url(movie.stream_url) or api_base_url
    cache_key = f'xt_vod_{stream_id}'
    cached = _get_cached(cache_key)
    if cached:
        info = cached
    else:
        try:
            import urllib.request
            req_url = f"{api_base_url}&action=get_vod_info&vod_id={stream_id}"
            req = urllib.request.Request(req_url, headers={'User-Agent': 'Mozilla/5.0'})
            with urllib.request.urlopen(req, timeout=10) as resp:
                data = json.loads(resp.read().decode('utf-8'))
                info = data.get('info', {})
                movie_data = data.get('movie_data', {})
                result = {
                    'plot': info.get('plot', '') or info.get('description', ''),
                    'cast': info.get('cast', '') or info.get('actors', ''),
                    'director': info.get('director', ''),
                    'genre': info.get('genre', '') or info.get('category', ''),
                    'releaseDate': info.get('releasedate', '') or info.get('release_date', '') or info.get('releasedate', ''),
                    'rating': info.get('rating', '') or info.get('rating_5based', ''),
                    'duration': info.get('duration', '') or info.get('episode_run_time', ''),
                    'movie_image': info.get('movie_image', '') or info.get('cover_big', '') or info.get('stream_icon', ''),
                    'backdrop_path': movie_data.get('backdrop_path', '') or info.get('backdrop_path', ''),
                    'tmdb_id': info.get('tmdb_id', ''),
                    'youtube_trailer': info.get('youtube_trailer', ''),
                }
                _set_cached(cache_key, result)
                info = result
        except Exception:
            return False
    updated = False
    if info.get('plot') and not movie.description:
        movie.description = info['plot'][:1990]
        updated = True
    if info.get('movie_image') and not movie.logo:
        movie.logo = info['movie_image']
        updated = True
    return updated


def _enrich_series(series_name, stream_url, db_session):
    """Busca e atualiza metadados de uma série via API Xtream."""
    cache_key = f'xt_series_{series_name.lower().strip()}'
    cached = _get_cached(cache_key)
    if cached:
        info = cached
    else:
        info = _fetch_xtream_series_info(series_name, stream_url)
        if not info:
            return False
    if not info.get('plot') and not info.get('cover'):
        return False
    try:
        update_vals = {}
        if info.get('plot'):
            update_vals['description'] = info['plot'][:1990]
        if info.get('cover'):
            update_vals['logo'] = info['cover']
        if update_vals:
            result = db_session.query(Canal).filter(
                Canal.tipo == 'series',
                Canal.is_hidden == False,
                Canal.series_name == series_name
            ).update(update_vals, synchronize_session=False)
            return result > 0
        return False
    except Exception:
        return False


def enrich_xtream_metadata(batch_size=50, max_items=0, budget_s=2700):
    """Enriquece filmes e séries com metadados da API Xtream (plot, capas, etc).

    Args:
        batch_size: Quantos itens processar por vez (para não sobrecarregar a API)
        max_items: Máximo de itens a processar por tipo (0 = todos)
        budget_s: Tempo máximo dos pedidos por filme; o que sobar fica para a
            próxima noite. Sem limite o cron podia estourar o timeout do cPanel.
    """
    with app.app_context():
        _enrich_xtream_progress('init', 0, 'Iniciando enriquecimento Xtream...')
        try:
            # Buscar primeira URL M3U para extrair credenciais
            first_stream = Canal.query.filter(Canal.stream_url.isnot(None)).first()
            if not first_stream:
                _enrich_xtream_finished(False, 'Nenhum stream encontrado')
                return {'success': False, 'message': 'Nenhum stream encontrado'}
            api_base_url = _get_xtream_api_url(first_stream.stream_url)
            if not api_base_url:
                _enrich_xtream_finished(False, 'Não foi possível extrair credenciais da API')
                return {'success': False, 'message': 'Credenciais não encontradas'}
            updated_movies = 0
            updated_series = 0
            # Processar filmes sem descrição
            movies_query = Canal.query.filter(
                Canal.tipo == 'movie',
                Canal.is_hidden == False,
                db.or_(Canal.description == None, Canal.description == ''),
                Canal.stream_url.isnot(None)
            )
            total_movies = movies_query.count()
            if total_movies > 0:
                _enrich_xtream_progress('movies', 10, f'Processando {total_movies} filmes...')
                movies = movies_query.limit(max_items if max_items > 0 else total_movies).all()
                processed = 0
                teto = _time.time() + budget_s
                for movie in movies:
                    if _enrich_movie(movie, api_base_url):
                        updated_movies += 1
                    processed += 1
                    if processed % batch_size == 0:
                        pct = 10 + int(40 * processed / len(movies))
                        _enrich_xtream_progress('movies', pct, f'Filmes: {processed}/{len(movies)}')
                    if processed % 500 == 0 and _time.time() > teto:
                        print(f'[Xtream Enrich] Orçamento de tempo dos filmes esgotado em '
                              f'{processed}/{len(movies)}; o resto fica para a próxima.',
                              flush=True)
                        break
                db.session.commit()
            # Séries: o get_series do provedor traz a capa e a sinopse de TODAS de
            # uma só vez, então não existe mais teto de 500 itens aqui.
            capas = {}
            try:
                capas = atualizar_capas_oficiais_series(
                    progress=lambda msg, pct=None: _enrich_xtream_progress(
                        'series', pct if pct is not None else 55, msg))
            except Exception as exc:
                print(f'[Xtream Enrich] Catalogo de capas falhou: {exc}', flush=True)
            if capas.get('cobertas'):
                updated_series = capas['cobertas']
            else:
                # Provedor sem get_series (ou lista enorme demais): volta ao
                # pedido por série, agora sem parar em 500.
                series_key = db.func.coalesce(db.func.nullif(Canal.series_name, ''), Canal.name)
                series_query = db.session.query(
                    series_key.label('series_name'),
                    Canal.stream_url
                ).filter(
                    Canal.tipo == 'series',
                    Canal.is_hidden == False,
                    db.or_(
                        Canal.description == None,
                        Canal.description == '',
                        Canal.logo == None,
                        Canal.logo == ''
                    )
                ).group_by(series_key)
                total_series = series_query.count()
                if total_series > 0:
                    _enrich_xtream_progress('series', 50, f'Processando {total_series} séries...')
                    series_list = series_query.limit(
                        max_items if max_items > 0 else total_series).all()
                    processed = 0
                    for item in series_list:
                        if _enrich_series(item.series_name, item.stream_url, db.session):
                            updated_series += 1
                        processed += 1
                        if processed % batch_size == 0:
                            pct = 50 + int(45 * processed / len(series_list))
                            _enrich_xtream_progress('series', pct, f'Séries: {processed}/{len(series_list)}')
                    db.session.commit()
            _enrich_xtream_progress('done', 100, f'Concluído: {updated_movies} filmes, {updated_series} séries atualizados')
            _enrich_xtream_finished(True, f'{updated_movies} filmes, {updated_series} séries atualizados')
            print(f'[Xtream Enrich] Concluído: {updated_movies} filmes, {updated_series} séries', flush=True)
            return {'success': True, 'movies': updated_movies, 'series': updated_series,
                    'capas': capas}
        except Exception as e:
            _enrich_xtream_finished(False, str(e))
            print(f'[Xtream Enrich] Erro: {e}', flush=True)
            return {'success': False, 'message': str(e)}
        except Exception as e:
            _enrich_xtream_finished(False, str(e))
            print(f'[Xtream Enrich] Erro: {e}', flush=True)
            return {'success': False, 'message': str(e)}


def replace_stream_credentials(stream_url, source):
    """Troca somente as credenciais no caminho, preservando tipo e ID do conteúdo."""
    if not stream_url or not source:
        return stream_url
    try:
        parsed = urlparse(stream_url)
        if parsed.hostname and source.get('hostname') and parsed.hostname.lower() != source.get('hostname'):
            return stream_url
        path = parsed.path or ''
        match = re.match(r'^(?P<prefix>/(?:movie/|series/|live/)?)(?P<user>[^/]+)/(?P<password>[^/]+)(?P<suffix>/.*)$', path, re.I)
        if not match:
            return stream_url
        new_path = f"{match.group('prefix')}{urllib.parse.quote(source['username'], safe='')}" \
                   f"/{urllib.parse.quote(source['password'], safe='')}{match.group('suffix')}"
        return urllib.parse.urlunparse(parsed._replace(path=new_path))
    except Exception:
        return stream_url


def _source_matches_stream(stream_url, source):
    """Retorna se a credencial pertence ao mesmo host do item do catálogo."""
    if not stream_url or not source:
        return True
    try:
        host = (urlparse(stream_url).hostname or '').lower()
    except Exception:
        host = ''
    source_host = (source.get('hostname') or '').lower()
    return not host or not source_host or host == source_host


def choose_m3u_source_index(user_id, stream_url=None, exclude_indexes=None):
    """Escolhe uma credencial com vaga e compatível com o host do conteúdo.

    Descarta credenciais vencidas/sem acesso e respeita o limite de conexoes
    do provedor: vaga = ``max_connections - max(active_cons, reservas
    locais)``. Se TODAS estiverem cheias, ainda aceita a cheia (o provedor
    pode contar a conexao do proprio usuario, e bloquear seria pior).

    Devolve ``None`` so quando nao ha nenhuma credencial compativel com vaga
    *nem* nenhuma cheia aproveitavel.
    """
    sources = get_m3u_credential_sources()
    if not sources:
        return None
    excluded = {int(value) for value in (exclude_indexes or set()) if str(value).isdigit()}
    eligible = [i for i, source in enumerate(sources) if i not in excluded and _source_matches_stream(stream_url, source)]
    if not eligible:
        return None

    # Contas vencidas/sem acesso saem; contas cheias ficam de sobreaviso.
    health_map = {}
    cheias = []
    try:
        usable_indexes, cheias, health_map = _m3u_usable_source_indexes(sources)
        com_vaga = [i for i in eligible if i in usable_indexes]
        if com_vaga:
            eligible = com_vaga
        else:
            # Nenhuma com vaga: ultimo recurso e uma conta cheia (nunca uma
            # vencida/sem acesso).
            cheias_compativeis = [i for i in eligible if i in cheias]
            if cheias_compativeis:
                eligible = cheias_compativeis
            else:
                # Nenhuma compativel com vaga nem nenhuma cheia compativel:
                # nao ha credencial utilizavel (todas vencidas/sem acesso).
                return None
    except Exception:
        pass

    cutoff = datetime.now(timezone.utc) - timedelta(seconds=STREAM_SESSION_IDLE_SECONDS)
    try:
        current = UserStreaming.query.filter_by(user_id=user_id).first()
        current_heartbeat = current.last_heartbeat if current else None
        if current_heartbeat is not None and current_heartbeat.tzinfo is None:
            current_heartbeat = current_heartbeat.replace(tzinfo=timezone.utc)
        if current and current.source_index in eligible and current_heartbeat and current_heartbeat >= cutoff:
            return int(current.source_index)
        active = UserStreaming.query.filter(UserStreaming.last_heartbeat >= cutoff).all()
    except Exception:
        active = []
    used = {}
    for row in active:
        if row.source_index is None or row.user_id == user_id:
            continue
        idx = int(row.source_index)
        used[idx] = used.get(idx, 0) + 1

    # Preferencia: girar pelo user_id (distribui entre as contas) e so usar a
    # credencial se ainda houver vaga nela, contando as conexoes do provedor.
    preferred_position = int(user_id or 0) % len(eligible)
    for offset in range(len(eligible)):
        index = eligible[(preferred_position + offset) % len(eligible)]
        limite = _m3u_source_capacity_limit(health_map.get(index))
        local = used.get(index, 0)
        provedor = health_map.get(index, {}).get('active_cons')
        provedor = provedor if isinstance(provedor, int) else 0
        if max(local, provedor) < limite:
            return index
    # Todas as compativeis estao no limite do provedor (cheias).
    # Se TODAS as elegiveis sao cheias (ultimo recurso), aceita mesmo assim
    # para nao travar o usuario — o provedor pode contar a conexao do proprio
    # usuario, e bloquear seria pior.
    all_cheias = all(
        health_map.get(i, {}).get('cheia') and
        max(used.get(i, 0), health_map.get(i, {}).get('active_cons') or 0) >= _m3u_source_capacity_limit(health_map.get(i))
        for i in eligible
    )
    if all_cheias and eligible:
        return eligible[preferred_position % len(eligible)]
    # Nenhuma credencial compativel disponivel.
    return None

# ─── Models ────────────────────────────────────────────────────────────────
class User(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(120), unique=True, nullable=False)
    whatsapp = db.Column(db.String(30), nullable=True)
    password_hash = db.Column(db.String(256), nullable=False)
    is_admin = db.Column(db.Boolean, default=False)
    is_paid = db.Column(db.Boolean, default=False)
    paid_until = db.Column(db.DateTime, nullable=True)
    free_daily_used = db.Column(db.Integer, default=0)
    free_daily_limit = db.Column(db.Integer, default=lambda: int(load_settings().get('free_daily_hours', 4) * 3600))
    last_free_reset = db.Column(db.Date, nullable=True)
    is_banned = db.Column(db.Boolean, default=False)
    ban_reason = db.Column(db.Text, default='')
    delete_reason = db.Column(db.Text, default='')
    is_active_user = db.Column(db.Boolean, default=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    referral_code = db.Column(db.String(20), unique=True, nullable=True)
    referred_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
    referral_bonus_used = db.Column(db.Boolean, default=False)
    token_version = db.Column(db.Integer, default=0)
    admin_permissions = db.Column(db.Text, default=None, nullable=True)
    affiliation_disabled = db.Column(db.Boolean, default=False)
    affiliate_commission_choice = db.Column(db.String(20), nullable=True)
    timezone_offset = db.Column(db.Float, default=-3.0, nullable=False)
    hide_adult = db.Column(db.Boolean, default=False)

    referrals_made = db.relationship('Referral', foreign_keys='Referral.referrer_id', backref='referrer', lazy='dynamic')
    referral_info = db.relationship('Referral', foreign_keys='Referral.referred_id', backref='referred', uselist=False)

    @property
    def paid_until_utc(self):
        if self.paid_until is None:
            return None
        if self.paid_until.tzinfo is None:
            return self.paid_until.replace(tzinfo=BRASILIA_TZ)
        return self.paid_until.astimezone(BRASILIA_TZ)

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

    def check_password(self, password):
        return check_password_hash(self.password_hash, password)

    def can_watch(self):
        if self.is_banned:
            return False
        if not self.is_active_user:
            return False
        if self.is_admin:
            return True
        if self.is_paid and self.paid_until:
            paid_until = self.paid_until
            if paid_until.tzinfo is None:
                paid_until = paid_until.replace(tzinfo=BRASILIA_TZ)
            if paid_until > datetime.now(BRASILIA_TZ):
                return True
        today = datetime.now(BRASILIA_TZ).date()
        if self.last_free_reset != today:
            self.free_daily_used = 0
            self.last_free_reset = today
            db.session.commit()
        return self.free_daily_used < self.free_daily_limit

    def remaining_seconds(self):
        if self.is_admin:
            return 999999
        if self.is_paid and self.paid_until:
            paid_until = self.paid_until
            if paid_until.tzinfo is None:
                paid_until = paid_until.replace(tzinfo=BRASILIA_TZ)
            if paid_until > datetime.now(BRASILIA_TZ):
                return 999999
        today = datetime.now(BRASILIA_TZ).date()
        if self.last_free_reset != today:
            self.free_daily_used = 0
            self.last_free_reset = today
            db.session.commit()
        return max(0, self.free_daily_limit - self.free_daily_used)

    def consume_time(self, seconds):
        self.free_daily_used += seconds
        db.session.commit()

    @property
    def is_premium_active(self):
        """Verifica se o premium esta ativo: is_paid=True E paid_until no futuro."""
        if not self.is_paid or not self.paid_until:
            return False
        pu = self.paid_until
        if isinstance(pu, str):
            try:
                pu = datetime.strptime(pu[:19], '%Y-%m-%d %H:%M:%S')
            except Exception:
                return False
        if pu.tzinfo is None:
            pu = pu.replace(tzinfo=BRASILIA_TZ)
        return pu > datetime.now(BRASILIA_TZ)


class Canal(db.Model):
    __tablename__ = 'canal'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(300), nullable=False)
    stream_url = db.Column(db.String(1000), nullable=False)
    logo = db.Column(db.String(1000), default='')
    group_title = db.Column(db.String(200), default='')
    tvg_id = db.Column(db.String(200), default='')
    tipo = db.Column(db.String(20), default='live', index=True)
    series_name = db.Column(db.String(300), default='', index=True)
    season_number = db.Column(db.Integer, nullable=True, index=True)
    episode_number = db.Column(db.Integer, nullable=True, index=True)
    stream_referrer = db.Column(db.String(1000), default='')
    stream_user_agent = db.Column(db.String(500), default='')
    is_hidden = db.Column(db.Boolean, default=False)
    view_count = db.Column(db.Integer, default=0)
    data_added = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    description = db.Column(db.Text, default='')


class SeriesCover(db.Model):
    """Capa oficial da serie, tal como o provedor Xtream a publica.

    Guarda fora de `canal` por dois motivos: o sync do M3U reescreve
    `canal.logo` a cada noite com o logo que vem na linha do episodio (a
    "cena de um episodio qualquer" que aparecia no site), e uma serie tem
    milhares de linhas repetindo a mesma capa. A chave e o nome da serie, entao
    a capa ate sobrevive a um reimport que recrie as linhas do zero.
    """
    __tablename__ = 'serie_capa'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(300), unique=True, index=True, nullable=False)
    cover = db.Column(db.String(1000), default='')
    series_id = db.Column(db.String(60), default='')
    plot = db.Column(db.Text, default='')
    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))


class ChannelHealth(db.Model):
    __tablename__ = 'channel_health'
    id = db.Column(db.Integer, primary_key=True)
    canal_id = db.Column(db.Integer, db.ForeignKey('canal.id'), unique=True, nullable=False)
    is_online = db.Column(db.Boolean, default=True)
    last_checked = db.Column(db.DateTime, nullable=True)
    offline_since = db.Column(db.DateTime, nullable=True)
    consecutive_fails = db.Column(db.Integer, default=0)
    canal = db.relationship('Canal', backref=db.backref('health', uselist=False))


class M3USourceLog(db.Model):
    """Log de acoes e eventos das fontes M3U (teste, sync, status, falhas)."""
    __tablename__ = 'm3u_source_log'
    id = db.Column(db.Integer, primary_key=True)
    source_index = db.Column(db.Integer, nullable=True)  # indice da credencial
    source_url = db.Column(db.Text, nullable=True)       # URL mascarada
    action = db.Column(db.String(50), nullable=False)    # test, sync, status, proxy_fail, health_fail, hide, unhide, delete
    status = db.Column(db.String(20), nullable=False)    # ok, warn, error
    message = db.Column(db.Text, default='')             # detalhes
    details = db.Column(db.Text, default='')             # JSON com extras
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True)

    @classmethod
    def log(cls, source_index, source_url, action, status, message, details=None):
        try:
            _ensure_m3u_log_table()
            from flask import has_app_context
            if not has_app_context():
                from app import app as flask_app
                with flask_app.app_context():
                    return cls._log_impl(source_index, source_url, action, status, message, details)
            return cls._log_impl(source_index, source_url, action, status, message, details)
        except Exception:
            # NAO deixar a sessao suja: um commit que falhou aqui corromperia
            # outras operacoes do mesmo request (ex.: o proprio /proxy).
            try:
                db.session.rollback()
            except Exception:
                pass

    @classmethod
    def _log_impl(cls, source_index, source_url, action, status, message, details=None):
        # details vem como dict/list na maioria das chamadas, mas a coluna e
        # Text. Sem serializar, o commit lanca e o log nunca e gravado (a aba
        # "Logs das Fontes" ficava sempre vazia). Serializamos para JSON.
        if details is None:
            details = ''
        elif isinstance(details, (dict, list)):
            try:
                details = json.dumps(details, ensure_ascii=False)
            except Exception:
                details = str(details)
        elif not isinstance(details, str):
            details = str(details)
        entry = cls(
            source_index=source_index,
            source_url=source_url[:500] if source_url else None,
            action=action,
            status=status,
            message=message[:500] if message else '',
            details=details or ''
        )
        db.session.add(entry)
        db.session.commit()

    @classmethod
    def get_recent(cls, source_index=None, limit=100):
        try:
            from flask import has_app_context
            if not has_app_context():
                from app import app as flask_app
                with flask_app.app_context():
                    return cls._get_recent_impl(source_index, limit)
            return cls._get_recent_impl(source_index, limit)
        except Exception:
            return []

    @classmethod
    def _get_recent_impl(cls, source_index=None, limit=100):
        q = cls.query.order_by(cls.created_at.desc())
        if source_index is not None:
            q = q.filter_by(source_index=source_index)
        return q.limit(limit).all()


_M3U_LOG_TABLE_OK = False


def _ensure_m3u_log_table():
    """Garante que a tabela m3u_source_log exista (deploy novo pode nao ter
    rodado create_all ainda). Roda uma vez; depois e no-op."""
    global _M3U_LOG_TABLE_OK
    if _M3U_LOG_TABLE_OK:
        return
    try:
        M3USourceLog.__table__.create(bind=db.engine, checkfirst=True)
        _M3U_LOG_TABLE_OK = True
    except Exception:
        try:
            db.session.rollback()
        except Exception:
            pass


# Estado de throttle do log de status: o auto-refresh da pagina (60s) nao deve
# inundar o log. Registramos status no maximo 1x a cada STATUS_LOG_THROTTLE s.
_m3u_status_log_at = {'t': 0.0}
STATUS_LOG_THROTTLE = 300


class WatchLog(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    canal_id = db.Column(db.Integer, db.ForeignKey('canal.id'), nullable=True)
    content_name = db.Column(db.String(500), default='')
    started_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    last_seen = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    is_active = db.Column(db.Boolean, default=True)


def deactivate_user_watchlogs(user_id, keep_canal_id=None):
    """Um usuario assiste UMA coisa por vez: desativa qualquer outro registro.

    Sem isso, cada conteudo aberto deixava um WatchLog ativo — principalmente
    no player em popup da TV, que nem avisava o servidor ao fechar. O painel
    admin mostrava varios conteudos "online" para o mesmo usuario ao mesmo
    tempo (6 no caso relatado).
    """
    try:
        query = WatchLog.query.filter(WatchLog.user_id == user_id, WatchLog.is_active == True)  # noqa: E712
        if keep_canal_id is not None:
            query = query.filter(db.or_(WatchLog.canal_id.is_(None), WatchLog.canal_id != keep_canal_id))
        changed = query.update({'is_active': False}, synchronize_session=False)
        db.session.commit()
        return changed
    except Exception:
        db.session.rollback()
        return 0


def _request_payload():
    """Aceita JSON ou form/multipart (sendBeacon envia FormData)."""
    data = request.get_json(silent=True)
    if isinstance(data, dict) and data:
        return data
    if request.form:
        return request.form.to_dict()
    return data or {}


def _payload_canal_id(data):
    value = (data or {}).get('canal_id')
    if value in (None, '', 'null'):
        return None
    try:
        return int(value)
    except (TypeError, ValueError):
        return None


class Payment(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    charge_id = db.Column(db.String(100), unique=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
    amount = db.Column(db.Float)
    plan_type = db.Column(db.String(20), default='mensal')
    plan_days = db.Column(db.Integer, default=30)
    status = db.Column(db.String(20), default='pending')
    pix_copy_paste = db.Column(db.Text)
    external_reference = db.Column(db.String(200))
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    paid_at = db.Column(db.DateTime, nullable=True)
    expires_at = db.Column(db.DateTime, nullable=True)


class Favorite(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    canal_id = db.Column(db.Integer, db.ForeignKey('canal.id'), nullable=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    __table_args__ = (db.UniqueConstraint('user_id', 'canal_id', name='uq_user_canal'),)


class Referral(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    referrer_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    referred_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    subscription_confirmed = db.Column(db.Boolean, default=False)
    bonus_granted = db.Column(db.Boolean, default=False)
    __table_args__ = (db.UniqueConstraint('referrer_id', 'referred_id', name='uq_referral_pair'),)


class Report(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    canal_id = db.Column(db.Integer, db.ForeignKey('canal.id'), nullable=False)
    issue_type = db.Column(db.String(50), nullable=False)
    content_name = db.Column(db.String(500), default='')
    content_type = db.Column(db.String(20), default='live')
    group_title = db.Column(db.String(200), default='')
    status = db.Column(db.String(20), default='pending', index=True)
    admin_note = db.Column(db.Text, default='')
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    resolved_at = db.Column(db.DateTime, nullable=True)
    user = db.relationship('User', backref='reports')
    canal = db.relationship('Canal', backref='reports')


class Plan(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    key = db.Column(db.String(30), unique=True, nullable=False)
    name = db.Column(db.String(100), nullable=False)
    price = db.Column(db.Float, nullable=False)
    days = db.Column(db.Integer, nullable=False)
    description = db.Column(db.String(300), default='')
    benefits = db.Column(db.Text, default='')
    is_active = db.Column(db.Boolean, default=True)
    sort_order = db.Column(db.Integer, default=0)
    promotion_enabled = db.Column(db.Boolean, default=False)
    promotion_price = db.Column(db.Float, nullable=True)
    promotion_label = db.Column(db.String(80), default='')
    promotion_until = db.Column(db.DateTime, nullable=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))

    @property
    def effective_price(self):
        return effective_plan_price(self)


class EPGProgram(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    canal_id = db.Column(db.Integer, db.ForeignKey('canal.id'), nullable=True)
    channel_id = db.Column(db.String(200), nullable=False, index=True)
    title = db.Column(db.String(500))
    description = db.Column(db.Text, default='')
    start_time = db.Column(db.DateTime, index=True)
    end_time = db.Column(db.DateTime)


class ChannelEPGMapping(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    canal_id = db.Column(db.Integer, db.ForeignKey('canal.id'), nullable=False)
    channel_id = db.Column(db.String(200), nullable=False)
    channel_name = db.Column(db.String(500), default='')
    __table_args__ = (db.UniqueConstraint('canal_id', name='uq_canal_epg'),)


class SessionToken(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    token = db.Column(db.String(128), unique=True, nullable=False)
    device_info = db.Column(db.String(500), default='')
    ip_address = db.Column(db.String(50), default='')
    user_agent = db.Column(db.String(500), default='')
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    expires_at = db.Column(db.DateTime, nullable=False)
    is_active = db.Column(db.Boolean, default=True)


class ActiveSession(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    session_token = db.Column(db.String(128), nullable=False)
    ip_address = db.Column(db.String(50), default='')
    user_agent = db.Column(db.String(500), default='')
    device_info = db.Column(db.String(500), default='')
    started_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    last_heartbeat = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    is_streaming = db.Column(db.Boolean, default=False)
    content_name = db.Column(db.String(500), default='')


class UserStreaming(db.Model):
    __tablename__ = 'user_streaming'
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), unique=True, nullable=False)
    session_token = db.Column(db.String(128), nullable=False)
    device_info = db.Column(db.String(500), default='')
    content_name = db.Column(db.String(500), default='')
    source_index = db.Column(db.Integer, nullable=True, unique=True)
    started_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    last_heartbeat = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))


class TVAuthCode(db.Model):
    __tablename__ = 'tv_auth_code'
    id = db.Column(db.Integer, primary_key=True)
    code = db.Column(db.String(16), unique=True, nullable=False, index=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True)


class SupportTicket(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    subject = db.Column(db.String(300), nullable=False)
    status = db.Column(db.String(20), default='aberto', index=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
    user = db.relationship('User', backref='tickets')
    messages = db.relationship('SupportMessage', backref='ticket', lazy='dynamic', order_by='SupportMessage.created_at')


class SupportMessage(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    ticket_id = db.Column(db.Integer, db.ForeignKey('support_ticket.id'), nullable=False)
    sender_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
    is_admin = db.Column(db.Boolean, default=False)
    message = db.Column(db.Text, nullable=False)
    attachment_url = db.Column(db.String(500), default='')
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    read = db.Column(db.Boolean, default=False)


class PlanHistory(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    plan_type = db.Column(db.String(30), nullable=False)
    plan_name = db.Column(db.String(100), default='')
    started_at = db.Column(db.DateTime, nullable=False)
    expires_at = db.Column(db.DateTime, nullable=True)
    status = db.Column(db.String(20), default='active')
    source = db.Column(db.String(20), default='direct')
    payment_id = db.Column(db.Integer, db.ForeignKey('payment.id'), nullable=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    user = db.relationship('User', backref='plan_history')


def _expire_other_active_planhistory(user_id, reason='substituido'):
    """Marca como 'expired' todas as linhas ativas de PlanHistory do usuario.

    Cada usuario deve ter NO MAXIMO 1 plano ativo. Sem isso, cada renovacao
    adicionava uma linha nova 'active' e as antigas ficavam ativas, inflando a
    contagem de "Planos Ativos" do painel (Anual/Mensal/Vitalicio) e duplicando
    usuarios na lista. Retorna quantas linhas mudaram.
    """
    try:
        return PlanHistory.query.filter(
            PlanHistory.user_id == user_id,
            PlanHistory.status == 'active'
        ).update({'status': 'expired'}, synchronize_session=False)
    except Exception:
        try:
            db.session.rollback()
        except Exception:
            pass
        return 0


def _expire_all_active_planhistory(user_id):
    """Remove o plano ativo de um usuario (usado quando o admin tira premium)."""
    return _expire_other_active_planhistory(user_id)


def _sweep_expired_planhistory():
    """Varredura preguicosa: planos cujo expires_at ja passou viram 'expired'.

    IMPORTANTE: expires_at/paid_until sao gravados como horario de wall-clock de
    BRASILIA (naive). Comparar com datetime.now(timezone.utc) (que o driver MySQL
    envia como parede UTC) fazia o plano ficar "ativo" ~3h depois de vencer -- o
    bug de "venceu e nao saiu do painel". Comparamos com o agora de Brasilia.
    """
    now_brt_naive = datetime.now(BRASILIA_TZ).replace(tzinfo=None)
    changed = 0
    try:
        # 1) Vencidos por data (comparando na parede de Brasilia, nao UTC).
        changed += PlanHistory.query.filter(
            PlanHistory.status == 'active',
            PlanHistory.expires_at.isnot(None),
            PlanHistory.expires_at < now_brt_naive,
        ).update({'status': 'expired'}, synchronize_session=False)

        # 2) Linhas ativas de usuarios que NAO estao mais pagos (banidos,
        #    inativos ou com premium removido por qualquer caminho). Sem isso,
        #    "removi e nao saiu do painel" permanecia.
        bad_ids = db.session.query(PlanHistory.id).join(
            User, User.id == PlanHistory.user_id
        ).filter(
            PlanHistory.status == 'active',
            db.or_(User.is_paid == False, User.is_banned == True),  # noqa: E712
        ).all()
        bad_ids = [i for (i,) in bad_ids]
        if bad_ids:
            changed += PlanHistory.query.filter(
                PlanHistory.id.in_(bad_ids)
            ).update({'status': 'expired'}, synchronize_session=False)

        # 3) Dedupe: no maximo 1 linha ativa por usuario. Mantemos a "melhor"
        #    (vitalicia/expires_at mais distante) e expiramos o resto, para o
        #    badge nao contar renovacoes em dobro.
        multi = db.session.query(PlanHistory.user_id).filter(
            PlanHistory.status == 'active'
        ).group_by(PlanHistory.user_id).having(
            func.count(PlanHistory.id) > 1
        ).all()
        for (uid,) in multi:
            rows = PlanHistory.query.filter(
                PlanHistory.user_id == uid, PlanHistory.status == 'active'
            ).all()

            def _rank(h):
                # vitalicio (None) vence o ranking; depois o expires_at maior.
                if h.expires_at is None:
                    return (2, now_brt_naive)
                return (1, h.expires_at)

            rows.sort(key=_rank, reverse=True)
            keep = rows[0].id
            drop = [r.id for r in rows[1:]]
            if drop:
                changed += PlanHistory.query.filter(
                    PlanHistory.id.in_(drop)
                ).update({'status': 'expired'}, synchronize_session=False)
        db.session.commit()
    except Exception:
        try:
            db.session.rollback()
        except Exception:
            pass
    return changed


class AuditLog(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
    action = db.Column(db.String(50), nullable=False, index=True)
    ip_address = db.Column(db.String(50), default='')
    user_agent = db.Column(db.String(500), default='')
    device_info = db.Column(db.String(500), default='')
    details = db.Column(db.Text, default='')
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    user = db.relationship('User', backref='audit_logs')


class PasswordResetCode(db.Model):
    __tablename__ = 'password_reset_code'
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
    code_hash = db.Column(db.String(128), nullable=False)
    expires_at = db.Column(db.DateTime, nullable=False)
    attempts = db.Column(db.Integer, default=0, nullable=False)
    used_at = db.Column(db.DateTime, nullable=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))


class Notification(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    title = db.Column(db.String(200), nullable=False)
    message = db.Column(db.Text, default='')
    is_read = db.Column(db.Boolean, default=False)
    link = db.Column(db.String(500), default='')
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    user = db.relationship('User', backref='notifications')


class Suggestion(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    tipo = db.Column(db.String(30), nullable=False)
    titulo = db.Column(db.String(300), nullable=False)
    descricao = db.Column(db.Text, default='')
    status = db.Column(db.String(20), default='pendente', index=True)
    admin_note = db.Column(db.Text, default='')
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    user = db.relationship('User', backref='suggestions')


class TermsAcceptance(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    accepted_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    ip_address = db.Column(db.String(50), default='')
    device_info = db.Column(db.String(500), default='')
    user_agent = db.Column(db.String(500), default='')
    terms_version = db.Column(db.String(20), default='1.0')
    accepted_terms = db.Column(db.Boolean, default=True)
    accepted_privacy = db.Column(db.Boolean, default=True)
    accepted_service = db.Column(db.Boolean, default=True)
    accepted_affiliates = db.Column(db.Boolean, default=True)
    user = db.relationship('User', backref='terms_acceptances')


class CookieConsent(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
    consented_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    ip_address = db.Column(db.String(50), default='')
    device_info = db.Column(db.String(500), default='')
    user_agent = db.Column(db.String(500), default='')
    consent_token = db.Column(db.String(64), unique=True, nullable=True)
    user = db.relationship('User', backref='cookie_consents')


class Term(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    slug = db.Column(db.String(50), unique=True, nullable=False)
    title = db.Column(db.String(100), nullable=False)
    content = db.Column(db.Text, nullable=False, default='')
    version = db.Column(db.String(20), default='1.0')
    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
                           onupdate=lambda: datetime.now(timezone.utc))


class SocialLink(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False)
    url = db.Column(db.String(500), nullable=False)
    icon_svg = db.Column(db.Text, nullable=False)
    sort_order = db.Column(db.Integer, default=0)
    is_active = db.Column(db.Boolean, default=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))


class VideoIntro(db.Model):
    """Vídeos institucionais exibidos em abas especificas do site/app.

    Um mesmo video pode aparecer em varios locais (landing, login, registro,
    app mobile, app TV etc). Cada VideoIntro define o ID do video no YouTube,
    um titulo, descricao, ordem, e em quais 'locais' deve ser exibido.
    """
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False, default='Apresentação Troço Play')
    description = db.Column(db.Text, default='')
    youtube_id = db.Column(db.String(50), nullable=False)
    locations = db.Column(db.Text, default='')  # CSV: landing,login,register,app_mobile,app_tv
    sort_order = db.Column(db.Integer, default=0)
    is_active = db.Column(db.Boolean, default=True)
    autoplay = db.Column(db.Boolean, default=False)
    show_controls = db.Column(db.Boolean, default=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
                           onupdate=lambda: datetime.now(timezone.utc))

    def location_list(self):
        if not self.locations:
            return []
        return [s.strip() for s in self.locations.split(',') if s.strip()]

    def has_location(self, loc):
        return loc in self.location_list()

    def youtube_embed_url(self):
        params = []
        if self.autoplay:
            params.append('autoplay=1')
        if not self.show_controls:
            params.append('controls=0')
        params.append('rel=0')
        params.append('modestbranding=1')
        return f'https://www.youtube.com/embed/{self.youtube_id}?' + '&'.join(params)

    def youtube_watch_url(self):
        return f'https://youtu.be/{self.youtube_id}'


class QueuedEmail(db.Model):
    """E-mails em massa agendados/enviados pelo painel admin."""
    id = db.Column(db.Integer, primary_key=True)
    subject = db.Column(db.String(200), nullable=False)
    body = db.Column(db.Text, nullable=False)
    recipients_type = db.Column(db.String(30), default='custom')  # custom | all | free | paid | banned | inactive
    recipients_emails = db.Column(db.Text, default='')  # CSV quando type=custom
    sender_email = db.Column(db.String(200), default='')  # qual email usar como remetente
    status = db.Column(db.String(20), default='pending')  # pending | sending | sent | failed | cancelled
    scheduled_at = db.Column(db.DateTime, nullable=True)
    sent_at = db.Column(db.DateTime, nullable=True)
    total_recipients = db.Column(db.Integer, default=0)
    sent_count = db.Column(db.Integer, default=0)
    failed_count = db.Column(db.Integer, default=0)
    delay_seconds = db.Column(db.Integer, default=5)
    created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    error_log = db.Column(db.Text, default='')

    creator = db.relationship('User', backref='queued_emails')


class EmailLog(db.Model):
    """Registro de cada e-mail individual enviado pelo sistema."""
    id = db.Column(db.Integer, primary_key=True)
    recipient_email = db.Column(db.String(120), nullable=False, index=True)
    recipient_user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
    subject = db.Column(db.String(200), nullable=False)
    body = db.Column(db.Text, nullable=False)
    sender = db.Column(db.String(200), default='')
    sender_key = db.Column(db.String(30), default='default')
    status = db.Column(db.String(20), default='sent')  # sent | failed
    error = db.Column(db.Text, default='')
    created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))

    recipient = db.relationship('User', backref='email_logs', foreign_keys=[recipient_user_id])
    sender_user = db.relationship('User', foreign_keys=[created_by], backref='emails_sent')


class SportEvent(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    sport = db.Column(db.String(200), nullable=False)
    championship = db.Column(db.String(300), default='')
    team1 = db.Column(db.String(200), nullable=False)
    team2 = db.Column(db.String(200), nullable=False)
    score = db.Column(db.String(50), default='')
    event_time = db.Column(db.String(10), nullable=False)
    channels = db.Column(db.Text, default='')
    event_date = db.Column(db.Date, nullable=False, index=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))


# ─── Affiliate System Models ──────────────────────────────────────────────────
class AffiliateConfig(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True, unique=True)
    commission_type = db.Column(db.String(20), default='percentage')
    percentage_value = db.Column(db.Float, default=10.0)
    fixed_value = db.Column(db.Float, default=0.0)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
                           onupdate=lambda: datetime.now(timezone.utc))


class AffiliateWallet(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=True)
    balance = db.Column(db.Float, default=0.0)
    total_earned = db.Column(db.Float, default=0.0)
    pending_balance = db.Column(db.Float, default=0.0)
    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
                           onupdate=lambda: datetime.now(timezone.utc))
    user = db.relationship('User', backref='affiliate_wallet', uselist=False)


class Commission(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    referrer_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    referred_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    payment_id = db.Column(db.Integer, db.ForeignKey('payment.id'), nullable=True)
    plan_name = db.Column(db.String(100), default='')
    plan_amount = db.Column(db.Float, default=0.0)
    commission_type = db.Column(db.String(20), default='percentage')
    commission_percentage = db.Column(db.Float, default=0.0)
    commission_amount = db.Column(db.Float, default=0.0)
    status = db.Column(db.String(20), default='pending')
    level = db.Column(db.Integer, default=1)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    referrer = db.relationship('User', foreign_keys=[referrer_id], backref='commissions_received')
    referred = db.relationship('User', foreign_keys=[referred_id], backref='commissions_generated')
    payment = db.relationship('Payment', backref='commissions')


class PayoutRequest(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    amount = db.Column(db.Float, nullable=False)
    pix_key_type = db.Column(db.String(20), nullable=False)
    pix_key = db.Column(db.String(200), nullable=False)
    status = db.Column(db.String(20), default='pending')
    rejection_reason = db.Column(db.Text, default='')
    paid_at = db.Column(db.DateTime, nullable=True)
    processed_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
    user = db.relationship('User', foreign_keys=[user_id], backref='payout_requests')
    processor = db.relationship('User', foreign_keys=[processed_by], backref='processed_payouts')


def get_or_create_wallet(user_id):
    wallet = AffiliateWallet.query.filter_by(user_id=user_id).first()
    if not wallet:
        wallet = AffiliateWallet(user_id=user_id)
        db.session.add(wallet)
        db.session.commit()
    return wallet


def get_affiliate_config(user_id=None):
    if user_id:
        config = AffiliateConfig.query.filter_by(user_id=user_id).first()
        if config:
            return config
    return AffiliateConfig.query.filter_by(user_id=None).first()


def get_commission_amount(plan_amount, referrer_id=None):
    config = get_affiliate_config(referrer_id)
    if not config:
        return 0, 'none', 0
    val = plan_amount * (config.percentage_value / 100)
    return val, config.commission_type, config.percentage_value


# ─── Programa de Afiliados Multinivel ──────────────────────────────────────
# Nivel 1: 50% do valor do plano
# Nivel 2: 5% do valor do plano
# Nivel 3, 4 e 5: 1% do valor do plano
INDICATION_LEVELS = [50, 5, 1, 1, 1]


def get_level_percentage(level):
    if not 1 <= level <= len(INDICATION_LEVELS):
        return 0
    return INDICATION_LEVELS[level - 1]


def credit_multilevel_commissions(user, plan_name, plan_amount, payment_id=None):
    """Credita comissoes multinivel (nivel 1 a 5) para a cadeia de indicacoes
    acima do usuario. Retorna o numero de comissoes creditadas."""
    if not plan_amount or plan_amount <= 0:
        return 0
    settings = load_settings()
    if settings.get('affiliation_disabled_all', False):
        return 0
    if not user or user.affiliation_disabled:
        return 0

    credited = 0
    seen = {user.id}
    ancestor = db.session.get(User, user.referred_by) if user.referred_by else None
    level = 1
    while ancestor and level <= len(INDICATION_LEVELS):
        if ancestor.id in seen:
            break
        seen.add(ancestor.id)
        if not ancestor.affiliation_disabled:
            pct = get_level_percentage(level)
            if pct > 0:
                comm_amount = round(plan_amount * (pct / 100), 2)
                if comm_amount > 0:
                    existing = Commission.query.filter_by(
                        referrer_id=ancestor.id,
                        referred_id=user.id,
                        payment_id=payment_id,
                        level=level,
                    ).first() if payment_id else None
                    if existing:
                        ancestor = db.session.get(User, ancestor.referred_by) if ancestor.referred_by else None
                        level += 1
                        continue
                    commission = Commission(
                        referrer_id=ancestor.id,
                        referred_id=user.id,
                        payment_id=payment_id,
                        plan_name=plan_name,
                        plan_amount=plan_amount,
                        commission_type='fixed',
                        commission_percentage=pct,
                        commission_amount=comm_amount,
                        status='available',
                        level=level,
                    )
                    db.session.add(commission)
                    wallet = get_or_create_wallet(ancestor.id)
                    wallet.balance += comm_amount
                    wallet.total_earned += comm_amount
                    create_notification(
                        ancestor.id,
                        'Comissao Recebida!',
                        f'Voce recebeu R$ {comm_amount:.2f} (nivel {level}) por indicacao de {user.email}',
                        link='/afiliados'
                    )
                    credited += 1
        ancestor = db.session.get(User, ancestor.referred_by) if ancestor.referred_by else None
        level += 1
    return credited


def grant_referral_commissions(user, plan_name, plan_amount, payment_id=None, force=False):
    """Confirma a indicação e credita a cadeia multinível uma única vez."""
    if not user or not user.referred_by or not plan_amount or plan_amount <= 0:
        return 0
    settings = load_settings()
    if settings.get('affiliation_disabled_all', False) or user.affiliation_disabled:
        return 0
    referral = Referral.query.filter_by(
        referrer_id=user.referred_by, referred_id=user.id
    ).first()
    if not referral:
        referral = Referral(
            referrer_id=user.referred_by, referred_id=user.id,
            subscription_confirmed=True, bonus_granted=False,
        )
        db.session.add(referral)
    if not force and (user.referral_bonus_used or referral.bonus_granted):
        return 0
    referral.subscription_confirmed = True
    referral.bonus_granted = True
    user.referral_bonus_used = True
    credited = credit_multilevel_commissions(user, plan_name, plan_amount, payment_id)
    return credited


def _is_in_affiliate_downline(user, root_id, max_levels=5):
    current = user
    for _ in range(max_levels + 1):
        if not current:
            return False
        if current.id == root_id:
            return True
        current = db.session.get(User, current.referred_by) if current.referred_by else None
    return False


def reconcile_affiliates(target_email=None):
    """Reconcilia a última assinatura paga das redes, sem duplicar comissões."""
    settings = load_settings()
    stats = {'users_scanned': 0, 'users_skipped': 0, 'lines_created': 0}
    if settings.get('affiliation_disabled_all', False):
        return stats
    root = User.query.filter_by(email=target_email).first() if target_email else None
    query = User.query.filter(User.referred_by.isnot(None), User.is_paid.is_(True))
    for user in query.yield_per(200):
        if root and not _is_in_affiliate_downline(user, root.id, 5):
            continue
        stats['users_scanned'] += 1
        if user.affiliation_disabled:
            stats['users_skipped'] += 1
            continue
        payment = Payment.query.filter_by(user_id=user.id, status='paid').order_by(
            Payment.paid_at.desc(), Payment.id.desc()
        ).first()
        if not payment or not payment.amount or payment.amount <= 0:
            stats['users_skipped'] += 1
            continue
        plan_name = f'Plano {payment.plan_type or "Pago"}'
        created = grant_referral_commissions(
            user, plan_name, payment.amount, payment.id, force=True
        )
        stats['lines_created'] += created
    db.session.commit()
    return stats


# ─── End Affiliate Models ──────────────────────────────────────────────────

DEFAULT_TERMS = {
    'termos': {
        'title': 'Termos de Uso',
        'content': '''<h3>1. Aceitação dos Termos</h3>
<p>Ao criar uma conta no Troço Play, você declara ter lido, compreendido e aceitado todos os Termos de Uso aqui descritos.</p>
<h3>2. Serviço</h3>
<p>O Troço Play é uma plataforma de streaming que oferece canais ao vivo e conteúdo sob demanda. O serviço é fornecido "como está" e pode sofrer alterações sem aviso prévio.</p>
<h3>3. Conta do Usuário</h3>
<p>Você é responsável por manter a confidencialidade de sua senha e por todas as atividades que ocorrem em sua conta. O compartilhamento de conta é proibido e pode resultar em suspensão.</p>
<h3>4. Uso Permitido</h3>
<p>O serviço destina-se apenas para uso pessoal e não comercial. É proibido revender, redistribuir ou transmitir o conteúdo a terceiros.</p>
<h3>5. Limitação de Responsabilidade</h3>
<p>O Troço Play não se responsabiliza por interrupções, atrasos ou falhas no serviço causadas por terceiros, problemas de rede ou manutenção.</p>
<h3>6. Dispositivo Simultâneo</h3>
<p>Cada conta permite reprodução em apenas 1 dispositivo por vez. Tentativas de uso simultâneo resultarão em bloqueio automático.</p>'''
    },
    'privacidade': {
        'title': 'Política de Privacidade',
        'content': '''<h3>1. Coleta de Dados</h3>
<p>Coletamos informações como e-mail, endereço IP, tipo de dispositivo e dados de navegação para melhorar sua experiência na plataforma.</p>
<h3>2. Uso dos Dados</h3>
<p>Seus dados são utilizados para: fornecer e manter o serviço, processar pagamentos, enviar notificações e melhorar a plataforma.</p>
<h3>3. Compartilhamento</h3>
<p>Não compartilhamos seus dados pessoais com terceiros, exceto quando necessário para processamento de pagamentos ou cumprimento de obrigações legais.</p>
<h3>4. Segurança</h3>
<p>Adotamos medidas de segurança para proteger seus dados contra acesso não autorizado, alteração ou destruição.</p>
<h3>5. Cookies</h3>
<p>Utilizamos cookies e tecnologias similares para manter sua sessão ativa e melhorar a navegação.</p>
<h3>6. Seus Direitos</h3>
<p>Você pode solicitar a exclusão de seus dados a qualquer momento entrando em contato com nosso suporte.</p>'''
    },
    'servico': {
        'title': 'Termos de Serviço',
        'content': '''<h3>1. Assinatura</h3>
<p>Ao assinar um plano, você concorda com a cobrança do valor especificado. O acesso é concedido imediatamente após a confirmação do pagamento.</p>
<h3>2. Planos</h3>
<p>Os planos disponíveis possuem períodos de acesso definidos (mensal, trimestral, semestral ou anual). O não pagamento resultará na suspensão do acesso premium.</p>
<h3>3. Cancelamento</h3>
<p>Você pode cancelar sua assinatura a qualquer momento. O cancelamento não resulta em reembolso do período já pago.</p>
<h3>4. Reembolsos</h3>
<p>Não oferecemos reembolso após a ativação do plano, exceto em casos de falha comprovada do serviço por período superior a 48 horas consecutivas.</p>
<h3>5. Suspensão</h3>
<p>O Troço Play reserva-se o direito de suspender ou cancelar contas que violem estes Termos de Serviço, sem aviso prévio e sem direito a reembolso.</p>
<h3>6. Alterações nos Planos</h3>
<p>Os preços e condições dos planos podem ser alterados a qualquer momento. Usuários com planos ativos mantêm as condições vigentes no momento da assinatura.</p>'''
    },
    'afiliados': {
        'title': 'Programa de Afiliados',
        'content': '''<h3>1. Programa de Afiliados</h3>
<p>O Programa de Afiliados do Troço Play permite que usuarios convidem amigos e ganhem comissoes em dinheiro por cada indicacao que resultar em assinatura, em ate 5 niveis de profundidade.</p>
<h3>2. Como Funciona</h3>
<p>Ao compartilhar seu link de indicacao, voce ganha uma comissao em reais (creditada na sua carteira) para cada amigo que assinar qualquer plano pago na sua rede.</p>
<h3>3. Niveis de Indicacao</h3>
<ul>
<li><strong>Nivel 1:</strong> 50% do valor do plano (indicacao direta).</li>
<li><strong>Nivel 2:</strong> 5% do valor do plano (indicacao da sua indicacao).</li>
<li><strong>Niveis 3, 4 e 5:</strong> 1% do valor do plano cada (geracoes mais profundas da sua rede).</li>
<li>As comissoes ficam disponiveis apos a confirmacao do pagamento do indicado.</li>
</ul>
<h3>4. Saques</h3>
<ul>
<li>O afiliado pode solicitar saque do saldo disponivel a qualquer momento (minimo de R$ 0,01).</li>
<li>O saque e realizado via PIX.</li>
<li>O administrador analisa e aprova ou recusa a solicitacao de saque.</li>
</ul>
<h3>5. Regras</h3>
<ul>
<li>Cada novo usuario pode ser indicado por uma pessoa.</li>
<li>A comissao e creditada apenas apos a confirmacao do pagamento do indicado.</li>
<li>Nao ha limite de indicacoes por usuario.</li>
<li>O saldo disponivel e atualizado automaticamente.</li>
<li>O programa esta sujeito a alteracoes. Abuso do sistema (auto-indicacao, contas falsas) resultara em banimento permanente.</li>
</ul>'''
    }
}


def get_terms_data():
    try:
        terms = Term.query.all()
        if not terms:
            return DEFAULT_TERMS
        return {t.slug: {'title': t.title, 'content': t.content, 'version': t.version, 'updated_at': t.updated_at} for t in terms}
    except Exception:
        return DEFAULT_TERMS


@app.context_processor
def inject_terms():
    return {'terms_data': get_terms_data()}

@app.context_processor
def inject_social_links():
    try:
        links = SocialLink.query.filter_by(is_active=True).order_by(SocialLink.sort_order).all()
        return {'social_links': links}
    except Exception:
        return {'social_links': []}


@app.context_processor
def inject_app_info():
    settings = load_settings()
    return {
        'app_version': settings.get('app_version', '1.0'),
        'app_release_date': settings.get('app_release_date', ''),
    }


@app.context_processor
def inject_support_whatsapp():
    return {'support_whatsapp': load_settings().get('support_whatsapp', SUPPORT_WHATSAPP)}


@app.context_processor
def inject_payment_whatsapp():
    return {'payment_whatsapp': load_settings().get('payment_whatsapp', SUPPORT_WHATSAPP)}


@login_manager.user_loader
def load_user(user_id):
    return db.session.get(User, int(user_id))


@app.before_request
def restore_persistent_session():
    if current_user.is_authenticated:
        if not session.get('persistent_token'):
            token = create_session_token(current_user.id, remember_me=True,
                                         ip=get_client_ip(),
                                         ua=request.headers.get('User-Agent', '')[:500])
            session['persistent_token'] = token
        return
    token = session.get('persistent_token')
    if token:
        user = validate_session_token(token)
        if user:
            login_user(user, remember=True)


@app.before_request
def auto_expire_subscriptions():
    """Reseta is_paid quando paid_until ja expirou e envia notificacoes
    de expiracao e lembrete para o usuario logado."""
    if current_user.is_authenticated and not current_user.is_admin:
        if current_user.is_paid and not current_user.is_premium_active:
            # ── Assinatura expirou agora ──
            plan_hist = PlanHistory.query.filter_by(
                user_id=current_user.id, status='active'
            ).order_by(PlanHistory.expires_at.desc()).first()
            plan_name = plan_hist.plan_name if plan_hist else 'Premium'
            current_user.is_paid = False
            if plan_hist:
                plan_hist.status = 'expired'
            try:
                db.session.commit()
            except Exception:
                db.session.rollback()
            try:
                notify_subscription_expired(current_user, plan_name)
            except Exception:
                pass
        elif current_user.is_paid and current_user.is_premium_active:
            # ── Lembrete: vencimento proximo ──
            now_br = datetime.now(BRASILIA_TZ)
            pu = current_user.paid_until
            if pu:
                if pu.tzinfo is None:
                    pu = pu.replace(tzinfo=BRASILIA_TZ)
                days_left = (pu - now_br).days
                if 0 < days_left <= 3:
                    plan_hist = PlanHistory.query.filter_by(
                        user_id=current_user.id, status='active'
                    ).order_by(PlanHistory.expires_at.desc()).first()
                    plan_name = plan_hist.plan_name if plan_hist else 'Premium'
                    try:
                        notify_expiring_soon(current_user, plan_name, pu, days_left)
                    except Exception:
                        pass


@app.after_request
def add_security_headers(response):
    if 'Server' in response.headers:
        del response.headers['Server']
    response.headers['Server'] = 'TrocoPlay'
    response.headers['X-Frame-Options'] = 'SAMEORIGIN'
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-XSS-Protection'] = '1; mode=block'
    response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
    response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=(), payment=()'
    response.headers['Cross-Origin-Opener-Policy'] = 'same-origin-allow-popups'
    if request.is_secure or os.environ.get('HTTPS_ENABLED', '').lower() == 'true':
        response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
    # Caching agressivo para performance
    path = request.path or ''
    method = request.method
    if path.startswith('/static/'):
        # 1.3 - Cache por tipo de arquivo estatico.
        # Imagens mudam raramente e dominam o trafego (logos): 30 dias.
        # CSS/JS tem hash estavel mas podem ser atualizados: 7 dias.
        _lower = path.lower()
        _img_exts = ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.avif')
        _asset_exts = ('.css', '.js', '.mjs', '.woff', '.woff2', '.ttf', '.eot')
        if _lower.endswith(_img_exts):
            response.headers['Cache-Control'] = 'public, max-age=2592000, immutable'  # 30 dias
        elif _lower.endswith(_asset_exts):
            response.headers['Cache-Control'] = 'public, max-age=604800, immutable'    # 7 dias
        else:
            response.headers['Cache-Control'] = 'public, max-age=604800, immutable'
        response.headers['ETag'] = ''
    elif path.startswith('/admin'):
        response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
    elif path.startswith('/api/proxy') or 'stream' in path.lower():
        response.headers['Cache-Control'] = 'no-store, no-cache'
    elif path.startswith('/api/') and method == 'GET':
        response.headers['Cache-Control'] = 'private, max-age=30, stale-while-revalidate=60'
    elif path.startswith('/tv/') and method == 'GET':
        response.headers['Cache-Control'] = 'private, max-age=30, stale-while-revalidate=60'
    elif method == 'GET' and response.status_code == 200:
        # Paginas publicas (landing, login, register): cache 5 min
        if path in ('/', '/login', '/register', '/landing', '/download'):
            response.headers['Cache-Control'] = 'public, max-age=300, stale-while-revalidate=600'
        # Paginas de conteudo (canais, filmes, series): cache 30s
        elif any(path.startswith(p) for p in ('/canais', '/filmes', '/series', '/esportes', '/favoritos', '/historico', '/planos', '/suporte', '/sugestoes', '/afiliados', '/conta', '/filme/')):
            response.headers['Cache-Control'] = 'private, max-age=30, stale-while-revalidate=60'
        # Pagina de download: cache 1 hora
        elif '/download' in path:
            response.headers['Cache-Control'] = 'public, max-age=3600, stale-while-revalidate=7200'
    return response


_MAINTENANCE_EXEMPT_ENDPOINTS = {
    'admin_panel', 'admin_login', 'admin_maintenance', 'static',
    'login', 'register', 'landing', 'forgot_password', 'reset_password',
    'api_proxy_stream', 'api_channel_health', 'api_online_viewers',
    'api_stream_heartbeat', 'api_stream_stop', 'api_logo_proxy',
    'api_notifications', 'api_terms_accept',
    'download', 'download_app', 'serve_apk',
    'page_not_found', 'not_found', 'internal_error', 'forbidden',
    'payment_webhook', 'cakto_webhook',
    'robots_txt', 'sitemap_xml', 'health',
    # A aba Status precisa funcionar durante a manutencao (e ela que diz o que
    # esta indisponivel).
    'status', 'api_status',
}

def _status_maintenance_state(settings, platform):
    """Resumo do que a manutencao atual alcanca (para exibir, nao para bloquear)."""
    enabled = bool(settings.get('maintenance_enabled'))
    sections = set(settings.get('maintenance_sections') or [])
    category = (settings.get('maintenance_category') or 'all')
    if not sections and category != 'all':
        sections = set(_MAINTENANCE_CATEGORY_SECTIONS.get(category, set()))
    pages = [p for p in (settings.get('maintenance_pages') or []) if p]
    platforms = settings.get('maintenance_platforms') or []
    active = enabled
    if active:
        # Admin nunca e alcançado pela manutencao (check_maintenance libera antes).
        if current_user.is_authenticated and current_user.is_admin:
            active = False
    if active and not settings.get('maintenance_immediate', True):
        now = datetime.now(timezone.utc)
        for key, cmp_ in (('maintenance_start', lambda d, n: n < d),
                          ('maintenance_end', lambda d, n: n > d)):
            raw = settings.get(key, '')
            if raw:
                try:
                    when = datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
                except ValueError:
                    continue
                if cmp_(when, now):
                    active = False
    if enabled and platforms and platform not in platforms:
        active = False
    scope = 'global'
    if pages:
        scope = 'paginas'
    elif sections:
        scope = 'secoes'
    return {
        'enabled': enabled,
        'active_for_you': active,
        'message': settings.get('maintenance_message', '') or 'Sistema em manutencao.',
        'sections': sorted(sections),
        'pages': pages,
        'platforms': platforms,
        'scope': scope,
        'immediate': bool(settings.get('maintenance_immediate', True)),
        'start': settings.get('maintenance_start', ''),
        'end': settings.get('maintenance_end', ''),
    }


# Mapeamento endpoint → secao de manutencao
_MAINTENANCE_SECTION_MAP = {
    'index': 'canais',
    'dashboard': 'canais',
    'canais': 'canais',
    'player': 'canais',
    'live_m3u8': 'canais',
    'esportes': 'canais',
    'favoritos': 'canais',
    'favoritos_all': 'canais',
    'historico': 'canais',
    'api_canais_status': 'canais',
    'api_stream_start': 'canais',
    'filmes': 'filmes',
    'movie_detail': 'filmes',
    'videos': 'filmes',
    'series': 'series',
    'series_detail': 'series',
    'series_by_id': 'series',
    'tv_canais': 'tv_canais',
    'tv_player': 'tv_canais',
    'tv_favoritos': 'tv_canais',
    'tv_historico': 'tv_canais',
    'tv_conta': 'tv_canais',
    'tv_esportes': 'tv_canais',
    'tv_login': 'tv_canais',
    'api_tv_generate': 'tv_canais',
    'api_tv_authenticate': 'tv_canais',
    'api_tv_play': 'tv_canais',
    'tv_filmes': 'tv_filmes',
    'tv_movie_detail': 'tv_filmes',
    'tv_series': 'tv_series',
    'tv_series_detail': 'tv_series',
    'api_device_stream_start': 'app_mobile',
    'device_heartbeat': 'app_mobile',
    'device_stream_start': 'app_mobile',
    'device_stream_stop': 'app_mobile',
    'content_counts': 'app_mobile',
    'api_content_counts': 'app_mobile',
    'api_esportes': 'canais',
    'api_watched_ids': 'canais',
    'continue_watching': 'canais',
}

# Secoes que cada categoria do painel de manutencao abrange.
_MAINTENANCE_CATEGORY_SECTIONS = {
    'site': {'canais', 'filmes', 'series'},
    'app': {'app_mobile', 'app_tv', 'tv_canais', 'tv_filmes', 'tv_series'},
    'canais': {'canais', 'tv_canais', 'app_mobile', 'app_tv'},
    'filmes': {'filmes', 'tv_filmes', 'app_mobile', 'app_tv'},
    'series': {'series', 'tv_series', 'app_mobile', 'app_tv'},
    'esportes': {'canais', 'tv_canais', 'app_mobile', 'app_tv'},
}

# Nome de rota ('/canais' ou '/tv/canais') → endpoint, para o campo
# "Paginas" aceitar tanto chaves quanto caminhos digitados pelo admin.
_MAINTENANCE_PATH_TO_ENDPOINT = None


def _maintenance_page_keys(pages):
    global _MAINTENANCE_PATH_TO_ENDPOINT
    if _MAINTENANCE_PATH_TO_ENDPOINT is None:
        mapping = {}
        for rule in app.url_map.iter_rules():
            ep = getattr(rule, 'endpoint', None)
            if ep in ('static', 'admin_login'):
                continue
            mapping[rule.rule.rstrip('/') or '/'] = ep
        _MAINTENANCE_PATH_TO_ENDPOINT = mapping
    keys = set()
    for p in pages or []:
        p = (p or '').strip()
        if not p:
            continue
        if p.startswith('/'):
            ep = _MAINTENANCE_PATH_TO_ENDPOINT.get(p.rstrip('/') or '/')
            if ep:
                keys.add(ep)
        else:
            keys.add(p)
    return keys


def _maintenance_sections_for(ep):
    """Secoes as quais um endpoint pertence (site e espelhos TV/app)."""
    direct = _MAINTENANCE_SECTION_MAP.get(ep, '')
    sections = {direct} if direct else set()
    if not direct:
        if ep.startswith('tv_'):
            sections.add('tv_canais')
        elif ep.startswith('api_device') or ep in ('api_catalog', 'api_search'):
            sections.add('app_mobile')
    return sections


@app.before_request
def check_maintenance():
    # Rota inexistente (404) nunca mostra manutencao — mostra a pagina de erro.
    if request.url_rule is None:
        return
    ep = request.endpoint or ''
    # Admin e endpoints essenciais sempre passam
    if ep in _MAINTENANCE_EXEMPT_ENDPOINTS or ep.startswith('admin_'):
        return
    settings = load_settings()
    if not settings.get('maintenance_enabled'):
        return
    # ADMIN NUNCA VE MANUTENCAO: continua navegando e testando o site normal.
    # Usuario comum ve a pagina de manutencao (era ao contrario antes).
    try:
        if current_user.is_authenticated and current_user.is_admin:
            return
    except Exception:
        pass
    now = datetime.now(timezone.utc)
    # Se 'immediate' estiver ligado, ignora agendamento
    if not settings.get('maintenance_immediate', True):
        start_str = settings.get('maintenance_start', '')
        end_str = settings.get('maintenance_end', '')
        in_schedule = True
        if start_str:
            try:
                start_dt = datetime.fromisoformat(start_str).replace(tzinfo=timezone.utc)
                if now < start_dt:
                    in_schedule = False
            except Exception:
                pass
        if end_str:
            try:
                end_dt = datetime.fromisoformat(end_str).replace(tzinfo=timezone.utc)
                if now > end_dt:
                    in_schedule = False
            except Exception:
                pass
        if not in_schedule:
            return
    # Plataforma (site/app_mobile/app_tv/celular)
    platform = detect_platform(request.headers.get('User-Agent', ''))
    platforms = settings.get('maintenance_platforms') or []
    if platforms and platform not in platforms:
        return
    # Escopo: pages explicitas > secoes/categoria > global
    pages = _maintenance_page_keys(settings.get('maintenance_pages', []) or [])
    if pages:
        if ep not in pages:
            return
    else:
        sections = set(settings.get('maintenance_sections', []) or [])
        if not sections:
            category = settings.get('maintenance_category', 'all') or 'all'
            if category != 'all':
                sections = _MAINTENANCE_CATEGORY_SECTIONS.get(category, set())
        if sections:
            ep_sections = _maintenance_sections_for(ep)
            if not ep_sections:
                # endpoint fora das areas escolhidas: permanece liberado
                return
            if not (ep_sections & sections):
                return
        elif request.path.startswith('/api/'):
            # APIs de contabilidade/suporte seguem vivas na manutencao
            # global (senao a sessao do usuario quebra em silencioso).
            if ep in ('watch_heartbeat', 'watch_stop', 'consume_time',
                      'api_player_diag', 'device_stream_stop',
                      'device_heartbeat', 'device_stream_start'):
                return
    msg = settings.get('maintenance_message', 'Sistema em manutencao.')
    # API endpoints retornam JSON. Decide pelo CAMINHO (nem todo endpoint de
    # API tem prefixo api_ no nome da funcao: /api/content_counts e "content_counts").
    if (request.path.startswith('/api/') or request.path.startswith('/proxy')
            or ep.startswith('api_') or ep == 'live_m3u8' or ep == 'proxy_stream'):
        return jsonify({'maintenance': True, 'message': msg, 'platform': platform}), 503
    return render_template('maintenance.html', message=msg, platform=platform), 503


# ─── Admin Permissions ──────────────────────────────────────────────────
ADMIN_TABS = {
    'dashboard': 'Dashboard',
    'usuarios': 'Usuarios',
    'pagamentos': 'Pagamentos',
    'ranking': 'Ranking Afiliados',
    'horas_free': 'Horas Free',
    'canais': 'Canais',
    'm3u': 'Config M3U',
    'planos': 'Planos',
    'esportes': 'Esportes',
    'reports': 'Reportes',
    'suporte': 'Suporte',
    'notify': 'Notificacoes',
    'sugestoes': 'Sugestoes',
    'manutencao': 'Manutencao',
    'videos': 'Vídeos (intro)',
    'enviar_email': 'Enviar Email',
    'termos': 'Termos',
    'editar_termos': 'Editar Termos',
    'social': 'Redes Sociais',
    'backup': 'Backup',
    'cookies': 'Cookies',
    'admin_users': 'Gerenciar Admins',
    'afiliacao': 'Afiliacao (usuarios)',
    'assinantes': 'Assinantes',
    'email_history': 'Historico Emails',
    'conteudo_adulto': 'Conteúdo Adulto',
    'logs': 'Logs',
}

def has_admin_tab(tab_name):
    if not current_user.is_authenticated or not current_user.is_admin:
        return False
    if not current_user.admin_permissions or current_user.admin_permissions == 'all':
        return True
    try:
        perms = json.loads(current_user.admin_permissions)
        return tab_name in perms
    except (json.JSONDecodeError, TypeError):
        return False

def get_admin_permissions_list(user):
    if not user.admin_permissions or user.admin_permissions == 'all':
        return list(ADMIN_TABS.keys())
    try:
        return json.loads(user.admin_permissions)
    except (json.JSONDecodeError, TypeError):
        return []

# ─── Admin decorator ──────────────────────────────────────────────────────
ADMIN_ENDPOINT_TABS = {
    'admin_usuarios': 'usuarios',
    'give_premium': 'usuarios',
    'remove_premium': 'usuarios',
    'ban_user': 'usuarios',
    'unban_user': 'usuarios',
    'deactivate_user': 'usuarios',
    'activate_user': 'usuarios',
    'adjust_hours': 'usuarios',
    'delete_user': 'usuarios',
    'make_admin': 'usuarios',
    'remove_admin': 'usuarios',
    'admin_edit_user': 'usuarios',
    'admin_user_history': 'usuarios',
    'admin_usuarios_ativos': 'usuarios',
    'admin_pagamentos': 'pagamentos',
    'admin_ranking': 'ranking',
    'admin_horas_free': 'horas_free',
    'admin_free_hours': 'horas_free',
    'admin_canais': 'canais',
    'admin_adult': 'conteudo_adulto',
    'admin_edit_canal': 'canais',
    'admin_delete_canal': 'canais',
    'toggle_hidden': 'canais',
    'admin_bulk_canais': 'canais',
    'admin_m3u': 'm3u',
    'admin_m3u_update': 'm3u',
    'admin_epg_refresh': 'm3u',
    'admin_m3u_upload': 'm3u',
    'admin_epg_upload': 'm3u',
    'admin_planos': 'planos',
    'admin_plan_add': 'planos',
    'admin_plan_edit': 'planos',
    'admin_plan_delete': 'planos',
    'admin_plan_toggle': 'planos',
    'admin_reports': 'reports',
    'admin_esportes': 'esportes',
    'admin_esportes_delete': 'esportes',
    'admin_esportes_delete_date': 'esportes',
    'admin_esportes_delete_all': 'esportes',
    'admin_esportes_edit': 'esportes',
    'admin_suporte': 'suporte',
    'admin_suporte_ticket': 'suporte',
    'admin_suporte_responder': 'suporte',
    'admin_suporte_status': 'suporte',
    'admin_sugestoes': 'sugestoes',
    'admin_sugestao_status': 'sugestoes',
    'admin_notify': 'notify',
    'admin_maintenance': 'manutencao',
    'admin_videos': 'videos',
    'admin_enviar_email': 'enviar_email',
    'admin_terms': 'termos',
    'admin_terms_edit_list': 'editar_termos',
    'admin_terms_edit': 'editar_termos',
    'admin_social': 'social',
    'admin_social_add': 'social',
    'admin_social_edit': 'social',
    'admin_social_delete': 'social',
    'admin_social_toggle': 'social',
    'admin_backup': 'backup',
    'admin_backup_download': 'backup',
    'admin_backup_delete': 'backup',
    'admin_assinantes': 'assinantes',
    'admin_email_history': 'email_history',
    'admin_cookies': 'cookies',
    'make_admin': 'admin_users',
    'remove_admin': 'admin_users',
    'update_admin_permissions': 'admin_users',
    'toggle_all_affiliation': 'afiliacao',
    'toggle_user_affiliation': 'afiliacao',
    'admin_afiliados_config': 'afiliacao',
    'admin_afiliados_saques': 'afiliacao',
    'admin_aprovar_saque': 'afiliacao',
    'admin_recusar_saque': 'afiliacao',
    'admin_afiliados_historico_saques': 'afiliacao',
    'admin_user_affiliate_config': 'afiliacao',
    'admin_credit_commissions': 'afiliacao',
    'admin_afiliados_gestao': 'afiliacao',
    'admin_afiliados_ajustar_saldo': 'afiliacao',
    'admin_afiliados_aprovar_indicado': 'afiliacao',
    'admin_afiliados_reconciliar': 'afiliacao',
    'bulk_remove_free_hours': 'admin_users',
    'admin_export_emails': 'dashboard',
    'admin_reset_views': 'dashboard',
    'admin_reset_stats': 'dashboard',
    'admin_esportes_export': 'esportes',
    'admin_email_detail': 'email_history',
    'admin_update_app': 'dashboard',
    'admin_whatsapp': 'dashboard',
    'admin_cakto': 'dashboard',
    'admin_cakto_test': 'dashboard',
    'admin_logs': 'logs',
}

def admin_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if not current_user.is_authenticated or not current_user.is_admin:
            flash('Acesso restrito.', 'error')
            return redirect(url_for('admin_login'))
        endpoint = request.endpoint
        if endpoint in ADMIN_ENDPOINT_TABS:
            required_tab = ADMIN_ENDPOINT_TABS[endpoint]
            if not has_admin_tab(required_tab):
                flash('Voce nao tem permissao para acessar esta pagina.', 'error')
                return redirect(url_for('admin_panel'))
        return f(*args, **kwargs)
    return decorated


# ─── Fast M3U Parser (line-by-line, no full file load) ────────────────────
def is_catalog_placeholder(value):
    """Indica se um campo M3U contém apenas um marcador sem nome real."""
    import unicodedata
    text = html.unescape(str(value or '')).strip().casefold()
    text = ''.join(ch for ch in unicodedata.normalize('NFKD', text) if not unicodedata.combining(ch))
    text = re.sub(r'[^a-z0-9]+', ' ', text).strip()
    return text in {'sem titulo', 'sem nome', 'unknown', 'undefined', 'null', 'none', 'n a', 'na', 'tbd'}


def clean_catalog_title(value, *, preserve_leading_hash=False, fallback='Sem título'):
    """Limpa títulos M3U para exibição, sem marcadores ou placeholders isolados."""
    value = html.unescape(str(value or ''))
    value = value.replace('#', '')
    value = value.replace('\ufeff', '').replace('\r', ' ').replace('\n', ' ')
    value = re.sub(r'[\x00-\x1f\x7f]', ' ', value)
    value = re.sub(r'\s+', ' ', value).strip(' \t\r\n|,;')
    value = re.sub(r'\(([^()]{1,12})\)(?=[A-Za-zÀ-ÿ])', r'\1', value)
    value = re.sub(r'(?<=[A-Za-zÀ-ÿ])\(([^()]{1,12})\)(?=[A-Za-zÀ-ÿ])', r'\1', value)
    value = re.sub(r'\s+', ' ', value).strip()
    if not preserve_leading_hash:
        value = re.sub(r'^#+\s*', '', value).strip()
    value = re.sub(r'^["“«]([^"”»]+)["”»]\s+', r'\1 ', value).strip()
    value = re.sub(r'^["“”«»]+\s*|\s*["“”«»]+$', '', value).strip()
    value = re.sub(r'^[-–—]+\s*', '', value).strip()
    value = re.sub(r'^(?:\.\.\.|…)+\s*', '', value).strip()
    value = re.sub(r'^\[([^\]]+)\]\s*', r'\1 ', value).strip()
    value = re.sub(r'^\(([^()]*)\)\s*', r'\1 ', value).strip()
    return value or fallback


def dedupe_repeated_title(value):
    value = clean_catalog_title(value, fallback='')
    if not value or is_catalog_placeholder(value):
        return ''
    words = value.split()
    if len(words) >= 4 and len(words) % 2 == 0:
        half = len(words) // 2
        if ' '.join(words[:half]).casefold() == ' '.join(words[half:]).casefold():
            return ' '.join(words[:half])
    return value


def dedupe_title_parts(*values):
    parts = []
    seen = set()
    for raw in values:
        title = dedupe_repeated_title(raw)
        key = title.casefold()
        if not title or key in seen:
            continue
        parts.append(title)
        seen.add(key)
    return ' '.join(parts)


def remove_placeholder_prefix(value):
    """Remove prefixos de placeholder gravados por versões antigas do parser."""
    cleaned = clean_catalog_title(value, fallback='')
    if not cleaned:
        return ''
    if is_catalog_placeholder(cleaned):
        return ''
    cleaned = re.sub(
        r'^(?:sem\s+t[ií]tulo|sem\s+nome)\b[\s:|._-]+',
        '', cleaned, flags=re.IGNORECASE,
    ).strip()
    return clean_catalog_title(cleaned, fallback='') if cleaned else ''


def _stream_path_type(stream_url):
    """Obtém o tipo explícito do conteúdo a partir do caminho da URL."""
    try:
        path = urllib.parse.urlparse(stream_url or '').path.lower()
    except Exception:
        path = str(stream_url or '').lower()
    if re.search(r'(^|/)live(/|$)', path):
        return 'live'
    if re.search(r'(^|/)(?:movie|movies|vod|film)(/|$)', path):
        return 'movie'
    if re.search(r'(^|/)series(/|$)', path):
        return 'series'
    # Canal ao vivo Xtream sem /live/: /usuario/senha/<id>[.ts|.m3u8]
    if re.search(r'^/[^/]+/[^/]+/(?:live/)?[0-9]+(?:\.(?:ts|m3u8|m3u))?/?$', path):
        return 'live'
    if path.endswith(('.ts', '.m3u8', '.m3u')):
        return 'live'
    return ''


def _decode_url_encoded(value):
    """Decodifica URL encoding (%20, %C3%A3, etc) se presente."""
    if not value or '%' not in value:
        return value
    decoded = urllib.parse.unquote(value)
    # So retorna se a decodificacao realmente mudou algo significativo
    if decoded != value:
        return decoded
    return value


def _catalog_signature():
    """Assinatura barata do catalogo: (linhas, maior id). So muda quando o M3U
    entra ou quando alguem apaga itens — os dois caminhos passam por
    repair_existing_catalog_metadata(), que grava esta assinatura depois de
    varrer. Custa um COUNT + um MAX, nao um scan de titulos."""
    linhas, maior = db.session.query(func.count(Canal.id), func.max(Canal.id)).one()
    return '%s:%s' % (linhas or 0, maior or 0)


def _catalog_ja_saneado():
    try:
        return load_settings().get('catalog_saneado') == _catalog_signature()
    except Exception:
        return False


def _marcar_catalogo_saneado():
    try:
        st = load_settings()
        st['catalog_saneado'] = _catalog_signature()
        save_settings(st)
    except Exception:
        pass


def _boot_sweeps_ja_rodados():
    try:
        return load_settings().get('catalog_sweeps') == _catalog_signature()
    except Exception:
        return False


def _marcar_boot_sweeps():
    try:
        st = load_settings()
        st['catalog_sweeps'] = _catalog_signature()
        save_settings(st)
    except Exception:
        pass


def repair_existing_catalog_metadata():
    """Repara títulos e tipos já gravados por versões antigas do importador."""
    changed = 0
    for item in Canal.query.yield_per(2000):
        old_name = item.name or ''
        # Decodificar URL encoding em nomes
        decoded_name = _decode_url_encoded(old_name)
        decoded_series = _decode_url_encoded(item.series_name or '')
        decoded_group = _decode_url_encoded(item.group_title or '')
        if decoded_name != old_name:
            item.name = decoded_name
            changed += 1
        if decoded_series and decoded_series != (item.series_name or ''):
            item.series_name = decoded_series
            changed += 1
        if decoded_group and decoded_group != (item.group_title or ''):
            item.group_title = decoded_group
            changed += 1
        clean_name = remove_placeholder_prefix(item.name or '')
        path_type = _stream_path_type(item.stream_url)
        if path_type == 'live':
            values = ('live', '', None, None)
        elif path_type == 'movie':
            values = ('movie', clean_name, None, None)
        elif path_type == 'series':
            values = _extract_vod_metadata(clean_name, '', item.stream_url, item.group_title or '')
        else:
            if item.tipo == 'series':
                values = ('series', remove_placeholder_prefix(item.series_name or '') or clean_name, item.season_number, item.episode_number)
            else:
                values = (item.tipo or 'live', item.series_name or '', item.season_number, item.episode_number)

        tipo, series_name, season_number, episode_number = values
        if tipo in ('movie', 'series') and not clean_name and not series_name:
            if not item.is_hidden:
                item.is_hidden = True
                changed += 1
            continue
        updates = {
            'name': clean_name or old_name,
            'tipo': tipo,
            'series_name': remove_placeholder_prefix(series_name or '') if tipo == 'series' else '',
            'season_number': season_number if tipo == 'series' else None,
            'episode_number': episode_number if tipo == 'series' else None,
        }
        for attr, value in updates.items():
            if getattr(item, attr) != value:
                setattr(item, attr, value)
                changed += 1
    if changed:
        db.session.commit()
    _marcar_catalogo_saneado()
    return changed


def repair_split_series_episodes():
    """Reagrupa episodios que viraram "series" separadas por causa do regex
    antigo de SxxExx (que so aceitava ate 3 digitos no episodio).

    Ex.: 'One Piece [L] 2025 S23E1166' estava gravado como uma serie propria
    com 1 temporada / 1 episodio; agora volta para 'One Piece [L] 2025'.
    """
    fixed = 0
    try:
        rows = db.session.execute(text(
            "SELECT id, name, series_name, group_title, stream_url, tipo, "
            "season_number, episode_number, description, logo FROM canal "
            "WHERE tipo = 'series'"
        )).fetchall()
    except Exception:
        return 0
    updates = []
    for r in rows:
        _id, name, series_name, group_title, stream_url, tipo, season, episode, desc, logo = r
        path_type = _stream_path_type(stream_url)
        if path_type != 'series':
            continue
        new_tipo, new_series, new_season, new_episode = _extract_vod_metadata(
            name or '', '', stream_url or '', group_title or ''
        )
        if new_tipo != 'series' or not new_series:
            continue
        if (new_series == (series_name or '')
                and new_season == season
                and new_episode == episode):
            continue
        updates.append({
            'id': _id,
            'series_name': remove_placeholder_prefix(new_series)[:290],
            'season_number': new_season,
            'episode_number': new_episode,
        })
    for upd in updates:
        try:
            db.session.execute(text(
                "UPDATE canal SET series_name = :sn, season_number = :se, episode_number = :ep "
                "WHERE id = :id"
            ), {'sn': upd['series_name'], 'se': upd['season_number'],
                'ep': upd['episode_number'], 'id': upd['id']})
            fixed += 1
        except Exception:
            pass
    if fixed:
        try:
            db.session.commit()
        except Exception:
            db.session.rollback()
            return 0
        print(f'[Migration] Series reagrupadas: {fixed} episodios que estavam como series separadas.', flush=True)
    return fixed


def extract_year_from_name(name):
    """Extrai ano de 4 digitos do nome (ex: 'Filme (2023)', 'Serie [2021]')."""
    m = re.search(r'[\(\[]?(19|20)\d{2}[\)\]]?', str(name or ''))
    if m:
        yr = int(m.group(0).strip('()[]'))
        if 1900 <= yr <= 2099:
            return yr
    return None


def extract_rating_from_desc(desc):
    """Tenta extrair rating numerico de uma descricao."""
    if not desc:
        return None
    m = re.search(r'(?:(?:rating|nota|avaliacao)[:\s]*)?(\d[.,]\d)\s*/\s*10', str(desc), re.IGNORECASE)
    if m:
        try:
            return float(m.group(1).replace(',', '.'))
        except Exception:
            pass
    return None


# Padrões SxxExx / NxNN. O episódio aceita 1-5 dígitos e a temporada 1-3:
# animes e novelas longas usam S23E1166, S01E1027 (4 dígitos). Limitar a 3
# dígitos fazia esses episódios virarem "séries" separadas (cada um com
# 1 temporada / 1 episódio) em vez de entrarem na série correta.
_EPISODE_PATTERNS = (
    r'(?i)\bS(?:EASON)?\s*0*(\d{1,3})\s*(?:E|EP|EPISODE|EPIS[ÓO]DIO)\s*0*(\d{1,5})\b',
    r'(?i)\b(\d{1,3})\s*[xX]\s*(\d{1,5})\b',
    r'(?i)\bT(?:EMP(?:ORADA)?)?\s*0*(\d{1,3})\s*[-_ ]?\s*(?:EP?|EPIS[ÓO]DIO)\s*0*(\d{1,5})\b',
)


def _extract_vod_metadata(name, tvg_name, stream_url, group_title):
    """Classifica uma entrada M3U e extrai série/temporada/episódio.

    A ÚNICA fonte confiável é o caminho da URL:
      /series/  → série
      /movie/   → filme
      /live/    → canal ao vivo
      .ts/.m3u8/.m3u sem /series/ ou /movie/ → ao vivo
      numeral sozinho no path (ex: /user/pass/123456.mp4) → ao vivo
    O group_title NUNCA deve determinar o tipo — só serve pro nome da categoria.
    """
    source = dedupe_title_parts(tvg_name, name)
    try:
        stream_path = urllib.parse.urlparse(stream_url).path.lower()
    except Exception:
        stream_path = str(stream_url or '').lower()

    # ── 1) URL path: /series/ → série ──
    if re.search(r'(^|/)series(/|$)', stream_path):
        patterns = _EPISODE_PATTERNS
        for pattern in patterns:
            match = re.search(pattern, source)
            if match:
                series_name = source[:match.start()].strip(' ._-|') or source
                return 'series', series_name, int(match.group(1)), int(match.group(2))
        series_name_src = tvg_name.strip() if tvg_name and tvg_name.strip() else source
        return 'series', series_name_src, None, None

    # ── 2) URL path: /movie/ | /movies/ | /vod/ | /film/ → filme ──
    if re.search(r'(^|/)(?:movie|movies|vod|film)(/|$)', stream_path):
        return 'movie', source, None, None

    # ── 3) URL path: /live/ → ao vivo ──
    if re.search(r'(^|/)live(/|$)', stream_path):
        return 'live', '', None, None

    # ── 4) Extensão de streaming ao vivo (.ts/.m3u8/.m3u) SEM /series/ ou /movie/ → ao vivo ──
    if stream_path.endswith(('.ts', '.m3u8', '.m3u')):
        return 'live', '', None, None

    # ── 5) Xtream ID numérico no path sem /series/ ou /movie/ → ao vivo ──
    #    Formato: /user/pass/123456.mp4 (sem /series/ ou /movie/)
    if re.search(r'/[^/]+/[^/]+/\d+(?:\.[a-z0-9]+)?/?$', stream_path):
        return 'live', '', None, None

    # ── 6) Padrão SxxExx no nome (sem URL clara) → série ──
    for pattern in _EPISODE_PATTERNS:
        match = re.search(pattern, source)
        if match:
            series_name = source[:match.start()].strip(' ._-|') or source
            return 'series', series_name, int(match.group(1)), int(match.group(2))

    # ── 7) Tudo mais: se tem URL mas não bateu nenhum padrão → ao vivo ──
    #    (URLs de canais ao vivo que não seguem padrão Xtream)
    return 'live', '', None, None


def parse_m3u_fast(filepath):
    """Parser M3U incremental, com suporte a HTTPS e cabeçalhos por item."""
    attr_re = re.compile(r'''([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s,]+))''')
    with open(filepath, 'r', encoding='utf-8-sig', errors='replace') as f:
        extinf_line = None
        pending_referrer = ''
        pending_user_agent = ''
        pending_group_title = ''
        for raw_line in f:
            line = raw_line.strip().lstrip('\ufeff')
            if not line:
                continue
            upper = line.upper()
            if upper.startswith('#EXTINF:'):
                extinf_line = line
                pending_referrer = ''
                pending_user_agent = ''
                pending_group_title = ''
                continue
            if upper.startswith('#EXTGRP:'):
                pending_group_title = line.split(':', 1)[1].strip()
                continue
            if upper.startswith('#EXTVLCOPT:'):
                option = line.split(':', 1)[1]
                key, _, value = option.partition('=')
                value = value.strip().strip('"')
                key = key.strip().lower()
                if key in ('http-referrer', 'http-referer', 'referer', 'referrer'):
                    pending_referrer = value
                elif key in ('http-user-agent', 'user-agent', 'user_agent'):
                    pending_user_agent = value
                continue
            if line.startswith('#'):
                continue
            if not re.match(r'(?i)^https?://', line):
                continue
            # Listas sem #EXTINF (e/ou sem cabecalho) tambem sao validas:
            # o nome e derivado da propria URL.
            _sem_extinf = not extinf_line

            stream_url, separator, options = line.partition('|')
            referrer = pending_referrer
            user_agent = pending_user_agent
            if separator:
                for pair in options.split('&'):
                    key, _, value = pair.partition('=')
                    key = key.strip().lower()
                    value = urllib.parse.unquote(value.strip())
                    if key in ('http-referrer', 'http-referer', 'referer', 'referrer'):
                        referrer = value
                    elif key in ('http-user-agent', 'user-agent', 'user_agent'):
                        user_agent = value

            attrs = {}
            raw_name = ''
            tvg_name = ''
            if extinf_line:
                attrs = {match.group(1).lower(): next((value for value in match.groups()[1:] if value is not None), '') for match in attr_re.finditer(extinf_line)}
                comma_pos = extinf_line.rfind(',')
                raw_name = extinf_line[comma_pos + 1:].strip() if comma_pos >= 0 else ''
                tvg_name = attrs.get('tvg-name', '').strip()
                # Decodificar URL encoding nos nomes (ex: %20 -> espaco)
                raw_name = urllib.parse.unquote(raw_name)
                tvg_name = urllib.parse.unquote(tvg_name)
            # ``Sem título`` costuma ser enviado em tvg-name enquanto o nome
            # real vem depois da vírgula. O placeholder nunca deve ser
            # concatenado ao título real.
            name = dedupe_title_parts(raw_name, tvg_name)
            if _sem_extinf and not name:
                _base_name = urllib.parse.unquote((urllib.parse.urlparse(stream_url).path or '').rsplit('/', 1)[-1])
                _base_name = re.sub(r'\.[A-Za-z0-9]{1,5}$', '', _base_name)
                _base_name = re.sub(r'[_+]+', ' ', _base_name).strip()
                name = _base_name or 'Canal'

            tvg_id = urllib.parse.unquote(attrs.get('tvg-id', '').strip())
            # Suporte a multiplos formatos de logo (tvg-logo, logo, tvg-thumb, poster, art)
            tvg_logo = ''
            for logo_key in ('tvg-logo', 'logo', 'tvg-thumb', 'tvg-icon', 'poster', 'art', 'banner'):
                tvg_logo = urllib.parse.unquote((attrs.get(logo_key) or '').strip())
                if tvg_logo:
                    break
            # Sanitizar logo: remover filenames solos (manter http e https)
            if tvg_logo and not tvg_logo.startswith('http'):
                tvg_logo = ''
            group_title = urllib.parse.unquote((attrs.get('group-title') or pending_group_title or '').strip())
            had_source_group = bool(group_title)
            if not group_title:
                # Algumas listas omitem group-title e usam [tag] no nome,
                # sobretudo nos canais 24h. Em VOD não inventamos gênero.
                tag_match = re.match(r'^\s*\[([^]]+)\]', raw_name)
                if tag_match:
                    tag = tag_match.group(1).strip()
                    # [24h] é um marcador de canais em listas Xtream
                    # que usam URL curta sem /live/.
                    if tag.lower().replace(' ', '') in {'24h', '24horas'}:
                        group_title = '24H'
                    elif re.search(r'(?i)(^|/)live(/|$)', urllib.parse.urlparse(stream_url).path):
                        group_title = tag
                group_title = group_title or 'OUTROS'

            tipo, series_name, season_number, episode_number = _extract_vod_metadata(
                name, tvg_name, stream_url, group_title
            )
            # Preserva a categoria real vinda do M3U (group-title). So usa
            # o generico quando a fonte nao forneceu nenhum grupo.
            if not had_source_group and group_title == 'OUTROS':
                group_title = {'live': 'CANAIS', 'movie': 'FILMES', 'series': 'SÉRIES'}.get(tipo, 'OUTROS')
            if tipo == 'series':
                name = remove_placeholder_prefix(name)
                series_name = remove_placeholder_prefix(series_name)
            elif series_name:
                series_name = dedupe_repeated_title(series_name)
            # Conteúdo VOD sem qualquer identificação não é navegável. É
            # melhor ignorá-lo do que criar uma série chamada “Sem título”.
            if tipo in ('movie', 'series') and not (series_name if tipo == 'series' else name):
                extinf_line = None
                pending_referrer = ''
                pending_user_agent = ''
                continue
            if not name:
                name = clean_catalog_title(tvg_id, fallback='Canal sem nome')

            # Extrair descricao de atributos M3U (tvg-desc, desc, plot, etc)
            desc = ''
            for desc_key in ('tvg-desc', 'desc', 'plot', 'description', 'tvg-description', 'tvg-plot'):
                desc = urllib.parse.unquote((attrs.get(desc_key) or '').strip())
                if desc:
                    break
            yield {
                'name': name,
                'stream_url': stream_url,
                'logo': tvg_logo,
                'group_title': group_title,
                'tvg_id': tvg_id,
                'tipo': tipo,
                'series_name': series_name,
                'season_number': season_number,
                'episode_number': episode_number,
                'stream_referrer': referrer,
                'stream_user_agent': user_agent,
                'description': desc,
            }
            extinf_line = None
            pending_referrer = ''
            pending_user_agent = ''


def _validate_m3u_file(filepath, minimum=None):
    """Valida uma lista antes de permitir que ela substitua o catálogo.

    Regras flexíveis de propósito: muitas fontes entregam a lista sem o
    cabeçalho ``#EXTM3U`` e algumas trazem apenas as URLs. O que não pode
    passar é resposta HTML/JSON (página de erro do provedor), arquivo vazio
    ou lista truncada — isso apagaria o catálogo bom.
    """
    extinf_count = 0
    url_count = 0
    has_header = False
    head_lines = []
    with open(filepath, 'r', encoding='utf-8-sig', errors='replace') as f:
        for index, raw_line in enumerate(f):
            line = raw_line.strip()
            if not line:
                continue
            if len(head_lines) < 30:
                head_lines.append(line[:200])
            upper = line.upper()
            if index < 60 and upper.startswith('#EXTM3U'):
                has_header = True
            if upper.startswith('#EXTINF:'):
                extinf_count += 1
            elif re.match(r'(?i)^https?://', line):
                url_count += 1
    head = '\n'.join(head_lines).lower()
    if '<html' in head or '<!doctype html' in head or '<head' in head:
        raise ValueError('Resposta HTML recebida em vez da lista M3U (página de erro do provedor). Verifique a URL e as credenciais.')
    if head.startswith('{') or head.startswith('['):
        raise ValueError('Resposta JSON recebida em vez da lista M3U. Verifique a URL e as credenciais.')
    if minimum is None:
        minimum = max(1, int(os.environ.get('M3U_MIN_ENTRIES', '10')))
    if url_count == 0:
        raise ValueError('Lista M3U vazia: nenhuma URL encontrada no arquivo baixado.')
    if url_count < minimum:
        raise ValueError(f'Lista M3U incompleta: {url_count} URLs encontradas; mínimo esperado {minimum}')
    return {'entries': extinf_count, 'urls': url_count, 'has_header': has_header}


def import_m3u_full_sync(filepath):
    """Sincroniza o catalogo M3U com o banco de forma incremental.

    Quem continua na lista mantem o mesmo ``canal.id`` (logo favoritos,
    historico, EPG e relatorios seguem apontando para o canal certo).
    Insere apenas o que e novo, atualiza apenas o que mudou e remove
    apenas o que desapareceu da fonte.
    """
    import unicodedata as _ucd
    _pf = os.path.join(BASE_DIR, 'progress_m3u.json')

    def _write_prog(phase, pct, msg, **kw):
        try:
            data = {'running': True, 'phase': phase, 'message': msg,
                    'percent': int(pct), 'timestamp': datetime.now().isoformat()}
            data.update(kw)
            with open(_pf, 'w') as f:
                json.dump(data, f)
        except Exception:
            pass

    def _is_cancelled():
        try:
            if os.path.exists(_pf):
                with open(_pf, 'r') as f:
                    return json.load(f).get('cancelled', False)
        except Exception:
            pass
        return False

    try:
        _dialect_name = db.engine.dialect.name
    except Exception:
        _dialect_name = 'mysql'

    def _escape(v):
        # Escapa valor literal para SQL. MySQL usa barra invertida; SQLite
        # (usado no ambiente local) so entende aspas duplicadas. Sem essa
        # distincao, titulos com apostrofo (ex.: "Rock N' Roll") quebravam o
        # INSERT inteiro do lote.
        if v is None:
            return 'NULL'
        s = str(v)
        if _dialect_name == 'sqlite':
            return "'" + s.replace("'", "''") + "'"
        return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"

    # ── Fase 1: Parse completo da M3U e agrupar por tipo ──
    _write_prog('parse', 5, 'Baixando e parseando arquivo M3U...')
    all_entries = []
    seen_urls = set()
    for entry in parse_m3u_fast(filepath):
        url = entry.get('stream_url', '')
        if url in seen_urls:
            continue
        seen_urls.add(url)
        all_entries.append(entry)
    parsed = len(all_entries)

    if parsed < max(1, int(os.environ.get('M3U_MIN_ENTRIES', '10'))):
        raise ValueError(f'Lista M3U incompleta: {parsed} itens')

    print(f'[{datetime.now()}] M3U: {parsed} itens unicos parseados', flush=True)

    by_tipo = {'live': [], 'movie': [], 'series': []}
    for e in all_entries:
        t = e.get('tipo', 'live')
        if t not in by_tipo:
            t = 'live'
        e['tipo'] = t
        by_tipo[t].append(e)

    print(f'[{datetime.now()}] M3U: live={len(by_tipo["live"])}, movie={len(by_tipo["movie"])}, series={len(by_tipo["series"])}', flush=True)

    # ── Fase 2: Sincronizacao INCREMENTAL (upsert) ──
    # Antes, este trecho apagava o catalogo inteiro por tipo e reinseria,
    # trocando o id de TODOS os canais a cada atualizacao diaria. Como
    # favoritos, historico, relatorios, EPG e health guardam canal_id, o
    # vinculo se perdia (o remapeamento por stream_url falhava quando a
    # fonte mudava a URL). Agora quem continua na lista MANTEM o mesmo id:
    # entra so o novo, atualiza so o que mudou e sai so o que desapareceu.
    _FK_TABLES = ['watch_log', 'favorite', 'report', 'epg_program',
                  'channel_epg_mapping', 'channel_health']
    _TIPOS = ('live', 'movie', 'series')
    _TRUNC = {'name': 290, 'stream_url': 990, 'logo': 990, 'group_title': 190,
              'tvg_id': 190, 'series_name': 290, 'stream_referrer': 990,
              'stream_user_agent': 490}

    def _num(v):
        if v is None or v == '':
            return None
        try:
            return int(v)
        except (TypeError, ValueError):
            return None

    def _canon_name(v):
        # Mesma canonizacao usada por repair_existing_catalog_metadata(),
        # para que a assinatura do banco bata com a da lista e nao haja
        # UPDATE inutil todas as noites.
        decoded = _decode_url_encoded(v or '')
        cleaned = remove_placeholder_prefix(decoded)
        return cleaned or decoded

    def _canon_group(v):
        return _decode_url_encoded(v or '')

    def _chaves_conteudo(tipo, nome, series_name, season, episode, stream_url, tvg_id):
        """Identidades de um conteudo que sobrevivem quando o provedor
        reformata o titulo ("Breaking Bad S01E01 Piloto" vira "Breaking Bad -
        S01E01 - Piloto") ou rotaciona o link.

        Sem isto a linha parece nova: o sync apagava a antiga -- levando
        view_count, favoritos, historico e mapa de EPG junto -- e criava outra
        zerada, que e o "as views somem quando atualizo a lista"."""
        chaves = []
        serie = _canon_name(series_name or '') or _canon_name(nome or '')
        if tipo == 'series' and serie:
            chaves.append(('ep', serie.lower(), _num(season), _num(episode)))
        tid = (tvg_id or '').strip()
        if tid:
            chaves.append(('tvg', tipo, tid.lower()))
        sid = _extract_xtream_id_from_url(stream_url or '')
        if sid:
            chaves.append(('sid', tipo, sid))
        return chaves

    def _sig_other(name, logo, group, tvg, ref, ua):
        """Assinatura dos campos que mudam sem trocar a URL.

        A URL fica de fora porque ela so muda quando o provedor rotaciona o
        link (tratado em lote proprio) — assim as linhas com a mesma
        alteracao de metadados podem ser atualizadas num unico UPDATE.
        """
        parts = (
            str(name or '')[:_TRUNC['name']],
            str(logo or '')[:_TRUNC['logo']],
            str(group or '')[:_TRUNC['group_title']],
            str(tvg or '')[:_TRUNC['tvg_id']],
            str(ref or '')[:_TRUNC['stream_referrer']],
            str(ua or '')[:_TRUNC['stream_user_agent']],
        )
        # int de 64 bits ocupa ~4x menos memoria que o hexdigest (catalogo
        # grande guarda uma assinatura por linha).
        return int(hashlib.md5('\x1f'.join(parts).encode('utf-8', 'replace')).hexdigest()[:16], 16)

    _write_prog('import', 8, 'Comparando catalogo atual com a lista...')
    idx_url = {}      # stream_url -> (id, assinatura)
    idx_nome = {}     # (tipo, nome canonico) -> [(id, stream_url), ...]
    idx_chave = {}    # chave de identidade -> [id, ...] (so linhas com link fora da lista)
    ids_por_tipo = {t: [] for t in _TIPOS}
    # Streaming (yield_per): o catalogo pode ter centenas de milhares de
    # linhas e nao cabe confortavelmente em memoria num cPanel.
    scan = text(
        "SELECT id, name, stream_url, logo, group_title, tvg_id, tipo, "
        "stream_referrer, stream_user_agent, series_name, season_number, "
        "episode_number FROM canal "
        "WHERE tipo IN ('live','movie','series')"
    ).execution_options(yield_per=2000)
    for r in db.session.execute(scan):
        rid = r[0]
        rtipo = r[6] or 'live'
        if rtipo not in ids_por_tipo:
            continue
        ids_por_tipo[rtipo].append(rid)
        rname_c = _canon_name(r[1])
        rurl = r[2] or ''
        if rurl and rurl not in idx_url:
            idx_url[rurl] = (rid, _sig_other(rname_c, '' if rtipo == 'series' else r[3],
                                             _canon_group(r[4]),
                                             r[5], r[7], r[8]))
        # O indice por nome so serve para achar a linha quando a URL antiga
        # saiu da lista (provedor rotacionou o link). Guardar todas as linhas
        # aqui custaria dezenas de MB num catalogo grande, entao so entram as
        # que realmente podem precisar do casamento por nome.
        if rurl not in seen_urls:
            idx_nome.setdefault((rtipo, rname_c), []).append((rid, rurl))
            for k in _chaves_conteudo(rtipo, r[1], r[9], r[10], r[11], r[2], r[5]):
                idx_chave.setdefault(k, []).append(rid)
    total_idx = sum(len(v) for v in ids_por_tipo.values())

    # Ids que JA tem descricao gravada (evita UPDATE de texto longo a toa).
    # A consulta so roda se a lista realmente trouxer descricoes.
    ids_com_desc = set()
    if any(e.get('description') for e in all_entries):
        try:
            for r in db.session.execute(text(
                "SELECT id FROM canal WHERE tipo IN ('live','movie','series') "
                "AND description IS NOT NULL AND description <> ''"
            )).fetchall():
                ids_com_desc.add(r[0])
        except Exception:
            pass
    print(f'[{datetime.now()}] M3U: {total_idx} canais no banco; comparando com {parsed} itens da lista.', flush=True)

    # ── Fase 3: Classificacao em memoria (ainda sem tocar no banco) ──
    matched_ids = set()
    to_update = {}                        # id -> entrada com algum campo alterado
    to_insert = {t: [] for t in _TIPOS}   # entradas novas, por tipo
    pendentes = []
    for e in all_entries:
        url = e.get('stream_url', '')
        hit = idx_url.get(url)
        if hit is not None and hit[0] not in matched_ids:
            rid = hit[0]
            matched_ids.add(rid)
            # URL igual: so atualiza se algum metadado mudou.
            if hit[1] != _sig_other(_canon_name(e.get('name')),
                                    '' if e.get('tipo') == 'series' else e.get('logo'),
                                    _canon_group(e.get('group_title')), e.get('tvg_id'),
                                    e.get('stream_referrer'), e.get('stream_user_agent')):
                to_update[rid] = e
        else:
            pendentes.append(e)
    sobrando = []
    for e in pendentes:
        tipo = e.get('tipo', 'live')
        rid = None
        for cid, curl in idx_nome.get((tipo, _canon_name(e.get('name'))), ()):
            if cid in matched_ids:
                continue
            # idx_nome so contem linhas cuja URL antiga nao esta mais na
            # lista, entao nao ha risco de roubar um canal casado por URL.
            rid = cid
            break
        if rid is not None:
            matched_ids.add(rid)
            # Casou por nome: a URL antiga saiu da lista, entao a linha
            # precisa ser reescrita com a URL nova (mantendo o mesmo id).
            to_update[rid] = e
        else:
            sobrando.append(e)

    # 3o casamento: identidade do conteudo (serie+temporada+episodio, tvg-id,
    # numero do stream). So vale quando a chave e unica dos dois lados -- se
    # dois itens da lista reclamam a mesma linha, nao adivinhamos.
    if sobrando and idx_chave:
        pares = []
        uso = {}
        for e in sobrando:
            ks = _chaves_conteudo(e.get('tipo', 'live'), e.get('name'),
                                  e.get('series_name'), e.get('season_number'),
                                  e.get('episode_number'), e.get('stream_url'),
                                  e.get('tvg_id'))
            pares.append((e, ks))
            for k in ks:
                uso[k] = uso.get(k, 0) + 1
        ainda = []
        for e, ks in pares:
            tipo = e.get('tipo', 'live')
            rid = None
            for k in ks:
                candidatos = idx_chave.get(k)
                if not candidatos or len(candidatos) != 1 or uso.get(k) != 1:
                    continue
                if candidatos[0] in matched_ids:
                    continue
                rid = candidatos[0]
                break
            if rid is not None:
                matched_ids.add(rid)
                to_update[rid] = e
            else:
                ainda.append(e)
        sobrando = ainda
    for e in sobrando:
        to_insert[e.get('tipo', 'live')].append(e)
    del pendentes, sobrando

    # Removidos: so dos tipos que vieram na lista (se a lista nao trouxe
    # filmes, nenhum filme e apagado).
    removidos = []
    for t in _TIPOS:
        if not by_tipo[t]:
            continue
        for rid in ids_por_tipo[t]:
            if rid not in matched_ids:
                removidos.append(rid)

    # Trava de seguranca: uma lista truncada nao pode varrer o catalogo.
    _max_rem = float(os.environ.get('M3U_MAX_REMOVAL', '0.6') or 0.6)
    if total_idx and len(removidos) > total_idx * _max_rem:
        raise ValueError(
            f'Sync M3U abortado por seguranca: {len(removidos)} de {total_idx} '
            f'canais ({len(removidos) * 100 // total_idx}%) sairam da lista '
            f'(limite {int(_max_rem * 100)}%). Nada foi alterado. Se a reducao '
            f'for real, aumente M3U_MAX_REMOVAL.'
        )
    if total_idx and removidos and len(removidos) > total_idx * 0.10:
        print(f'[{datetime.now()}] M3U: ATENCAO - {len(removidos)} de {total_idx} '
              f'canais ({len(removidos) * 100 // total_idx}%) sairam da lista '
              f'numa unica sincronizacao. Confira a fonte se nao era esperado.',
              flush=True)

    # ── Fase 4: Aplicar alteracoes ──
    _now_str = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
    total_updated = 0
    total_inserted = 0
    total_removed = 0

    def _flush_updates(items):
        """Reescreve um lote de canais em UM UPDATE (CASE por campo).

        Agrupar por clausula SET nao funcionava: o nome/URL de cada canal e
        diferente, entao cada linha virava um UPDATE proprio. Com CASE, 100
        canais saem numa unica instrucao independentemente dos valores.

        Serie nao entra com a coluna `logo`: a linha do M3U traz a imagem daquele
        episodio, e reescreve-la toda noite era o que apagava a capa oficial
        gravada pelo enriquecimento Xtream -- o card do site voltava a mostrar
        "uma cena de um episodio qualquer".
        """
        com_logo = [p for p in items if (p[1].get('tipo') or 'live') != 'series']
        sem_logo = [p for p in items if (p[1].get('tipo') or 'live') == 'series']
        for bloco, com_coluna_logo in ((com_logo, True), (sem_logo, False)):
            for i in range(0, len(bloco), 100):
                chunk = bloco[i:i + 100]
                ids_sql = ','.join(str(rid) for rid, _ in chunk)

                def _case(column, getter):
                    whens = ' '.join(
                        f'WHEN {rid} THEN {_escape(getter(entry))}'
                        for rid, entry in chunk
                    )
                    return f'{column} = CASE id {whens} END'

                sets = [
                    _case('name', lambda e: _canon_name(e.get('name'))[:_TRUNC['name']]),
                    _case('stream_url', lambda e: (e.get('stream_url') or '')[:_TRUNC['stream_url']]),
                    _case('group_title', lambda e: _canon_group(e.get('group_title'))[:_TRUNC['group_title']]),
                    _case('tvg_id', lambda e: (e.get('tvg_id') or '')[:_TRUNC['tvg_id']]),
                    _case('stream_referrer', lambda e: (e.get('stream_referrer') or '')[:_TRUNC['stream_referrer']]),
                    _case('stream_user_agent', lambda e: (e.get('stream_user_agent') or '')[:_TRUNC['stream_user_agent']]),
                ]
                if com_coluna_logo:
                    sets.insert(1, _case('logo', lambda e: (e.get('logo') or '')[:_TRUNC['logo']]))
                try:
                    db.session.execute(text(f'UPDATE canal SET {", ".join(sets)} WHERE id IN ({ids_sql})'))
                except Exception as exc:
                    print(f'[{datetime.now()}] M3U: Erro update canal: {str(exc)[:300]}', flush=True)
        items.clear()

    def _flush_desc(items):
        for i in range(0, len(items), 500):
            chunk = items[i:i + 500]
            try:
                db.session.execute(
                    text("UPDATE canal SET description = :d WHERE id = :i "
                         "AND (description IS NULL OR description = '')"),
                    [{'d': d, 'i': cid} for cid, d in chunk]
                )
            except Exception as exc:
                print(f'[{datetime.now()}] M3U: Erro descricao: {str(exc)[:300]}', flush=True)
        items.clear()

    if to_update:
        _write_prog('import', 12, f'Atualizando {len(to_update)} canais alterados...',
                    total=parsed, processed=0, added=0, updated=0)
    total_updated = len(to_update)
    batch_upd = []
    desc_fill = []
    for rid, e in to_update.items():
        batch_upd.append((rid, e))
        desc = (e.get('description') or '').strip()
        if desc and rid not in ids_com_desc:
            desc_fill.append((rid, desc[:1990]))
            if len(desc_fill) >= 2000:
                _flush_desc(desc_fill)
        if len(batch_upd) >= 4000:
            _flush_updates(batch_upd)
            if _is_cancelled():
                print(f'[{datetime.now()}] M3U: Cancelado durante as atualizacoes', flush=True)
                if desc_fill:
                    _flush_desc(desc_fill)
                db.session.commit()
                _write_prog('cancelled', 99, 'Cancelado pelo administrador.',
                            total=parsed, processed=total_updated, added=0,
                            updated=total_updated, running=False)
                return {'added': 0, 'updated': total_updated, 'removed': 0,
                        'parsed': parsed, 'total': parsed}
    _flush_updates(batch_upd)
    if desc_fill:
        _flush_desc(desc_fill)

    # 4.2 Insercoes dos canais novos, por tipo, em lotes
    for tipo_name in _TIPOS:
        entries = to_insert[tipo_name]
        if not entries:
            continue
        pct_start = 10 + {'live': 0, 'movie': 30, 'series': 60}[tipo_name]
        pct_end = 10 + {'live': 30, 'movie': 60, 'series': 90}[tipo_name]
        phase = {'live': 'import', 'movie': 'movies', 'series': 'series'}[tipo_name]
        batch = []
        for idx, b in enumerate(entries):
            batch.append(b)
            if len(batch) < 500 and idx != len(entries) - 1:
                continue
            vals = []
            for b in batch:
                vals.append(
                    f"({_escape(_canon_name(b.get('name'))[:_TRUNC['name']])}, "
                    f"{_escape((b.get('stream_url') or '')[:_TRUNC['stream_url']])}, "
                    f"{_escape((b.get('logo') or '')[:_TRUNC['logo']])}, "
                    f"{_escape(_canon_group(b.get('group_title'))[:_TRUNC['group_title']])}, "
                    f"{_escape((b.get('tvg_id') or '')[:_TRUNC['tvg_id']])}, {_escape(tipo_name)}, "
                    f"{_escape((b.get('series_name') or '')[:_TRUNC['series_name']])}, "
                    f"{_num(b.get('season_number')) if _num(b.get('season_number')) is not None else 'NULL'}, "
                    f"{_num(b.get('episode_number')) if _num(b.get('episode_number')) is not None else 'NULL'}, "
                    f"{_escape((b.get('stream_referrer') or '')[:_TRUNC['stream_referrer']])}, "
                    f"{_escape((b.get('stream_user_agent') or '')[:_TRUNC['stream_user_agent']])}, 0, 0, "
                    f"{_escape(_now_str)}, {_escape((b.get('description') or '')[:1990])})"
                )
            for i in range(0, len(vals), 100):
                chunk = vals[i:i + 100]
                sql = ('INSERT INTO canal '
                       '(name, stream_url, logo, group_title, tvg_id, tipo, '
                       'series_name, season_number, episode_number, '
                       'stream_referrer, stream_user_agent, is_hidden, view_count, '
                       'data_added, description) VALUES ' + ','.join(chunk))
                try:
                    res = db.session.execute(text(sql))
                    total_inserted += max(0, res.rowcount or 0)
                except Exception as exc:
                    print(f'[{datetime.now()}] M3U: Erro insert {tipo_name}: {str(exc)[:300]}', flush=True)
            pct = pct_start + int((pct_end - pct_start) * idx / max(len(entries), 1))
            _write_prog(phase, min(pct, pct_end),
                        f'Inserindo novos {tipo_name}: {total_inserted}',
                        total=parsed, processed=total_updated + total_inserted,
                        added=total_inserted, updated=total_updated)
            batch = []
            if _is_cancelled():
                print(f'[{datetime.now()}] M3U: Cancelado durante as insercoes', flush=True)
                db.session.commit()
                _write_prog('cancelled', 99, 'Cancelado pelo administrador.',
                            total=parsed, processed=total_updated + total_inserted,
                            added=total_inserted, updated=total_updated, running=False)
                return {'added': total_inserted, 'updated': total_updated, 'removed': 0,
                        'parsed': parsed, 'total': parsed}
        print(f'[{datetime.now()}] M3U: {tipo_name} = {len(entries)} novos', flush=True)

    # 4.3 Remocao apenas do que saiu da fonte (e dependentes FK)
    if removidos:
        _write_prog('cleanup', 92, f'Removendo {len(removidos)} canais fora da lista...',
                    total=parsed, processed=parsed, added=total_inserted,
                    updated=total_updated, removed=0)
        for i in range(0, len(removidos), 400):
            chunk = removidos[i:i + 400]
            ids_sql = ','.join(str(x) for x in chunk)
            for tbl in _FK_TABLES:
                try:
                    db.session.execute(text(f'DELETE FROM `{tbl}` WHERE canal_id IN ({ids_sql})'))
                except Exception:
                    pass
            try:
                result = db.session.execute(text(f'DELETE FROM canal WHERE id IN ({ids_sql})'))
                total_removed += max(0, result.rowcount or 0)
            except Exception as exc:
                print(f'[{datetime.now()}] M3U: Erro ao remover canais: {str(exc)[:300]}', flush=True)
        print(f'[{datetime.now()}] M3U: {total_removed} canais sairam da lista.', flush=True)

    # ── Fase 5: Commit final ──
    db.session.commit()
    print(f'[{datetime.now()}] M3U: Concluido! +{total_inserted} novos, ~{total_updated} '
          f'atualizados, -{total_removed} removidos de {parsed} itens (ids preservados).', flush=True)
    _write_prog('done', 100,
                f'Concluido! +{total_inserted} novos, ~{total_updated} atualizados, -{total_removed} removidos.',
                total=parsed, processed=parsed, added=total_inserted,
                updated=total_updated, removed=total_removed, running=False)
    return {'added': total_inserted, 'updated': total_updated, 'removed': total_removed,
            'parsed': parsed, 'total': parsed}

def update_m3u_from_url(refresh_epg=True):
    """Baixa a M3U configurada e sincroniza o catálogo sem ocultar erros.

    refresh_epg=False deixa o remapeamento do guia para quem estiver
    orquestrando as etapas (run_catalog_update), para nao baixar o EPG duas
    vezes na mesma atualizacao.
    """
    with app.app_context():
        settings = load_settings()
        m3u_sources = get_m3u_sources(settings)
        if not m3u_sources:
            env_url = os.environ.get('M3U_URL', '').strip()
            if env_url:
                m3u_sources = [env_url]
        m3u_url = m3u_sources[0] if m3u_sources else ''
        if not m3u_url:
            raise RuntimeError('M3U URL não configurada no painel nem em M3U_URL.')
        print(f'[{datetime.now()}] Baixando catálogo M3U ({len(m3u_sources)} credenciais configuradas)...', flush=True)
        temp_path = M3U_PATH + '.part'
        previous_path = M3U_PATH + '.previous'

        # Ordem de download: credenciais com vaga/sem problema primeiro; as
        # vencidas ficam por ultimo (ultimo recurso). Assim uma conta vencida
        # nao impede o catalogo de atualizar.
        ordem = list(range(len(m3u_sources)))
        try:
            usable_indexes, cheias, _health = _m3u_usable_source_indexes(m3u_sources)
            ruins = [i for i in ordem if i not in usable_indexes and i not in cheias]
            ordem = usable_indexes + cheias + ruins
        except Exception:
            pass

        last_error = None
        downloaded = False
        for source_pos, source_index in enumerate(ordem):
            m3u_url = m3u_sources[source_index]
            if source_pos:
                print(f'[{datetime.now()}] Tentando credencial alternativa #{source_index + 1}...', flush=True)
            result = _m3u_source_health(m3u_sources[source_index])
            if not result.get('usable', True):
                print(f'[{datetime.now()}] Credencial #{source_index + 1} ignorada: {result.get("reason")}', flush=True)
                last_error = RuntimeError(result.get('reason') or 'credencial indisponivel')
                continue
            for attempt in range(1, 4):
                try:
                    with requests.get(
                        m3u_url,
                        headers={'User-Agent': 'VLC/3.0', 'Accept': '*/*'},
                        timeout=(30, int(os.environ.get('M3U_READ_TIMEOUT', '900'))),
                        stream=True,
                    ) as r:
                        r.raise_for_status()
                        with open(temp_path, 'wb') as f:
                            for chunk in r.iter_content(chunk_size=256 * 1024):
                                if chunk:
                                    f.write(chunk)
                    _validate_m3u_file(temp_path)
                    if os.path.exists(M3U_PATH):
                        shutil.copy2(M3U_PATH, previous_path)
                    os.replace(temp_path, M3U_PATH)
                    downloaded = True
                    if source_pos:
                        print(f'[{datetime.now()}] Catalogo baixado com a credencial #{source_index + 1}.', flush=True)
                    break
                except Exception as exc:
                    last_error = exc
                    print(f'[{datetime.now()}] Tentativa M3U {attempt}/3 falhou (credencial #{source_index + 1}): {exc}', flush=True)
                    try:
                        os.remove(temp_path)
                    except OSError:
                        pass
                    if attempt < 3:
                        _time.sleep(min(15 * attempt, 45))
            if downloaded:
                break
        if not downloaded:
            M3USourceLog.log(
                None, None, 'sync', 'error',
                f'Sync M3U falhou no download: {last_error}',
                details={'error': str(last_error)}
            )
            raise RuntimeError(f'Não foi possível baixar a M3U após 3 tentativas: {last_error}')

        used_source = m3u_sources[source_index] if downloaded else None
        used_index = source_index if downloaded else None
        masked = _m3u_mask_url(used_source) if used_source else None

        try:
            result = import_m3u_full_sync(M3U_PATH)
            repaired_titles = repair_existing_catalog_metadata()
            result['repaired'] = repaired_titles
            result['total'] = Canal.query.count()
            print(f'[{datetime.now()}] M3U sincronizada: +{result["added"]}, ~{result["updated"]}, -{result["removed"]}, saneados: {result.get("repaired", 0)}. Total: {result["total"]}', flush=True)
            # Log do sync com os contadores REAIS da importacao (antes usava o
            # dict de health, entao novos/atualizados/removidos saiam zerados).
            M3USourceLog.log(
                used_index, masked, 'sync', 'ok',
                f'Sync M3U concluido: +{result.get("added", 0)} novos, ~{result.get("updated", 0)} atualizados, '
                f'-{result.get("removed", 0)} removidos, saneados {result.get("repaired", 0)}. Total: {result.get("total", 0)}.',
                details={'source_index': used_index, 'added': result.get('added', 0),
                         'updated': result.get('updated', 0), 'removed': result.get('removed', 0),
                         'parsed': result.get('parsed', 0), 'repaired': result.get('repaired', 0),
                         'total': result.get('total', 0)}
            )
            # Mapear EPG para os canais novos. Os ids agora sao preservados,
            # entao NAO apagamos os mapeamentos existentes: se o download do
            # EPG falhar, a grade atual continua no ar.
            if refresh_epg:
                try:
                    epg_result = update_epg()
                    print(f'[{datetime.now()}] EPG remapeado apos M3U: {epg_result.get("mapped", 0)} canais.', flush=True)
                except Exception as epg_err:
                    print(f'[{datetime.now()}] Aviso: EPG remapeamento falhou (não crítico): {epg_err}', flush=True)
                    db.session.rollback()
            invalidate_cache('canais:')
            invalidate_cache('filmes:')
            invalidate_cache('series:')
            invalidate_cache('contagens:')
            invalidate_cache('landing:')
            invalidate_cache('admin:')
            return result
        except Exception as e:
            db.session.rollback()
            M3USourceLog.log(
                used_index, masked, 'sync', 'error',
                f'Sync M3U falhou na sincronizacao: {e}',
                details={'source_index': used_index, 'error': str(e)}
            )
            if os.path.exists(previous_path):
                try:
                    shutil.copy2(previous_path, M3U_PATH)
                except OSError:
                    pass
            print(f'[{datetime.now()}] Erro na atualização M3U; catálogo anterior preservado: {e}', flush=True)
            raise


# ─── EPG (Electronic Program Guide) ────────────────────────────────────────
def normalize_name(name):
    import unicodedata
    value = str(name or '').strip().lower()
    value = ''.join(ch for ch in unicodedata.normalize('NFKD', value) if not unicodedata.combining(ch))
    return re.sub(r'[^a-z0-9]', '', value)


_QUALITY_SUFFIX = re.compile(r'(\bHD\b|\bFHD\b|\bUHD\b|\b4K\b|\b2K\b|\bSD\b|\bH265\b|\bH264\b|\bHEVC\b|\bALT\b|\bALTERNATIVO\b|\bPLUS\b|\bEXTRAS?\b|\bLIVE\b|\bAOVIVO\b|\bFULL\b)$')

def strip_quality(name):
    """Remove suffixos de qualidade de um nome normalizado."""
    return _QUALITY_SUFFIX.sub('', name).strip()

def fuzzy_match_channel(m3u_name, epg_names):
    """Match rigido: prioriza exato > sufixo de qualidade > prefixo curto > nada.
    Threshold minimo: 0.75 (antes era 0.45, causava falsos positivos)."""
    best_score = 0
    best_match = None
    norm_m3u = normalize_name(m3u_name)
    stripped_m3u = strip_quality(norm_m3u)
    for ename in epg_names:
        norm_epg = normalize_name(ename)
        stripped_epg = strip_quality(norm_epg)
        # 1. Exato: 1.0
        if norm_m3u == norm_epg:
            return ename, 1.0
        # 2. Exato sem sufixo de qualidade: 0.98
        if stripped_m3u and stripped_epg and stripped_m3u == stripped_epg:
            score = 0.98
            if score > best_score:
                best_score = score
                best_match = ename
            continue
        # 3. Um eh prefixo do outro (so se o menor tiver >= 5 chars e
        #    representar >= 60% do maior — evita "band" casar com "bandeirantes")
        shorter, longer = (norm_m3u, norm_epg) if len(norm_m3u) <= len(norm_epg) else (norm_epg, norm_m3u)
        shorter_s, longer_s = (stripped_m3u, stripped_epg) if len(stripped_m3u) <= len(stripped_epg) else (stripped_epg, stripped_m3u)
        if shorter_s and longer_s and shorter_s in longer_s:
            ratio = len(shorter_s) / len(longer_s)
            if ratio >= 0.65:
                score = 0.85 * ratio
                if score > best_score:
                    best_score = score
                    best_match = ename
        # 4. SequenceMatcher — so se >= 0.75
        score = SequenceMatcher(None, norm_m3u, norm_epg).ratio()
        if score >= 0.75 and score > best_score:
            best_score = score
            best_match = ename
        score2 = SequenceMatcher(None, stripped_m3u, stripped_epg).ratio()
        if score2 >= 0.75 and score2 > best_score:
            best_score = score2
            best_match = ename
    if best_score >= 0.75:
        return best_match, best_score
    return None, 0


def _epg_key_norm(s):
    """Normaliza titulo para comparacao de duplicatas."""
    return re.sub(r'[^a-z0-9]', '', (s or '').lower())


def _dedupe_programs(progs):
    """Remove duplicatas e sobreposicoes da grade EPG de um canal.

    A fonte XMLTV traz DUAS grades sobrepostas para o mesmo canal
    (ex.: 'A Tarde É Sua' 14:40-16:00 e 15:00-16:00). Isso polui o guia e
    confunde o programa atual. Aqui mantemos uma sequencia sem sobreposicao:
    - duplicata exata -> descarta;
    - mesmo programa (mesmo titulo) sobreposto -> fica a versao que comeca antes;
    - titulos diferentes sobrepostos -> o novo so substitui se cobrir o anterior;
      caso contrario o novo e descartado (grade continua sem buracos).
    """
    valid = [p for p in progs if p.start_time and p.end_time and p.end_time > p.start_time]
    valid.sort(key=lambda p: (p.start_time, -(p.end_time - p.start_time).total_seconds()))
    kept = []
    for p in valid:
        if kept:
            last = kept[-1]
            if p.start_time == last.start_time and p.end_time == last.end_time:
                continue
            if p.start_time >= last.end_time:
                kept.append(p)
                continue
            if _epg_key_norm(p.title) == _epg_key_norm(last.title):
                continue  # variante do mesmo programa -> fica a que comeca antes
            if p.start_time <= last.start_time and p.end_time >= last.end_time:
                kept[-1] = p  # novo cobre inteiramente o anterior
                continue
            continue  # sobreposicao parcial -> mantem grade unica
        kept.append(p)
    return kept


def _epg_import_lock():
    """Trava entre processos/threads: evita duas importacoes EPG simultaneas
    (que geravam programas duplicados/sobrepostos no banco).
    Retorna objeto de lock (fechar ao final) ou None se ja estiver em andamento."""
    import fcntl as _fcntl
    lock_dir = os.path.join(BASE_DIR, 'instance')
    try:
        os.makedirs(lock_dir, exist_ok=True)
        fh = open(os.path.join(lock_dir, 'epg_import.lock'), 'w')
        _fcntl.flock(fh, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
        return fh
    except OSError:
        try:
            fh.close()
        except Exception:
            pass
        return None


def _derive_epg_url_from_m3u(source):
    """Gera a URL de EPG de uma lista Xtream trocando o endpoint por xmltv.php.

    ``.../get.php?username=..&password=..`` -> ``.../xmltv.php?username=..&password=..``
    (tambem aceita ``player_api.php``). Mantem host, porta e credenciais.
    """
    s = (source or '').strip()
    if not s:
        return ''
    if 'xmltv.php' in s.lower():
        return s
    for endpoint in ('get.php', 'player_api.php'):
        if endpoint in s.lower():
            return re.sub(re.escape(endpoint), 'xmltv.php', s, count=1, flags=re.IGNORECASE)
    return ''


def download_and_parse_epg():
    settings = load_settings()
    # O EPG nao precisa de fonte separada: ele e derivado das proprias listas
    # M3U/Xtream (troca get.php -> xmltv.php). A ordem de tentativa segue o
    # download do M3U -- fontes livres/ativas primeiro, vencidas/cheias por
    # ultimo -- e cada fonte tenta ate 3 vezes. O epg_url manual do painel, se
    # existir, vira ultimo recurso.
    try:
        m3u_sources = get_m3u_sources(settings, include_disabled=False) or get_m3u_sources(settings)
    except Exception:
        m3u_sources = []
    order = list(range(len(m3u_sources)))
    try:
        usable, cheias, _health = _m3u_usable_source_indexes(m3u_sources)
        ruins = [i for i in order if i not in usable and i not in cheias]
        order = usable + cheias + ruins
    except Exception:
        pass

    candidates = []
    for i in order:
        derived = _derive_epg_url_from_m3u(m3u_sources[i])
        if derived and derived not in candidates:
            candidates.append(derived)
    explicit = (settings.get('epg_url') or os.environ.get('EPG_URL', '')).strip()
    for _exp in [x.strip() for x in re.split(r'[\r\n]+', explicit) if x.strip()]:
        if _exp not in candidates:
            candidates.append(_exp)

    if not candidates:
        raise RuntimeError('Nenhuma fonte de EPG disponivel: configure ao menos uma lista '
                           'M3U/Xtream com get.php (ou defina EPG_URL).')

    root = None
    last_error = None
    used_url = ''
    max_attempts = 3
    for epg_url in candidates:
        for attempt in range(1, max_attempts + 1):
            try:
                print(f'[{datetime.now()}] Baixando EPG de {epg_url[:60]} (tentativa {attempt}/{max_attempts})...', flush=True)
                r = requests.get(epg_url, headers={'User-Agent': 'VLC/3.0'}, timeout=180)
                r.raise_for_status()
                root = ET.fromstring(r.content)
                used_url = epg_url
                break
            except Exception as e:
                last_error = e
                root = None
                print(f'[{datetime.now()}] Fonte EPG {epg_url[:60]} tentativa {attempt}/{max_attempts} falhou: {e}', flush=True)
                if attempt < max_attempts:
                    _time.sleep(min(10 * attempt, 30))
        if root is not None:
            break
    if root is None:
        print(f'[{datetime.now()}] Erro no download/parse EPG apos {len(candidates)} fonte(s) (3 tentativas cada): {last_error}', flush=True)
        raise RuntimeError(f'Falha ao baixar EPG apos {len(candidates)} fonte(s) e {max_attempts} tentativas cada: {last_error}')
    if used_url:
        print(f'[{datetime.now()}] EPG obtido de {used_url[:60]}.', flush=True)

    channels = {}      # channel_id -> display-name
    channel_icons = {}  # channel_id -> icon URL
    for ch in root.findall('.//channel'):
        ch_id = ch.get('id', '')
        display = ch.find('display-name')
        if display is not None and display.text:
            dn = display.text.strip()
            if not ch_id:
                ch_id = dn
            channels[ch_id] = dn
        # Extrair icon/logo do EPG XMLTV
        icon_el = ch.find('icon')
        if icon_el is not None:
            icon_src = (icon_el.get('src') or '').strip()
            if icon_src and ch_id:
                channel_icons[ch_id] = icon_src

    print(f'[{datetime.now()}] EPG: {len(channels)} canais encontrados, {len(channel_icons)} com logo.', flush=True)

    lock_fh = _epg_import_lock()
    if lock_fh is None:
        print(f'[{datetime.now()}] EPG: importacao ja em andamento em outro processo; esta execucao sera ignorada.', flush=True)
        return {'skipped': True, 'channels': len(channels), 'programs': 0}, None, None
    try:
        now = datetime.now(timezone.utc)
        cutoff = (now - timedelta(days=2)).replace(tzinfo=None)

        # Limpar TUDO e reimportar do zero, sempre convertido para UTC
        print(f'[{datetime.now()}] EPG: Limpando dados antigos...', flush=True)
        EPGProgram.query.delete()
        ChannelEPGMapping.query.delete()
        db.session.commit()
        print(f'[{datetime.now()}] EPG: Dados antigos limpos.', flush=True)

        from types import SimpleNamespace as _NS
        groups = {}  # channel_id -> lista de programas
        for prog in root.findall('.//programme'):
            ch_id = prog.get('channel', '')
            start = parse_xmltv_program_time(prog, 'start')
            end = parse_xmltv_program_time(prog, 'stop')
            title_el = prog.find('title')
            desc_el = prog.find('desc')
            title = title_el.text.strip() if title_el is not None and title_el.text else ''
            desc = desc_el.text.strip() if desc_el is not None and desc_el.text else ''
            desc = clean_epg_description(desc)
            if start and end and end > start and start > cutoff:
                groups.setdefault(ch_id, []).append(_NS(
                    channel_id=ch_id, title=title, description=desc,
                    start_time=start, end_time=end,
                ))

        count = 0
        batch = []
        for ch_id, progs in groups.items():
            clean = _dedupe_programs(progs)
            for p in clean:
                batch.append(EPGProgram(
                    canal_id=None,
                    channel_id=p.channel_id,
                    title=p.title,
                    description=p.description,
                    start_time=p.start_time,
                    end_time=p.end_time,
                ))
                count += 1
                if len(batch) >= 1000:
                    db.session.add_all(batch)
                    db.session.commit()
                    batch = []

        if batch:
            db.session.add_all(batch)
            db.session.commit()

        print(f'[{datetime.now()}] EPG: {count} programas importados (duplicatas/sobreposicoes removidas).', flush=True)
        return {'channels': len(channels), 'programs': count}, root, channel_icons
    finally:
        try:
            lock_fh.close()
        except Exception:
            pass


def parse_xmltv_time(s):
    """Converte horario XMLTV para UTC (convencao de armazenamento do banco).

    O offset informado no proprio XML DEVE ser aplicado — e o padrao XMLTV.
    Ex.: start="20260915012000 -0300" e 01:20 em Brasilia = 04:20 UTC.

    (Antes o offset era descartado por causa de um provedor antigo que rotulava
    errado. O provedor atual rotula certo — conferido contra o start_timestamp
    epoch do proprio arquivo — e descartar o offset deixava o guia 3h adiantado,
    mostrando como "agora" um programa que ainda vai passar.)
    """
    if not s:
        return None
    s = s.strip()
    # Formato "AAAAMMDDHHMMSS +HHMM" (ou -0300 colado) — o mais comum
    match = re.match(r'^(\d{14})\s*([+-])(\d{2}):?(\d{2})$', s)
    if match:
        try:
            dt = datetime.strptime(match.group(1), '%Y%m%d%H%M%S')
            offset = timedelta(hours=int(match.group(3)), minutes=int(match.group(4)))
            if match.group(2) == '-':
                offset = -offset
            return (dt - offset).replace(tzinfo=None)
        except Exception:
            pass
    for fmt in ('%Y%m%d%H%M%S %z', '%Y%m%d%H%M%S%z', '%Y%m%d%H%M%S', '%Y%m%d%H%M'):
        try:
            dt = datetime.strptime(s, fmt)
            if dt.tzinfo is not None:
                # Aplica o offset e normaliza para UTC
                dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
            return dt
        except ValueError:
            continue
    return None


def parse_xmltv_program_time(prog, attr):
    """Horario de um <programme>, preferindo o epoch (start_timestamp).

    Quando o XML traz *_timestamp (epoch UTC) ele e a fonte mais confiavel:
    nao depende do rotulo de fuso do arquivo.
    """
    try:
        ts = prog.get(attr + '_timestamp')
        if ts:
            return datetime.fromtimestamp(int(ts), timezone.utc).replace(tzinfo=None)
    except Exception:
        pass
    return parse_xmltv_time(prog.get(attr, ''))


def match_epg_to_channels(root=None):
    """Mapeamento rigido EPG -> Canais.

    Regras (em ordem de prioridade):
    1. tvg_id do M3U == id do EPG  (exato)  -> score 1.0
    2. nome normalizado M3U == display-name normalizado EPG (exato) -> score 1.0
    3. fuzzy_match com threshold 0.75
    4. Um EPG ja mapeado NAO sera reatribuido a outro canal.
    """
    if root is None:
        try:
            settings = load_settings()
            # epg_url pode guardar varias linhas (uma fonte por linha); tenta
            # cada uma (e a derivacao das listas M3U) ate uma responder.
            urls = []
            for _raw in ((settings.get('epg_url') or os.environ.get('EPG_URL', '')).strip(),
                         EPG_URL):
                for _u in re.split(r'[\r\n]+', _raw or ''):
                    _u = _u.strip()
                    if _u and _u not in urls:
                        urls.append(_u)
            for _u in urls:
                try:
                    r = requests.get(_u, headers={'User-Agent': 'VLC/3.0'}, timeout=(30, 180))
                    r.raise_for_status()
                    root = ET.fromstring(r.content)
                    break
                except Exception:
                    continue
        except Exception as exc:
            print(f'[{datetime.now()}] EPG: nao foi possivel carregar canais para mapeamento: {exc}', flush=True)
            root = None

    # --- Monta indice de canais EPG ---
    epg_display_names = {}      # channel_id -> display-name
    epg_ids_set = set()         # todos os channel_id do XMLTV
    epg_norm_index = {}         # normalizado -> channel_id (1:1, primeiro vence)
    if root is not None:
        for ch in root.findall('.//channel'):
            ch_id = ch.get('id', '')
            epg_ids_set.add(ch_id)
            for dn in ch.findall('display-name'):
                if dn.text:
                    dn_text = dn.text.strip()
                    epg_display_names[ch_id] = dn_text
                    norm = normalize_name(dn_text)
                    if norm and norm not in epg_norm_index:
                        epg_norm_index[norm] = ch_id
                    break

    if not epg_display_names:
        for row in db.session.query(EPGProgram.channel_id).distinct().all():
            cid = row[0]
            if cid not in epg_display_names:
                epg_display_names[cid] = cid

    m3u_canals = Canal.query.filter(Canal.tipo == 'live', Canal.is_hidden == False).all()
    matched = 0

    # Canais EPG ja atribuidos (channel_id -> canal_id) — 1:1
    assigned_epg = {}

    # 1a passada: preserva mapeamentos existentes validos
    existing_mappings = {m.canal_id: m for m in ChannelEPGMapping.query.all()}
    for canal in m3u_canals:
        existing = existing_mappings.get(canal.id)
        if existing and existing.channel_id in epg_ids_set:
            # Ja mapeado e o EPG ainda existe — mantem
            assigned_epg[existing.channel_id] = canal.id
            EPGProgram.query.filter_by(channel_id=existing.channel_id, canal_id=None).update(
                {'canal_id': canal.id}, synchronize_session=False
            )
            matched += 1
            continue
        if existing:
            db.session.delete(existing)
            db.session.flush()

    # 2a passada: mapeia canais ainda sem EPG
    # Ordena por nome para determinismo
    unmapped = [c for c in m3u_canals if c.id not in [m.canal_id for m in ChannelEPGMapping.query.all()]]
    unmapped.sort(key=lambda c: c.name)

    for canal in unmapped:
        best_cid = None
        best_score = 0
        is_exact = False

        # Regra 1: tvg_id exato — sempre permitido (streams alternativos)
        if canal.tvg_id and canal.tvg_id in epg_ids_set:
            best_cid = canal.tvg_id
            best_score = 1.0
            is_exact = True

        # Regra 2: nome normalizado exato (com e sem sufixo de qualidade) — sempre permitido
        if not best_cid:
            norm_canal = normalize_name(canal.name)
            stripped_canal = strip_quality(norm_canal)
            if norm_canal in epg_norm_index:
                best_cid = epg_norm_index[norm_canal]
                best_score = 1.0
                is_exact = True
            elif stripped_canal and stripped_canal in epg_norm_index:
                best_cid = epg_norm_index[stripped_canal]
                best_score = 1.0
                is_exact = True

        # Regra 3: fuzzy match (threshold 0.75) — so se EPG nao esta atribuido
        if not best_cid:
            for cid, display_name in epg_display_names.items():
                if cid in assigned_epg:
                    continue
                match, score = fuzzy_match_channel(canal.name, [display_name])
                if score > best_score:
                    best_score = score
                    best_cid = cid

        if best_cid and best_score >= 0.75:
            assigned_epg[best_cid] = canal.id
            mapping = ChannelEPGMapping(
                canal_id=canal.id,
                channel_id=best_cid,
                channel_name=epg_display_names.get(best_cid, best_cid),
            )
            db.session.add(mapping)
            EPGProgram.query.filter_by(channel_id=best_cid, canal_id=None).update(
                {'canal_id': canal.id}, synchronize_session=False
            )
            matched += 1

    db.session.commit()
    print(f'[{datetime.now()}] EPG: {matched} canais mapeados (rigido).', flush=True)
    return matched


def _copy_epg_logos_to_canais(channel_icons=None):
    """Copia logos do EPG XMLTV para canais que nao tem logo."""
    if not channel_icons:
        return 0
    mappings = ChannelEPGMapping.query.all()
    updated = 0
    for m in mappings:
        icon_url = channel_icons.get(m.channel_id)
        if not icon_url:
            continue
        # Ignorar logos que sao apenas nomes de arquivo (sem http)
        if not icon_url.startswith('http'):
            continue
        canal = db.session.get(Canal, m.canal_id)
        if canal and not canal.logo:
            canal.logo = icon_url
            updated += 1
    if updated:
        db.session.commit()
        print(f'[{datetime.now()}] EPG: {updated} logos copiados do EPG para canais.', flush=True)
    return updated


def update_epg():
    with app.app_context():
        parsed, root, channel_icons = download_and_parse_epg()
        if not root:
            return {'skipped': True, 'channels': parsed.get('channels', 0), 'programs': 0, 'mapped': 0, 'logos': 0}
        matched = match_epg_to_channels(root)
        logos = _copy_epg_logos_to_canais(channel_icons)
        invalidate_cache('canais:')
        return {'channels': parsed['channels'], 'programs': parsed['programs'], 'mapped': matched, 'logos': logos}


CATALOG_STAGES = ('m3u', 'epg', 'xtream')


def _catalog_update_running():
    """True se uma atualizacao de catalogo ja esta rodando.

    Duas importacoes simultaneas travam o banco (lock wait timeout) e derrubam
    o site, por isso painel e cron usam a mesma guarda.
    """
    try:
        pf = os.path.join(BASE_DIR, 'progress_m3u.json')
        if not os.path.exists(pf):
            return False
        with open(pf, 'r') as f:
            prog = json.load(f)
        if not prog.get('running'):
            return False
        ts = prog.get('timestamp')
        if not ts:
            return False
        return (datetime.now() - datetime.fromisoformat(ts)).total_seconds() < 1800
    except Exception:
        return False


def run_catalog_update(stages=CATALOG_STAGES, progress=None, xtream_items=0,
                      should_stop=None):
    """M3U + EPG + Xtream numa fila so.

    E o unico caminho de atualizacao do catalogo (botao do painel, upload de
    arquivo e cron): atualizar a lista nunca deixa o guia de programas ou as
    capas/sinopses para tras. Cada etapa e independente — uma falha nao impede
    as outras e fica registrada no retorno e no log das fontes.
    """
    escolhidas = [etapa for etapa in CATALOG_STAGES if etapa in set(stages)]
    resumo = {'stages': escolhidas, 'results': {}, 'errors': []}

    def _parar():
        if should_stop and should_stop():
            resumo['cancelled'] = True
            return True
        return False

    def _progress(etapa, pct, msg):
        if progress:
            try:
                progress(etapa, pct, msg)
            except Exception:
                pass

    if 'm3u' in escolhidas and not _parar():
        _progress('m3u', 5, 'Baixando e sincronizando a lista M3U...')
        try:
            resultado = update_m3u_from_url(refresh_epg=('epg' not in escolhidas))
            resumo['results']['m3u'] = {chave: resultado.get(chave)
                                        for chave in ('added', 'updated', 'removed', 'total')}
            _record_cron_run('update-m3u')
        except Exception as exc:
            resumo['errors'].append({'stage': 'm3u', 'error': str(exc)[:300]})

    if 'epg' in escolhidas and not _parar():
        _progress('epg', 45, 'Baixando e mapeando o guia de programas (EPG)...')
        try:
            epg = update_epg()
            resumo['results']['epg'] = epg
            _record_cron_run('update-epg')
            M3USourceLog.log(
                None, 'EPG', 'sync', 'warn' if epg.get('skipped') else 'ok',
                f'EPG: {epg.get("channels", 0)} canais, {epg.get("programs", 0)} programas, '
                f'{epg.get("mapped", 0)} mapeados.',
                details=epg)
        except Exception as exc:
            db.session.rollback()
            resumo['errors'].append({'stage': 'epg', 'error': str(exc)[:300]})
            try:
                M3USourceLog.log(None, 'EPG', 'sync', 'error', f'EPG falhou: {str(exc)[:200]}')
            except Exception:
                db.session.rollback()

    if 'xtream' in escolhidas and not _parar():
        _progress('xtream', 70, 'Buscando sinopses e capas na fonte Xtream...')
        try:
            xtream = enrich_xtream_metadata(batch_size=30, max_items=xtream_items)
            resumo['results']['xtream'] = xtream
            _record_cron_run('xtream-enrich')
            if not xtream.get('success'):
                resumo['errors'].append({'stage': 'xtream',
                                         'error': str(xtream.get('message') or 'sem dados')[:300]})
        except Exception as exc:
            db.session.rollback()
            resumo['errors'].append({'stage': 'xtream', 'error': str(exc)[:300]})

    invalidate_cache('canais:')
    invalidate_cache('filmes:')
    invalidate_cache('series:')
    invalidate_cache('landing:')
    invalidate_cache('admin:')
    resumo['success'] = not resumo['errors']
    _progress('done', 100, 'Atualizacao concluida.')
    return resumo


def _spawn_catalog_update(mode):
    """Roda a fila em processo separado (sobrevive ao timeout do cPanel).

    Se o servidor nao permitir o subprocesso, segue em thread no proprio
    processo para a atualizacao nao simplesmente deixar de acontecer.
    """
    log_path = os.path.join(BASE_DIR, 'bg_m3u.log')
    try:
        _keep_last_bytes(log_path, keep_bytes=2 * 1024 * 1024, max_bytes=4 * 1024 * 1024)
        subprocess.Popen(
            [sys.executable, os.path.join(BASE_DIR, 'run_bg_update.py'), mode],
            cwd=BASE_DIR,
            stdout=open(log_path, 'a'),
            stderr=subprocess.STDOUT,
            start_new_session=True,
        )
        return 'subprocess'
    except Exception as exc:
        def _bg():
            try:
                if mode == 'm3u_import':
                    import run_bg_update
                    run_bg_update.run_m3u_import()
                else:
                    run_catalog_update()
            except Exception as thread_exc:
                print(f'[Catalog] Falha na thread de atualizacao: {thread_exc}', flush=True)
        threading.Thread(target=_bg, daemon=True).start()
        return f'thread ({exc})'


# ─── Sports Events Parser ────────────────────────────────────────────
_TIME_RE = re.compile(
    r'(?P<h>\d{1,2})\s*'
    r'(?:[hH:\.\-\u2013\u2014]|h\s*(?:s|oras?)\b|horas?\b)\s*'
    r'(?P<m>\d{2})\s*'
    r'(?:(?:[hH]|h\s*(?:s|oras?)\b|horas?\b)\s*)?',
    re.IGNORECASE
)

_KNOWN_SPORTS = {
    'futebol', 'futebol americano', 'basquete', 'basquetebol', 'beisebol',
    'volei', 'vôlei', 'voleibol', 'handebol', 'tenis', 'tênis', 'golfe',
    'boxe', 'mma', 'ufc', 'automobilismo', 'formula 1', 'f1', 'motovelocidade',
    'motogp', 'ciclismo', 'natacao', 'nado', 'atletismo', 'rugby', 'rúgbi',
    'esportes', 'e-sports', 'esports', 'lol', 'counter strike', 'csgo', 'dota',
    'futevôlei', 'futsal', 'surfe', 'skate', 'hipismo', 'tiro', 'pesca',
    'sinuca', 'bilhar', 'dardos', 'xadrez', 'corrida', 'maratona',
    'luta', 'wrestling', 'afl', 'nfl', 'nba', 'mlb', 'nhl', 'uefa', 'champions',
    'libertadores', 'copa do brasil', 'brasileirao', 'brasileirão',
    'premier league', 'la liga', 'serie a', 'bundesliga', 'ligue 1',
    'tenis de mesa', 'tênis de mesa', 'badminton', 'baseball',
}

_SPORT_STOPWORDS = {'de', 'da', 'do', 'das', 'dos', 'a', 'e', 'o', 'em', 'no', 'na',
                     '1', '2', '3', 'i', 'ii', 'iii', 'iv', 'v', 'vi', 'vii'}
_SPORT_CORE_KEYWORDS = set()
for s in _KNOWN_SPORTS:
    for w in s.split():
        if w not in _SPORT_STOPWORDS:
            _SPORT_CORE_KEYWORDS.add(w)

def _is_time_line(s):
    return bool(_TIME_RE.search(s))

def _extract_time(s):
    m = _TIME_RE.search(s)
    if not m:
        return None, s
    hour = m.group('h').zfill(2)
    minu = m.group('m')
    time_str = f'{hour}h{minu}'
    rest = (s[:m.start()] + s[m.end():]).strip()
    return time_str, rest

def _split_channels(text):
    text = text.strip()
    if (text.startswith('(') and text.endswith(')')) or \
       (text.startswith('[') and text.endswith(']')):
        text = text[1:-1].strip()
    parts = [p.strip() for p in re.split(r'[,/|]', text) if p.strip()]
    return parts if parts else [text]

def _looks_like_sport_header(text):
    t = text.lower().strip()
    direct = {s.lower() for s in _KNOWN_SPORTS}
    if t in direct:
        return True
    words_no_stop = {w for w in re.sub(r'[^a-z0-9]', ' ', t).split() if w not in _SPORT_STOPWORDS}
    core = words_no_stop & _SPORT_CORE_KEYWORDS
    return len(core) >= 1 and len(words_no_stop) <= 3

def _looks_like_champ_header(text):
    t = text.lower().strip()
    keywords = {'copa', 'campeonato', 'liga', 'tour', 'grand prix', 'trophy',
                'open', 'cup', 'championship', 'series', 'gp', 'grande premio',
                'grande prêmio', 'taca', 'taça', 'champions', 'derby',
                'classic', 'masters', 'world', 'troféu', 'trofeu',
                'league', 'trophy', 'derby', 'campionship'}
    for kw in keywords:
        if kw in t:
            return True
    known_champs = ['major league baseball', 'premier league', 'la liga',
                    'serie a', 'ligue 1', 'world cup', 'super bowl',
                    'final four', 'grand slam', 'superliga', 'super liga',
                    'copa do mundo', 'copa america', 'copa libertadores',
                    'campeonato brasileiro', 'liga das nações', 'liga das nacoes',
                    'champions league', 'europa league', 'copa do brasil',
                    'copa do nordeste', 'copa verde', 'libertadores',
                    'sul-americana', 'sulamericana', 'recopa', 'mundial',
                    'brasileirão', 'brasileirao', 'premier league',
                    'la liga', 'bundesliga', 'serie a', 'serie b',
                    'primeira liga', 'segunda liga', 'carioca', 'paulista',
                    'mineiro', 'gaucho', 'gaúcho', 'nba', 'nfl', 'mlb', 'nhl',
                    'afl', 'uefa champions', 'uefa europa', 'libertadores',
                    'copa do rei', 'fa cup', 'dfb pokal']
    for kc in known_champs:
        if kc in t:
            return True
    return False

_CHANNEL_HINTS = {
    'tv', 'hd', 'sd', 'espn', 'sportv', 'globo', 'record', 'band', 'sbt',
    'tnt', 'fox', 'fx', 'combate', 'premiere', 'telecine', 'megapix',
    'studio', 'universal', 'discovery', 'history', 'animal planet',
    'cartoon', 'nick', 'disney', 'bbc', 'cnn', 'netflix', 'prime',
    'max', 'hbo', 'paramount', 'youtube', 'twitch', 'kwai', 'tiktok',
    'facebook', 'instagram', 'twitter', 'cazé', 'caze',
    'canal', 'multishow', 'bis', 'curta', 'woohoo', 'fishtv',
    'mtv', 'sony', 'warner', 'a&e', 'amc',
    'tnt sports', 'tnt sport', 'fox sports', 'fox sport',
    'espn brasil', 'espn 1', 'espn 2', 'espn 3', 'espn 4',
    'sportv 1', 'sportv 2', 'sportv 3', 'sportv 4',
    'band sports', 'band sport', 'watch espn', 'star+', 'star plus',
    'paramount+', 'paramount plus', 'hbo max', 'hbo+',
    'youtube tv', 'twitch tv', 'caze tv', 'cazé tv',
    'premiere fc', 'premiere 1', 'premiere 2',
    'combate 1', 'combate 2',
}

def _strip_trailing_channel(text):
    """If team2 text ends with a word or dash-separated channel name, split it off."""
    t = text.strip()
    # Try splitting on " - " at the end
    dash_split = re.split(r'\s*[-–—]\s*', t)
    if len(dash_split) >= 2:
        last = dash_split[-1].strip()
        if _is_channel_name(last):
            team = ' - '.join(dash_split[:-1]).strip()
            return team if team else t, [last]
    # Check last 1-2 words for channel names
    parts = t.split()
    if len(parts) <= 1:
        return t, []
    # Try smaller n first so we don't grab too much as channel
    for n in (1, 2):
        if len(parts) >= n:
            candidate = ' '.join(parts[-n:]).lower().strip().strip(',;')
            if _is_channel_name(candidate):
                team = ' '.join(parts[:-n]).strip()
                ch = [' '.join(parts[-n:]).strip().strip(',;')]
                if team:
                    return team, ch
    # n=2 with no team left — actually entire text is one or more channels
    if len(parts) >= 2:
        candidate = ' '.join(parts[-2:]).lower().strip()
        if _is_channel_name(candidate):
            ch = [' '.join(parts[-2:]).strip()]
            return '', ch
    return t, []

def _is_channel_name(text):
    t = text.lower().strip().replace(' ', '')
    if t in _CHANNEL_HINTS or text.lower().strip() in _CHANNEL_HINTS:
        return True
    # Check individual words
    words = text.lower().split()
    if any(w in _CHANNEL_HINTS for w in words) and len(words) <= 3:
        return True
    # Strip trailing digits: "sportv2" → "sportv", "espn1" → "espn"
    base = re.sub(r'\d+$', '', t)
    if base and base in _CHANNEL_HINTS:
        return True
    return False


def parse_sports_text(text, event_date):
    events = []
    current_sport = ''
    current_champ = ''
    lines = re.split(r'\r?\n', text)
    last_sport_line = ''
    last_champ_line = ''

    for raw_line in lines:
        line = raw_line.strip()
        if not line:
            continue

        # ── Detect if this is an event line (has a time) ──
        if _is_time_line(line):
            time_str, rest = _extract_time(line)
            if not time_str:
                continue

            # Remove scores like (2x1), (3-0), (4 x 2) from rest
            score = ''
            score_match = re.search(r'[\(\[][^)\]]*?(\d+)\s*[xX\-]\s*(\d+)[^)\]]*[\)\]]', rest)
            if score_match:
                score = score_match.group(0).strip('[]()')
                rest = (rest[:score_match.start()] + ' ' + rest[score_match.end():]).strip()

            # Extract channels from trailing parentheses/brackets: (Canal1, Canal2)
            channels = []
            rest_stripped = rest.strip()
            paren_match = re.search(r'[\(\[][^)\]]+[\)\]](?:\s*[,;]\s*[\(\[][^)\]]+[\)\]])*\s*$', rest_stripped)
            if paren_match:
                ch_text = paren_match.group(0)
                ch_all = re.findall(r'[\(\[][^)\]]+[\)\]]', ch_text)
                for ch_group in ch_all:
                    channels.extend(_split_channels(ch_group))
                rest = (rest_stripped[:paren_match.start()] + rest_stripped[paren_match.end():]).strip()
            else:
                rest = rest_stripped

            rest = rest.strip().strip(',;|/').strip()

            # Clean leading/trailing separators before parsing teams
            rest = rest.strip().strip('-–—|/;:,').strip()

            # ── Split teams only by: x / vs / versus ──
            team1 = rest
            team2 = ''
            ch_from_rest = []

            team_split = re.match(
                r'^(.+?)\s+(?:[xX](?:\s|$)|[vV][sS](?:\s|$)|versus\s)\s*(.+)$',
                rest
            )
            if team_split:
                team1 = team_split.group(1).strip().strip('-–—')
                right = team_split.group(2).strip().strip('-–—')
                right_parts = [p.strip() for p in re.split(r',', right)]
                team2 = right_parts[0].strip('-–— ') if right_parts else right.strip('-–— ')
                if len(right_parts) > 1:
                    ch_from_rest = [c for c in right_parts[1:] if c.strip()]
                # Clean team1: strip leading event phase like "Semifinal - França" → "França"
                _phase_clean = re.match(
                    r'^(?:Final|Semifinal|Semifinais|Quartas?|Oitavas?|Decis[ãa]o|Disputa|Repescagem|Playoff|Qualifica[çc][ãa]o|Rodada|Fase|Grupo|Partida|Jogo|Eliminat[óo]ria)\s*[-–—:]\s*(.+)$',
                    team1, re.IGNORECASE
                )
                if _phase_clean:
                    team1 = _phase_clean.group(1).strip()
                # If team2 has trailing words that look like channels, move them
                team2_clean = _strip_trailing_channel(team2)
                if team2_clean[1]:
                    team2 = team2_clean[0]
                    ch_from_rest = team2_clean[1] + ch_from_rest

            # ── If no teams found, split by comma: description, channel1, channel2 ──
            if not team2:
                comma_parts = [p.strip() for p in re.split(r',', rest)]
                team1 = comma_parts[0]
                if len(comma_parts) > 1:
                    ch_from_rest = [c for c in comma_parts[1:] if c.strip()]

            expanded = []
            for c in ch_from_rest:
                expanded.extend(_split_channels(c))
            channels = expanded + channels

            events.append({
                'sport': current_sport if current_sport else ('Esportes' if not events else events[-1]['sport']),
                'championship': current_champ,
                'team1': team1.strip().strip(','),
                'team2': team2.strip().strip(','),
                'score': score,
                'event_time': time_str,
                'channels': json.dumps(channels) if channels else '[]',
                'event_date': event_date,
            })
            continue

        # ── Lines without a time are headers (sport or championship) ──
        line_lower = line.lower().strip()

        # Determine if this looks like a sport or a championship
        is_sport = _looks_like_sport_header(line)
        looks_like_champ = _looks_like_champ_header(line)

        if is_sport and not looks_like_champ:
            current_sport = line
            current_champ = ''
        elif looks_like_champ and not is_sport:
            current_champ = line
        elif is_sport and looks_like_champ:
            # Ambiguous: if we already have a sport, treat as championship
            if current_sport and current_sport.lower().strip() not in {s.lower() for s in _KNOWN_SPORTS}:
                current_champ = line
            elif not current_sport:
                current_sport = line
                current_champ = ''
            else:
                current_champ = line
        else:
            # Unknown header: if no sport yet, treat as sport
            if not current_sport:
                current_sport = line
            else:
                current_champ = line

    return events


def _looks_like_team_name(text):
    """Heuristic: team names are typically short (1-3 words), capitalized."""
    t = text.strip()
    if not t or len(t) > 40:
        return False
    # Check if it looks like a proper name (capitalized words)
    words = t.split()
    if len(words) > 4:
        return False
    # Most words should start with uppercase or be a short word
    upper_count = sum(1 for w in words if w and w[0].isupper())
    return upper_count >= max(1, len(words) - 1)


_channel_index_cache = None
_channel_index_time = 0

def build_channel_index():
    import unicodedata
    def normalize(s):
        s = unicodedata.normalize('NFKD', s)
        s = s.encode('ascii', 'ignore').decode('ascii')
        return re.sub(r'[^a-z0-9]', '', s.lower())
    def tokenize(s):
        s = unicodedata.normalize('NFKD', s)
        s = s.encode('ascii', 'ignore').decode('ascii')
        return [t for t in re.split(r'[^a-z0-9]+', s.lower()) if t]
    norm_map = {}
    token_map = []
    prefix_map = {}
    id_map = {}
    for c in Canal.query.filter(Canal.tipo == 'live', Canal.is_hidden == False).all():
        n = normalize(c.name)
        norm_map[n] = c
        token_map.append((tokenize(c.name), n, c))
        id_map[c.id] = c
        for i in range(1, len(n) + 1):
            prefix_map.setdefault(n[:i], set()).add(c.id)
    return norm_map, token_map, prefix_map, id_map


NOISE_PREFIXES = ['youtube', 'twitch', 'facebook', 'instagram', 'tiktok']


_index_renovando = threading.Lock()
_index_em_renovacao = False


def get_channel_index():
    """Index de canais para EPG/esportes: nunca segura a pagina.

    Vencidos os 60 s, a pagina usa o index antigo e a renovacao roda em segundo
    plano. Do jeito antigo, 1 em cada 60 pedidos pagava a varredura inteira do
    catalogo (2-3 s) — era o /esportes "abrindo devagar" do nada.
    """
    global _channel_index_cache, _channel_index_time, _index_em_renovacao
    now = datetime.now(timezone.utc).timestamp()
    if _channel_index_cache is None:
        _channel_index_cache = build_channel_index()
        _channel_index_time = now
        return _channel_index_cache
    if now - _channel_index_time > 60:
        with _index_renovando:
            precisa = not _index_em_renovacao
            if precisa:
                _index_em_renovacao = True

        def _renovar():
            global _channel_index_cache, _channel_index_time, _index_em_renovacao
            try:
                with app.app_context():
                    novo = build_channel_index()
                    _channel_index_cache = novo
                    _channel_index_time = datetime.now(timezone.utc).timestamp()
            except Exception as exc:
                log_error('renovar index de canais falhou: %s' % exc)
            finally:
                with _index_renovando:
                    _index_em_renovacao = False
        threading.Thread(target=_renovar, daemon=True).start()
    return _channel_index_cache

def find_all_matching_channels(name, norm_map, token_map, prefix_map, id_map):
    import unicodedata
    def normalize(s):
        s = unicodedata.normalize('NFKD', s)
        s = s.encode('ascii', 'ignore').decode('ascii')
        return re.sub(r'[^a-z0-9]', '', s.lower())
    def tokenize(s):
        s = unicodedata.normalize('NFKD', s)
        s = s.encode('ascii', 'ignore').decode('ascii')
        return [t for t in re.split(r'[^a-z0-9]+', s.lower()) if t]
    norm_input = normalize(name)
    input_tokens = tokenize(name)

    # Fast path: exact match → find all siblings via prefix_map
    exact = norm_map.get(norm_input)
    if exact:
        siblings = prefix_map.get(norm_input, set())
        if len(siblings) <= 1:
            return [exact]
        result = [id_map[cid] for cid in siblings]
        result.sort(key=lambda x: (len(normalize(x.name)), x.name))
        return result

    # Fast path: check prefix_map for candidates
    candidates = prefix_map.get(norm_input, set())
    if not candidates and len(norm_input) >= 3:
        candidates = prefix_map.get(norm_input[:3], set())

    # Token match against candidates (or all if no candidates)
    if len(input_tokens) >= 1:
        matched = []
        for db_tokens, norm_db, c in token_map:
            if candidates and c.id not in candidates:
                continue
            if all(any(it == dt for dt in db_tokens) for it in input_tokens):
                matched.append((c, 0.9, len(norm_db)))
        if matched:
            matched.sort(key=lambda x: (-x[1], x[2], x[0].name))
            return [c for c, _, _ in matched]

    # Slow path: scan all for substring / fuzzy
    matched = []
    for db_tokens, norm_db, c in token_map:
        if candidates and c.id not in candidates:
            continue
        score = 0
        if len(norm_input) <= len(norm_db):
            short, long = norm_input, norm_db
            if len(short) >= 3 and len(short) / max(len(long), 1) >= 0.55 and short in long:
                score = 0.8
        if score == 0 and len(norm_db) <= len(norm_input):
            short, long = norm_db, norm_input
            if len(short) >= 3 and len(short) / max(len(long), 1) >= 0.55 and short in long:
                score = 0.6
        if score == 0 and len(norm_input) >= 4:
            s = SequenceMatcher(None, norm_input, norm_db).ratio()
            if s >= 0.85:
                score = s
        if score > 0:
            matched.append((c, score, len(norm_db)))
    matched.sort(key=lambda x: (-x[1], x[2], x[0].name))
    return [c for c, _, _ in matched]


def resolve_event_channels(channels_json, norm_map=None, token_map=None, prefix_map=None, id_map=None):
    if norm_map is None or token_map is None:
        norm_map, token_map, prefix_map, id_map = get_channel_index()
    ch_names = json.loads(channels_json) if isinstance(channels_json, str) else channels_json
    result = []
    for raw_name in ch_names:
        name = raw_name.strip()
        if not name:
            result.append({'name': '', 'id': None, 'found': False, 'matches': []})
            continue
        canals = find_all_matching_channels(name, norm_map, token_map, prefix_map, id_map)
        if not canals:
            name_lower = name.lower()
            for prefix in NOISE_PREFIXES:
                if name_lower.startswith(prefix):
                    rest = name[len(prefix):].strip().lstrip(',;:/@#|-').strip()
                    if rest:
                        canals = find_all_matching_channels(rest, norm_map, token_map, prefix_map, id_map)
                        if canals:
                            break
        if canals:
            result.append({
                'name': canals[0].name,
                'id': canals[0].id,
                'logo': canals[0].logo,
                'found': True,
                'matches': [{'name': c.name, 'id': c.id, 'logo': c.logo} for c in canals],
            })
        else:
            result.append({'name': name, 'id': None, 'found': False, 'matches': []})
    return result


# ─── Carteira do 7 PIX Integration ─────────────────────────────────────────
def c7_sign(body_str):
    ts = str(int(_time.time()))
    nonce = uuid.uuid4().hex
    sig = hmac.new(C7_API_SECRET.encode(), (ts + '.' + nonce + '.' + body_str).encode(), hashlib.sha256).hexdigest()
    return {'X-C7-Timestamp': ts, 'X-C7-Nonce': nonce, 'X-C7-Signature': sig}


def c7_create_charge(amount, external_ref, callback_url):
    amt = int(amount) if float(amount).is_integer() else float(amount)
    body_dict = {'amount': amt, 'externalId': external_ref, 'callbackUrl': callback_url}
    body_str = json.dumps(body_dict, separators=(',', ':'))
    headers = {'Authorization': f'Bearer {C7_API_KEY}', 'Content-Type': 'application/json'}
    headers.update(c7_sign(body_str))
    resp = requests.post(f'{C7_API}/payment/create', headers=headers, data=body_str, timeout=30)
    if resp.status_code != 200:
        print(f'[C7] Erro {resp.status_code}: {resp.text[:500]}', flush=True)
    resp.raise_for_status()
    data = resp.json()
    payment = data.get('payment', {})
    return {
        'id': payment.get('id'),
        'pix_copy_paste': payment.get('pixCopiaECola', ''),
        'expiresAt': payment.get('expiresAt'),
    }


def c7_check_status(payment_id):
    resp = requests.get(f'{C7_API}/payment/{payment_id}/status', headers={
        'Authorization': f'Bearer {C7_API_KEY}',
    }, timeout=15)
    if resp.status_code != 200:
        print(f'[C7] Erro status {resp.status_code}: {resp.text[:500]}', flush=True)
    resp.raise_for_status()
    return resp.json()


def generate_qrcode_b64(text):
    img = make_qr(text)
    buf = BytesIO()
    img.save(buf, 'PNG')
    buf.seek(0)
    return base64.b64encode(buf.read()).decode()


def generate_referral_code():
    return ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))


def get_referral_stats(user_id):
    referrals = Referral.query.filter_by(referrer_id=user_id).all()
    total_invited = len(referrals)
    confirmed = sum(1 for r in referrals if r.subscription_confirmed)
    bonus_months = sum(1 for r in referrals if r.bonus_granted)
    return {
        'total_invited': total_invited,
        'confirmed': confirmed,
        'bonus_months': bonus_months,
        'referrals': referrals,
    }


def get_referral_network(user_id, max_level=5):
    """Retorna toda a rede de indicacoes (nivel 1 ate max_level) do usuario."""
    nodes = []
    seen = {user_id}
    current = [user_id]
    level = 1
    while current and level <= max_level:
        nxt = []
        for uid in current:
            refs = Referral.query.filter_by(referrer_id=uid).all()
            for r in refs:
                if r.referred_id in seen:
                    continue
                seen.add(r.referred_id)
                u = db.session.get(User, r.referred_id)
                if not u:
                    continue
                nodes.append({
                    'user': u,
                    'level': level,
                    'referrer_id': uid,
                    'subscription_confirmed': r.subscription_confirmed,
                    'bonus_granted': r.bonus_granted,
                    'created_at': r.created_at,
                })
                nxt.append(u.id)
        current = nxt
        level += 1
    return nodes


def effective_plan_price(plan):
    """Retorna o preço vigente, respeitando promoção ativa e validade opcional."""
    if not plan:
        return 0.0
    now = datetime.now(BRASILIA_TZ)
    until = plan.promotion_until
    if until and until.tzinfo is None:
        until = until.replace(tzinfo=BRASILIA_TZ)
    if plan.promotion_enabled and plan.promotion_price is not None and float(plan.promotion_price) >= 0 and (not until or until >= now):
        return float(plan.promotion_price)
    return float(plan.price or 0)


def get_active_plans():
    plans = Plan.query.filter_by(is_active=True).order_by(Plan.sort_order, Plan.price).all()
    _ensure_plan_benefits()
    return plans


def _default_benefits(key):
    return 'Acesso ilimitado ao catálogo\nFilmes e séries por temporadas e episódios\nCanais ao vivo, favoritos e histórico\n1 dispositivo por vez'


def _ensure_plan_benefits():
    """Garante que todos os plans tenham benefits e description preenchidos.
    Tambem remove prefixo 'Admin - ' dos nomes de planos no historico."""
    try:
        changed = False
        for p in Plan.query.all():
            old_b = (p.benefits or '').strip()
            if not old_b or old_b in ('Acesso ilimitado 24h\nTodos os canais ao vivo', ''):
                p.benefits = _default_benefits(p.key)
                changed = True
            if not (p.description or '').strip():
                p.description = f'Troço Play - {p.name} {p.days} dias'
                changed = True
        for ph in PlanHistory.query.filter(PlanHistory.plan_name.ilike('Admin - %')).all():
            ph.plan_name = ph.plan_name.replace('Admin - ', '').strip()
            changed = True
        if changed:
            db.session.commit()
    except Exception:
        db.session.rollback()


def clean_epg_description(desc):
    if not desc:
        return ''
    desc = re.sub(r'\s*\([Nn]\)\s*$', '', desc)
    desc = re.sub(r'\s*\([A-Z0-9]\)\s*$', '', desc)
    return desc.strip()


# ─── Audit Logging ───────────────────────────────────────────────────────
def log_audit(action, user_id=None, details='', ip=None, ua=None, device=None):
    if user_id is None and current_user.is_authenticated:
        user_id = current_user.id
    if ip is None:
        ip = request.remote_addr if request else ''
    if ua is None:
        ua = request.headers.get('User-Agent', '')[:500] if request else ''
    if device is None:
        device = parse_device_info(ua)
    log = AuditLog(
        user_id=user_id, action=action, ip_address=ip,
        user_agent=ua, device_info=device, details=details,
    )
    db.session.add(log)
    try:
        db.session.commit()
    except Exception:
        db.session.rollback()


def parse_device_info(ua):
    if not ua:
        return 'Desconhecido'
    ua_lower = ua.lower()
    if 'mobile' in ua_lower or 'android' in ua_lower:
        if 'android' in ua_lower:
            return 'Android Mobile'
        return 'Mobile'
    if 'windows' in ua_lower:
        return 'Windows'
    if 'macintosh' in ua_lower or 'mac os' in ua_lower:
        return 'Mac'
    if 'linux' in ua_lower:
        return 'Linux'
    if 'iphone' in ua_lower or 'ipad' in ua_lower:
        return 'iOS'
    return 'Desktop'


def detect_browser(ua):
    if not ua:
        return 'Desconhecido'
    ua_lower = ua.lower()
    if 'edg/' in ua_lower:
        return 'Edge'
    if 'chrome' in ua_lower and 'safari' in ua_lower:
        return 'Chrome'
    if 'firefox' in ua_lower:
        return 'Firefox'
    if 'safari' in ua_lower:
        return 'Safari'
    if 'opera' in ua_lower or 'opr/' in ua_lower:
        return 'Opera'
    return 'Outro'


def detect_platform(ua=''):
    """Identifica a plataforma de origem (site, app_mobile, app_tv, celular)."""
    ua = (ua or '').lower()
    if not ua:
        return 'site'
    if any(x in ua for x in ['android tv', 'googletv', 'aft', 'fire tv', 'firetv', 'leanback', 'smarttv', 'web0s', 'tizen', 'hbbtv']):
        return 'app_tv'
    if any(x in ua for x in ['trocoplay/app', 'trocoplay-tv', 'trocoplay_android_tv', 'trocoplayandroidtv']):
        return 'app_tv'
    if any(x in ua for x in ['trocoplay', 'okhttp', 'exoplayer', 'dalvik']):
        return 'app_mobile'
    if 'mobile' in ua or 'android' in ua or 'iphone' in ua or 'ipad' in ua:
        return 'celular'
    return 'site'


def lookup_geo(ip):
    """Consulta API gratuita de geolocalizacao por IP (ip-api.com).
    Retorna dict com 'city', 'country', 'region' ou {} em falha.
    Cacheia em memoria por 24h para evitar repeticoes."""
    if not ip or ip in {'127.0.0.1', 'localhost', '::1'} or ip.startswith('192.168.') or ip.startswith('10.'):
        return {}
    try:
        cache_key = f'geo::{ip}'
        entry = _query_cache.get(cache_key)
        if entry and _time.time() - entry[1] < 86400:
            return entry[0]
        r = requests.get(f'http://ip-api.com/json/{ip}?fields=status,city,regionName,country,countryCode', timeout=2)
        data = {}
        try:
            j = r.json()
            if j.get('status') == 'success':
                data = {
                    'city': j.get('city') or '',
                    'region': j.get('regionName') or '',
                    'country': j.get('country') or '',
                    'country_code': j.get('countryCode') or '',
                }
        except Exception:
            pass
        _query_cache[cache_key] = (data, _time.time())
        return data
    except Exception:
        return {}


def format_login_context(ip='', ua=''):
    """Monta um resumo curto (IP, cidade/UF, pais, dispositivo, navegador)."""
    parts = []
    if ip:
        parts.append(f'IP {ip}')
    geo = lookup_geo(ip) if ip else {}
    if geo:
        loc = ', '.join([x for x in [geo.get('city'), geo.get('region')] if x])
        country = geo.get('country') or ''
        if loc and country:
            parts.append(f'{loc}, {country}')
        elif country:
            parts.append(country)
    parts.append(parse_device_info(ua))
    parts.append(detect_browser(ua))
    return ' • '.join(p for p in parts if p)


# Locais validos para videos (landing/login/register) — espelha VideoIntro.locations
VIDEO_LOCATIONS = [
    ('landing', 'Landing Page (inicio)'),
    ('login', 'Pagina de Login'),
    ('register', 'Pagina de Cadastro'),
    ('app_mobile', 'App Mobile (Celular)'),
    ('app_tv', 'App TV (Android TV)'),
]


def get_active_videos(location=None):
    """Retorna videos ativos ordenados; opcionalmente filtrando por local."""
    try:
        q = VideoIntro.query.filter_by(is_active=True).order_by(VideoIntro.sort_order.asc(), VideoIntro.id.asc())
        items = q.all()
        if not location:
            return items
        return [v for v in items if v.has_location(location)]
    except Exception:
        return []


def get_admin_videos():
    """Lista completa para o painel admin (ativos e inativos)."""
    try:
        return VideoIntro.query.order_by(VideoIntro.sort_order.asc(), VideoIntro.id.asc()).all()
    except Exception:
        return []


# ─── Notification and Email Helpers ─────────────────────────────────────
def _email_footer_text():
    """Rodape padrao exibido em todos os emails (texto plano e HTML)."""
    return f'Troço Play — Seu IPTV favorito\ntrocoplay.dpdns.org'


EMAIL_SENDERS = [
    ('default',     'Padrão do Sistema',      os.environ.get('SMTP_FROM', '').strip() or None),
    ('marketing',   'Marketing',               os.environ.get('SMTP_MARKETING_FROM', '').strip() or None),
    ('notificacoes','Notificações',           os.environ.get('SMTP_NOTIFICATIONS_FROM', '').strip() or None),
    ('codigos',     'Códigos / Recuperação',  os.environ.get('SMTP_CODES_FROM', '').strip() or None),
    ('suporte',     'Suporte',                os.environ.get('SMTP_SUPORTE_FROM', '').strip() or None),
]


def _get_sender_creds(sender_key):
    """Retorna (sender_email, username, password) baseado na chave selecionada."""
    key_map = {
        'default':     (os.environ.get('SMTP_FROM', '').strip() or None,
                        os.environ.get('SMTP_USERNAME', '').strip() or None,
                        os.environ.get('SMTP_PASSWORD', '')),
        'marketing':   (os.environ.get('SMTP_MARKETING_FROM', '').strip() or
                         os.environ.get('SMTP_FROM', '').strip() or None,
                        os.environ.get('SMTP_MARKETING_USERNAME', '').strip() or
                         os.environ.get('SMTP_USERNAME', '').strip() or None,
                        os.environ.get('SMTP_MARKETING_PASSWORD', '') or
                         os.environ.get('SMTP_PASSWORD', '')),
        'notificacoes': (os.environ.get('SMTP_NOTIFICATIONS_FROM', '').strip() or
                          os.environ.get('SMTP_FROM', '').strip() or None,
                          os.environ.get('SMTP_NOTIFICATIONS_USERNAME', '').strip() or
                          os.environ.get('SMTP_USERNAME', '').strip() or None,
                          os.environ.get('SMTP_NOTIFICATIONS_PASSWORD', '') or
                          os.environ.get('SMTP_PASSWORD', '')),
        'codigos':     (os.environ.get('SMTP_CODES_FROM', '').strip() or
                         os.environ.get('SMTP_FROM', '').strip() or None,
                        os.environ.get('SMTP_CODES_USERNAME', '').strip() or
                         os.environ.get('SMTP_USERNAME', '').strip() or None,
                        os.environ.get('SMTP_CODES_PASSWORD', '') or
                         os.environ.get('SMTP_PASSWORD', '')),
        'suporte':     (os.environ.get('SMTP_SUPORTE_FROM', '').strip() or
                         os.environ.get('SMTP_FROM', '').strip() or None,
                        os.environ.get('SMTP_SUPORTE_USERNAME', '').strip() or
                         os.environ.get('SMTP_USERNAME', '').strip() or None,
                        os.environ.get('SMTP_SUPORTE_PASSWORD', '') or
                         os.environ.get('SMTP_PASSWORD', '')),
    }
    return key_map.get(sender_key, key_map.get('default', (None, None, None)))


def _email_html_template(title, body_text, color='#E50914'):
    """Gera HTML profissional para emails — tema vermelho/preto/branco
    igual ao site — e reduz flag de spam do Gmail."""
    import re as _re
    # Transforma URLs em links clicaveis
    def _linkify(m):
        u = m.group(0)
        return f'<a href="{u}" style="color:{color};text-decoration:underline;">{u}</a>'
    lines = _re.sub(r'https?://[^\s<]+', _linkify, body_text).replace('\n', '<br>')
    return f'''<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#0a0a0a;font-family:'Segoe UI',Helvetica,Arial,sans-serif;">
<div style="max-width:520px;margin:30px auto;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(229,9,20,0.25);">
<div style="background:#E50914;padding:26px 30px;text-align:center;border-bottom:4px solid #000000;">
<h1 style="color:#ffffff;margin:0;font-size:22px;font-weight:700;">{html.escape(title)}</h1>
</div>
<div style="padding:30px;color:#111111;font-size:15px;line-height:1.7;">{lines}</div>
<div style="padding:18px 30px;background:#000000;text-align:center;color:#b3b3b3;font-size:12px;border-top:1px solid #333;">
Troço Play &mdash; Seu IPTV favorito<br>
<a href="https://trocoplay.dpdns.org" style="color:#E50914;text-decoration:none;font-weight:600;">trocoplay.dpdns.org</a>
</div></div></body></html>'''


def _absolute_link(link):
    """Converte um link relativo (/dashboard, /planos...) em URL absoluta."""
    if not link:
        return link
    if link.startswith('http://') or link.startswith('https://'):
        return link
    base = os.environ.get('PUBLIC_BASE_URL', '').strip().rstrip('/')
    if not base:
        try:
            from flask import request as _req
            base = _req.url_root.rstrip('/')
        except Exception:
            pass
    if not base:
        base = 'https://trocoplay.dpdns.org'
    return base + ('/' + link.lstrip('/') if link.startswith('/') else '/' + link)


def send_user_email(recipient, subject, body, sender=None, username=None, password=None, sender_key='default', created_by=None):
    """Envia e-mail via SMTP do cPanel com HTML + plain-text (multipart).
    Inclui headers RFC 5322 completos para maximizar entregabilidade no Gmail.
    Falha de SMTP nunca interrompe a ação principal do usuário.
    """
    host = os.environ.get('SMTP_HOST', '').strip()
    port = int(os.environ.get('SMTP_PORT', '587') or 587)
    username = (username or os.environ.get('SMTP_USERNAME', '')).strip()
    password = os.environ.get('SMTP_PASSWORD', '') if password is None else password
    sender = (sender or os.environ.get('SMTP_FROM', '').strip() or username).strip()
    if not host or not sender or not recipient:
        print(f'[Email] Config incompleta: host={bool(host)} sender={bool(sender)} recipient={bool(recipient)}', flush=True)
        return False
    domain = sender.split('@')[-1] if '@' in sender else 'trocoplay.dpdns.org'
    status = 'sent'
    error_msg = ''
    try:
        msg = EmailMessage()
        # ── Headers RFC 5322 (Gmail exige todos) ──
        msg['Subject'] = subject
        msg['From'] = f'Troço Play <{sender}>'
        msg['To'] = recipient
        msg['Reply-To'] = sender
        msg['Date'] = datetime.now(timezone.utc).strftime('%a, %d %b %Y %H:%M:%S +0000')
        msg['Message-ID'] = f'<{uuid.uuid4().hex}@{domain}>'
        msg['MIME-Version'] = '1.0'
        msg['X-Mailer'] = 'TrocoPlay/1.0'
        msg['Precedence'] = 'bulk'
        msg['List-Unsubscribe'] = f'<mailto:{sender}?subject=unsubscribe>'
        # ── Corpo multipart: plain-text + HTML ──
        # O rodape ja esta includo no body (via _email_html_template no HTML).
        # Para o plain-text, usamos o body original que agora contem o rodape inline.
        msg.set_content(body)
        html_body = _email_html_template(subject.replace('Troço Play — ', ''), body)
        msg.add_alternative(html_body, subtype='html')
        # ── Enviar via SMTP ──
        if port == 465:
            with smtplib.SMTP_SSL(host, port, timeout=30) as smtp:
                if username and password:
                    smtp.login(username, password)
                smtp.send_message(msg)
        else:
            with smtplib.SMTP(host, port, timeout=30) as smtp:
                smtp.ehlo()
                if os.environ.get('SMTP_TLS', 'true').lower() not in {'0', 'false', 'no'}:
                    smtp.starttls(); smtp.ehlo()
                if username and password:
                    smtp.login(username, password)
                smtp.send_message(msg)
        print(f'[Email] Enviado: {subject} -> {recipient}', flush=True)
    except Exception as exc:
        status = 'failed'
        error_msg = str(exc)
        print(f'[Email] Falha ao enviar {subject} para {recipient}: {exc}', flush=True)
    finally:
        try:
            user_id = None
            u = User.query.filter_by(email=recipient).first()
            if u:
                user_id = u.id
            log_entry = EmailLog(
                recipient_email=recipient,
                recipient_user_id=user_id,
                subject=subject,
                body=body,
                sender=sender,
                sender_key=sender_key,
                status=status,
                error=error_msg,
                created_by=created_by,
            )
            db.session.add(log_entry)
            db.session.commit()
        except Exception:
            db.session.rollback()
    return status == 'sent'


def create_notification(user_id, title, message='', link='', send_email=True, save_to_db=True):
    """Cria notificacao no banco e opcionalmente envia email.
    save_to_db=True  → salva no sino + email (se send_email=True)
    save_to_db=False → envia APENAS email, sem criar notificacao no banco"""
    notif = None
    if save_to_db:
        notif = Notification(user_id=user_id, title=title, message=message, link=link)
        db.session.add(notif)
        try:
            db.session.commit()
        except Exception:
            db.session.rollback()
            notif = None
    if send_email:
        user = db.session.get(User, user_id)
        if user and user.email:
            if link:
                account_link = _absolute_link(link)
            else:
                try:
                    account_link = url_for('dashboard', _external=True)
                except RuntimeError:
                    account_link = os.environ.get('PUBLIC_BASE_URL', '').rstrip('/') + '/dashboard'
            send_user_email(
                user.email,
                f'Troço Play — {title}',
                f'{message}\n\nAcesse sua conta: {account_link}',
                sender=os.environ.get('SMTP_NOTIFICATIONS_FROM', '').strip() or None,
                username=os.environ.get('SMTP_NOTIFICATIONS_USERNAME', '').strip() or None,
                password=os.environ.get('SMTP_NOTIFICATIONS_PASSWORD'),
                sender_key='notificacoes',
                created_by=user_id
            )


def get_unread_notifications(user_id):
    return Notification.query.filter_by(user_id=user_id, is_read=False).order_by(Notification.created_at.desc()).all()


def get_unread_count(user_id):
    return Notification.query.filter_by(user_id=user_id, is_read=False).count()


# ─── Subscription Lifecycle Notifications ────────────────────────────────────

def _has_recent_notification(user_id, title_keyword, hours=24):
    """Verifica se ja existe notificacao com titulo contendo `title_keyword`
    enviada nas ultimas `hours` horas para evitar duplicatas."""
    cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
    return Notification.query.filter(
        Notification.user_id == user_id,
        Notification.title.ilike(f'%{title_keyword}%'),
        Notification.created_at >= cutoff
    ).first() is not None


def notify_subscription_activated(user, plan_name, paid_until):
    """Email + notificacao quando assinatura e ativada (compra aprovada)."""
    try:
        pu = paid_until
        if pu.tzinfo is None:
            pu = pu.replace(tzinfo=BRASILIA_TZ)
        expiry_str = pu.strftime('%d/%m/%Y')
    except Exception:
        expiry_str = 'N/A'

    title = 'Assinatura Ativada!'
    message = (
        f'Parabens! Sua assinatura {plan_name} foi ativada com sucesso.\n\n'
        f'Data de vencimento: {expiry_str}\n'
        f'Aproveite todo o conteudo premium do Troco Play!'
    )
    link = '/dashboard'
    send_email = not _has_recent_notification(user.id, 'Ativada', hours=1)
    create_notification(user.id, title, message, link=link, send_email=send_email)


def notify_expiring_soon(user, plan_name, paid_until, days_left):
    """Email + notificacao de lembrete quando assinatura esta para vencer."""
    title = 'Sua assinatura esta vencendo!'
    if days_left == 1:
        message = (
            f'Sua assinatura {plan_name} vence AMANHA.\n\n'
            f'Para continuar aproveitando todo o conteudo premium, '
            f'renovize agora mesmo!'
        )
    else:
        message = (
            f'Sua assinatura {plan_name} vence em {days_left} dias.\n\n'
            f'Nao perca acesso ao conteudo premium. Renovize agora!'
        )
    link = '/planos'
    send_email = not _has_recent_notification(user.id, 'vencendo', hours=12)
    create_notification(user.id, title, message, link=link, send_email=send_email)


def notify_subscription_expired(user, plan_name):
    """Email + notificacao quando a assinatura expira."""
    title = 'Assinatura Expirada'
    message = (
        f'Sua assinatura {plan_name} expirou e voce nao tem mais acesso '
        f'ao conteudo premium.\n\n'
        f'Que tal renovar? Volte a aproveitar todos os canais, filmes '
        f'e series do Troco Play!'
    )
    link = '/planos'
    send_email = not _has_recent_notification(user.id, 'Expirada', hours=24)
    create_notification(user.id, title, message, link=link, send_email=send_email)


def notify_win_back(user, plan_name, days_expired):
    """Email + notificacao de win-back oferecendo premium de volta."""
    if days_expired == 1:
        periodo = '1 dia'
    elif days_expired <= 7:
        periodo = f'{days_expired} dias'
    elif days_expired <= 30:
        periodo = f'{days_expired} dias'
    else:
        periodo = 'vários dias'

    title = 'Estamos com saudades!'
    message = (
        f'Faz {periodo} que voce nao acessa o Troco Play.\n\n'
        f'O conteudo premium continua esperando por voce: canais ao vivo, '
        f'filmes, series e muito mais!\n\n'
        f'Volte agora e ganhe novamente acesso a tudo.'
    )
    link = '/planos'
    send_email = not _has_recent_notification(user.id, 'saudades', hours=48)
    create_notification(user.id, title, message, link=link, send_email=send_email)


# ─── Single Device Session Control (UNIQUE constraint on user_id) ──────
STREAM_SESSION_IDLE_SECONDS = max(90, int(os.environ.get('STREAM_SESSION_IDLE_SECONDS', '300') or 300))
# Sem batimento por este tempo (segundos), a sessao de reproducao e considerada
# MORTA e liberada sozinha. Antes o parametro era 90s aqui e 300s no idle, entao
# quem saia do player ficava "preso" como se ainda estivesse assistindo.
# Os players batem a cada 10s, entao 30s ja e folgado.
STREAM_STALE_SECONDS = max(15, int(os.environ.get('STREAM_STALE_SECONDS', '30') or 30))


def get_active_stream_row(user_id):
    """Sessao de reproducao ATIVA do usuario; apaga sozinha a que morreu.

    Isso torna a liberacao pratica e instantanea: ao sair do player (aba fechada,
    app morto, crash) a linha expira em STREAM_STALE_SECONDS em vez de segurar o
    usuario como se ele ainda estivesse assistindo.
    """
    try:
        row = UserStreaming.query.filter_by(user_id=user_id).first()
    except Exception:
        db.session.rollback()
        return None
    if row is None:
        return None
    hb = row.last_heartbeat
    if hb is not None and hb.tzinfo is None:
        hb = hb.replace(tzinfo=timezone.utc)
    if hb is None or hb < datetime.now(timezone.utc) - timedelta(seconds=STREAM_STALE_SECONDS):
        try:
            db.session.delete(row)
            db.session.commit()
        except Exception:
            db.session.rollback()
        return None
    return row

def get_client_ip():
    if request.headers.get('X-Forwarded-For'):
        return request.headers['X-Forwarded-For'].split(',')[0].strip()
    return request.remote_addr or ''


def stream_start_allowed(user_id, session_token, device_info, content_name, source_index=None, stream_url=None):
    """Registra a sessão e reserva uma credencial M3U sem duplicar contas livres."""
    from sqlalchemy.exc import IntegrityError
    now = datetime.now(timezone.utc)
    current = UserStreaming.query.filter_by(user_id=user_id).first()
    current_heartbeat = current.last_heartbeat if current else None
    if current_heartbeat is not None and current_heartbeat.tzinfo is None:
        current_heartbeat = current_heartbeat.replace(tzinfo=timezone.utc)
    if current and current.session_token == session_token and current_heartbeat and current_heartbeat >= now - timedelta(seconds=STREAM_SESSION_IDLE_SECONDS):
        current.device_info = device_info or current.device_info
        current.content_name = content_name or current.content_name
        current.last_heartbeat = now
        db.session.commit()
        return True, None

    # Limpeza GLOBAL de sessões mortas (não só as deste usuário): no cPanel o
    # scheduler fica desligado e, sem cron, linhas obsoletas ocupam os
    # source_index (unique) para sempre — todo play novo falhava com
    # "credenciais ocupadas" mesmo sem ninguém assistindo.
    UserStreaming.query.filter(
        UserStreaming.last_heartbeat < now - timedelta(seconds=STREAM_STALE_SECONDS),
    ).delete(synchronize_session=False)
    db.session.commit()

    outro = UserStreaming.query.filter_by(user_id=user_id).first()
    if outro:
        db.session.delete(outro)
        db.session.commit()

    sources = get_m3u_credential_sources()
    eligible = [i for i, source in enumerate(sources) if _source_matches_stream(stream_url, source)]
    # Credencial vencida/sem acesso nao entra na reserva: nao adianta ocupar
    # uma conta que o provedor ja recusou. Conta cheia (active_cons =
    # max_connections) tambem sai, desde que sobre alguma com vaga.
    try:
        usable_indexes, cheias, _health = _m3u_usable_source_indexes(sources)
        if usable_indexes:
            filtered = [i for i in eligible if i in usable_indexes]
            if filtered:
                eligible = filtered
            else:
                cheias_compativeis = [i for i in eligible if i in cheias]
                if cheias_compativeis:
                    eligible = cheias_compativeis
                # Sem nenhuma conta valida: mantem eligible como esta para
                # nao bloquear o usuario (o /proxy ainda tenta a URL).
    except Exception:
        pass
    # NUNCA bloquear por incompatibilidade de credencial. Isso virava
    # "Acesso Bloqueado: ... nenhuma credencial M3U é compatível", prendendo a
    # conta inteira ao trocar de provedor. Playlists Xtream anunciam os streams
    # em outro dominio, e o catalogo pode ter itens de um provedor antigo — o
    # stream ja leva as credenciais na propria URL, entao seguir sem reservar
    # credencial e o comportamento correto.
    if source_index not in eligible:
        source_index = None
    attempts = max(1, len(eligible))
    tried = set()
    for attempt in range(attempts):
        candidate = source_index if attempt == 0 else choose_m3u_source_index(user_id, stream_url, tried)
        if candidate is None and eligible:
            remaining = [i for i in eligible if i not in tried]
            candidate = remaining[0] if remaining else None
        if candidate in tried:
            continue
        tried.add(candidate)
        try:
            streaming = UserStreaming(
                user_id=user_id, session_token=session_token,
                device_info=device_info, content_name=content_name,
                source_index=candidate,
            )
            db.session.add(streaming)
            db.session.commit()
            return True, None
        except IntegrityError:
            db.session.rollback()
            # Concorrência (duplo clique/play simultâneo): limpa mortos de novo
            # antes de tentar a próxima credencial.
            try:
                UserStreaming.query.filter(
                    UserStreaming.last_heartbeat < datetime.now(timezone.utc) - timedelta(seconds=STREAM_STALE_SECONDS),
                ).delete(synchronize_session=False)
                db.session.commit()
            except Exception:
                db.session.rollback()
            source_index = None
            continue
    return False, 'todas as credenciais M3U compatíveis estão ocupadas'


def get_reserved_source_index(user_id, session_token):
    """Retorna a fonte realmente persistida para a sessão ativa."""
    row = UserStreaming.query.filter_by(user_id=user_id, session_token=session_token).first()
    return row.source_index if row else None


def stream_heartbeat(user_id, session_token):
    now = datetime.now(timezone.utc)
    entry = UserStreaming.query.filter_by(user_id=user_id).first()
    if not entry or entry.session_token != session_token:
        return False
    last_heartbeat = entry.last_heartbeat
    if last_heartbeat is not None and last_heartbeat.tzinfo is None:
        last_heartbeat = last_heartbeat.replace(tzinfo=timezone.utc)
    if last_heartbeat and last_heartbeat < now - timedelta(seconds=STREAM_SESSION_IDLE_SECONDS):
        db.session.delete(entry)
        db.session.commit()
        return False
    entry.last_heartbeat = now
    db.session.commit()
    return True


def stream_stop(user_id, session_token):
    UserStreaming.query.filter_by(user_id=user_id, session_token=session_token).delete()
    db.session.commit()


# ─── Persistent Login ───────────────────────────────────────────────────
REMEMBER_ME_DAYS = 7

def create_session_token(user_id, remember_me=True, ip='', ua=''):
    token = secrets.token_hex(64)
    expires = datetime.now(timezone.utc) + timedelta(days=REMEMBER_ME_DAYS if remember_me else 1)
    session_token = SessionToken(
        user_id=user_id, token=token,
        ip_address=ip, user_agent=ua,
        device_info=parse_device_info(ua),
        expires_at=expires,
    )
    db.session.add(session_token)
    db.session.commit()
    return token


def validate_session_token(token):
    if not token:
        return None
    st = SessionToken.query.filter_by(token=token, is_active=True).first()
    if not st:
        return None
    if st.expires_at < datetime.now(timezone.utc):
        st.is_active = False
        db.session.commit()
        return None
    user = db.session.get(User, st.user_id)
    if not user or not user.is_active_user or user.is_banned:
        return None
    return user


def invalidate_all_sessions(user_id):
    SessionToken.query.filter_by(user_id=user_id, is_active=True).update({'is_active': False})
    ActiveSession.query.filter_by(user_id=user_id).delete()
    UserStreaming.query.filter_by(user_id=user_id).delete()
    db.session.commit()


# ─── PIX Payment Helpers ────────────────────────────────────────────────
PIX_EXPIRY_MINUTES = 20

def get_pending_payment(user_id, plan_type):
    now_utc = datetime.now(timezone.utc)
    return Payment.query.filter(
        Payment.user_id == user_id,
        Payment.plan_type == plan_type,
        Payment.status == 'pending',
    ).filter(
        db.or_(Payment.expires_at == None, Payment.expires_at > now_utc)
    ).first()


# ─── FFmpeg Live HLS Proxy ─────────────────────────────────────────────────
HLS_DIR = os.path.join(BASE_DIR, 'instance', 'hls')
os.makedirs(HLS_DIR, exist_ok=True)

live_processes = {}
live_lock = threading.Lock()

def _get_ts_url(channel_id):
    return f'http://primevido.asia:80/JucaNovaDNTV/Jcaa092024/{channel_id}'

def _start_ffmpeg(channel_id):
    if not FFMPEG_ENABLED:
        return False
    with live_lock:
        if channel_id in live_processes:
            p = live_processes[channel_id]
            if p.poll() is None:
                p.last_access = _time.time()
                return True
            try:
                os.killpg(os.getpgid(p.pid), signal.SIGTERM)
            except Exception:
                pass
            del live_processes[channel_id]

        if len(live_processes) >= 20:
            oldest_id = min(live_processes, key=lambda k: live_processes[k].last_access)
            _stop_ffmpeg(oldest_id)

        m3u8_path = os.path.join(HLS_DIR, f'{channel_id}.m3u8')
        seg_pattern = os.path.join(HLS_DIR, f'{channel_id}_%03d.ts')

        cmd = [
            FFMPEG_BINARY, '-y',
            '-loglevel', 'warning',
            '-reconnect', '1',
            '-reconnect_streamed', '1',
            '-reconnect_delay_max', '5',
            '-rw_timeout', '10000000',
            '-i', _get_ts_url(channel_id),
            '-c', 'copy',
            '-f', 'hls',
            '-hls_time', '4',
            '-hls_list_size', '10',
            '-hls_flags', 'delete_segments+append_list+omit_endlist',
            '-hls_segment_filename', seg_pattern,
            '-hls_allow_cache', '0',
            m3u8_path,
        ]

        try:
            log_path = os.path.join(HLS_DIR, f'{channel_id}.log')
            log_file = open(log_path, 'w')
            proc = subprocess.Popen(
                cmd,
                stdout=log_file,
                stderr=log_file,
                preexec_fn=os.setsid,
            )
            proc.last_access = _time.time()
            proc.log_file = log_file
            live_processes[channel_id] = proc
            print(f'[FFmpeg] Started channel {channel_id}, pid={proc.pid}', flush=True)
            return True
        except Exception as e:
            print(f'[FFmpeg] Error starting {channel_id}: {e}', flush=True)
            return False

def _stop_ffmpeg(channel_id):
    with live_lock:
        proc = live_processes.pop(channel_id, None)
    if proc:
        try:
            os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
        except Exception:
            try:
                proc.kill()
            except Exception:
                pass
        try:
            proc.log_file.close()
        except Exception:
            pass
        for f in glob.glob(os.path.join(HLS_DIR, f'{channel_id}_*.ts')):
            try:
                os.remove(f)
            except Exception:
                pass
        for ext in ['m3u8', 'log']:
            p = os.path.join(HLS_DIR, f'{channel_id}.{ext}')
            if os.path.exists(p):
                try:
                    os.remove(p)
                except Exception:
                    pass
        print(f'[FFmpeg] Stopped channel {channel_id}', flush=True)

def _cleanup_stale():
    now = _time.time()
    stale = []
    with live_lock:
        for cid, proc in list(live_processes.items()):
            if now - proc.last_access > 120 or proc.poll() is not None:
                stale.append(cid)
    for cid in stale:
        _stop_ffmpeg(cid)
    for f in glob.glob(os.path.join(HLS_DIR, '*.ts')):
        if now - os.path.getmtime(f) > 60:
            try:
                os.remove(f)
            except Exception:
                pass
    for f in glob.glob(os.path.join(HLS_DIR, '*.m3u8')):
        if now - os.path.getmtime(f) > 60:
            try:
                os.remove(f)
            except Exception:
                pass
    try:
        with app.app_context():
            cutoff = datetime.now(timezone.utc) - timedelta(seconds=35)
            UserStreaming.query.filter(UserStreaming.last_heartbeat < cutoff).delete()
            db.session.commit()
    except Exception:
        try:
            with app.app_context():
                db.session.rollback()
        except Exception:
            pass


# ─── TV Login via Code ────────────────────────────────────────────────
# Codigos persistidos no banco (compartilhados entre workers do Passenger).
TV_CODE_EXPIRY = 300  # 5 minutes

def generate_tv_code(user_id):
    # Remove codigos antigos do mesmo usuario e expirados de todos
    cutoff = (datetime.now(timezone.utc) - timedelta(seconds=TV_CODE_EXPIRY)).replace(tzinfo=None)
    try:
        TVAuthCode.query.filter(TVAuthCode.user_id == user_id).delete()
        TVAuthCode.query.filter(TVAuthCode.created_at < cutoff).delete()
        db.session.commit()
    except Exception:
        db.session.rollback()
    code = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
    try:
        db.session.add(TVAuthCode(code=code, user_id=user_id))
        db.session.commit()
    except Exception:
        db.session.rollback()
        # Tentativa unica com outro codigo em caso de colisao
        code = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
        db.session.add(TVAuthCode(code=code, user_id=user_id))
        db.session.commit()
    return code

def consume_tv_code(code):
    row = TVAuthCode.query.filter_by(code=code).first()
    if not row:
        return None
    try:
        db.session.delete(row)
        db.session.commit()
    except Exception:
        db.session.rollback()
    created = row.created_at
    if created is not None:
        if created.tzinfo is not None:
            created = created.replace(tzinfo=None)
        if (datetime.now(timezone.utc).replace(tzinfo=None) - created).total_seconds() > TV_CODE_EXPIRY:
            return None
    return row.user_id

def cleanup_tv_codes():
    cutoff = (datetime.now(timezone.utc) - timedelta(seconds=TV_CODE_EXPIRY)).replace(tzinfo=None)
    try:
        TVAuthCode.query.filter(TVAuthCode.created_at < cutoff).delete()
        db.session.commit()
    except Exception:
        db.session.rollback()

try:
    with app.app_context():
        cleanup_tv_codes()
except Exception:
    pass


# ─── Stream Access Tokens (stateless, signed) ───────────────────────────
# Tokens assinados com HMAC. Funcionam em qualquer worker/processo
# (multithread do Passenger no cPanel), sem estado compartilhado.
STREAM_TOKEN_EXPIRY = 86400  # 24 hours

# ─── Limites do proxy de streaming (anti-travamento) ────────────────────
# Cada request /proxy ocupa um processo do Passenger enquanto transmite.
# Sem limites, uma credencial M3U morta fazia o proxy tentar TODAS as
# credenciais (31+), cada uma esperando ate 15s para conectar: o processo
# ficava preso por minutos, o pool esgotava e o site inteiro "caia"
# (paginas em carregamento infinito e o usuario preso dentro do player).
PROXY_MAX_CANDIDATES = max(1, int(os.environ.get('PROXY_MAX_CANDIDATES', '3') or 3))
PROXY_FAILOVER_BUDGET = max(3, int(os.environ.get('PROXY_FAILOVER_BUDGET', '15') or 15))  # segundos no total
PROXY_UPSTREAM_TIMEOUT = (
    max(2, int(os.environ.get('PROXY_CONNECT_TIMEOUT', '5') or 5)),
    max(5, int(os.environ.get('PROXY_READ_TIMEOUT', '20') or 20)),
)
PROXY_MAX_STREAM_SECONDS = max(300, int(os.environ.get('PROXY_MAX_STREAM_SECONDS', '10800') or 10800))  # 3h

# Token de handoff cPanel->VPS.
#
# ANTES era 5 min de proposito, achando que o VPS reemitia child tokens e o
# cPanel nao precisaria valer o dia todo. Isso estava errado: o hls.js guarda
# a URL do manifest por toda a sessao e so troca o token quando o player e
# recarregado. Entao, aos 5 min, TODO reload do manifest voltava 403 e o
# recovery automatico reiniciava o player a cada ciclo. 24 h elimina a janela
# sem depender de recarga - e o mesmo prazo ja usado no /proxy.
VPS_TOKEN_EXPIRY = max(60, int(os.environ.get('VPS_TOKEN_EXPIRY', '86400') or 86400))  # 24 h

# ─── Preferência HLS para ao vivo ────────────────────────────────────
# Canais ao vivo chegam do M3U como Xtream sem extensão (host/user/pass/ID)
# e tocavam via mpegts numa conexão única frágil: qualquer corte = fim total.
# O formato .m3u8 do próprio provedor é fatiado (resiliente: perde-se no máximo
# um segmento) — igual aos apps de referência. VOD/séries já são .mp4.
HLS_VARIANT_CACHE_TTL = 3600
_hls_variant_cache = {}  # (canal_id, source_index) -> (ok, timestamp, variant_url)
_HLS_BG_FILA = set()     # (canal_id, source_index) ja sendo validado agora

def _xtream_hls_variant(url):
    """Variante .m3u8 de URL Xtream live sem extensão. None se não se aplica."""
    try:
        parts = urllib.parse.urlsplit(url or '')
    except Exception:
        return None
    if parts.scheme not in ('http', 'https'):
        return None
    segs = (parts.path or '').strip('/').split('/')
    if len(segs) != 3:
        return None
    user, pwd, sid = segs
    if not user or not pwd:
        return None
    # O catálogo Xtream devolve live como .../<id>.ts. O ".ts" NAO impede a
    # variante HLS — é ela justamente que resolve o travamento, porque entrega
    # segmentos completos ao navegador em vez de um TS único sem chave.
    if '.' in sid:
        sid, _, ext = sid.rpartition('.')
        if ext.lower() not in ('ts', 'm3u8', 'm3u'):
            return None
    if not sid.isdigit():
        return None
    return f'{parts.scheme}://{parts.netloc}/live/{user}/{pwd}/{sid}.m3u8'


def _check_hls_variant(variant_url, referrer='', user_agent=''):
    """True se a variante responde playlist HLS válida (lê só o início)."""
    try:
        headers = {
            'User-Agent': user_agent or os.environ.get('STREAM_USER_AGENT', 'VLC/3.0'),
            'Accept': '*/*',
            'Connection': 'close',
        }
        if referrer:
            headers['Referer'] = referrer
        r = requests.get(variant_url, headers=headers, timeout=(5, 8), stream=True)
        try:
            if r.status_code != 200:
                return False
            head = next(r.iter_content(2048), b'')
            if isinstance(head, str):
                head = head.encode('utf-8', 'ignore')
            return head.lstrip(b'\xef\xbb\xbf \r\n\t').upper().startswith(b'#EXTM3U')
        finally:
            try:
                r.close()
            except Exception:
                pass
    except Exception:
        return False


def _hls_variant_em_background(canal_id, stream_url, source_index, referrer, user_agent):
    """Valida a variante HLS sem atrasar a pagina aberta pelo usuario.

    Um provedor que nao responde custa ~15 s no clique. A validacao roda aqui e
    aquece _hls_variant_cache: a proxima abertura (ou o /api/stream/refresh, que
    roda quando o video de fato comeca) ja encontra a resposta pronta."""
    chave = (canal_id, source_index)
    if chave in _HLS_BG_FILA:
        return
    _HLS_BG_FILA.add(chave)

    def _rodar():
        try:
            variant = _xtream_hls_variant(stream_url)
            ok = bool(variant) and _check_hls_variant(variant, referrer=referrer,
                                                      user_agent=user_agent)
            now = _time.time()
            if len(_hls_variant_cache) > 2000:
                _hls_variant_cache.clear()
            _hls_variant_cache[chave] = (ok, now, variant if ok else '')
        except Exception as e:
            log_error(f'HLS BG canal={canal_id}: {e}')
        finally:
            _HLS_BG_FILA.discard(chave)
    threading.Thread(target=_rodar, daemon=True).start()


def resolve_playable_stream(item, stream_url, source_index=None, probe=True):
    """Escolhe a melhor URL (HLS validado p/ live, ou a original) + flag is_hls.

    Retorna (url, is_hls). Com cache por canal para não atrasar cada play.
    A credencial continua só dentro do token assinado — nunca vai ao navegador.
    Com probe=False a pagina abre na hora: usa o cache e, se ele estiver vazio,
    entrega o stream original enquanto a validacao roda em background.
    """
    if not stream_url:
        return stream_url, False
    if '.m3u8' in stream_url:
        return stream_url, True
    if item is None or getattr(item, 'tipo', '') != 'live':
        return stream_url, False
    canal_id = getattr(item, 'id', None)
    now = _time.time()
    if canal_id is not None:
        hit = _hls_variant_cache.get((canal_id, source_index))
        if hit and now - hit[1] < HLS_VARIANT_CACHE_TTL:
            if hit[0]:
                return hit[2], True
            return stream_url, False
        if not probe:
            _hls_variant_em_background(canal_id, stream_url, source_index,
                                       getattr(item, 'stream_referrer', '') or '',
                                       getattr(item, 'stream_user_agent', '') or '')
            return stream_url, False
    variant = _xtream_hls_variant(stream_url)
    ok = bool(variant) and _check_hls_variant(
        variant,
        referrer=getattr(item, 'stream_referrer', '') or '',
        user_agent=getattr(item, 'stream_user_agent', '') or '',
    )
    if canal_id is not None:
        if len(_hls_variant_cache) > 2000:
            _hls_variant_cache.clear()
        _hls_variant_cache[canal_id, source_index] = (ok, now, variant if ok else '')
    if ok:
        log_info(f'RESOLVE canal={canal_id} -> HLS variante={variant}')
        return variant, True
    log_info(f'RESOLVE canal={canal_id} -> TS cru (HLS indisponivel, '
             f'variante testada={variant or "nao aplicavel"}) url={stream_url[:110]}')
    return stream_url, False

def _sign_token(payload_b64, secret=None):
    key = secret or app.config['SECRET_KEY']
    return hmac.new(key.encode('utf-8'),
                    payload_b64.encode('utf-8'), hashlib.sha256).hexdigest()

# ─── Handoff para a VPS de streaming (Opção B) ─────────────────────────
# A VPS (stream_server.py) assina os tokens de HLS com a MESMA chave, então
# os child tokens rewritten nas playlists continuam válidos lá. Criptografar
# a URL aqui quebraria a validação na VPS, por isso o payload segue em
# base64+HMAC — o mesmo formato que o /proxy do cPanel já usava.
def _stream_proxy_secret():
    return (os.environ.get('STREAM_PROXY_SECRET') or app.config['SECRET_KEY'])

def stream_vps_base():
    """Base pública da VPS (sem barra final). '' se a VPS não está configurada."""
    return (os.environ.get('STREAM_VPS_URL') or '').rstrip('/')

def generate_vps_stream_token(stream_url, user_id, referrer='', user_agent='', source_index=None):
    """Token de 24 h assinado com a chave compartilhada cPanel<->VPS."""
    payload = json.dumps({
        'url': stream_url,
        'uid': user_id,
        'referrer': referrer or '',
        'user_agent': user_agent or '',
        'source_index': source_index,
        'exp': _time.time() + VPS_TOKEN_EXPIRY,
    }, separators=(',', ':'))
    payload_b64 = base64.urlsafe_b64encode(payload.encode('utf-8')).decode('utf-8')
    return payload_b64 + '.' + _sign_token(payload_b64, _stream_proxy_secret())

def generate_stream_token(stream_url, user_id, referrer='', user_agent='', source_index=None):
    payload = json.dumps({
        'url': stream_url,
        'uid': user_id,
        'referrer': referrer or '',
        'user_agent': user_agent or '',
        'source_index': source_index,
        'exp': _time.time() + STREAM_TOKEN_EXPIRY,
    })
    payload_b64 = base64.urlsafe_b64encode(payload.encode('utf-8')).decode('utf-8')
    return payload_b64 + '.' + _sign_token(payload_b64)

def _get_stream_token_payload(token, user_id=None):
    if not token:
        return None
    try:
        payload_b64, sig = token.rsplit('.', 1)
        if not hmac.compare_digest(sig, _sign_token(payload_b64)):
            return None
        payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode('utf-8')).decode('utf-8'))
        if user_id is not None and payload.get('uid') != user_id:
            return None
        if _time.time() > payload.get('exp', 0):
            return None
        return payload
    except Exception:
        return None

def get_stream_url_from_token(token, user_id):
    payload = _get_stream_token_payload(token, user_id)
    return payload.get('url') if payload else None

def cleanup_stream_tokens():
    pass


@app.route('/live/<channel_id>.m3u8')
@limiter.exempt
@login_required
def live_m3u8(channel_id):
    if not channel_id.isdigit():
        return 'Invalid channel', 400

    token = session.get('persistent_token', '')
    outro = UserStreaming.query.filter_by(user_id=current_user.id).first()
    if outro and outro.session_token != token:
        return 'Acesso bloqueado: esta conta ja esta reproduzindo em outro dispositivo.', 403

    m3u8_path = os.path.join(HLS_DIR, f'{channel_id}.m3u8')

    if os.path.exists(m3u8_path):
        with live_lock:
            if channel_id in live_processes:
                live_processes[channel_id].last_access = _time.time()
        try:
            with open(m3u8_path, 'r') as f:
                content = f.read()
        except Exception:
            return 'Error reading playlist', 500
        resp = Response(content, content_type='application/vnd.apple.mpegurl')
        resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
        resp.headers['Access-Control-Allow-Origin'] = '*'
        return resp

    if not FFMPEG_ENABLED:
        return jsonify({
            'error': 'live_hls_disabled',
            'message': 'A conversão HLS local está desativada neste ambiente. Use uma URL de stream HLS direta.'
        }), 501
    _start_ffmpeg(channel_id)
    resp = Response('', status=503)
    resp.headers['Retry-After'] = '2'
    resp.headers['Access-Control-Allow-Origin'] = '*'
    return resp


@app.route('/live/<channel_id>_<int:segment>.ts')
@limiter.exempt
@login_required
def live_segment(channel_id, segment):
    token = session.get('persistent_token', '')
    outro = UserStreaming.query.filter_by(user_id=current_user.id).first()
    if outro and outro.session_token != token:
        return 'Acesso bloqueado', 403

    seg_path = os.path.join(HLS_DIR, f'{channel_id}_{segment:03d}.ts')
    if not os.path.exists(seg_path):
        return 'Segment not found', 404

    with live_lock:
        if channel_id in live_processes:
            live_processes[channel_id].last_access = _time.time()

    return send_file(seg_path, mimetype='video/mp2t')


@app.route('/api/live_stop/<channel_id>', methods=['POST'])
@login_required
def live_stop(channel_id):
    _stop_ffmpeg(channel_id)
    return jsonify({'ok': True})


@app.route('/api/live_debug/<channel_id>')
@login_required
@admin_required
def live_debug(channel_id):
    m3u8_path = os.path.join(HLS_DIR, f'{channel_id}.m3u8')
    log_path = os.path.join(HLS_DIR, f'{channel_id}.log')
    segs = glob.glob(os.path.join(HLS_DIR, f'{channel_id}_*.ts'))

    result = {
        'channel_id': channel_id,
        'm3u8_exists': os.path.exists(m3u8_path),
        'm3u8_size': os.path.getsize(m3u8_path) if os.path.exists(m3u8_path) else 0,
        'segments': len(segs),
        'process_alive': channel_id in live_processes and live_processes[channel_id].poll() is None,
    }

    if os.path.exists(log_path):
        with open(log_path) as f:
            result['ffmpeg_log'] = f.read()[-500:]

    if os.path.exists(m3u8_path):
        with open(m3u8_path) as f:
            result['m3u8_content'] = f.read()[:300]

    return jsonify(result)

# ─── Image proxy for external logos (fix Mixed Content) ──────────
_logo_cache = {}  # url -> (data, content_type, timestamp)
_LOGO_CACHE_TTL = 86400  # 24 hours (server-side cache)
# Uma pagina de canais pede ~60 logos; se 20 hosts estao mudos, cada card segura
# o worker do cPanel por 8 s e a grade inteira demora mais que o conteudo.
# Alem de cache positivo, guarda a FALHA por URL e abre "disjuntor" por host.
_logo_fail = {}        # url -> timestamp ate quando nao tentar de novo
_logo_host_fail = {}   # host -> (falhas seguidas, disjuntor ate quando)
_LOGO_FAIL_TTL = 900
_LOGO_HOST_BREAK = 300
_LOGO_HOST_TRIP = 3

# ─── Conversao de imagem para WebP (item 2.5) ──────────────────────
# Reduz o tamanho das logos em ~60% e padroniza o formato servido.
_WEBP_QUALITY = 85
_LOGO_MAX_WIDTH = 300

try:
    from PIL import Image as _PILImage
    HAS_PIL = True
except Exception:
    HAS_PIL = False


def _optimize_logo(raw, ctype):
    """Converte uma imagem para WebP (q85) e limita a largura a 300px.

    Devolve (bytes, content_type). Se o Pillow nao estiver disponivel ou a
    imagem for invalida, devolve a original intacta — o proxy nunca quebra
    por causa desta otimizacao.
    """
    if not HAS_PIL or not raw:
        return raw, ctype
    try:
        import io as _io
        img = _PILImage.open(_io.BytesIO(raw))
        img.load()
        # Logos costumam ter fundo transparente; WebP preserva o alfa.
        if img.mode not in ('RGB', 'RGBA'):
            img = img.convert('RGBA' if 'A' in img.getbands() else 'RGB')
        w, h = img.size
        if w > _LOGO_MAX_WIDTH and w > 0:
            new_h = max(1, int(round(h * (_LOGO_MAX_WIDTH / float(w)))))
            img = img.resize((_LOGO_MAX_WIDTH, new_h), _PILImage.LANCZOS)
        out = _io.BytesIO()
        img.save(out, 'WEBP', quality=_WEBP_QUALITY, method=4)
        data = out.getvalue()
        # Só troca se realmente ganhar algo (evita servir WebP maior).
        if data and len(data) < len(raw):
            return data, 'image/webp'
        return raw, ctype
    except Exception:
        return raw, ctype


def _logo_failure_response(motivo):
    """Resposta do proxy quando a origem nao entregou imagem: 204 vazio.

    Devolver um pixel (transparente ou nao) faz o <img> achar que carregou: o
    navegador NAO dispara 'error', entao o placeholder do card nunca aparece e
    o pixel estica ate virar o fundo colorido do cartao inteiro. Com 204 o erro
    e honesto, quem trata onerror mostra o placeholder e quem nao trata fica no
    fundo escuro proprio do card — nunca numa cor inventada.
    """
    resp = Response(status=204)
    resp.headers['X-Logo-Proxy'] = 'v3-%s' % motivo
    # no-store: uma falha momentanea da origem nao pode ficar presa no cache
    resp.headers['Cache-Control'] = 'no-store'
    return resp


@app.route('/api/logo')
@limiter.exempt
def api_logo_proxy():
    """Proxy external images to fix Mixed Content (http->https).

    Aceita dois formatos:
      ?u=<base64url>  -> formato novo (nao contem "http" e passa por WAF)
      ?url=<url>      -> formato antigo, mantido por compatibilidade
    Se a origem falhar, devolve 204 (vazio) para que o <img> dispare 'error' e
    a pagina mostre o placeholder. Nunca devolve pixel colorido.
    """
    url = ''
    enc = (request.args.get('u') or '').strip()
    if enc:
        try:
            padded = enc + '=' * (-len(enc) % 4)
            url = base64.urlsafe_b64decode(padded.encode('ascii')).decode('utf-8', 'replace').strip()
        except Exception:
            url = ''
    if not url:
        url = (request.args.get('url') or '').strip()
    if not url or not url.lower().startswith(('http://', 'https://')):
        return _logo_failure_response('badurl')
    # Cache check
    now = _time.time()
    cached = _logo_cache.get(url)
    if cached and (now - cached[2]) < _LOGO_CACHE_TTL:
        return Response(cached[0], mimetype=cached[1],
                        headers={'Cache-Control': 'public, max-age=86400, immutable',
                                 'X-Logo-Proxy': 'v2-cache'})
    ate_quando = _logo_fail.get(url)
    if ate_quando and now < ate_quando:
        return _logo_failure_response('cached-fail')
    host = (urlparse(url).netloc or '').lower()
    quebra = _logo_host_fail.get(host)
    if quebra and now < quebra[1]:
        return _logo_failure_response('host-break')
    try:
        resp = requests.get(url, timeout=6, headers={
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0'
        })
        if resp.status_code == 200 and len(resp.content) < 2_000_000:
            ct = resp.headers.get('Content-Type', 'image/png')
            if 'image' not in ct:
                ct = 'image/png'
            data, ct = _optimize_logo(resp.content, ct)
            if not data:
                # origem respondeu 200 com corpo invalido: tratar como falha,
                # senao o <img> quebra sem onerror e o card fica estourado
                try:
                    _logger.info('logo_origem_vazia url=%s', url[:160])
                except Exception:
                    pass
                return _logo_failure_response('empty')
            _logo_cache[url] = (data, ct, now)
            _logo_fail.pop(url, None)
            _logo_host_fail.pop(host, None)
            # Limit cache size
            if len(_logo_cache) > 2000:
                oldest = min(_logo_cache, key=lambda k: _logo_cache[k][2])
                del _logo_cache[oldest]
            return Response(data, mimetype=ct,
                            headers={'Cache-Control': 'public, max-age=86400, immutable',
                                     'X-Logo-Proxy': 'v2-ok'})
        try:
            _logger.info('logo_origem_falhou status=%s url=%s', resp.status_code, url[:160])
        except Exception:
            pass
    except Exception as e:
        try:
            _logger.info('logo_origem_erro err=%r url=%s', e, url[:160])
        except Exception:
            pass
    _logo_fail[url] = now + _LOGO_FAIL_TTL
    seguidas, _ = _logo_host_fail.get(host, (0, 0.0))
    seguidas += 1
    ate = now + _LOGO_HOST_BREAK if seguidas >= _LOGO_HOST_TRIP else 0.0
    _logo_host_fail[host] = (0 if ate else seguidas, ate)
    if len(_logo_fail) > 4000:
        # o mapa de falhas nao pode crescer sozinho: mantem so o que ainda vale
        for k in [k for k, v in _logo_fail.items() if v <= now]:
            _logo_fail.pop(k, None)
    if len(_logo_host_fail) > 500:
        for k in [k for k, v in _logo_host_fail.items() if v[1] <= now]:
            _logo_host_fail.pop(k, None)
    return _logo_failure_response('fallback')

@app.route('/api/capa-serie')
@login_required
def api_capa_serie():
    """Capa oficial das series que a pagina pede depois de ja ter aparecido.

    O site abre com o que tem (e mostra skeleton no lugar da arte); este endpoint
    resolve no provedor — no maximo alguns segundos por lote, com a categoria em
    RAM — e devolve so quem tem capa oficial. Nada aqui segura a abertura da
    pagina: quem nao resolve volta vazio e o placeholder fica.
    """
    nomes = []
    for valor in request.args.getlist('n'):
        for parte in str(valor or '').split('\x1f'):
            nome = parte.strip()[:300]
            if nome and nome not in nomes:
                nomes.append(nome)
        if len(nomes) >= 40:
            break
    nomes = nomes[:40]
    t0 = _time.time()
    capas = {}
    for nome in nomes:
        if _capa_estado(nome) != 'oficial':
            if _time.time() - t0 > 5:
                break          # o resto fica para o proximo lote do navegador
            linha = db.session.execute(text(
                "SELECT stream_url, group_title FROM canal WHERE tipo='series' "
                "AND COALESCE(NULLIF(series_name,''), name) = :n LIMIT 1"),
                {'n': nome}).fetchone()
            _garantir_capa_oficial(nome, (linha[0] if linha else ''),
                                   (linha[1] if linha else ''))
        cover, _plot = _capa_oficial_serie(nome)
        if cover:
            capas[nome] = _logo_proxy_href(cover)
    return jsonify({'capas': capas, 'segundos': round(_time.time() - t0, 2)})


@app.route('/proxy')
@limiter.exempt
@login_required
def proxy_stream():
    persistent_token = session.get('persistent_token', '')
    outro = get_active_stream_row(current_user.id)
    if outro and outro.session_token != persistent_token:
        return 'Acesso bloqueado: esta conta ja esta reproduzindo em outro dispositivo.', 403

    token = request.args.get('token', '')
    url = request.args.get('url', '')
    token_payload = _get_stream_token_payload(token, current_user.id) if token else None
    if token:
        if not token_payload:
            return 'Token de acesso invalido ou expirado.', 403
        url = token_payload.get('url', '')
    if not url:
        return 'Missing url param', 400

    # ── OPÇÃO B: handoff para a VPS ──────────────────────────────────────
    # O cPanel para de carregar bytes de video: valida sessao/token aqui e
    # entrega o player direto na VPS. Range e If-None-Match vao em query
    # string (o 307 preserva corpo/metodo, mas nao os headers do player).
    vps_base = stream_vps_base()
    if vps_base and token_payload:
        try:
            uid = current_user.id if current_user.is_authenticated else 0
        except Exception:
            uid = 0
        vps_url = vps_base + '/stream?token=' + urllib.parse.quote(
            generate_vps_stream_token(
                url,
                current_user.id,
                token_payload.get('referrer') or '',
                token_payload.get('user_agent') or '',
                token_payload.get('source_index'),
            ),
            safe='',
        )
        carry = []
        for hname in ('Range', 'If-None-Match', 'If-Modified-Since', 'If-Range'):
            hval = request.headers.get(hname)
            if hval:
                carry.append((hname, hval))
        for hname, hval in carry:
            vps_url += '&' + hname + '=' + urllib.parse.quote(hval, safe='')
        resp = redirect(vps_url, code=307)
        resp.headers['Cache-Control'] = 'no-store'
        rng = request.headers.get('Range') or ''
        _low = (url or '').lower().split('?')[0]
        _kind = 'hls' if _low.endswith('.m3u8') else 'binario'
        log_info(f'VPS_HANDOFF uid={uid} kind={_kind} '
                 f'range={rng[:32] or "-"} vps={vps_base}')
        return resp

    try:
        parsed = urlparse(url)
        if parsed.scheme not in ('http', 'https'):
            return 'Invalid scheme', 400
    except Exception:
        return 'Invalid URL', 400

    headers = {
        'User-Agent': (token_payload or {}).get('user_agent') or os.environ.get('STREAM_USER_AGENT', 'VLC/3.0'),
        'Accept': '*/*',
        'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7',
        'Connection': 'keep-alive',
    }
    referrer = (token_payload or {}).get('referrer') or os.environ.get('STREAM_REFERER', '')
    if referrer:
        headers['Referer'] = referrer
        referrer_url = urlparse(referrer)
        if referrer_url.scheme and referrer_url.netloc:
            headers['Origin'] = f'{referrer_url.scheme}://{referrer_url.netloc}'
    if request.headers.get('Range'):
        headers['Range'] = request.headers['Range']
    if request.headers.get('If-None-Match'):
        headers['If-None-Match'] = request.headers['If-None-Match']
    if request.headers.get('If-Modified-Since'):
        headers['If-Modified-Since'] = request.headers['If-Modified-Since']

    source_pool = get_m3u_credential_sources()
    current_source_index = (token_payload or {}).get('source_index')
    try:
            current_source_index = int(current_source_index) if current_source_index is not None else None
    except (TypeError, ValueError):
        current_source_index = None

    # Ordem de tentativa: credencial do token primeiro, depois as que tem
    # vaga (evitando contas vencidas/sem acesso e lotadas), e so no fim as
    # lotadas. Assim o failover nao gasta tempo numa conta que ja sabemos
    # que nao tem conexao livre.
    priority_indexes = []
    contas_ruins = []
    try:
        usable_indexes, cheias, _health = _m3u_usable_source_indexes(source_pool)
        contas_ruins = [i for i in range(len(source_pool))
                        if i not in usable_indexes and i not in cheias]
        priority_indexes = usable_indexes + cheias
    except Exception:
        priority_indexes = list(range(len(source_pool)))

    candidates = [(url, current_source_index)]
    exhausted = False
    if source_pool:
        for candidate_index in priority_indexes:
            if candidate_index == current_source_index:
                continue
            if not _source_matches_stream(url, source_pool[candidate_index]):
                continue
            candidate_url = replace_stream_credentials(url, source_pool[candidate_index])
            if candidate_url not in [entry[0] for entry in candidates]:
                candidates.append((candidate_url, candidate_index))
        # Contas vencidas/sem acesso vao para o fim (ultimo recurso).
        for candidate_index in contas_ruins:
            if candidate_index == current_source_index:
                continue
            if not _source_matches_stream(url, source_pool[candidate_index]):
                continue
            candidate_url = replace_stream_credentials(url, source_pool[candidate_index])
            if candidate_url not in [entry[0] for entry in candidates]:
                candidates.append((candidate_url, candidate_index))
        # Todas as contas estao sem vaga/vencidas: se nem a do token abrir,
        # nao ha o que tentar.
        if contas_ruins and len(contas_ruins) == len(source_pool):
            exhausted = True

    failover_statuses = {401, 403, 404, 406, 410, 429, 500, 502, 503, 504}
    r = None
    last_error = None
    failover_count = 0
    # ORCAMENTO DE TEMPO: sem isto, um conteudo offline fazia o /proxy tentar
    # TODAS as credenciais M3U, cada uma com ate 15s de timeout. O processo do
    # Passenger ficava preso por minutos, o pool esgotava e o site todo
    # travava ("carregando infinito", sem conseguir sair do player).
    attempt_list = candidates[:PROXY_MAX_CANDIDATES]
    deadline = _time.time() + PROXY_FAILOVER_BUDGET
    for attempt_pos, (candidate_url, candidate_index) in enumerate(attempt_list):
        if _time.time() >= deadline:
            break
        is_last = (attempt_pos == len(attempt_list) - 1)
        try:
            r = requests.get(candidate_url, headers=dict(headers), timeout=PROXY_UPSTREAM_TIMEOUT, stream=True)
            if r.status_code not in failover_statuses or is_last:
                url = candidate_url
                current_source_index = candidate_index
                if attempt_pos > 0:
                    failover_count = attempt_pos
                break
            # Failover: esta credencial falhou, tenta a próxima
            if attempt_pos == 0:
                # Primeira tentativa falhou = a credencial reservada pelo usuário não funcionou
                masked_fail = _m3u_mask_url(candidate_url)
                M3USourceLog.log(
                    candidate_index, masked_fail, 'proxy_fail',
                    'warn', f'Falha no proxy (HTTP {r.status_code}) - iniciando failover',
                    details={'status': r.status_code, 'content_type': r.headers.get('Content-Type')}
                )
            failover_count = attempt_pos + 1
            r.close()
        except requests.RequestException as exc:
            last_error = exc
            r = None
            if attempt_pos == 0:
                masked_fail = _m3u_mask_url(candidate_url)
                M3USourceLog.log(
                    candidate_index, masked_fail, 'proxy_fail',
                    'error', f'Erro de conexao no proxy - iniciando failover',
                    details={'erro': str(exc)[:200]}
                )
            failover_count = attempt_pos + 1
            continue
    if r is None:
        if exhausted:
            return ('Todas as contas M3U estao vencidas, sem acesso ou no limite de '
                    'conexoes. Renove as credenciais em Admin > M3U.'), 502
        return ('Conteudo indisponivel no momento (canal/filme offline ou servidor de origem fora do ar). '
                f'{last_error or "todas as credenciais M3U falharam"}'), 502
    # Log sucesso após failover
    if failover_count > 0:
        masked_ok = _m3u_mask_url(url)
        M3USourceLog.log(
            current_source_index, masked_ok, 'proxy_failover',
            'ok', f'Failover bem-sucedido apos {failover_count} tentativa(s)',
            details={'tentativas': failover_count + 1, 'final_source': current_source_index}
        )

    excluded = ['content-length', 'transfer-encoding', 'content-encoding', 'connection']
    resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in excluded}

    content_type = r.headers.get('Content-Type', 'application/octet-stream')

    is_m3u8 = (
        'mpegurl' in content_type.lower() or
        url.endswith('.m3u8') or
        '.m3u8?' in url or
        url.endswith('.m3u8/')
    )

    if is_m3u8:
        try:
            body = r.text
        except Exception:
            body = r.content.decode('utf-8', errors='replace')

        if not body.strip() or not ('#EXT' in body or '#EXTM3U' in body):
            return Response(r.content, status=r.status_code, content_type=content_type, headers=resp_headers)

        final_url = r.url if r.url else url
        base_url = final_url.rsplit('/', 1)[0] + '/'

        def proxy_url(target_url):
            child_token = generate_stream_token(
                target_url, current_user.id,
                (token_payload or {}).get('referrer') or os.environ.get('STREAM_REFERER', ''),
                (token_payload or {}).get('user_agent') or os.environ.get('STREAM_USER_AGENT', 'VLC/3.0'),
                current_source_index,
            )
            return '/proxy?token=' + urllib.parse.quote(child_token, safe='')

        def rewrite_line(line):
            line = line.strip()
            if not line or line.startswith('#'):
                if 'URI="' in line:
                    uri_match = re.search(r'URI="([^"]+)"', line)
                    if uri_match:
                        uri = uri_match.group(1)
                        if not uri.startswith('http'):
                            uri = urljoin(base_url, uri)
                        line = line[:uri_match.start(1)] + proxy_url(uri) + line[uri_match.end(1):]
                return line
            if line.startswith('http://') or line.startswith('https://'):
                return proxy_url(line)
            else:
                resolved = urljoin(base_url, line)
                return proxy_url(resolved)

        rewritten = '\n'.join(rewrite_line(l) for l in body.splitlines())
        resp_headers['Content-Type'] = 'application/vnd.apple.mpegurl'
        resp_headers['Access-Control-Allow-Origin'] = '*'
        return Response(rewritten, content_type='application/vnd.apple.mpegurl', headers=resp_headers)

    resp_headers['Access-Control-Allow-Origin'] = '*'
    resp_headers['Access-Control-Allow-Headers'] = 'Range, If-None-Match, If-Modified-Since'
    resp_headers['X-Accel-Buffering'] = 'no'
    resp_headers['Cache-Control'] = 'no-store'

    def generate():
        started = _time.time()
        try:
            for chunk in r.iter_content(chunk_size=262144):
                if not chunk:
                    continue
                yield chunk
                # Trava de seguranca: um stream "zumbi" nao pode segurar um
                # processo do Passenger indefinidamente.
                if _time.time() - started > PROXY_MAX_STREAM_SECONDS:
                    break
        except GeneratorExit:
            pass
        except Exception:
            pass
        finally:
            try:
                r.close()
            except Exception:
                pass

    return Response(
        generate(),
        status=r.status_code,
        content_type=content_type,
        headers=resp_headers,
    )


# ─── Native player token (stateless, signed, no session binding) ───────

def generate_native_token(stream_url):
    payload = json.dumps({
        'url': stream_url,
        'exp': _time.time() + STREAM_TOKEN_EXPIRY,
    })
    payload_b64 = base64.urlsafe_b64encode(payload.encode('utf-8')).decode('utf-8')
    return payload_b64 + '.' + _sign_token(payload_b64)

def get_native_token_url(token):
    if not token:
        return None
    try:
        payload_b64, sig = token.rsplit('.', 1)
        if not hmac.compare_digest(sig, _sign_token(payload_b64)):
            return None
        payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode('utf-8')).decode('utf-8'))
        if _time.time() > payload.get('exp', 0):
            return None
        return payload.get('url')
    except Exception:
        return None

def cleanup_native_tokens():
    pass


@app.route('/api/proxy/stream')
@limiter.limit('6000 per hour')
def api_proxy_stream():
    proto = 'https' if request.is_secure else 'http'
    native_proxy_base = f'{proto}://{request.host}/api/proxy/stream'

    token = request.args.get('token', '')
    url = request.args.get('url', '')
    if not token:
        return 'Token obrigatorio.', 401
    if token:
        resolved_url = get_native_token_url(token)
        if not resolved_url:
            return 'Token de acesso invalido ou expirado.', 403
        url = resolved_url
    if not url:
        return 'Missing url param', 400
    try:
        parsed = urlparse(url)
        if parsed.scheme not in ('http', 'https') or not parsed.hostname:
            return 'Invalid URL', 400
        allowed_hosts = {source['hostname'] for source in get_m3u_credential_sources() if source.get('hostname')}
        if allowed_hosts and parsed.hostname.lower() not in allowed_hosts:
            return 'Host nao autorizado.', 403
    except Exception:
        return 'Invalid URL', 400

    headers = {
        'User-Agent': os.environ.get('STREAM_USER_AGENT', 'VLC/3.0'),
        'Accept': '*/*',
        'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7',
        'Connection': 'keep-alive',
    }
    referrer = os.environ.get('STREAM_REFERER', '')
    if referrer:
        headers['Referer'] = referrer
        referrer_url = urlparse(referrer)
        if referrer_url.scheme and referrer_url.netloc:
            headers['Origin'] = f'{referrer_url.scheme}://{referrer_url.netloc}'
    if request.headers.get('Range'):
        headers['Range'] = request.headers['Range']

    # Timeouts curtos + no maximo 2 tentativas: 3 tentativas de 15s deixavam o
    # request preso por quase 1 minuto segurando um processo do Passenger.
    try:
        r = requests.get(url, headers=headers, timeout=PROXY_UPSTREAM_TIMEOUT, stream=True)
        if r.status_code in (403, 406, 429, 500, 502, 503, 504):
            first_status = r.status_code
            r.close()
            retry_headers = dict(headers)
            retry_headers['User-Agent'] = 'VLC/3.0' if headers.get('User-Agent') != 'VLC/3.0' else 'Mozilla/5.0'
            r = requests.get(url, headers=retry_headers, timeout=PROXY_UPSTREAM_TIMEOUT, stream=True)
            if r.status_code >= 400 and r.status_code != first_status:
                r.close()
                return ('Conteudo indisponivel no momento (canal/filme offline ou servidor de origem fora do ar). '
                        f'Status {first_status}.'), 502
    except Exception as e:
        return f'Erro de conexao: {e}', 502

    excluded = ['content-length', 'transfer-encoding', 'content-encoding', 'connection']
    resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in excluded}

    content_type = r.headers.get('Content-Type', 'application/octet-stream')

    is_m3u8 = (
        'mpegurl' in content_type.lower() or
        url.endswith('.m3u8') or
        '.m3u8?' in url or
        url.endswith('.m3u8/')
    )

    if is_m3u8:
        try:
            body = r.text
        except Exception:
            body = r.content.decode('utf-8', errors='replace')

        if not body.strip() or not ('#EXT' in body or '#EXTM3U' in body):
            return Response(r.content, status=r.status_code, content_type=content_type, headers=resp_headers)

        final_url = r.url if r.url else url
        base_url = final_url.rsplit('/', 1)[0] + '/'

        def proxy_url(target_url):
            return native_proxy_base + '?url=' + urllib.parse.quote(target_url, safe='')

        def rewrite_line(line):
            line = line.strip()
            if not line or line.startswith('#'):
                if 'URI="' in line:
                    uri_match = re.search(r'URI="([^"]+)"', line)
                    if uri_match:
                        uri = uri_match.group(1)
                        if not uri.startswith('http'):
                            uri = urljoin(base_url, uri)
                        line = line[:uri_match.start(1)] + proxy_url(uri) + line[uri_match.end(1):]
                return line
            if line.startswith('http://') or line.startswith('https://'):
                return proxy_url(line)
            else:
                resolved = urljoin(base_url, line)
                return proxy_url(resolved)

        rewritten = '\n'.join(rewrite_line(l) for l in body.splitlines())
        resp_headers['Content-Type'] = 'application/vnd.apple.mpegurl'
        resp_headers['Access-Control-Allow-Origin'] = '*'
        return Response(rewritten, content_type='application/vnd.apple.mpegurl', headers=resp_headers)

    resp_headers['Access-Control-Allow-Origin'] = '*'
    resp_headers['Access-Control-Allow-Headers'] = 'Range'
    resp_headers['X-Accel-Buffering'] = 'no'
    resp_headers['Cache-Control'] = 'no-store'

    def generate():
        started = _time.time()
        try:
            for chunk in r.iter_content(chunk_size=262144):
                if not chunk:
                    continue
                yield chunk
                if _time.time() - started > PROXY_MAX_STREAM_SECONDS:
                    break
        except GeneratorExit:
            pass
        finally:
            try:
                r.close()
            except Exception:
                pass

    return Response(
        generate(),
        status=r.status_code,
        content_type=content_type,
        headers=resp_headers,
    )


@app.route('/api/stream/refresh/<int:item_id>')
@login_required
def refresh_stream_access(item_id):
    item = db.session.get(Canal, item_id)
    if not item or not item.stream_url or item.tipo not in ('live', 'movie', 'series'):
        return jsonify({'can_play': False, 'message': 'Conteúdo não encontrado.'}), 404
    token = session.get('persistent_token', '')
    ua = request.headers.get('User-Agent', '')[:500]
    source_index = get_reserved_source_index(current_user.id, token)
    if source_index is None:
        source_index = choose_m3u_source_index(current_user.id, item.stream_url)
    can_play, block_device = stream_start_allowed(
        current_user.id, token, parse_device_info(ua), item.name, source_index, item.stream_url
    )
    if not can_play:
        return jsonify({'can_play': False, 'message': block_device or 'Reprodução não permitida.'}), 403
    source_index = get_reserved_source_index(current_user.id, token)
    stream_url = item.stream_url
    sources = get_m3u_credential_sources()
    if source_index is not None and source_index < len(sources):
        stream_url = replace_stream_credentials(stream_url, sources[source_index])
    # force_ts=1: downgrade no meio da reprodução (HLS instável -> TS original).
    force_ts = request.args.get('force_ts') == '1'
    if force_ts and '.m3u8' not in item.stream_url:
        is_hls = False
    else:
        stream_url, is_hls = resolve_playable_stream(item, stream_url, source_index)
    return jsonify({'can_play': True, 'stream_token': generate_stream_token(stream_url, current_user.id, item.stream_referrer, item.stream_user_agent, source_index), 'source_index': source_index, 'is_hls': is_hls})


@app.route('/api/tv/play/<int:item_id>')
@login_required
def api_tv_play(item_id):
    if not current_user.can_watch():
        return jsonify({'error': 'Plano invalido', 'can_play': False}), 403

    item = db.session.get(Canal, item_id)
    if not item or item.tipo != 'live':
        return jsonify({'error': 'Canal nao encontrado'}), 404

    token = session.get('persistent_token', '')
    ua = request.headers.get('User-Agent', '')[:500]
    source_index = choose_m3u_source_index(current_user.id, item.stream_url)
    can_play, block_device = stream_start_allowed(
        current_user.id, token, parse_device_info(ua), item.name, source_index, item.stream_url
    )

    if not can_play:
        return jsonify({'error': 'Reproducao nao permitida', 'can_play': False, 'block_device': block_device}), 403
    reserved_index = get_reserved_source_index(current_user.id, token)
    if reserved_index is not None:
        source_index = reserved_index

    stream_url = item.stream_url
    source_pool = get_m3u_credential_sources()
    if can_play and source_index is not None and source_index < len(source_pool):
        stream_url = replace_stream_credentials(stream_url, source_pool[source_index])
    stream_url, is_hls = resolve_playable_stream(item, stream_url, source_index)
    stream_token = generate_stream_token(stream_url, current_user.id, item.stream_referrer, item.stream_user_agent, source_index) if stream_url else ''

    log_audit('stream_start', current_user.id, details=f'content={item.name}')
    item.view_count = (item.view_count or 0) + 1

    deactivate_user_watchlogs(current_user.id, keep_canal_id=item_id)
    wl = WatchLog(user_id=current_user.id, canal_id=item_id, content_name=item.name, is_active=True)
    db.session.add(wl)
    db.session.commit()

    remaining = current_user.remaining_seconds()
    is_premium = current_user.is_premium_active or current_user.is_admin
    if is_premium:
        plan_label = '⭐ Premium'
    else:
        h = remaining // 3600
        m = (remaining % 3600) // 60
        plan_label = f'⏱️ {h}h {m}m' if remaining > 0 else '⏰ Esgotado'

    return jsonify({
        'can_play': True,
        'stream_url': stream_url,
        'stream_token': stream_token,
        'channel_name': item.name,
        'channel_id': item.id,
        'is_hls': is_hls,
        'channel_logo': item.logo or '',
        'channel_group': item.group_title or '',
        'is_premium': is_premium,
        'remaining_seconds': remaining,
        'plan_label': plan_label,
    })


def check_pending_payments():
    with app.app_context():
        now_utc = datetime.now(timezone.utc)
        pending = Payment.query.filter_by(status='pending').all()
        for p in pending:
            exp = p.expires_at
            if exp and hasattr(exp, 'replace') and exp.tzinfo is not None:
                exp = exp.replace(tzinfo=None)
            if exp and exp < now_utc:
                p.status = 'expired'
                try:
                    db.session.commit()
                except Exception:
                    db.session.rollback()
                log_audit('pix_expired_scheduled', p.user_id, details=f'charge={p.charge_id}')
                create_notification(p.user_id, 'PIX Expirado', 'Sua cobranca PIX expirou.', link='/planos')
                continue
            created = p.created_at
            if created and hasattr(created, 'replace') and created.tzinfo is not None:
                created = created.replace(tzinfo=None)
            if created and created < now_utc - timedelta(hours=2):
                p.status = 'cancelled'
                try:
                    db.session.commit()
                except Exception:
                    db.session.rollback()
                continue
            try:
                data = c7_check_status(p.charge_id)
                st = (data.get('payment') or {}).get('status')
                if st == 'approved' and p.status != 'paid':
                    p.status = 'paid'
                    p.paid_at = datetime.now(timezone.utc)
                    db.session.commit()
                    activate_subscription(p.user_id, payment_id=p.id)
                    log_audit('payment_confirmed_scheduled', p.user_id, details=f'charge={p.charge_id}')
            except Exception as e:
                print(f'Payment check error for {p.charge_id}: {e}')


def activate_subscription(user_id, days=30, payment_id=None):
    user = db.session.get(User, user_id)
    if user:
        user.is_paid = True
        now = datetime.now(BRASILIA_TZ)
        paid_until = user.paid_until
        if paid_until and paid_until.tzinfo is None:
            paid_until = paid_until.replace(tzinfo=BRASILIA_TZ)
        if paid_until and paid_until > now:
            user.paid_until = paid_until + timedelta(days=days)
        else:
            user.paid_until = now + timedelta(days=days)

        plan_type = 'custom'
        plan_name_display = f'Plano Custom ({days} dias)'
        plan_obj = Plan.query.filter_by(days=days, is_active=True).first()
        if plan_obj:
            plan_type = plan_obj.key
            plan_name_display = plan_obj.name
        plan_hist = PlanHistory(
            user_id=user_id, plan_type=plan_type,
            plan_name=plan_name_display,
            started_at=now, expires_at=user.paid_until,
            status='active', source='direct',
        )
        # 1 plano ativo por usuario (evita contagem dobrada no painel).
        _expire_other_active_planhistory(user_id)
        db.session.add(plan_hist)

        plan_amount = 0
        if payment_id:
            pay = db.session.get(Payment, payment_id)
            if pay:
                plan_amount = pay.amount
        if plan_amount <= 0:
            plan = Plan.query.filter_by(key=plan_type, is_active=True).first()
            if plan:
                plan_amount = effective_plan_price(plan)

        db.session.commit()
        if plan_amount > 0:
            credited = grant_referral_commissions(
                user, plan_hist.plan_name, plan_amount, payment_id
            )
            db.session.commit()
            if credited:
                print(f'Multilevel commissions credited: {credited} (valued by {user.email} R${plan_amount:.2f})')
        print(f'User {user.email} subscription activated until {user.paid_until}')
        try:
            plan_display = plan_hist.plan_name or f'Plano {plan_type.capitalize()}'
            notify_subscription_activated(user, plan_display, user.paid_until)
        except Exception as e:
            print(f'Error sending activation notification for {user.email}: {e}')


# ─── Channel Health Check ──────────────────────────────────────────────────
import socket as _socket
from concurrent.futures import ThreadPoolExecutor, as_completed

def check_channel_health():
    with app.app_context():
        try:
            channels = Canal.query.filter_by(tipo='live', is_hidden=False).all()
            if not channels:
                return
            now = datetime.now(timezone.utc)
            SEVEN_DAYS = timedelta(days=7)
            UA = 'TrocoPlay-HealthCheck/1.0'

            # Testa com uma credencial VIVA. Antes, se a 1a conta do painel
            # vencesse, todos os canais eram testados com ela, marcados
            # offline e escondidos do site depois de 7 dias — mesmo com os
            # usuarios assistindo por outra conta. Agora o health check troca
            # as credenciais da URL antes de testar.
            sources = get_m3u_credential_sources()
            health_credential = None
            try:
                usable_indexes, cheias, _health = _m3u_usable_source_indexes(sources)
                for candidate in (usable_indexes or []) + (cheias or []):
                    if sources[candidate]:
                        health_credential = sources[candidate]
                        break
            except Exception:
                health_credential = None
            if sources and health_credential is None:
                # Nenhuma credencial valida: testar agora marcaria tudo como
                # offline e derrubaria o catalogo. Melhor nao mexer em nada.
                print('[Health] Nenhuma credencial M3U valida no momento; '
                      'health check ignorado para nao esconder o catalogo.', flush=True)
                return

            def _health_url(ch):
                url = (ch.stream_url or '').strip()
                if url and health_credential:
                    try:
                        url = replace_stream_credentials(url, health_credential)
                    except Exception:
                        pass
                return url

            def check_one(ch):
                if not ch.stream_url:
                    return ch.id, True
                is_ok = False
                try:
                    url = _health_url(ch)
                    if '.m3u8' in url:
                        r = requests.get(url, timeout=3, headers={'User-Agent': UA})
                        if r.status_code < 400:
                            is_ok = b'#EXTM3U' in r.content[:4096]
                        r.close()
                    else:
                        r = requests.head(url, timeout=3, allow_redirects=True, headers={'User-Agent': UA})
                        is_ok = r.status_code < 400 or r.status_code == 403
                        r.close()
                except Exception:
                    is_ok = False
                return ch.id, is_ok

            results = {}
            with ThreadPoolExecutor(max_workers=HEALTH_MAX_WORKERS) as executor:
                futures = {executor.submit(check_one, ch): ch for ch in channels}
                for future in as_completed(futures):
                    try:
                        cid, is_ok = future.result()
                        results[cid] = is_ok
                    except Exception:
                        pass

            # Trava: se quase TODOS os canais falharam nesta rodada, o
            # problema e a fonte/rede (ou a credencial), nao os canais.
            # Neste caso so registra o status e nao esconde nada.
            tested = [cid for cid, ok in results.items()]
            off = [cid for cid, ok in results.items() if not ok]
            hide_allowed = True
            if tested and len(off) >= max(20, int(len(tested) * 0.9)):
                hide_allowed = False
                print(f'[Health] {len(off)}/{len(tested)} canais offline nesta rodada '
                      '(provedor/rede fora?); ocultacao automatica suspensa.', flush=True)
                M3USourceLog.log(
                    None, None, 'health_fail',
                    'warn', f'Health check: {len(off)}/{len(tested)} canais offline - ocultacao suspensa',
                    details={'tested': len(tested), 'offline': len(off), 'reason': 'mass_failure'}
                )
            else:
                M3USourceLog.log(
                    None, None, 'health_check',
                    'ok', f'Health check concluido: {len(tested) - len(off)}/{len(tested)} canais online',
                    details={'tested': len(tested), 'online': len(tested) - len(off), 'offline': len(off), 'hide_allowed': hide_allowed}
                )

            batch = 0
            for ch in channels:
                is_ok = results.get(ch.id, True)
                health = ChannelHealth.query.filter_by(canal_id=ch.id).first()
                if not health:
                    health = ChannelHealth(canal_id=ch.id, is_online=is_ok)
                    db.session.add(health)
                health.last_checked = now
                if is_ok:
                    if not health.is_online:
                        print(f'[Health] {ch.name} is back online')
                    health.is_online = True
                    health.consecutive_fails = 0
                    health.offline_since = None
                else:
                    health.is_online = False
                    health.consecutive_fails = (health.consecutive_fails or 0) + 1
                    if health.offline_since is None:
                        health.offline_since = now
                    if hide_allowed and health.offline_since and (now - health.offline_since) >= SEVEN_DAYS:
                        if not ch.is_hidden:
                            ch.is_hidden = True
                            print(f'[Health] Hiding {ch.name} - offline for 7+ days')
                batch += 1
                if batch % 50 == 0:
                    db.session.commit()
            db.session.commit()
        except Exception as e:
            db.session.rollback()
            print(f'[Health] Error: {e}')


# ─── Backup ────────────────────────────────────────────────────────────────
def backup_databases():
    try:
        os.makedirs(BACKUP_DIR, exist_ok=True)
        ts = datetime.now().strftime('%Y%m%d_%H%M%S')
        db_path = os.path.join(BASE_DIR, 'instance', 'troco_play.db')

        for suffix in ('.db', '.db-wal', '.db-shm'):
            src = db_path.replace('.db', suffix)
            if os.path.exists(src):
                shutil.copy2(src, os.path.join(BACKUP_DIR, f'troco_play_{ts}{suffix}'))

        settings_path = SETTINGS_PATH
        if os.path.exists(settings_path):
            shutil.copy2(settings_path, os.path.join(BACKUP_DIR, f'settings_{ts}.json'))

        if os.path.exists(M3U_PATH):
            shutil.copy2(M3U_PATH, os.path.join(BACKUP_DIR, f'canais_{ts}.txt'))

        backup_groups = [
            ('troco_play_', 2, ('.db', '.db-wal', '.db-shm')),
            ('settings_', 2, ('.json',)),
            ('canais_', 2, ('.txt',)),
        ]
        for prefix, keep_count, suffixes in backup_groups:
            sets = sorted(set(
                f.rsplit('.', 1)[0]
                for f in os.listdir(BACKUP_DIR)
                if f.startswith(prefix) and any(f.endswith(s) for s in suffixes)
            ), reverse=True)
            for old_base in sets[keep_count:]:
                for suffix in suffixes:
                    fp = os.path.join(BACKUP_DIR, old_base + suffix)
                    if os.path.exists(fp):
                        os.remove(fp)
    except Exception as e:
        print(f'[Backup] Error: {e}', flush=True)

# ─── Scheduler ─────────────────────────────────────────────────────────────
scheduler = BackgroundScheduler()

_status_sample_fn = None


def _status_sample_tick():
    if _status_sample_fn is not None:
        _status_sample_fn()


if SCHEDULER_ENABLED:
    scheduler.add_job(check_pending_payments, 'interval', minutes=5, id='payment_check',
                      next_run_time=datetime.now(timezone.utc) + timedelta(seconds=30))
    scheduler.add_job(_cleanup_stale, 'interval', seconds=30, id='ffmpeg_cleanup')
    scheduler.add_job(cleanup_stream_tokens, 'interval', minutes=5, id='token_cleanup')
    scheduler.add_job(cleanup_native_tokens, 'interval', minutes=5, id='native_token_cleanup')
    scheduler.add_job(check_channel_health, 'interval', minutes=5, id='channel_health',
                      next_run_time=datetime.now(timezone.utc) + timedelta(seconds=30))
    scheduler.add_job(_status_sample_tick, 'interval', minutes=1, id='status_sample',
                      next_run_time=datetime.now(timezone.utc) + timedelta(seconds=20))
    scheduler.add_job(backup_databases, 'interval',
                      seconds=load_settings().get('backup_interval_seconds', 3600),
                      id='db_backup')
    scheduler.start()


# ─── Cron endpoints (cPanel) ──────────────────────────────────────────────
# Execute as tarefas do scheduler via cron do cPanel quando
# FLASK_SKIP_SCHEDULER=1. Protegido por CRON_SECRET.
CRON_SECRET = os.environ.get('CRON_SECRET', '').strip()

def _cron_auth():
    if not CRON_SECRET:
        abort(403)
    key = request.headers.get('X-Cron-Secret', '')
    if not key or key != CRON_SECRET:
        abort(403)

@app.route('/cron/update-m3u')
def cron_update_m3u():
    """Cron: fila completa (M3U + EPG + Xtream)."""
    _cron_auth()
    if _catalog_update_running():
        return 'OK (atualizacao ja em andamento)', 200
    _spawn_catalog_update('all')
    return 'OK (iniciado: M3U + EPG + Xtream)', 200

@app.route('/cron/update-epg')
def cron_update_epg():
    _cron_auth()
    _record_cron_run('update-epg')
    try:
        update_epg()
        return 'OK', 200
    except Exception as e:
        return f'ERR: {e}', 500

@app.route('/cron/xtream-enrich')
def cron_xtream_enrich():
    """Roda enriquecimento Xtream em background."""
    _cron_auth()
    _record_cron_run('xtream-enrich')
    try:
        import threading
        def _bg():
            with app.app_context():
                enrich_xtream_metadata(batch_size=30, max_items=0)
        threading.Thread(target=_bg, daemon=True).start()
        return 'OK', 200
    except Exception as e:
        return f'ERR: {e}', 500

@app.route('/cron/check-payments')
def cron_check_payments():
    _cron_auth()
    _record_cron_run('check-payments')
    try:
        check_pending_payments()
        return 'OK', 200
    except Exception as e:
        return f'ERR: {e}', 500

@app.route('/cron/backup')
def cron_backup():
    _cron_auth()
    _record_cron_run('backup')
    try:
        backup_databases()
        return 'OK', 200
    except Exception as e:
        return f'ERR: {e}', 500

@app.route('/cron/cleanup')
def cron_cleanup():
    _cron_auth()
    _record_cron_run('cleanup')
    try:
        _cleanup_stale()
        cleanup_stream_tokens()
        cleanup_native_tokens()
        return 'OK', 200
    except Exception as e:
        return f'ERR: {e}', 500


@app.route('/cron/subscription-notifications')
def cron_subscription_notifications():
    """Cron job: envia lembretes de vencimento e win-back para todos os usuarios.
    Executar 1x por dia via cron do cPanel."""
    _cron_auth()
    _record_cron_run('subscription-notifications')
    try:
        now_utc = datetime.now(timezone.utc)
        now_br = now_utc.astimezone(BRASILIA_TZ)
        sent = 0

        # ── 1. Lembretes: assinatura vence em 3, 2 ou 1 dia ──
        for days_before in [3, 2, 1]:
            target_br = now_br + timedelta(days=days_before)
            target_start = target_br.replace(hour=0, minute=0, second=0, microsecond=0)
            target_end = target_start + timedelta(days=1)
            target_start_utc = target_start.astimezone(timezone.utc).replace(tzinfo=None)
            target_end_utc = target_end.astimezone(timezone.utc).replace(tzinfo=None)
            users_expiring = User.query.filter(
                User.is_paid == True,
                User.is_admin == False,
                User.paid_until >= target_start_utc,
                User.paid_until < target_end_utc,
            ).all()
            for user in users_expiring:
                plan_hist = PlanHistory.query.filter_by(
                    user_id=user.id, status='active'
                ).order_by(PlanHistory.expires_at.desc()).first()
                plan_name = plan_hist.plan_name if plan_hist else 'Premium'
                notify_expiring_soon(user, plan_name, user.paid_until, days_before)
                sent += 1

        # ── 2. Win-back: assinatura expirou ha 1, 3, 7, 15 ou 30 dias ──
        for days_after in [1, 3, 7, 15, 30]:
            target_br = now_br - timedelta(days=days_after)
            target_start = target_br.replace(hour=0, minute=0, second=0, microsecond=0)
            target_end = target_start + timedelta(days=1)
            target_start_utc = target_start.astimezone(timezone.utc).replace(tzinfo=None)
            target_end_utc = target_end.astimezone(timezone.utc).replace(tzinfo=None)
            users_expired = User.query.filter(
                User.is_paid == False,
                User.is_admin == False,
                User.paid_until >= target_start_utc,
                User.paid_until < target_end_utc,
            ).all()
            for user in users_expired:
                plan_hist = PlanHistory.query.filter_by(
                    user_id=user.id, status='active'
                ).order_by(PlanHistory.expires_at.desc()).first()
                plan_name = plan_hist.plan_name if plan_hist else 'Premium'
                notify_win_back(user, plan_name, days_after)
                sent += 1

        # ── 3. Expirados recentemente (nao notificados ainda) ──
        cutoff = now_utc - timedelta(hours=24)
        users_just_expired = User.query.filter(
            User.is_paid == False,
            User.is_admin == False,
            User.paid_until.isnot(None),
            User.paid_until < now_utc,
            User.paid_until >= now_utc - timedelta(days=1),
        ).all()
        for user in users_just_expired:
            if not _has_recent_notification(user.id, 'Expirada', hours=24):
                plan_hist = PlanHistory.query.filter_by(
                    user_id=user.id, status='active'
                ).order_by(PlanHistory.expires_at.desc()).first()
                plan_name = plan_hist.plan_name if plan_hist else 'Premium'
                notify_subscription_expired(user, plan_name)
                sent += 1

        # ── 4. Expirados que nunca logaram (is_paid ainda True, PlanHistory ativo) ──
        stale_expired = db.session.query(User).join(
            PlanHistory, PlanHistory.user_id == User.id
        ).filter(
            User.is_paid == True,
            User.is_admin == False,
            PlanHistory.status == 'active',
            PlanHistory.expires_at < now_utc,
        ).all()
        for user in stale_expired:
            plan_hist = PlanHistory.query.filter_by(
                user_id=user.id, status='active'
            ).order_by(PlanHistory.expires_at.desc()).first()
            plan_name = plan_hist.plan_name if plan_hist else 'Premium'
            user.is_paid = False
            if plan_hist:
                plan_hist.status = 'expired'
            try:
                db.session.commit()
            except Exception:
                db.session.rollback()
            if not _has_recent_notification(user.id, 'Expirada', hours=24):
                notify_subscription_expired(user, plan_name)
                sent += 1

        return f'OK sent={sent}', 200
    except Exception as e:
        return f'ERR: {e}', 500


@app.route('/cron/health')
def cron_health():
    _cron_auth()
    _record_cron_run('health')
    try:
        check_channel_health()
        return 'OK', 200
    except Exception as e:
        return f'ERR: {e}', 500


def _render_error(template, code, fallback, **ctx):
    """Renderiza a pagina de erro personalizada. Se o proprio template falhar
    (ex.: banco fora), devolve um HTML minimo para nunca mostrar erro cru."""
    try:
        return render_template(template, code=code, **ctx), code
    except Exception:
        return ('<!DOCTYPE html><html lang="pt-BR"><head><meta charset="utf-8">'
                f'<meta name="viewport" content="width=device-width, initial-scale=1">'
                f'<title>{code} - Troco Play</title></head>'
                '<body style="margin:0;height:100vh;display:flex;align-items:center;justify-content:center;'
                'background:#0a0a0a;color:#e0e0e0;font-family:sans-serif;text-align:center">'
                f'<div><div style="font-size:3em;font-weight:800;color:#e50914">{code}</div>'
                f'<p style="color:#888">{fallback}</p>'
                '<p><a href="/" style="color:#e50914;font-weight:600">Voltar ao inicio</a></p>'
                '</div></body></html>'), code


@app.errorhandler(400)
def bad_request(e):
    return _render_error('404.html', 400, 'Requisicao invalida.',
                         heading='Requisição inválida', icon='⚠️',
                         text='Os dados enviados não puderam ser processados. Verifique e tente novamente.')


@app.errorhandler(403)
def forbidden(e):
    return _render_error('403.html', 403, 'Acesso negado.')


@app.errorhandler(404)
def not_found(e):
    # URL inexistente SEMPRE devolve a pagina 404 real.
    # A manutencao e tratada no before_request (e so para rotas existentes).
    return _render_error('404.html', 404, 'Pagina nao encontrada.')


@app.errorhandler(405)
def method_not_allowed(e):
    return _render_error('404.html', 405, 'Metodo nao permitido.',
                         heading='Ação não permitida', icon='🚫',
                         text='Este endereço não aceita essa ação. Volte e tente pelo caminho normal do site.')


@app.errorhandler(500)
def internal_error(e):
    db.session.rollback()
    import traceback
    # 3.2 - Log de erro 500 com contexto util para diagnostico rapido:
    # data/hora ( vem do formatter), URL, metodo e User-Agent do cliente.
    _ua = (request.headers.get('User-Agent', '') or '')[:200]
    _url = request.url or request.path or '?'
    log_error(
        f'500 Error: endpoint={request.endpoint} method={request.method} '
        f'url={_url} ip={_rate_limit_key()} ua={_ua!r} - {str(e)}\n{traceback.format_exc()}'
    )
    return _render_error('500.html', 500, 'Erro interno do servidor.')


@app.errorhandler(502)
def bad_gateway(e):
    return _render_error('503.html', 502, 'Servidor indisponivel.',
                         message='Servidor indisponível no momento. Tente novamente em instantes.')


@app.errorhandler(503)
def service_unavailable(e):
    # Resposta JSON ja montada (ex.: manutencao sobre endpoint de API/app)
    # nao pode virar HTML aqui — o app Android espera JSON.
    try:
        if isinstance(e, HTTPException) and getattr(e, 'response', None) is not None:
            if 'json' in (e.response.content_type or ''):
                return e.response
    except Exception:
        pass
    settings = load_settings()
    if settings.get('maintenance_enabled'):
        msg = settings.get('maintenance_message', 'Sistema em manutencao. Volte em breve.')
        try:
            if request.path.startswith('/api/') or (request.endpoint or '').startswith('api_'):
                return jsonify({'maintenance': True, 'message': msg}), 503
            return render_template('maintenance.html', message=msg), 503
        except Exception:
            pass
    return _render_error('503.html', 503, 'Servico temporariamente indisponivel.',
                         message='Serviço temporariamente indisponível. Tente novamente em instantes.')


@app.errorhandler(429)
def too_many_requests(e):
    """Nunca devolve o 429 cru do limitador.

    O limitador respondia um HTML minúsculo ("Too Many Requests") sem layout.
    Para o usuario isso era "o site nao carrega nada". Aqui devolvemos JSON
    para as chamadas de API e uma pagina amigavel (com auto-recarga) para a
    navegacao, alem do header Retry-After.
    """
    retry_after = 60
    try:
        retry_after = int(getattr(e, 'retry_after', None) or 60)
    except Exception:
        retry_after = 60
    retry_after = max(5, min(retry_after, 3600))
    wants_json = (
        request.path.startswith('/api/')
        or request.path.startswith('/cron/')
        or 'application/json' in (request.headers.get('Accept') or '')
        or request.headers.get('X-Requested-With') == 'XMLHttpRequest'
    )
    if wants_json:
        resp = jsonify({
            'ok': False,
            'rate_limited': True,
            'error': 'Muitas requisicoes. Tente novamente em instantes.',
            'retry_after': retry_after,
        })
        resp.status_code = 429
    else:
        resp = _render_error(
            '503.html', 429, 'Muitas requisicoes. Aguarde alguns segundos.',
            heading='Muitas requisições',
            icon='⏳',
            text=f'Você fez muitas requisições em pouco tempo. A página se recupera sozinha em {retry_after}s.',
            retry_after=retry_after,
        )[0]
        resp = app.make_response(resp)
        resp.status_code = 429
    resp.headers['Retry-After'] = str(retry_after)
    return resp


# ─── Password Recovery ───────────────────────────────────────────────────
def _reset_code_hash(code):
    return hmac.new(app.config['SECRET_KEY'].encode('utf-8'), str(code).encode('utf-8'), hashlib.sha256).hexdigest()


def _aware_utc(value):
    if value is None:
        return None
    return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value


@app.route('/esqueci-senha', methods=['GET', 'POST'])
@limiter.limit('5 per hour', methods=['POST'])
def forgot_password():
    if request.method == 'POST':
        email = request.form.get('email', '').strip().lower()
        user = User.query.filter_by(email=email).first() if email else None
        # Resposta genérica evita revelar se um e-mail está cadastrado.
        if user and user.is_active_user and not user.is_banned:
            PasswordResetCode.query.filter_by(user_id=user.id, used_at=None).update({'used_at': datetime.now(timezone.utc)})
            code = f'{secrets.randbelow(1000000):06d}'
            record = PasswordResetCode(
                user_id=user.id,
                code_hash=_reset_code_hash(code),
                expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
            )
            db.session.add(record)
            db.session.commit()
            sent = send_user_email(
                user.email,
                'Troço Play — código para redefinir sua senha',
                f'Seu código de recuperação é: {code}\n\nEle expira em 15 minutos e pode ser usado uma única vez. Se você não solicitou a troca, ignore esta mensagem.',
                sender=os.environ.get('SMTP_CODES_FROM', '').strip() or None,
                username=os.environ.get('SMTP_CODES_USERNAME', '').strip() or None,
                password=os.environ.get('SMTP_CODES_PASSWORD')
            )
            session['reset_email'] = user.email
            if not sent:
                app.logger.warning('SMTP não configurado ou indisponível para recuperação de senha.')
        flash('Se o e-mail estiver cadastrado, enviaremos um código de recuperação válido por 15 minutos.', 'success')
        return redirect(url_for('reset_password'))
    return render_template('forgot_password.html')


@app.route('/redefinir-senha', methods=['GET', 'POST'])
@limiter.limit('10 per hour', methods=['POST'])
def reset_password():
    if request.method == 'POST':
        email = request.form.get('email', '').strip().lower() or session.get('reset_email', '')
        code = re.sub(r'\D', '', request.form.get('code', ''))
        new_password = request.form.get('password', '')
        confirm = request.form.get('password_confirm', '')
        user = User.query.filter_by(email=email).first() if email else None
        record = PasswordResetCode.query.filter_by(user_id=user.id, used_at=None).order_by(PasswordResetCode.created_at.desc()).first() if user else None
        now = datetime.now(timezone.utc)
        expires_at = _aware_utc(record.expires_at) if record else None
        if not user or not record or not expires_at or expires_at < now or record.attempts >= 5:
            flash('Código inválido ou expirado. Solicite um novo código.', 'error')
            return render_template('reset_password.html', email=email)
        if not hmac.compare_digest(record.code_hash, _reset_code_hash(code)):
            record.attempts += 1
            db.session.commit()
            flash('Código inválido ou expirado. Solicite um novo código.', 'error')
            return render_template('reset_password.html', email=email)
        if len(new_password) < 8 or new_password != confirm:
            flash('A nova senha deve ter pelo menos 8 caracteres e as duas senhas devem ser iguais.', 'error')
            return render_template('reset_password.html', email=email)
        user.set_password(new_password)
        user.token_version = (user.token_version or 0) + 1
        record.used_at = now
        db.session.commit()
        invalidate_all_sessions(user.id)
        session.pop('reset_email', None)
        send_user_email(user.email, 'Troço Play — senha alterada', 'Sua senha foi alterada com sucesso. Se não foi você, entre em contato com o suporte.', sender=os.environ.get('SMTP_CODES_FROM', '').strip() or None, username=os.environ.get('SMTP_CODES_USERNAME', '').strip() or None, password=os.environ.get('SMTP_CODES_PASSWORD'))
        flash('Senha alterada com sucesso. Faça login com a nova senha.', 'success')
        return redirect(url_for('login'))
    return render_template('reset_password.html', email=session.get('reset_email', ''))


# ─── SEO Routes ──────────────────────────────────────────────────────────
@app.route('/robots.txt')
def robots_txt():
    return app.send_static_file('robots.txt'), 200, {'Content-Type': 'text/plain; charset=utf-8'}


@app.route('/health')
def health():
    """Healthcheck para monitoramento (UptimeRobot, etc).

    Leve e sem banco: devolve status + timestamp. Servico de monitoracao
    bate aqui a cada 5 min e alerta se nao receber 200.
    """
    from datetime import datetime as _dt
    return jsonify({
        'status': 'ok',
        'timestamp': _dt.utcnow().isoformat() + 'Z',
        'uptime_s': int(_time.time() - _APP_START_TS) if '_APP_START_TS' in globals() else None,
    }), 200, {'Cache-Control': 'no-store'}


@app.route('/google6e9687cc56887b40.html')
def google_verification():
    return 'google-site-verification: google6e9687cc56887b40.html', 200, {'Content-Type': 'text/html; charset=utf-8'}


@app.route('/sitemap.xml')
def sitemap_xml():
    """Gera sitemap.xml dinamico com todas as paginas indexaveis."""
    base = os.environ.get('PUBLIC_BASE_URL', 'https://trocoplay.dpdns.org').rstrip('/')
    now = datetime.utcnow().strftime('%Y-%m-%d')
    pages = []

    # Paginas publicas (indexaveis)
    pages.append({'loc': base + '/', 'changefreq': 'daily', 'priority': '1.0', 'lastmod': now})
    pages.append({'loc': base + '/login', 'changefreq': 'monthly', 'priority': '0.6', 'lastmod': now})
    pages.append({'loc': base + '/register', 'changefreq': 'monthly', 'priority': '0.8', 'lastmod': now})
    pages.append({'loc': base + '/download', 'changefreq': 'weekly', 'priority': '0.7', 'lastmod': now})
    pages.append({'loc': base + '/esqueci-senha', 'changefreq': 'yearly', 'priority': '0.3', 'lastmod': now})

    # Paginas de conteudo (requerem login, mas indexaveis para SEO)
    try:
        page_canais = db.session.query(Canal.series_name).filter(
            Canal.tipo == 'live', Canal.is_hidden == False,
            Canal.series_name != None, Canal.series_name != ''
        ).distinct().limit(500).all()
        for row in page_canais:
            pages.append({'loc': base + '/canais', 'changefreq': 'daily', 'priority': '0.9', 'lastmod': now})
            break
    except Exception:
        pass

    try:
        page_filmes = db.session.query(Canal.series_name).filter(
            Canal.tipo == 'movie', Canal.is_hidden == False,
            Canal.series_name != None, Canal.series_name != ''
        ).distinct().limit(500).all()
        for row in page_filmes:
            pages.append({'loc': base + '/filmes', 'changefreq': 'daily', 'priority': '0.9', 'lastmod': now})
            break
    except Exception:
        pass

    try:
        series_list = db.session.query(Canal.series_name).filter(
            Canal.tipo == 'series', Canal.is_hidden == False,
            Canal.series_name != None, Canal.series_name != ''
        ).distinct().all()
        if series_list:
            pages.append({'loc': base + '/series', 'changefreq': 'daily', 'priority': '0.9', 'lastmod': now})
            for s in series_list[:200]:
                sname = s[0] if s else ''
                if sname:
                    safe_name = urllib.parse.quote(str(sname), safe='')
                    pages.append({'loc': f'{base}/series/{safe_name}', 'changefreq': 'weekly', 'priority': '0.7', 'lastmod': now})
    except Exception:
        pass

    # Paginas de filme individual (/filme/<id>) — ajudam o Google a indexar
    # o catalogo, nao so as vitrines.
    try:
        movie_ids = [r[0] for r in db.session.query(Canal.id).filter(
            Canal.tipo == 'movie', Canal.is_hidden == False
        ).order_by(Canal.view_count.desc()).limit(300).all()]
        for mid in movie_ids:
            pages.append({'loc': f'{base}/filme/{mid}', 'changefreq': 'weekly', 'priority': '0.6', 'lastmod': now})
    except Exception:
        pass

    # Paginas TV
    pages.append({'loc': base + '/tv/canais', 'changefreq': 'daily', 'priority': '0.8', 'lastmod': now})
    pages.append({'loc': base + '/tv/filmes', 'changefreq': 'daily', 'priority': '0.8', 'lastmod': now})
    pages.append({'loc': base + '/tv/series', 'changefreq': 'daily', 'priority': '0.8', 'lastmod': now})

    xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
    xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
    xml += 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
    xml += 'xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 '
    xml += 'http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">\n'
    for p in pages:
        xml += '  <url>\n'
        xml += f'    <loc>{p["loc"]}</loc>\n'
        xml += f'    <lastmod>{p["lastmod"]}</lastmod>\n'
        xml += f'    <changefreq>{p["changefreq"]}</changefreq>\n'
        xml += f'    <priority>{p["priority"]}</priority>\n'
        xml += '  </url>\n'
    xml += '</urlset>'
    return xml, 200, {'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'public, max-age=3600'}


@app.route('/manifest.json')
def manifest_json():
    return app.send_static_file('manifest.json'), 200, {'Content-Type': 'application/manifest+json; charset=utf-8'}


# ─── Deteccao de smart TV / TV box ─────────────────────────────────────────
_TV_UA_MARKERS = (
    'android tv', 'androidtv', 'googletv', 'google tv', 'fire tv', 'firetv',
    'leanback', 'smarttv', 'smart tv', 'web0s', 'webos', 'tizen', 'hbbtv',
    'tv box', 'tvbox', 'apple tv',
)


def is_tv_client(ua=None):
    """Detecta smart TV / TV box pelo User-Agent.

    Cobre o navegador da TV e o app Android TV (WebView). O WebView de
    celular sempre envia o token "Mobile"; o da TV/tablet nao envia.
    """
    if ua is None:
        ua = request.headers.get('User-Agent', '')
    ua_l = (ua or '').lower()
    if not ua_l:
        return False
    if any(marker in ua_l for marker in _TV_UA_MARKERS):
        return True
    if re.search(r'\baft[a-z0-9]{1,6}\b', ua_l):  # modelos Fire TV (AFTxx)
        return True
    if 'trocoplayapp/' in ua_l and 'android' in ua_l:
        if 'mobile' not in ua_l:
            return True
        m = re.search(r'android [0-9.]+; ([^;)]*)', ua_l)
        if m and 'tv' in m.group(1):  # ex.: "TV BOX", "MiTV-AFR0"
            return True
    return False


# Pagina web (versao PC/celular) -> pagina equivalente da versao TV.
# Paginas sem equivalente (planos, pagamento, suporte, cadastro) ficam de
# fora de proposito: o fluxo de compra/registro continua funcionando na TV.
_TV_ENDPOINT_MAP = {
    'landing': 'tv_canais',
    'login': 'tv_login',
    'dashboard': 'tv_canais',
    'canais': 'tv_canais',
    'esportes': 'tv_esportes',
    'filmes': 'tv_filmes',
    'series': 'tv_series',
    'series_by_id': 'tv_series',
    'series_detail': 'tv_series_detail',
    'movie_detail': 'tv_movie_detail',
    'player': 'tv_player',
    'favoritos': 'tv_favoritos',
    'historico': 'tv_historico',
    'conta': 'tv_conta',
    'notificacoes': 'tv_canais',
    'videos': 'tv_canais',
}


@app.before_request
def force_tv_mode():
    """Manda smart TV / TV box direto para a versao TV.

    Sem isso o app da TV abria a landing/dashboard (versao PC), porque o
    cliente e identificado so pelo User-Agent.
    """
    if request.method not in ('GET', 'HEAD'):
        return
    if not is_tv_client():
        return
    target = _TV_ENDPOINT_MAP.get(request.endpoint or '')
    if not target:
        return
    if not current_user.is_authenticated:
        return redirect(url_for('tv_login'))
    try:
        return redirect(url_for(target, **(request.view_args or {})))
    except Exception:
        return redirect(url_for('tv_canais'))


@app.route('/tv')
def tv_home():
    """Atalho /tv: usado pelo app Android no botao voltar do player."""
    if current_user.is_authenticated:
        return redirect(url_for('tv_canais'))
    return redirect(url_for('tv_login'))


# ─── Routes ────────────────────────────────────────────────────────────────
@app.route('/')
@limiter.limit('600 per hour')
def landing():
    if current_user.is_authenticated:
        session.pop('tv_login', None)
        return redirect(url_for('dashboard'))
    def _landing_counts():
        try:
            return (
                Canal.query.filter_by(tipo='live', is_hidden=False).count(),
                Canal.query.filter_by(tipo='movie', is_hidden=False).count(),
                db.session.query(Canal.series_name).filter(
                    Canal.tipo == 'series', Canal.is_hidden == False,
                    Canal.series_name != '', Canal.series_name.isnot(None)
                ).distinct().count(),
            )
        except Exception:
            return 0, 0, 0
    qtd_canais, qtd_filmes, qtd_series = cached_query('landing:counts', 300, _landing_counts)
    plans = get_active_plans()
    settings = load_settings()
    free_hours = settings.get('free_daily_hours', FREE_DAILY_HOURS_DEFAULT)
    return render_template('landing.html',
                           qtd_canais=qtd_canais, qtd_filmes=qtd_filmes,
                           qtd_series=qtd_series, plans=plans,
                           free_hours=free_hours)


@app.route('/download')
def download_app():
    settings = load_settings()
    version = settings.get('app_version', '1.0')
    size = settings.get('apk_size', '6.0 MB')
    release_date = settings.get('app_release_date', '')
    apk_url = url_for('serve_apk', _external=True)
    return render_template('download.html', version=version, size=size, release_date=release_date, apk_url=apk_url)


@app.route('/download/apk')
@limiter.exempt
def serve_apk():
    """Serve APK directly via Flask (bypasses cPanel static file blocking)."""
    apk_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'trocoplay.apk')
    if not os.path.isfile(apk_path):
        abort(404)
    return send_file(
        apk_path,
        mimetype='application/vnd.android.package-archive',
        as_attachment=True,
        download_name='trocoplay.apk'
    )


@app.route('/login', methods=['GET', 'POST'])
@limiter.limit('10 per minute', methods=['POST'])
def login():
    tv_mode = request.form.get('tv') == '1'
    if current_user.is_authenticated:
        if tv_mode or session.get('tv_login'):
            return redirect(url_for('tv_canais'))
        return redirect(url_for('dashboard'))
    if request.method == 'POST':
        email = request.form.get('email', '').strip().lower()
        pw = request.form.get('password', '')
        remember = request.form.get('remember', 'on') == 'on'
        user = User.query.filter_by(email=email).first()
        if user and user.check_password(pw):
            if user.is_banned:
                reason = user.ban_reason or 'Motivo nao informado.'
                flash(f'Conta banida. Motivo: {reason}', 'error')
            elif not user.is_active_user:
                reason = user.delete_reason or 'Conta desativada.'
                flash(f'Conta desativada. Motivo: {reason}', 'error')
            else:
                login_user(user, remember=True)
                session.permanent = True
                ip = get_client_ip()
                ua = request.headers.get('User-Agent', '')[:500]
                token = create_session_token(user.id, remember_me=True, ip=ip, ua=ua)
                session['persistent_token'] = token
                log_audit('login', user.id, ip=ip, ua=ua)
                _login_now = datetime.now(timezone.utc)
                _login_label = _tz_label()
                _ctx = format_login_context(ip=ip, ua=ua)
                _ctx_line = f'\nContexto: {_ctx}' if _ctx else ''
                create_notification(
                    user.id,
                    'Novo login na sua conta',
                    f'Login realizado em {to_user_tz(_login_now).strftime("%d/%m/%Y %H:%M")} ({_login_label}).{_ctx_line}\nSe não foi você, altere sua senha imediatamente.',
                    link='/conta',
                    send_email=os.environ.get('EMAIL_LOGIN_NOTIFICATIONS', 'true').lower() not in {'0', 'false', 'no'}
                )
                tv_mode = request.form.get('tv') == '1'
                if tv_mode or is_tv_client(ua):
                    session['tv_login'] = True
                    return redirect(url_for('tv_canais'))
                session.pop('tv_login', None)
                next_url = request.args.get('next')
                if next_url:
                    parsed = urlparse(next_url)
                    if parsed.scheme or parsed.netloc:
                        next_url = None
                return redirect(next_url or url_for('dashboard'))
        else:
            flash('Email ou senha incorretos.', 'error')
            if tv_mode:
                return render_template('tv_login.html', register_url=url_for('register', _external=True))
    if tv_mode:
        return render_template('tv_login.html', register_url=url_for('register', _external=True))
    return render_template('login.html')


@app.route('/register', methods=['GET', 'POST'])
@limiter.limit('3 per minute', methods=['POST'])
def register():
    if current_user.is_authenticated:
        return redirect(url_for('dashboard'))
    ref = request.args.get('ref', '').strip()
    if request.method == 'POST':
        email = request.form.get('email', '').strip().lower()
        pw = request.form.get('password', '')
        confirm = request.form.get('confirm', '')
        whatsapp = re.sub(r'[^0-9]', '', request.form.get('whatsapp', '').strip())
        ref_code = request.form.get('referral', '').strip() or ref
        terms_accepted = request.form.get('terms', '') == 'on'
        if not email or not pw:
            flash('Preencha todos os campos.', 'error')
        elif not whatsapp or len(whatsapp) < 10:
            flash('Informe um numero de WhatsApp valido (obrigatorio).', 'error')
        elif len(pw) < 6:
            flash('A senha deve ter pelo menos 6 caracteres.', 'error')
        elif pw != confirm:
            flash('Senhas não conferem.', 'error')
        elif User.query.filter_by(email=email).first():
            flash('Email já cadastrado.', 'error')
        elif not terms_accepted:
            flash('Voce precisa aceitar os Termos de Uso, Política de Privacidade, Termos de Serviço e Programa de Afiliados.', 'error')
        else:
            settings = load_settings()
            referred_by_id = None
            if ref_code and not settings.get('affiliation_disabled_all', False):
                referrer = User.query.filter_by(referral_code=ref_code).first()
                if referrer and referrer.id != None and not referrer.affiliation_disabled:
                    referred_by_id = referrer.id
            settings = load_settings()
            user = User(email=email, whatsapp=whatsapp, last_free_reset=datetime.now(timezone.utc).date(),
                        referred_by=referred_by_id,
                        free_daily_limit=int(settings.get('free_daily_hours', 4) * 3600))
            user.set_password(pw)
            user.referral_code = generate_referral_code()
            db.session.add(user)
            db.session.commit()
            if referred_by_id:
                existing = Referral.query.filter_by(referrer_id=referred_by_id, referred_id=user.id).first()
                if not existing:
                    ref = Referral(referrer_id=referred_by_id, referred_id=user.id)
                    db.session.add(ref)
                    db.session.commit()
            ip = get_client_ip()
            ua = request.headers.get('User-Agent', '')[:500]
            device = parse_device_info(ua)
            acceptance = TermsAcceptance(
                user_id=user.id, ip_address=ip, device_info=device,
                user_agent=ua, terms_version='1.0',
            )
            db.session.add(acceptance)
            db.session.commit()
            create_notification(
                user.id,
                'Bem-vindo ao Troço Play!',
                'Sua conta foi criada com sucesso. Explore o catálogo e consulte os planos quando quiser.',
                link='/dashboard'
            )
            flash('Cadastro realizado! Faça login.', 'success')
            log_audit('register', user.id)
            return redirect(url_for('login'))
    return render_template('register.html', prefill_ref=ref)


@app.route('/api/accept_terms', methods=['POST'])
@login_required
def api_accept_terms():
    data = request.get_json() or {}
    if not data.get('accepted'):
        return jsonify({'error': 'Aceitacao necessaria.'}), 400
    ip = get_client_ip()
    ua = request.headers.get('User-Agent', '')[:500]
    device = parse_device_info(ua)
    existing = TermsAcceptance.query.filter_by(user_id=current_user.id).order_by(TermsAcceptance.accepted_at.desc()).first()
    if existing and existing.terms_version == '1.0':
        return jsonify({'ok': True, 'message': 'Ja aceito anteriormente.'})
    acceptance = TermsAcceptance(
        user_id=current_user.id,
        ip_address=ip,
        device_info=device,
        user_agent=ua,
        terms_version='1.0',
    )
    db.session.add(acceptance)
    db.session.commit()
    log_audit('terms_accepted', current_user.id, ip=ip, ua=ua, device=device)
    return jsonify({'ok': True, 'message': 'Termos aceitos com sucesso.'})


@app.route('/api/set_timezone', methods=['POST'])
@login_required
def api_set_timezone():
    data = request.get_json() or {}
    offset = data.get('offset')
    if offset is None:
        return jsonify({'error': 'Offset obrigatorio.'}), 400
    try:
        offset = float(offset)
    except (TypeError, ValueError):
        return jsonify({'error': 'Offset invalido.'}), 400
    if not (-12 <= offset <= 14):
        return jsonify({'error': 'Offset fora do range (-12 a +14).'}), 400
    current_user.timezone_offset = offset
    db.session.commit()
    return jsonify({'ok': True, 'offset': offset})


@app.route('/logout')
@login_required
def logout():
    ip = get_client_ip()
    ua = request.headers.get('User-Agent', '')[:500]
    log_audit('logout', current_user.id, ip=ip, ua=ua)
    token = session.get('persistent_token', '')
    if token:
        stream_stop(current_user.id, token)
        SessionToken.query.filter_by(token=token).update({'is_active': False})
        db.session.commit()
    session.pop('persistent_token', None)
    session.pop('tv_login', None)
    logout_user()
    return redirect(url_for('landing'))


@app.route('/tv/login')
def tv_login():
    if current_user.is_authenticated:
        session['tv_login'] = True
        return redirect(url_for('tv_canais'))
    register_url = url_for('register', _external=True)
    return render_template('tv_login.html', register_url=register_url)


@app.route('/api/tv/generate', methods=['POST'])
@login_required
def api_tv_generate():
    # Remove codigos antigos do usuario antes de gerar um novo
    try:
        TVAuthCode.query.filter(TVAuthCode.user_id == current_user.id).delete()
        db.session.commit()
    except Exception:
        db.session.rollback()
    code = generate_tv_code(current_user.id)
    return jsonify({'code': code, 'expires_in': TV_CODE_EXPIRY})


@app.route('/api/tv/authenticate', methods=['POST'])
def api_tv_authenticate():
    code = (request.form.get('code') or '').strip().upper()
    source = (request.form.get('source') or 'tv').strip()
    if not code:
        return jsonify({'error': 'Codigo invalido.'}), 400
    user_id = consume_tv_code(code)
    if not user_id:
        return jsonify({'error': 'Codigo expirado ou invalido.'}), 401
    user = db.session.get(User, user_id)
    if not user or user.is_banned or not user.is_active_user:
        return jsonify({'error': 'Conta invalida.'}), 403
    login_user(user, remember=True)
    session.permanent = True
    ip = get_client_ip()
    ua = request.headers.get('User-Agent', '')[:500]
    if source == 'tv':
        session['tv_login'] = True
        log_audit('tv_login', user.id, ip=ip, ua=ua)
        return jsonify({'ok': True, 'redirect': url_for('tv_canais')})
    # Login via codigo vindo do site/app normal: permanece na versao web
    session.pop('tv_login', None)
    log_audit('login', user.id, ip=ip, ua=ua)
    return jsonify({'ok': True, 'redirect': url_for('dashboard')})


def _contagens_catalogo():
    """Total de canais, filmes e series que o usuario pode ver.

    O count distinct sobre as ~250 mil linhas do catalogo custa ~1 s no banco
    frio; e o mesmo numero para todos com o mesmo filtro adulto, entao vale
    guardar e refazer em segundo plano. O flag adulto e capturado aqui, no
    pedido, porque o calculo do fundo roda sem sessao nenhuma."""
    bloqueia = user_blocks_adult()
    chave = 'contagens:%d' % (1 if bloqueia else 0)

    def _vez():
        bloqueadas = get_adult_categories() if bloqueia else []

        def _filtro(q):
            return q.filter(~Canal.group_title.in_(bloqueadas)) if bloqueadas else q
        try:
            return (
                _filtro(Canal.query.filter_by(tipo='live', is_hidden=False)).count(),
                _filtro(Canal.query.filter_by(tipo='movie', is_hidden=False)).count(),
                _filtro(db.session.query(Canal.series_name).filter(
                    Canal.tipo == 'series', Canal.is_hidden == False,
                    Canal.series_name != '', Canal.series_name.isnot(None)
                )).distinct().count(),
            )
        except Exception:
            return 0, 0, 0
    return cached_swrc(chave, 180, _vez)


def _admin_catalog_counts():
    """Os numeros de topo do painel admin, contados uma vez por dois minutos."""
    return {
        'qtd_users': User.query.filter(User.is_admin == False).count(),
        'qtd_canais': Canal.query.filter_by(tipo='live').count(),
        'qtd_filmes': Canal.query.filter_by(tipo='movie').count(),
        'qtd_series': db.session.query(Canal.series_name).filter(
            Canal.tipo == 'series', Canal.series_name != '', Canal.series_name.isnot(None)
        ).distinct().count(),
        'qtd_payments': Payment.query.count(),
        'qtd_banned': User.query.filter(User.is_banned == True).count(),
        'qtd_inactive': User.query.filter(User.is_active_user == False, User.is_admin == False, User.is_banned == False).count(),
    }


@app.route('/dashboard')
@login_required
def dashboard():
    session.pop('tv_login', None)
    remaining = current_user.remaining_seconds()
    can_watch = current_user.can_watch()
    h = remaining // 3600
    m = (remaining % 3600) // 60
    qc, qf, qs = _contagens_catalogo()
    referral_stats = get_referral_stats(current_user.id)
    unread_count = get_unread_count(current_user.id)

    pending_payment = Payment.query.filter(
        Payment.user_id == current_user.id,
        Payment.status == 'pending',
    ).filter(
        db.or_(Payment.expires_at == None, Payment.expires_at > datetime.now(timezone.utc))
    ).order_by(Payment.created_at.desc()).first()

    cw_logs = db.session.query(WatchLog).filter(
        WatchLog.user_id == current_user.id,
        WatchLog.canal_id.isnot(None)
    ).order_by(WatchLog.last_seen.desc()).limit(5).all()
    continue_items = []
    seen_cw = set()
    for log in cw_logs:
        if log.canal_id in seen_cw:
            continue
        seen_cw.add(log.canal_id)
        canal = db.session.get(Canal, log.canal_id)
        if canal:
            if user_blocks_adult() and is_adult_group(canal.group_title):
                continue
            continue_items.append({
                'id': canal.id,
                'name': canal.name,
                'tipo': canal.tipo,
                'logo': canal.logo,
                'group_title': canal.group_title,
                'last_seen': log.last_seen,
            })

    return render_template('dashboard.html', horas=h, minutos=m,
                           can_watch=can_watch, qtd_canais=qc,
                           qtd_filmes=qf, qtd_series=qs,
                           now=datetime.now(timezone.utc),
                           referral_stats=referral_stats,
                           continue_items=continue_items,
                           unread_count=unread_count,
                           pending_payment=pending_payment)


@app.route('/canais')
@login_required
def canais():
    if session.get('tv_login'):
        return redirect(url_for('tv_canais'))
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    from types import SimpleNamespace
    MAIS_ASSISTIDOS_KEY = '__top__'
    MAIS_ASSISTIDOS_LABEL = '📈 Mais Assistidos'
    q = request.args.get('q', '').strip()
    grp = request.args.get('group', '').strip()
    fav = request.args.get('fav', '').strip()
    page = request.args.get('page', 1, type=int)
    is_top = (grp == MAIS_ASSISTIDOS_KEY)
    grp = sanitize_adult_group_param(grp)
    query = apply_adult_block(Canal.query.filter_by(tipo='live', is_hidden=False))
    if fav == '1':
        query = query.join(Favorite, Favorite.canal_id == Canal.id).filter(Favorite.user_id == current_user.id)
    if q:
        query = query.filter(Canal.name.ilike(f'%{q}%'))
    pp = _list_per_page()
    if is_top:
        canais_raw = query.order_by(Canal.view_count.desc()).limit(pp).all()
        canais_list = SimpleNamespace(items=canais_raw, total=len(canais_raw), page=1, pages=1, has_prev=False, has_next=False, prev_num=None, next_num=None, per_page=pp)
    else:
        if grp:
            query = query.filter(Canal.group_title == grp)
        canais_list = query.order_by(Canal.group_title, Canal.name).paginate(page=page, per_page=pp)
    def _live_groups():
        return [g[0] for g in db.session.query(Canal.group_title)
                  .filter_by(tipo='live').filter(Canal.group_title != '', Canal.group_title.isnot(None)).distinct().order_by(Canal.group_title).all()]
    groups_raw = cached_query('canais:groups', 600, _live_groups)
    groups = filter_adult_groups([{'key': MAIS_ASSISTIDOS_KEY, 'label': MAIS_ASSISTIDOS_LABEL}] + [{'key': g, 'label': g} for g in groups_raw])

    canal_ids = [c.id for c in canais_list.items]
    now = datetime.now(timezone.utc).replace(tzinfo=None)  # EPG armazenado em UTC

    current_programs = {}
    if canal_ids:
        try:
            subq = db.session.query(
                EPGProgram.canal_id,
                db.func.max(EPGProgram.id).label('max_id')
            ).filter(
                EPGProgram.canal_id.in_(canal_ids),
                EPGProgram.start_time <= now,
                db.or_(EPGProgram.end_time > now, EPGProgram.end_time.is_(None))
            ).group_by(EPGProgram.canal_id).subquery()
            progs = db.session.query(EPGProgram).filter(
                EPGProgram.id.in_(db.session.query(subq.c.max_id))
            ).all()
            for prog in progs:
                cid = prog.canal_id
                if cid:
                    remaining = ''
                    if prog.end_time:
                        diff = (prog.end_time - now).total_seconds()
                        if diff > 0:
                            h = int(diff // 3600)
                            m = int((diff % 3600) // 60)
                            remaining = f'{h}h {m}m' if h > 0 else f'{m}min'
                    current_programs[cid] = {
                        'title': prog.title,
                        'remaining': remaining,
                        'end_brt': to_user_tz(prog.end_time).strftime('%H:%M') if prog.end_time else '',
                    }
        except Exception:
            pass

    if canal_ids and len(current_programs) < len(canal_ids):
        try:
            mappings = ChannelEPGMapping.query.filter(ChannelEPGMapping.canal_id.in_(canal_ids)).all()
            mapping_by_canal = {m.canal_id: m for m in mappings}
            epg_channel_ids = [m.channel_id for m in mappings if m.canal_id not in current_programs]
            if epg_channel_ids:
                subq2 = db.session.query(
                    EPGProgram.channel_id,
                    db.func.max(EPGProgram.id).label('max_id')
                ).filter(
                    EPGProgram.channel_id.in_(epg_channel_ids),
                    EPGProgram.start_time <= now,
                    db.or_(EPGProgram.end_time > now, EPGProgram.end_time.is_(None))
                ).group_by(EPGProgram.channel_id).subquery()
                ch_progs = db.session.query(EPGProgram).filter(
                    EPGProgram.id.in_(db.session.query(subq2.c.max_id))
                ).all()
                ch_map = {p.channel_id: p for p in ch_progs}
                for cid, map_entry in mapping_by_canal.items():
                    if cid in current_programs:
                        continue
                    p = ch_map.get(map_entry.channel_id)
                    if p:
                        remaining = ''
                        if p.end_time:
                            diff = (p.end_time - now).total_seconds()
                            if diff > 0:
                                h = int(diff // 3600)
                                m = int((diff % 3600) // 60)
                                remaining = f'{h}h {m}m' if h > 0 else f'{m}min'
                        current_programs[cid] = {
                            'title': p.title,
                            'remaining': remaining,
                            'end_brt': to_user_tz(p.end_time).strftime('%H:%M') if p.end_time else '',
                        }
        except Exception:
            pass

    online_counts_q = db.session.query(
        WatchLog.canal_id, db.func.count(WatchLog.id)
    ).filter(WatchLog.is_active == True)
    if canal_ids:
        online_counts_q = online_counts_q.filter(WatchLog.canal_id.in_(canal_ids))
    online_counts = dict(online_counts_q.group_by(WatchLog.canal_id).all())

    return render_template('canais.html', canais=canais_list, groups=groups,
                           search=q, current_group=grp, show_fav=fav == '1',
                           epg_data=current_programs, online_counts=online_counts,
                           channel_health={h.canal_id: h for h in ChannelHealth.query.filter(ChannelHealth.canal_id.in_(canal_ids)).all()},
                           is_top_group=is_top,
                           MAIS_ASSISTIDOS_KEY=MAIS_ASSISTIDOS_KEY,
                           MAIS_ASSISTIDOS_LABEL=MAIS_ASSISTIDOS_LABEL)


@app.route('/play/<tipo>/<int:item_id>')
@login_required
def player(tipo, item_id):
    if session.get('tv_login'):
        return redirect(url_for('tv_player', tipo=tipo, item_id=item_id))
    try:
        if not current_user.can_watch():
            return redirect(url_for('planos'))
        item = db.session.get(Canal, item_id)
        if not item or item.tipo != tipo:
            flash('Conteudo nao encontrado.', 'error')
            return redirect(url_for('dashboard'))
        if user_blocks_adult() and is_adult_group(item.group_title):
            flash('Conteúdo bloqueado pelo controle parental.', 'error')
            return redirect(url_for({'live': 'canais', 'movie': 'filmes', 'series': 'series'}.get(tipo, 'dashboard')))

        if tipo == 'live' and item.stream_url and '.m3u8' in item.stream_url:
            pass

        token = session.get('persistent_token', '')
        ip = get_client_ip()
        ua = request.headers.get('User-Agent', '')[:500]

        source_index = choose_m3u_source_index(current_user.id, item.stream_url)
        can_play, block_device = stream_start_allowed(
            current_user.id, token, parse_device_info(ua), item.name, source_index, item.stream_url
        )

        if can_play:
            reserved_index = get_reserved_source_index(current_user.id, token)
            if reserved_index is not None:
                source_index = reserved_index
            log_audit('stream_start', current_user.id, details=f'content={item.name}')

        item.view_count = (item.view_count or 0) + 1
        deactivate_user_watchlogs(current_user.id, keep_canal_id=item_id)
        wl = WatchLog(user_id=current_user.id, canal_id=item_id, content_name=item.name, is_active=True)
        db.session.add(wl)
        db.session.commit()

        stream_token = ''
        stream_is_hls = False
        stream_channel_id = ''
        if can_play and item.stream_url:
            stream_url = item.stream_url
            source_pool = get_m3u_credential_sources()
            if source_index is not None and source_index < len(source_pool):
                stream_url = replace_stream_credentials(stream_url, source_pool[source_index])
            stream_url, stream_is_hls = resolve_playable_stream(item, stream_url, source_index, probe=False)
            stream_token = generate_stream_token(stream_url, current_user.id, item.stream_referrer, item.stream_user_agent, source_index)
            if stream_is_hls and '/live/' in stream_url:
                import re as _re
                m = _re.search(r'/(\d+)\.m3u8', stream_url)
                if m:
                    stream_channel_id = m.group(1)

        remaining = current_user.remaining_seconds()
        h = remaining // 3600
        m = (remaining % 3600) // 60

        return render_template('player.html', item=item, tipo=tipo,
                               horas=h, minutos=m,
                               can_play=can_play, block_device=block_device,
                               stream_already_registered=can_play,
                               stream_token=stream_token,
                               stream_is_hls=stream_is_hls,
                               stream_channel_id=stream_channel_id)
    except Exception as e:
        db.session.rollback()
        try:
            _logger.exception('player erro tipo=%s item_id=%s uid=%s: %r',
                              tipo, item_id, getattr(current_user, 'id', None), e)
        except Exception:
            pass
        flash('Erro ao carregar player. Tente novamente.', 'error')
        return redirect(url_for('dashboard'))


# ─── VOD Catalog: Movies and Series ───────────────────────────────────────
def _paginate_items(items, page, per_page):
    from types import SimpleNamespace
    total = len(items)
    pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, pages))
    start = (page - 1) * per_page
    selected = items[start:start + per_page]
    return SimpleNamespace(
        items=selected, total=total, page=page, pages=pages,
        has_prev=page > 1, has_next=page < pages,
        prev_num=page - 1 if page > 1 else None,
        next_num=page + 1 if page < pages else None,
        per_page=per_page,
    )


def _list_per_page(desktop=30, mobile=10):
    """PC: 3 colunas x 10 linhas (30). Mobile: 1 coluna x 10 (10)."""
    ua = (request.headers.get('User-Agent') or '').lower()
    is_mobile = any(x in ua for x in (
        'android', 'iphone', 'ipod', 'windows phone', 'blackberry', 'mobi',
    )) or ('ipad' in ua and 'safari' in ua)
    return mobile if is_mobile else desktop


def _is_series_group(group_title):
    """Retorna apenas grupos próprios de séries, excluindo filmes/canais híbridos."""
    normalized = ' '.join(str(group_title or '').upper().split())
    if not normalized:
        return False
    if normalized == '24H | SÉRIES' or normalized.startswith('24H '):
        return False
    if normalized.startswith('FILMES'):
        return False
    return True


# ─── Bloqueio de Conteúdo Adulto ──────────────────────────────────────
ADULT_CATEGORY_HINT = re.compile(
    r'adult|xxx|porn|porno|sexo|\bsex\b|erot|hentai|\+18|\(18|18\+|'
    r'brazzers|brasileirinhas|playboy|redtube|xvideos|xhamster|onlyfans',
    re.IGNORECASE,
)

def get_adult_categories():
    """Lista de group_title marcadas como adultas pelo admin (settings)."""
    try:
        cats = load_settings().get('adult_categories', []) or []
    except Exception:
        return []
    return [str(c).strip() for c in cats if str(c or '').strip()]


def user_blocks_adult():
    """True se o usuario logado ativou o bloqueio de conteudo adulto."""
    try:
        return bool(current_user.is_authenticated and getattr(current_user, 'hide_adult', False))
    except Exception:
        return False


def is_adult_group(group_title):
    """True se a categoria esta na lista de adultas definida pelo admin."""
    if not group_title:
        return False
    return str(group_title).strip() in get_adult_categories()


def apply_adult_block(query):
    """Exclui da query as categorias adultas quando o usuario ativou o bloqueio."""
    if user_blocks_adult():
        blocked = get_adult_categories()
        if blocked:
            query = query.filter(~Canal.group_title.in_(blocked))
    return query


def filter_adult_groups(groups):
    """Remove categorias adultas da lista de grupos (lista de str ou de dicts key/label)."""
    if not user_blocks_adult():
        return groups
    blocked = set(get_adult_categories())
    if not blocked:
        return groups
    filtered = []
    for g in groups or []:
        key = g.get('key') if isinstance(g, dict) else g
        if key in blocked:
            continue
        filtered.append(g)
    return filtered


def sanitize_adult_group_param(grp):
    """Se o usuario bloqueia adulto e pediu uma categoria adulta, ignora o filtro."""
    if grp and user_blocks_adult() and grp in get_adult_categories():
        return ''
    return grp


def _paginate_query(query, page, per_page):
    from types import SimpleNamespace
    total = query.order_by(None).count()
    pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, pages))
    items = query.offset((page - 1) * per_page).limit(per_page).all()
    return SimpleNamespace(
        items=items, total=total, page=page, pages=pages,
        has_prev=page > 1, has_next=page < pages,
        prev_num=page - 1 if page > 1 else None,
        next_num=page + 1 if page < pages else None,
        per_page=per_page,
    )


@app.route('/filmes')
@login_required
def filmes():
    if session.get('tv_login'):
        return redirect(url_for('tv_filmes'))
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    MAIS_ASSISTIDOS_KEY = '__top__'
    MAIS_ASSISTIDOS_LABEL = '📈 Mais Assistidos'
    q = request.args.get('q', '').strip()
    grp = request.args.get('group', '').strip()
    fav = request.args.get('fav', '').strip()
    page = request.args.get('page', 1, type=int)
    is_top = grp == MAIS_ASSISTIDOS_KEY
    grp = sanitize_adult_group_param(grp)
    query = apply_adult_block(Canal.query.filter_by(tipo='movie', is_hidden=False))
    if q:
        query = query.filter(Canal.name.ilike(f'%{q}%'))
    if grp and not is_top:
        query = query.filter(Canal.group_title == grp)
    if fav == '1':
        query = query.join(Favorite, Favorite.canal_id == Canal.id).filter(Favorite.user_id == current_user.id)
    if is_top:
        movies = query.order_by(Canal.view_count.desc(), Canal.name).limit(_list_per_page()).all()
        paged = _paginate_items(movies, page, _list_per_page())
    else:
        paged = _paginate_query(query.order_by(Canal.name), page, _list_per_page())
    groups = filter_adult_groups(_movie_groups_cached())
    return render_template('filmes.html', filmes=paged,
                           groups=groups, search=q, current_group=grp, show_fav=fav == '1',
                           is_top_group=is_top, MAIS_ASSISTIDOS_KEY=MAIS_ASSISTIDOS_KEY,
                           MAIS_ASSISTIDOS_LABEL=MAIS_ASSISTIDOS_LABEL)


def _movie_groups_raw():
    return [g[0] for g in db.session.query(Canal.group_title).filter_by(tipo='movie')
              .filter(Canal.group_title != '', Canal.group_title.isnot(None)).distinct().order_by(Canal.group_title).all()]


def _movie_groups_cached():
    return cached_query('filmes:groups', 600, _movie_groups_raw)


def _series_groups_raw():
    return [g[0] for g in db.session.query(Canal.group_title).filter_by(tipo='series')
              .filter(Canal.group_title != '', Canal.group_title.isnot(None)).distinct().order_by(Canal.group_title).all()
              if _is_series_group(g[0])]


def _series_groups_cached():
    return filter_adult_groups(cached_query('series:groups', 600, _series_groups_raw))


def _series_representative_logos(nomes):
    """Capa de cada serie: a oficial do Xtream primeiro, o episodio 1 como plano B.

    A capa oficial mora em ``serie_capa`` porque o sync do M3U reescreve
    ``canal.logo`` com o logo da linha do episodio (uma cena qualquer), e era
    isso que o site mostrava no lugar da capa da serie.

    Sem capa oficial registrada, cai no episodio 1 da temporada 1 de cada serie:
    o GROUP BY das listagens usava min(logo), que devolve a capa que vier primeiro
    em ordem alfabetica. Cada listagem mostrava ate 60 series; pedir a capa uma a
    uma virava 60 consultas, e cada uma ordenava o catalogo inteiro de series
    (226 mil linhas) porque a chave era uma expressao coalesce(...). Juntas em um
    unico filtro elas usam o indice de series_name e a pagina sai em milissegundos.
    """
    nomes = [n for n in dict.fromkeys(nomes or []) if n]
    if not nomes:
        return {}
    saida = {}
    try:
        for nome, cover in db.session.query(SeriesCover.name, SeriesCover.cover).filter(
                SeriesCover.name.in_(nomes),
                SeriesCover.cover.isnot(None), SeriesCover.cover != '').all():
            if cover:
                saida[nome] = cover
    except Exception:
        db.session.rollback()
    nomes = [n for n in nomes if n not in saida]
    if not nomes:
        return saida
    faltam = []
    agora = _time.time()
    for n in nomes:
        entry = _query_cache.get(f'series:cover:{n}')
        if entry and agora - entry[1] < 900:
            saida[n] = entry[0]
        else:
            faltam.append(n)
    if faltam:
        try:
            rows = db.session.query(
                Canal.series_name, Canal.name, Canal.logo,
                func.coalesce(Canal.season_number, 1),
                func.coalesce(Canal.episode_number, 1), Canal.id,
            ).filter(
                Canal.tipo == 'series', Canal.is_hidden == False,
                Canal.logo.isnot(None), Canal.logo != '',
                db.or_(Canal.series_name.in_(faltam),
                       db.and_(db.or_(Canal.series_name.is_(None), Canal.series_name == ''),
                               Canal.name.in_(faltam))),
            ).all()
            melhores = {}
            for series_name, name, logo, _s, _e, cid in rows:
                chave = str(series_name or name or '').strip()
                rank = (_s if _s is not None else 1, _e if _e is not None else 1, cid)
                atual = melhores.get(chave)
                if atual is None or rank < atual[0]:
                    melhores[chave] = (rank, logo)
            for n in faltam:
                par = melhores.get(n)
                logo = par[1] if par else None
                saida[n] = logo
                _query_cache[f'series:cover:{n}'] = (logo, agora)
        except Exception:
            db.session.rollback()
    return saida


def _series_total(q, grp, adulto):
    """Quantas series tem a busca/grupo atual.

    COUNT(DISTINCT coalesce(series_name, name)) varre o catalogo inteiro (4 s
    frio) e o numero so muda quando o M3U entra, entao mora no cache
    'series:total:', que o invalidate_cache('series:') tambem derruba."""
    def _conta():
        series_key = func.coalesce(func.nullif(Canal.series_name, ''), Canal.name)
        total = db.session.query(func.count(func.distinct(series_key))).filter(
            Canal.tipo == 'series', Canal.is_hidden == False)
        total = apply_adult_block(total)
        if q:
            total = total.filter(db.or_(Canal.name.ilike(f'%{q}%'), Canal.series_name.ilike(f'%{q}%')))
        if grp:
            total = total.filter(Canal.group_title == grp)
        return total.scalar() or 0
    return cached_query('series:total:%s|%s|%s' % (q, grp, adulto), 900, _conta)


def _series_listing(q, grp, fav, page, per_page, is_top, top_limit=None):
    """Listagem de series agrupadas no banco. /series e /tv/series usam a mesma
    conta: antes a tela da TV carregava os 226 mil episodios no Python para
    agrupar em cada clique (37 s para abrir).

    O resultado fica 15 minutos em memoria: agrupar 226 mil linhas e contar
    series distintas custava ~5 s em CADA clique, e o catalogo so muda quando o
    M3U e reimportado (la vai invalidate_cache('series:'))."""
    chave = 'series:list:%s|%s|%s|%s|%s|%s|%s|%s' % (
        q, grp, page, per_page, 1 if is_top else 0, 1 if top_limit else 0,
        1 if user_blocks_adult() else 0,
        current_user.id if fav == '1' else 0,
    )
    return cached_query(chave, 900, lambda: _series_listing_nua(
        q, grp, fav, page, per_page, is_top, top_limit))


def _series_listing_nua(q, grp, fav, page, per_page, is_top, top_limit=None):
    series_key = func.coalesce(func.nullif(Canal.series_name, ''), Canal.name)
    series_query = db.session.query(
        series_key.label('name'),
        func.min(Canal.logo).label('logo'),
        func.min(Canal.group_title).label('group_title'),
        func.count(Canal.id).label('count'),
        func.count(func.distinct(func.coalesce(Canal.season_number, 1))).label('seasons'),
        func.coalesce(func.sum(Canal.view_count), 0).label('view_count'),
        func.min(Canal.id).label('representative_id'),
    ).filter(Canal.tipo == 'series', Canal.is_hidden == False)
    series_query = apply_adult_block(series_query)
    if q:
        series_query = series_query.filter(db.or_(Canal.name.ilike(f'%{q}%'), Canal.series_name.ilike(f'%{q}%')))
    if grp and not is_top:
        series_query = series_query.filter(Canal.group_title == grp)
    if fav == '1':
        favorite_series = db.session.query(series_key).join(
            Favorite, Favorite.canal_id == Canal.id
        ).filter(Favorite.user_id == current_user.id).distinct()
        series_query = series_query.filter(series_key.in_(favorite_series))
    series_query = series_query.group_by(series_key)
    from types import SimpleNamespace
    if is_top:
        rows = series_query.order_by(func.sum(Canal.view_count).desc(), series_key).limit(top_limit or per_page).all()
        items = [SimpleNamespace(**row._mapping) for row in rows]
        total_series = len(items)
        pages = max(1, (total_series + per_page - 1) // per_page)
        page = max(1, min(page, pages))
        if top_limit:
            selecionados = items
            pages, page = 1, 1
        else:
            selecionados = items[(page - 1) * per_page:page * per_page]
        return SimpleNamespace(items=selecionados, total=total_series, page=page, pages=pages,
                               has_prev=page > 1, has_next=page < pages,
                               prev_num=page - 1 if page > 1 else None,
                               next_num=page + 1 if page < pages else None)
    total_series = _series_total(q, grp, 1 if user_blocks_adult() else 0)
    pages = max(1, (total_series + per_page - 1) // per_page)
    page = max(1, min(page, pages))
    rows = series_query.order_by(series_key).offset((page - 1) * per_page).limit(per_page).all()
    return SimpleNamespace(items=[SimpleNamespace(**row._mapping) for row in rows],
                           total=total_series, page=page, pages=pages,
                           has_prev=page > 1, has_next=page < pages,
                           prev_num=page - 1 if page > 1 else None,
                           next_num=page + 1 if page < pages else None)


@app.route('/series')
@login_required
def series():
    if session.get('tv_login'):
        return redirect(url_for('tv_series'))
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    MAIS_ASSISTIDOS_KEY = '__top__'
    MAIS_ASSISTIDOS_LABEL = '📈 Mais Assistidos'
    q = request.args.get('q', '').strip()
    grp = request.args.get('group', '').strip()
    fav = request.args.get('fav', '').strip()
    page = request.args.get('page', 1, type=int)
    is_top = grp == MAIS_ASSISTIDOS_KEY
    if grp and not is_top and not _is_series_group(grp):
        grp = ''
    grp = sanitize_adult_group_param(grp)
    pp = _list_per_page()
    paged = _series_listing(q, grp, fav, page, pp, is_top)
    capas = _series_representative_logos([s.name for s in paged.items])
    for serie in paged.items:
        serie.logo = capas.get(serie.name) or serie.logo

    groups = _series_groups_cached()
    return render_template('series.html', series_list=paged.items, total=paged.total,
                           total_pages=paged.pages, page=paged.page, pages=paged.pages,
                           has_prev=paged.has_prev, has_next=paged.has_next,
                           prev_num=paged.prev_num, next_num=paged.next_num,
                           groups=groups, search=q, current_group=grp, show_fav=fav == '1',
                           is_top_group=is_top, MAIS_ASSISTIDOS_KEY=MAIS_ASSISTIDOS_KEY,
                           MAIS_ASSISTIDOS_LABEL=MAIS_ASSISTIDOS_LABEL)


@app.route('/serie/<int:series_id>')
@login_required
def series_by_id(series_id):
    """Redireciona para a página de detalhes da série usando o ID de um episódio."""
    episode = Canal.query.filter_by(id=series_id, tipo='series', is_hidden=False).first()
    if not episode:
        flash('Série não encontrada.', 'error')
        return redirect(url_for('series'))
    if user_blocks_adult() and is_adult_group(episode.group_title):
        flash('Conteúdo bloqueado pelo controle parental.', 'error')
        return redirect(url_for('series'))
    series_name = episode.series_name or episode.name
    if not series_name:
        flash('Série não encontrada.', 'error')
        return redirect(url_for('series'))
    return redirect(url_for('series_detail', series_name=series_name), code=302)


@app.route('/series/<path:series_name>')
@login_required
def series_detail(series_name):
    try:
        if session.get('tv_login'):
            return redirect(url_for('tv_series_detail', series_name=series_name))
        if not current_user.can_watch():
            return redirect(url_for('planos'))
        import urllib.parse as _up
        decoded_name = _up.unquote(series_name)
        print(f'[DEBUG series_detail] Looking for series: {decoded_name}', flush=True)
        episodes = Canal.query.filter(
            Canal.tipo == 'series', Canal.is_hidden == False,
            Canal.series_name == decoded_name
        ).order_by(Canal.season_number, Canal.episode_number, Canal.name).all()
        print(f'[DEBUG series_detail] Query 1 (series_name exact): found {len(episodes)}', flush=True)
        if not episodes:
            episodes = Canal.query.filter(
                Canal.tipo == 'series', Canal.is_hidden == False,
                Canal.name == decoded_name
            ).order_by(Canal.season_number, Canal.episode_number, Canal.name).all()
            print(f'[DEBUG series_detail] Query 2 (name exact): found {len(episodes)}', flush=True)
        if not episodes:
            episodes = Canal.query.filter(
                Canal.tipo == 'series', Canal.is_hidden == False,
                db.func.lower(Canal.series_name) == decoded_name.lower()
            ).order_by(Canal.season_number, Canal.episode_number, Canal.name).all()
            print(f'[DEBUG series_detail] Query 3 (series_name lower): found {len(episodes)}', flush=True)
        if not episodes:
            episodes = Canal.query.filter(
                Canal.tipo == 'series', Canal.is_hidden == False,
                db.func.lower(Canal.name) == decoded_name.lower()
            ).order_by(Canal.season_number, Canal.episode_number, Canal.name).all()
            print(f'[DEBUG series_detail] Query 4 (name lower): found {len(episodes)}', flush=True)
        if not episodes:
            episodes = Canal.query.filter(
                Canal.tipo == 'series', Canal.is_hidden == False,
                Canal.name.ilike(f'%{decoded_name}%')
            ).order_by(Canal.series_name, Canal.season_number, Canal.episode_number, Canal.name).all()
            print(f'[DEBUG series_detail] Query 5 (name LIKE): found {len(episodes)}', flush=True)
            if episodes and episodes[0].series_name:
                real_name = episodes[0].series_name
                print(f'[DEBUG series_detail] Found via LIKE, series_name={real_name}', flush=True)
                episodes = Canal.query.filter(
                    Canal.tipo == 'series', Canal.is_hidden == False,
                    Canal.series_name == real_name
                ).order_by(Canal.season_number, Canal.episode_number, Canal.name).all()
                print(f'[DEBUG series_detail] Query 6 (series_name from LIKE): found {len(episodes)}', flush=True)
        if not episodes:
            print(f'[DEBUG series_detail] ALL QUERIES FAILED for: {decoded_name}', flush=True)
            flash('Série não encontrada.', 'error')
            return redirect(url_for('series'))
        if user_blocks_adult():
            _blocked = set(get_adult_categories())
            if _blocked and any(str(e.group_title or '') in _blocked for e in episodes):
                flash('Conteúdo bloqueado pelo controle parental.', 'error')
                return redirect(url_for('series'))
        from collections import defaultdict
        seasons = defaultdict(list)
        for index, episode in enumerate(episodes, start=1):
            if not episode.episode_number:
                episode.episode_number = index
            seasons[episode.season_number or 1].append(episode)
        first = episodes[0]
        display_name = first.series_name or first.name or decoded_name
        description = first.description or ''
        capa_oficial, plot_oficial = _capa_oficial_serie(display_name)
        # nunca mais do que isto: a arte da serie e a capa OFICIAL do provedor;
        # a cena do episodio e ultimo recurso, e só aparece depois que a busca
        # pela oficial respondeu que nao ha capa para aquela serie
        logo_cena = first.logo or ''
        logo = capa_oficial or ''
        estado_capa = _capa_estado(display_name)
        if plot_oficial and not description:
            description = plot_oficial
        year = ''
        rating = ''
        genre = ''
        if not year:
            for ep in episodes:
                if not year:
                    year = extract_year_from_name(ep.name) or extract_year_from_name(ep.description)
                if not rating:
                    rating = extract_rating_from_desc(ep.description)
                if year and rating:
                    break
        series_suggestions = _get_series_suggestions(display_name, first.group_title)
        return render_template('series_detail.html', series_name=display_name,
                               seasons=dict(seasons), total_eps=len(episodes),
                               year=year or '', rating=rating or '', description=description or '',
                               logo=logo or '', logo_cena=logo_cena,
                               capa_estado=estado_capa,
                               group_title=first.group_title or '',
                               genre=genre, xtream_episodes={},
                               suggestions=series_suggestions or {'similar_name': [], 'same_category': []})
    except Exception as e:
        import traceback
        log_error(f'series_detail error: {str(e)}\n{traceback.format_exc()}')
        flash('Erro ao carregar série.', 'error')
        return redirect(url_for('series'))


def _get_movie_suggestions(movie, year=None, genre=None, limit=6):
    """Busca sugestões de filmes por diferentes critérios."""
    suggestions = {'same_year': [], 'similar_name': [], 'same_genre': [], 'same_category': []}
    if not movie:
        return suggestions
    base_q = Canal.query.filter(Canal.tipo == 'movie', Canal.is_hidden == False, Canal.id != movie.id)
    base_q = apply_adult_block(base_q)
    # Mesmo ano
    if year:
        suggestions['same_year'] = base_q.filter(
            db.or_(
                Canal.name.ilike(f'%{year}%'),
                Canal.description.ilike(f'%{year}%')
            )
        ).order_by(func.random()).limit(limit).all()
    # Nome Similar
    clean_name = re.sub(r'[^\w\s]', '', movie.name or '').strip()
    if clean_name:
        words = clean_name.split()[:3]
        for word in words:
            if len(word) > 3:
                suggestions['similar_name'] = base_q.filter(
                    Canal.name.ilike(f'%{word}%')
                ).order_by(func.random()).limit(limit).all()
                if suggestions['similar_name']:
                    break
    # Mesma categoria (group_title)
    if movie.group_title:
        suggestions['same_category'] = base_q.filter(
            Canal.group_title == movie.group_title
        ).order_by(func.random()).limit(limit).all()
    # Limitar cada lista
    for key in suggestions:
        if len(suggestions[key]) > limit:
            suggestions[key] = suggestions[key][:limit]
    return suggestions


def _get_series_suggestions(series_name, group_title=None, limit=12):
    """Busca sugestões de séries por diferentes critérios (agrupado por série, não episódios)."""
    suggestions = {'similar_name': [], 'same_category': []}
    if not series_name:
        return suggestions
    series_key = func.coalesce(func.nullif(Canal.series_name, ''), Canal.name)
    base_q = db.session.query(
        series_key.label('series_name'),
        func.min(Canal.logo).label('logo'),
        func.min(Canal.group_title).label('group_title'),
        func.min(Canal.id).label('rep_id'),
    ).filter(Canal.tipo == 'series', Canal.is_hidden == False)
    base_q = apply_adult_block(base_q)
    base_q = base_q.filter(series_key != series_name)
    clean_name = re.sub(r'[^\w\s]', '', series_name or '').strip()
    print(f'[DEBUG suggestions] Looking for series: {series_name}, clean_name: {clean_name}', flush=True)
    if clean_name:
        words = clean_name.split()[:4]
        query_words = base_q
        for word in words:
            if len(word) > 2:
                query_words = query_words.filter(
                    db.or_(
                        Canal.name.ilike(f'%{word}%'),
                        Canal.series_name.ilike(f'%{word}%')
                    )
                )
        query_sim = query_words.group_by(series_key).order_by(func.random()).limit(limit)
        results = query_sim.all()
        print(f'[DEBUG suggestions] Found {len(results)} similar results', flush=True)
        suggestions['similar_name'] = [
            type('obj', (object,), {'name': r.series_name, 'logo': r.logo, 'group_title': r.group_title, 'id': r.rep_id, 'series_name': r.series_name})
            for r in results
        ]
    if group_title:
        query_cat = base_q.filter(Canal.group_title == group_title).group_by(series_key).order_by(func.random()).limit(limit)
        suggestions['same_category'] = [
            type('obj', (object,), {'name': r.series_name, 'logo': r.logo, 'group_title': r.group_title, 'id': r.rep_id, 'series_name': r.series_name})
            for r in query_cat.all()
        ]
    # Evitar duplicatas entre similar e category
    shown_names = set(s.name for s in suggestions['similar_name'])
    suggestions['same_category'] = [s for s in suggestions['same_category'] if s.name not in shown_names]
    capas = _series_representative_logos(
        [s.name for lista in suggestions.values() for s in lista])
    for semelhantes in suggestions.values():
        for serie in semelhantes:
            serie.logo = capas.get(serie.name) or serie.logo
    print(f'[DEBUG suggestions] Returning: {len(suggestions["similar_name"])} similar, {len(suggestions["same_category"])} same_category', flush=True)
    return suggestions


@app.route('/filme/<int:item_id>')
@login_required
def movie_detail(item_id):
    try:
        if session.get('tv_login'):
            return redirect(url_for('tv_player', tipo='movie', item_id=item_id))
        if not current_user.can_watch():
            return redirect(url_for('planos'))
        movie = Canal.query.filter_by(id=item_id, tipo='movie', is_hidden=False).first()
        if not movie:
            flash('Filme nao encontrado.', 'error')
            return redirect(url_for('filmes'))
        if user_blocks_adult() and is_adult_group(movie.group_title):
            flash('Conteúdo bloqueado pelo controle parental.', 'error')
            return redirect(url_for('filmes'))
        description = movie.description or ''
        movie_image = movie.logo or ''
        rating = ''
        cast = ''
        director = ''
        duration = ''
        backdrop_path = ''
        genre = ''
        release_date = ''
        year = ''
        # Buscar da API Xtream se não tiver dados completos
        if movie.stream_url and (not description or not movie.logo):
            xtream_data = _peek_xtream_vod_info(movie.stream_url) or {}
            if not xtream_data:
                _xtream_movie_em_background(movie.id, movie.stream_url)
            if xtream_data:
                if not description and xtream_data.get('plot'):
                    description = xtream_data['plot']
                    # Persistir no banco para nao repetir a consulta e para
                    # aparecer tambem nas listagens/busca.
                    movie.description = description[:1990]
                    if xtream_data.get('movie_image'):
                        movie.logo = xtream_data['movie_image']
                    db.session.commit()
                if xtream_data.get('rating'):
                    rating = xtream_data['rating']
                if xtream_data.get('cast'):
                    cast = xtream_data['cast']
                if xtream_data.get('director'):
                    director = xtream_data['director']
                if xtream_data.get('duration'):
                    duration = xtream_data['duration']
                if xtream_data.get('backdrop_path'):
                    backdrop_path = xtream_data['backdrop_path']
                if xtream_data.get('genre'):
                    genre = xtream_data['genre']
                if xtream_data.get('releaseDate'):
                    release_date = xtream_data['releaseDate']
                    year = release_date[:4]
        if not year:
            year = extract_year_from_name(movie.name) or extract_year_from_name(description)
        if not rating:
            rating = extract_rating_from_desc(description)
        suggestions = _get_movie_suggestions(movie, year, genre)
        return render_template('movie_detail.html', movie=movie,
                               year=year or '', rating=rating or '',
                               movie_image=movie.logo or movie_image, backdrop_path=backdrop_path,
                               cast=cast, director=director, duration=duration,
                               genre=genre, release_date=release_date,
                               suggestions=suggestions or {'same_year': [], 'similar_name': [], 'same_category': []})
    except Exception as e:
        import traceback
        log_error(f'movie_detail error: {str(e)}\n{traceback.format_exc()}')
        flash('Erro ao carregar filme.', 'error')
        return redirect(url_for('filmes'))


# ─── TV Routes ────────────────────────────────────────────────────

@app.route('/tv/filmes')
@login_required
def tv_filmes():
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    top_key = '__top__'
    q = request.args.get('q', '').strip()
    group = request.args.get('group', '').strip()
    fav = request.args.get('fav', '').strip()
    page = request.args.get('page', 1, type=int)
    group = sanitize_adult_group_param(group)
    query = apply_adult_block(Canal.query.filter_by(tipo='movie', is_hidden=False))
    if q:
        query = query.filter(Canal.name.ilike(f'%{q}%'))
    if group and group != top_key:
        query = query.filter(Canal.group_title == group)
    if fav == '1':
        query = query.join(Favorite, Favorite.canal_id == Canal.id).filter(Favorite.user_id == current_user.id)
    if group == top_key:
        items = query.order_by(Canal.view_count.desc(), Canal.name).limit(10).all()
        total = len(items)
        total_pages = 1
        page_items = items
    else:
        paged = query.order_by(Canal.name).paginate(page=page, per_page=30, error_out=False)
        page_items, total, total_pages = paged.items, paged.total, paged.pages
    groups = filter_adult_groups(_movie_groups_cached())
    return render_template('tv_filmes.html', filmes=page_items, total=total, total_pages=total_pages, page=page, groups=groups, search=q, current_group=group, show_fav=fav == '1', is_top_group=group == top_key, top_key=top_key)


@app.route('/tv/filme/<int:item_id>')
@login_required
def tv_movie_detail(item_id):
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    movie = Canal.query.filter_by(id=item_id, tipo='movie', is_hidden=False).first()
    if not movie:
        flash('Filme não encontrado.', 'error')
        return redirect(url_for('tv_filmes'))
    if user_blocks_adult() and is_adult_group(movie.group_title):
        flash('Conteúdo bloqueado pelo controle parental.', 'error')
        return redirect(url_for('tv_filmes'))
    description = movie.description or ''
    movie_image = movie.logo or ''
    year = ''
    rating = ''
    cast = ''
    director = ''
    duration = ''
    genre = ''
    backdrop_path = ''
    release_date = ''
    if movie.stream_url and (not description or not movie.logo):
        xtream_data = _peek_xtream_vod_info(movie.stream_url) or {}
        if not xtream_data:
            _xtream_movie_em_background(movie.id, movie.stream_url)
        if xtream_data:
            if not description and xtream_data.get('plot'):
                description = xtream_data['plot']
                movie.description = description[:1990]
                if xtream_data.get('movie_image'):
                    movie.logo = xtream_data['movie_image']
                db.session.commit()
            if xtream_data.get('movie_image'):
                movie_image = xtream_data['movie_image']
            if xtream_data.get('rating'):
                rating = xtream_data['rating']
            if xtream_data.get('cast'):
                cast = xtream_data['cast']
            if xtream_data.get('director'):
                director = xtream_data['director']
            if xtream_data.get('duration'):
                duration = xtream_data['duration']
            if xtream_data.get('backdrop_path'):
                backdrop_path = xtream_data['backdrop_path']
            if xtream_data.get('genre'):
                genre = xtream_data['genre']
            if xtream_data.get('releaseDate'):
                release_date = xtream_data['releaseDate']
                year = release_date[:4]
    if not year:
        year = extract_year_from_name(movie.name) or extract_year_from_name(description)
    if not rating:
        rating = extract_rating_from_desc(description)
    suggestions = _get_movie_suggestions(movie, year, genre)
    return render_template('tv_movie_detail.html', movie=movie,
                           year=year or '', rating=rating or '',
                           movie_image=movie_image or movie.logo, backdrop_path=backdrop_path,
                           cast=cast, director=director, duration=duration,
                           genre=genre, release_date=release_date,
                           suggestions=suggestions or {'same_year': [], 'similar_name': [], 'same_category': []})


@app.route('/tv/series')
@login_required
def tv_series():
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    top_key = '__top__'
    q = request.args.get('q', '').strip()
    group = request.args.get('group', '').strip()
    fav = request.args.get('fav', '').strip()
    page = request.args.get('page', 1, type=int)
    is_top = group == top_key
    if group and not is_top and not _is_series_group(group):
        group = ''
    group = sanitize_adult_group_param(group)
    paged = _series_listing(q, group, fav, page, 30, is_top, top_limit=10 if is_top else None)
    capas = _series_representative_logos([s.name for s in paged.items])
    from types import SimpleNamespace
    page_items = [SimpleNamespace(
        name=s.name,
        group_title=s.group_title or 'Série',
        episodes=s.count,
        views=int(s.view_count or 0),
        representative=SimpleNamespace(id=s.representative_id,
                                       logo=capas.get(s.name) or s.logo))
        for s in paged.items]

    groups = _series_groups_cached()
    return render_template('tv_series.html', series_list=page_items, total=paged.total, total_pages=paged.pages, page=paged.page, groups=groups, search=q, current_group=group, show_fav=fav == '1', is_top_group=is_top, top_key=top_key)


@app.route('/tv/series/<path:series_name>')
@login_required
def tv_series_detail(series_name):
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    import urllib.parse as _up
    decoded_name = _up.unquote(series_name)
    episodes = Canal.query.filter(
        Canal.tipo == 'series', Canal.is_hidden == False,
        Canal.series_name == decoded_name
    ).order_by(Canal.season_number, Canal.episode_number, Canal.name).all()
    if not episodes:
        episodes = Canal.query.filter(
            Canal.tipo == 'series', Canal.is_hidden == False,
            Canal.name == decoded_name
        ).order_by(Canal.season_number, Canal.episode_number, Canal.name).all()
    if not episodes:
        episodes = Canal.query.filter(
            Canal.tipo == 'series', Canal.is_hidden == False,
            db.func.lower(Canal.series_name) == decoded_name.lower()
        ).order_by(Canal.season_number, Canal.episode_number, Canal.name).all()
    if not episodes:
        episodes = Canal.query.filter(
            Canal.tipo == 'series', Canal.is_hidden == False,
            Canal.name.ilike(f'%{decoded_name}%')
        ).order_by(Canal.series_name, Canal.season_number, Canal.episode_number, Canal.name).all()
        if episodes and episodes[0].series_name:
            real_name = episodes[0].series_name
            episodes = Canal.query.filter(
                Canal.tipo == 'series', Canal.is_hidden == False,
                Canal.series_name == real_name
            ).order_by(Canal.season_number, Canal.episode_number, Canal.name).all()
    if not episodes:
        return redirect(url_for('tv_series'))
    if user_blocks_adult():
        _blocked = set(get_adult_categories())
        if _blocked and any(str(e.group_title or '') in _blocked for e in episodes):
            flash('Conteúdo bloqueado pelo controle parental.', 'error')
            return redirect(url_for('tv_series'))
    seasons = {}
    for episode in episodes:
        seasons.setdefault(episode.season_number or 1, []).append(episode)
    display_name = episodes[0].series_name or episodes[0].name or decoded_name
    first_ep = episodes[0]
    group_title = first_ep.group_title or ''
    description = first_ep.description or ''
    capa_oficial, plot_oficial = _capa_oficial_serie(display_name)
    # na TV a tela precisa aparecer na hora: nada de baixar o get_series inteiro
    # do provedor aqui. A capa oficial que ja esta em serie_capa aparece
    # direto; se ainda nao se sabe, o hero fica em skeleton e o proprio
    # app-da-TV pede /api/capa-serie depois de pintar.
    logo_cena = first_ep.logo or ''
    logo = capa_oficial or ''
    estado_capa = _capa_estado(display_name)
    if plot_oficial and not description:
        description = plot_oficial
    year = ''
    rating = ''
    genre = ''
    if not year:
        for ep in episodes:
            if not year:
                year = extract_year_from_name(ep.name) or extract_year_from_name(ep.description)
            if not rating:
                rating = extract_rating_from_desc(ep.description)
            if year and rating:
                break
    suggestions = _get_series_suggestions(display_name, group_title, limit=8)
    return render_template('tv_series_detail.html', series_name=display_name, seasons=seasons, total_eps=len(episodes),
                           year=year or '', rating=rating or '', description=description or '',
                           logo=logo or '', logo_cena=logo_cena, capa_estado=estado_capa,
                           group_title=group_title, genre=genre,
                           suggestions=suggestions)


@app.route('/tv/favoritos')
@login_required
def tv_favoritos():
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    from types import SimpleNamespace
    content_type = request.args.get('type', 'all').strip().lower()
    if content_type not in ('all', 'live', 'movie', 'series'):
        content_type = 'all'
    # ── Contagens ──
    counts = {}
    for key, value in (('all', None), ('live', 'live'), ('movie', 'movie'), ('series', 'series')):
        count_query = db.session.query(Canal).join(Favorite, Favorite.canal_id == Canal.id).filter(
            Favorite.user_id == current_user.id, Canal.is_hidden == False
        )
        count_query = apply_adult_block(count_query)
        if value:
            count_query = count_query.filter(Canal.tipo == value)
        if value == 'series':
            grouped = {}
            for c in count_query.all():
                key_name = c.series_name or c.name
                grouped[key_name] = True
            counts[key] = len(grouped)
        elif value is None:
            grouped = {}
            for c in count_query.all():
                if c.tipo == 'series':
                    key_name = c.series_name or c.name
                    grouped[key_name] = c
                else:
                    grouped[f'__single_{c.id}__'] = c
            counts[key] = len(grouped)
        else:
            counts[key] = count_query.count()
    page = request.args.get('page', 1, type=int)
    if content_type == 'series' or content_type == 'all':
        fav_canal_ids = [f.canal_id for f in Favorite.query.filter_by(user_id=current_user.id).all()]
        if not fav_canal_ids:
            items_list = []
            total = 0
            pages = 1
        else:
            fav_canals = apply_adult_block(Canal.query.filter(Canal.id.in_(fav_canal_ids), Canal.is_hidden == False)).all()
            series_groups = {}
            for c in fav_canals:
                if c.tipo == 'series':
                    key = c.series_name or c.name
                    if key not in series_groups:
                        series_groups[key] = SimpleNamespace(
                            name=key,
                            representative=c,
                            group_title=c.group_title or 'Série',
                        )
                elif content_type == 'all' or c.tipo == content_type:
                    series_groups[f'__single_{c.id}__'] = c
            items_list = list(series_groups.values())
            if content_type == 'series':
                total = len(series_groups)
                start = (page - 1) * 50
                items_list = items_list[start:start + 50]
            else:
                total = len(items_list)
                start = (page - 1) * 50
                items_list = items_list[start:start + 50]
        pages = max(1, (total + 49) // 50)
        page = max(1, min(page, pages))
        items = SimpleNamespace(items=items_list, total=total, page=page, pages=pages,
                               has_prev=page > 1, has_next=page < pages,
                               prev_num=page - 1 if page > 1 else None,
                               next_num=page + 1 if page < pages else None)
    else:
        query = apply_adult_block(db.session.query(Canal).join(Favorite, Favorite.canal_id == Canal.id).filter(
            Favorite.user_id == current_user.id, Canal.is_hidden == False, Canal.tipo == content_type
        ))
        items = query.order_by(Canal.name).paginate(page=page, per_page=50, error_out=False)
    if content_type == 'live' and items.items:
        canal_ids = [c.id for c in items.items]
        online_counts = dict(db.session.query(WatchLog.canal_id, db.func.count(WatchLog.id)).filter(
            WatchLog.canal_id.in_(canal_ids), WatchLog.is_active == True
        ).group_by(WatchLog.canal_id).all())
    else:
        online_counts = {}
    return render_template('tv_favoritos.html', items=items, online_counts=online_counts, content_type=content_type, counts=counts)


@app.route('/tv/historico')
@login_required
def tv_historico():
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    content_type = request.args.get('type', 'all').strip().lower()
    if content_type not in ('all', 'live', 'movie', 'series'):
        content_type = 'all'
    logs = WatchLog.query.filter(WatchLog.user_id == current_user.id).order_by(
        WatchLog.last_seen.desc()
    ).limit(200).all()
    seen = set()
    items = []
    for log in logs:
        if log.canal_id in seen:
            continue
        seen.add(log.canal_id)
        canal = db.session.get(Canal, log.canal_id)
        if canal and not canal.is_hidden and (content_type == 'all' or canal.tipo == content_type):
            if user_blocks_adult() and is_adult_group(canal.group_title):
                continue
            items.append(canal)
    all_logs = WatchLog.query.filter_by(user_id=current_user.id).order_by(WatchLog.last_seen.desc()).limit(300).all()
    count_ids = {'all': set(), 'live': set(), 'movie': set(), 'series': set()}
    for log in all_logs:
        canal = db.session.get(Canal, log.canal_id)
        if canal and not canal.is_hidden:
            if user_blocks_adult() and is_adult_group(canal.group_title):
                continue
            count_ids['all'].add(canal.id)
            count_ids.setdefault(canal.tipo, set()).add(canal.id)
    counts = {key: len(value) for key, value in count_ids.items()}
    return render_template('tv_historico.html', items=items, online_counts={}, content_type=content_type, counts=counts)


@app.route('/tv/conta')
@login_required
def tv_conta():
    remaining = current_user.remaining_seconds()
    horas = remaining // 3600
    minutos = (remaining % 3600) // 60
    used = current_user.free_daily_used
    used_h = used // 3600
    used_m = (used % 3600) // 60
    limit_h = current_user.free_daily_limit // 3600
    if current_user.is_paid and current_user.paid_until and current_user.paid_until_utc:
        time_diff = current_user.paid_until_utc - datetime.now(BRASILIA_TZ)
        remaining_days = max(0, time_diff.days)
    else:
        remaining_days = 0
    return render_template('tv_conta.html', horas=horas, minutos=minutos,
                           remaining=remaining, used_h=used_h, used_m=used_m,
                           limit_h=limit_h, remaining_days=remaining_days)


@app.route('/tv/canais')
@login_required
def tv_canais():
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    from types import SimpleNamespace
    MAIS_ASSISTIDOS_KEY = '__top__'
    MAIS_ASSISTIDOS_LABEL = '📈 Mais Assistidos'
    q = request.args.get('q', '').strip()
    grp = request.args.get('group', '').strip()
    page = request.args.get('page', 1, type=int)
    is_top = (grp == MAIS_ASSISTIDOS_KEY)
    grp = sanitize_adult_group_param(grp)
    query = apply_adult_block(Canal.query.filter_by(tipo='live', is_hidden=False))
    if q:
        query = query.filter(Canal.name.ilike(f'%{q}%'))
    if is_top:
        canais_raw = query.order_by(Canal.view_count.desc()).limit(10).all()
        canais_list = SimpleNamespace(items=canais_raw, total=len(canais_raw))
    else:
        if grp:
            query = query.filter(Canal.group_title == grp)
        canais_list = query.order_by(Canal.group_title, Canal.name).paginate(page=page, per_page=50)

    canal_ids = [c.id for c in canais_list.items]
    now = datetime.now(timezone.utc).replace(tzinfo=None)  # EPG armazenado em UTC

    groups = [g[0] for g in db.session.query(Canal.group_title)
              .filter_by(tipo='live').filter(Canal.group_title != '', Canal.group_title.isnot(None))
              .distinct().order_by(Canal.group_title).all()]
    groups = filter_adult_groups([{'key': MAIS_ASSISTIDOS_KEY, 'label': MAIS_ASSISTIDOS_LABEL}] + [{'key': g, 'label': g} for g in groups])

    online_counts_q = db.session.query(
        WatchLog.canal_id, db.func.count(WatchLog.id)
    ).filter(WatchLog.is_active == True)
    if canal_ids:
        online_counts_q = online_counts_q.filter(WatchLog.canal_id.in_(canal_ids))
    online_counts = dict(online_counts_q.group_by(WatchLog.canal_id).all())

    current_programs = {}
    if canal_ids:
        try:
            subq = db.session.query(
                EPGProgram.canal_id,
                db.func.max(EPGProgram.id).label('max_id')
            ).filter(
                EPGProgram.canal_id.in_(canal_ids),
                EPGProgram.start_time <= now,
                db.or_(EPGProgram.end_time > now, EPGProgram.end_time.is_(None))
            ).group_by(EPGProgram.canal_id).subquery()
            progs = db.session.query(EPGProgram).filter(
                EPGProgram.id.in_(db.session.query(subq.c.max_id))
            ).all()
            for prog in progs:
                cid = prog.canal_id
                if cid:
                    remaining = ''
                    if prog.end_time:
                        diff = (prog.end_time - now).total_seconds()
                        if diff > 0:
                            h = int(diff // 3600)
                            m = int((diff % 3600) // 60)
                            remaining = f'{h}h {m}m' if h > 0 else f'{m}min'
                    current_programs[cid] = {
                        'title': prog.title,
                        'remaining': remaining,
                        'end_brt': to_user_tz(prog.end_time).strftime('%H:%M') if prog.end_time else '',
                    }
        except Exception:
            pass

    if canal_ids and len(current_programs) < len(canal_ids):
        try:
            mappings = ChannelEPGMapping.query.filter(ChannelEPGMapping.canal_id.in_(canal_ids)).all()
            mapping_by_canal = {m.canal_id: m for m in mappings}
            epg_channel_ids = [m.channel_id for m in mappings if m.canal_id not in current_programs]
            if epg_channel_ids:
                subq2 = db.session.query(
                    EPGProgram.channel_id,
                    db.func.max(EPGProgram.id).label('max_id')
                ).filter(
                    EPGProgram.channel_id.in_(epg_channel_ids),
                    EPGProgram.start_time <= now,
                    db.or_(EPGProgram.end_time > now, EPGProgram.end_time.is_(None))
                ).group_by(EPGProgram.channel_id).subquery()
                ch_progs = db.session.query(EPGProgram).filter(
                    EPGProgram.id.in_(db.session.query(subq2.c.max_id))
                ).all()
                ch_map = {p.channel_id: p for p in ch_progs}
                for cid, map_entry in mapping_by_canal.items():
                    if cid in current_programs:
                        continue
                    p = ch_map.get(map_entry.channel_id)
                    if p:
                        remaining = ''
                        if p.end_time:
                            diff = (p.end_time - now).total_seconds()
                            if diff > 0:
                                h = int(diff // 3600)
                                m = int((diff % 3600) // 60)
                                remaining = f'{h}h {m}m' if h > 0 else f'{m}min'
                        current_programs[cid] = {
                            'title': p.title,
                            'remaining': remaining,
                            'end_brt': to_user_tz(p.end_time).strftime('%H:%M') if p.end_time else '',
                        }
        except Exception:
            pass

    return render_template('tv_canais.html', canais=canais_list,
                           search=q, current_group=grp,
                           groups=groups,
                           online_counts=online_counts,
                           epg_data=current_programs,
                           channel_health={h.canal_id: h for h in ChannelHealth.query.filter(ChannelHealth.canal_id.in_(canal_ids)).all()},
                           is_top_group=is_top,
                           MAIS_ASSISTIDOS_KEY=MAIS_ASSISTIDOS_KEY,
                           MAIS_ASSISTIDOS_LABEL=MAIS_ASSISTIDOS_LABEL)


@app.route('/videos')
def videos():
    # A aba Videos lista todos os videos institucionais ativos (sem filtro de local)
    if VideoIntro.query.count() == 0:
        vids = []
    else:
        vids = get_active_videos()
    return render_template('videos.html', videos=vids)


@app.route('/esportes')
@login_required
def esportes():
    if session.get('tv_login'):
        return redirect(url_for('tv_esportes'))
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    today = datetime.now(BRASILIA_TZ).date()
    tomorrow = today + timedelta(days=1)
    date_param = request.args.get('date', '')
    selected_date = None
    if date_param:
        try:
            selected_date = datetime.strptime(date_param, '%Y-%m-%d').date()
        except (ValueError, TypeError):
            selected_date = today
    else:
        selected_date = today
    query = SportEvent.query
    if selected_date:
        query = query.filter(SportEvent.event_date == selected_date)
    events = query.order_by(SportEvent.event_date, SportEvent.sport, SportEvent.event_time).all()
    norm_map, token_map, prefix_map, id_map = get_channel_index()
    grouped = {}
    for ev in events:
        if ev.event_date == today:
            date_label = 'Hoje'
        elif ev.event_date == tomorrow:
            date_label = 'Amanhã'
        else:
            date_label = ev.event_date.strftime('%d/%m/%Y')
        ch_data = resolve_event_channels(ev.channels, norm_map, token_map, prefix_map, id_map)
        info = {
            'team1': ev.team1,
            'team2': ev.team2,
            'score': ev.score,
            'event_time': ev.event_time,
            'channels': ch_data,
            'id': ev.id,
        }
        grouped.setdefault(date_label, {}).setdefault(ev.sport, {}).setdefault(ev.championship, []).append(info)
    prev_date = selected_date - timedelta(days=1)
    next_date = selected_date + timedelta(days=1)
    return render_template('esportes.html', grouped=grouped, today=today, tomorrow=tomorrow, selected_date=selected_date, prev_date=prev_date, next_date=next_date)


@app.route('/tv/esportes')
@login_required
def tv_esportes():
    if not current_user.can_watch():
        return redirect(url_for('planos'))
    today = datetime.now(BRASILIA_TZ).date()
    tomorrow = today + timedelta(days=1)
    date_param = request.args.get('date', '')
    selected_date = None
    if date_param:
        try:
            selected_date = datetime.strptime(date_param, '%Y-%m-%d').date()
        except (ValueError, TypeError):
            selected_date = today
    else:
        selected_date = today
    query = SportEvent.query
    if selected_date:
        query = query.filter(SportEvent.event_date == selected_date)
    events = query.order_by(SportEvent.event_date, SportEvent.sport, SportEvent.event_time).all()
    norm_map, token_map, prefix_map, id_map = get_channel_index()
    grouped = {}
    for ev in events:
        if ev.event_date == today:
            date_label = 'Hoje'
        elif ev.event_date == tomorrow:
            date_label = 'Amanhã'
        else:
            date_label = ev.event_date.strftime('%d/%m/%Y')
        ch_data = resolve_event_channels(ev.channels, norm_map, token_map, prefix_map, id_map)
        info = {
            'team1': ev.team1,
            'team2': ev.team2,
            'score': ev.score,
            'event_time': ev.event_time,
            'channels': ch_data,
            'id': ev.id,
        }
        grouped.setdefault(date_label, {}).setdefault(ev.sport, {}).setdefault(ev.championship, []).append(info)
    prev_date = selected_date - timedelta(days=1)
    next_date = selected_date + timedelta(days=1)
    return render_template('tv_esportes.html', grouped=grouped, today=today, tomorrow=tomorrow, selected_date=selected_date, prev_date=prev_date, next_date=next_date)


@app.route('/api/esportes')
@login_required
def api_esportes():
    today = datetime.now(BRASILIA_TZ).date()
    tomorrow = today + timedelta(days=1)
    events = SportEvent.query.filter(
        SportEvent.event_date.in_([today, tomorrow])
    ).order_by(SportEvent.event_date, SportEvent.sport, SportEvent.event_time).all()
    norm_map, token_map, prefix_map, id_map = get_channel_index()
    result = []
    for ev in events:
        ch_data = resolve_event_channels(ev.channels, norm_map, token_map, prefix_map, id_map)
        result.append({
            'id': ev.id,
            'sport': ev.sport,
            'championship': ev.championship,
            'team1': ev.team1,
            'team2': ev.team2,
            'score': ev.score,
            'event_time': ev.event_time,
            'channels': ch_data,
            'date': ev.event_date.isoformat(),
        })
    return jsonify(result)


@app.route('/tv/play/<tipo>/<int:item_id>')
@login_required
def tv_player(tipo, item_id):
    try:
        if not current_user.can_watch():
            return redirect(url_for('planos'))
        item = db.session.get(Canal, item_id)
        if not item or item.tipo != tipo:
            flash('Conteudo nao encontrado.', 'error')
            return redirect(url_for('tv_canais'))
        if user_blocks_adult() and is_adult_group(item.group_title):
            flash('Conteúdo bloqueado pelo controle parental.', 'error')
            return redirect(url_for({'live': 'tv_canais', 'movie': 'tv_filmes', 'series': 'tv_series'}.get(tipo, 'tv_canais')))

        token = session.get('persistent_token', '')
        ua = request.headers.get('User-Agent', '')[:500]
        source_index = choose_m3u_source_index(current_user.id, item.stream_url)
        can_play, block_device = stream_start_allowed(
            current_user.id, token, parse_device_info(ua), item.name, source_index, item.stream_url
        )

        if can_play:
            reserved_index = get_reserved_source_index(current_user.id, token)
            if reserved_index is not None:
                source_index = reserved_index
            log_audit('stream_start', current_user.id, details=f'content={item.name}')

        item.view_count = (item.view_count or 0) + 1
        deactivate_user_watchlogs(current_user.id, keep_canal_id=item_id)
        wl = WatchLog(user_id=current_user.id, canal_id=item_id, content_name=item.name, is_active=True)
        db.session.add(wl)
        db.session.commit()

        stream_token = ''
        stream_is_hls = False
        stream_channel_id = ''
        native_stream_url = ''
        if can_play and item.stream_url:
            stream_url = item.stream_url
            source_pool = get_m3u_credential_sources()
            if source_index is not None and source_index < len(source_pool):
                stream_url = replace_stream_credentials(stream_url, source_pool[source_index])
            stream_url, stream_is_hls = resolve_playable_stream(item, stream_url, source_index, probe=False)
            stream_token = generate_stream_token(stream_url, current_user.id, item.stream_referrer, item.stream_user_agent, source_index)
            if stream_is_hls and '/live/' in stream_url:
                import re as _re
                m = _re.search(r'/(\d+)\.m3u8', stream_url)
                if m:
                    stream_channel_id = m.group(1)
            native_stream_url = stream_url

        remaining = current_user.remaining_seconds()
        horas = remaining // 3600
        minutos = (remaining % 3600) // 60

        return render_template('tv_player.html', item=item, tipo=tipo,
                               can_play=can_play, block_device=block_device,
                               stream_already_registered=can_play,
                               stream_token=stream_token,
                               stream_is_hls=stream_is_hls,
                               stream_channel_id=stream_channel_id,
                               native_stream_url=native_stream_url,
                               horas=horas, minutos=minutos, remaining=remaining)
    except Exception as e:
        db.session.rollback()
        flash('Erro ao carregar player. Tente novamente.', 'error')
        return redirect(url_for('tv_canais'))


@app.route('/api/favorites/toggle', methods=['POST'])
@csrf.exempt
@login_required
def toggle_favorite():
    data = request.get_json(silent=True) or request.form.to_dict() or {}
    series_name = str(data.get('series_name') or data.get('series') or '').strip()
    raw_id = data.get('canal_id') or data.get('item_id') or data.get('id')
    canal_id = None
    is_series_request = False
    if series_name:
        is_series_request = True
        representative = Canal.query.filter_by(
            tipo='series', is_hidden=False, series_name=series_name
        ).order_by(Canal.season_number, Canal.episode_number, Canal.id).first()
        canal_id = representative.id if representative else None
    else:
        try:
            canal_id = int(raw_id)
        except (TypeError, ValueError):
            canal_id = None
    if not canal_id or canal_id <= 0:
        return jsonify({'ok': False, 'favorited': False, 'error': 'Conteudo nao identificado.'}), 400
    item = db.session.get(Canal, canal_id)
    if not item or item.is_hidden:
        return jsonify({'ok': False, 'favorited': False, 'error': 'Conteudo nao encontrado.'}), 404
    resolved_series_name = None
    if item.tipo == 'series' and item.series_name:
        resolved_series_name = item.series_name
    elif is_series_request and series_name:
        resolved_series_name = series_name
    try:
        if resolved_series_name:
            all_episodes = Canal.query.filter_by(
                tipo='series', is_hidden=False, series_name=resolved_series_name
            ).all()
            episode_ids = [ep.id for ep in all_episodes]
            existing = set(f.canal_id for f in Favorite.query.filter(
                Favorite.user_id == current_user.id,
                Favorite.canal_id.in_(episode_ids)
            ).all())
            if existing:
                Favorite.query.filter(
                    Favorite.user_id == current_user.id,
                    Favorite.canal_id.in_(episode_ids)
                ).delete(synchronize_session=False)
                db.session.commit()
                return jsonify({'ok': True, 'favorited': False, 'series_name': resolved_series_name})
            for ep in all_episodes:
                db.session.add(Favorite(user_id=current_user.id, canal_id=ep.id))
            db.session.commit()
            return jsonify({'ok': True, 'favorited': True, 'series_name': resolved_series_name})
        fav = Favorite.query.filter_by(user_id=current_user.id, canal_id=canal_id).first()
        if fav:
            db.session.delete(fav)
            db.session.commit()
            return jsonify({'ok': True, 'favorited': False, 'canal_id': canal_id})
        db.session.add(Favorite(user_id=current_user.id, canal_id=canal_id))
        db.session.commit()
        return jsonify({'ok': True, 'favorited': True, 'canal_id': canal_id})
    except Exception:
        db.session.rollback()
        return jsonify({'ok': False, 'favorited': False, 'error': 'Nao foi possivel salvar o favorito.'}), 500


@app.route('/api/favorites/check_series_bulk', methods=['POST'])
@csrf.exempt
@login_required
def check_series_favorites_bulk():
    names = [str(name).strip() for name in (request.get_json(silent=True) or {}).get('names', []) if str(name).strip()]
    if not names:
        return jsonify({})
    representative_ids = {}
    for name in names:
        episode = Canal.query.filter_by(tipo='series', is_hidden=False, series_name=name).order_by(
            Canal.season_number, Canal.episode_number, Canal.id
        ).first()
        if episode:
            representative_ids[name] = episode.id
    favorite_ids = {f.canal_id for f in Favorite.query.filter(
        Favorite.user_id == current_user.id,
        Favorite.canal_id.in_(list(representative_ids.values()) or [-1])
    ).all()}
    return jsonify({name: item_id in favorite_ids for name, item_id in representative_ids.items()})


@app.route('/api/favorites/check/<int:canal_id>')
@limiter.exempt
@login_required
def check_favorite(canal_id):
    item = db.session.get(Canal, canal_id)
    if item and item.tipo == 'series' and item.series_name:
        representative = Canal.query.filter_by(
            tipo='series', is_hidden=False, series_name=item.series_name
        ).order_by(Canal.season_number, Canal.episode_number, Canal.id).first()
        canal_id = representative.id if representative else canal_id
    fav = Favorite.query.filter_by(user_id=current_user.id, canal_id=canal_id).first()
    return jsonify({'favorited': fav is not None})


@app.route('/api/favorites/check_bulk', methods=['POST'])
@csrf.exempt
@login_required
def check_favorites_bulk():
    data = request.get_json(silent=True) or {}
    ids = data.get('ids', [])
    series_names = data.get('series_names', [])
    result = {}
    if series_names:
        representative_ids = {}
        for name in series_names:
            episode = Canal.query.filter_by(tipo='series', is_hidden=False, series_name=name).order_by(
                Canal.season_number, Canal.episode_number, Canal.id
            ).first()
            if episode:
                representative_ids[name] = episode.id
        if representative_ids:
            fav_ids = {f.canal_id for f in Favorite.query.filter(
                Favorite.user_id == current_user.id,
                Favorite.canal_id.in_(list(representative_ids.values()))
            ).all()}
            for name, rep_id in representative_ids.items():
                result[name] = rep_id in fav_ids
    if ids:
        favs = {f.canal_id for f in Favorite.query.filter(
            Favorite.user_id == current_user.id, Favorite.canal_id.in_(ids)
        ).all()}
        for i in ids:
            result[str(i)] = i in favs
    return jsonify(result)


@app.route('/favoritos')
@login_required
def favoritos():
    if session.get('tv_login'):
        return redirect(url_for('tv_canais'))
    from types import SimpleNamespace
    page = request.args.get('page', 1, type=int)
    category = request.args.get('tipo', 'all').strip().lower()
    category_map = {'canais': 'live', 'filmes': 'movie', 'series': 'series'}
    # Buscar todos os favoritos do usuario
    fav_canal_ids = [f.canal_id for f in Favorite.query.filter_by(user_id=current_user.id).all()]
    if not fav_canal_ids:
        fav_items = []
    else:
        fav_canals = apply_adult_block(Canal.query.filter(Canal.id.in_(fav_canal_ids), Canal.is_hidden == False)).all()
        # Agrupar series por series_name, manter canais/filmes individuais
        display_items = []
        seen_series = set()
        for c in fav_canals:
            if c.tipo == 'series':
                key = c.series_name or c.name
                if key in seen_series:
                    continue
                seen_series.add(key)
                # Buscar TODOS os episodios da serie no banco (nao so os favoritados)
                all_eps = apply_adult_block(Canal.query.filter(
                    Canal.tipo == 'series', Canal.is_hidden == False,
                    db.or_(Canal.series_name == key, Canal.name == key)
                )).all()
                total_episodes = len(all_eps)
                total_seasons = len(set(x.season_number or 1 for x in all_eps))
                display_items.append(SimpleNamespace(
                    id=c.id, name=key, tipo='series', logo=c.logo or '',
                    group_title=c.group_title or '',
                    episode_count=total_episodes,
                    season_count=total_seasons,
                    view_count=sum(x.view_count or 0 for x in all_eps),
                    is_series_group=True,
                ))
            elif c.tipo == category_map.get(category, '') or category == 'all':
                display_items.append(c)
        # Filtrar por categoria
        if category in category_map:
            display_items = [x for x in display_items if x.tipo == category_map[category]]
        fav_items = display_items
    # Paginacao simples
    total = len(fav_items)
    per_page = _list_per_page()
    pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, pages))
    start = (page - 1) * per_page
    page_items = fav_items[start:start + per_page]
    items = SimpleNamespace(items=page_items, total=total, page=page, pages=pages,
                           has_prev=page > 1, has_next=page < pages,
                           prev_num=page - 1 if page > 1 else None,
                           next_num=page + 1 if page < pages else None)
    # Contagens
    all_series = len([x for x in (fav_items if category == 'all' else [c for c in apply_adult_block(Canal.query.filter(Canal.id.in_(fav_canal_ids), Canal.is_hidden == False)).all() if c.tipo == 'series']) if getattr(x, 'is_series_group', False)])
    all_live = len([c for c in apply_adult_block(Canal.query.filter(Canal.id.in_(fav_canal_ids), Canal.is_hidden == False, Canal.tipo == 'live')).all()]) if fav_canal_ids else 0
    all_movie = len([c for c in apply_adult_block(Canal.query.filter(Canal.id.in_(fav_canal_ids), Canal.is_hidden == False, Canal.tipo == 'movie')).all()]) if fav_canal_ids else 0
    counts = {
        'all': len(fav_canal_ids) if fav_canal_ids else 0,
        'canais': all_live,
        'filmes': all_movie,
        'series': all_series,
    }
    return render_template('favoritos.html', items=items,
                           category=category if category in category_map else 'all', counts=counts)


@csrf.exempt
@app.route('/api/player_diag', methods=['POST'])
@limiter.exempt
@login_required
def player_diag():
    """O player reporta o estado do video/MSE para o app.log.

    Sem isso, "a imagem travou" e "os bytes chegam" sao indistinguiveis. Aqui
    entram readyState, networkState, buffered, currentTime, erro do MediaSource
    e a ultima URL pedida — que e exatamente o que falta para diagnosticar.
    """
    data = _request_payload() or {}
    ev = str(data.get('ev') or '?')[:40]
    def n(key, default=None):
        v = data.get(key, default)
        try:
            return float(v)
        except (TypeError, ValueError):
            return v
    url = str(data.get('url') or '')
    # Nunca gravar o token inteiro: ele carrega a credencial do provedor.
    if 'token=' in url:
        url = url.split('token=')[0] + 'token=<oculto>'
    log_info(
        f'DIAG ev={ev} engine={str(data.get("engine"))[:18]} '
        f'rs={n("readyState")} ns={n("networkState")} '
        f'ct={n("currentTime")} dur={n("duration")} '
        f'buf={n("buffered")} pend={n("pending")} '
        f'pausado={data.get("paused")} err={str(data.get("err"))[:90]} '
        f'{url[:110]}'
    )
    return jsonify({'ok': True})


@app.route('/api/watch_heartbeat', methods=['POST'])
@limiter.exempt
@login_required
def watch_heartbeat():
    data = _request_payload()
    canal_id = _payload_canal_id(data)
    if canal_id is None:
        return jsonify({'ok': True})
    # Só UM conteudo ativo por usuario: qualquer outro registro e desativado.
    deactivate_user_watchlogs(current_user.id, keep_canal_id=canal_id)
    log = WatchLog.query.filter_by(user_id=current_user.id, canal_id=canal_id, is_active=True).first()
    if log:
        log.last_seen = datetime.now(timezone.utc)
    else:
        item = db.session.get(Canal, canal_id)
        log = WatchLog(user_id=current_user.id, canal_id=canal_id,
                       content_name=item.name if item else '', is_active=True)
        db.session.add(log)
    db.session.commit()
    return jsonify({'ok': True})


@app.route('/api/watch_stop', methods=['POST'])
@limiter.exempt
@login_required
def watch_stop():
    # Aceita JSON (fetch) ou FormData (sendBeacon na saida da pagina/app).
    # Sem canal_id encerra TUDO do usuario — parar de assistir e parar tudo.
    data = _request_payload()
    canal_id = _payload_canal_id(data)
    deactivate_user_watchlogs(current_user.id, keep_canal_id=None if canal_id is None else canal_id)
    if canal_id is not None:
        WatchLog.query.filter_by(user_id=current_user.id, canal_id=canal_id, is_active=True).update({'is_active': False})
        db.session.commit()
    return jsonify({'ok': True})


@app.route('/api/content_counts')
@login_required
def content_counts():
    qc, qf, qs = _contagens_catalogo()
    return jsonify({'canais': qc, 'filmes': qf, 'series': qs})


@app.route('/api/app/update-check')
@login_required
def app_update_check():
    settings = load_settings()
    ua = (request.headers.get('User-Agent', '') + ' ' +
          request.headers.get('X-App-Version', '')).lower()

    # Only report updates for native app, not web browsers
    if 'trocoplayapp/' not in ua:
        return jsonify({
            'has_update': False,
            'current_version': '1.0',
            'latest_version': settings.get('app_version', '1.0'),
            'download_url': '',
            'is_required': False,
            'changelog': '',
            'apk_size': settings.get('apk_size', '6.0 MB'),
        })

    current_version = '1.0'
    m = re.search(r'trocoplayapp/([\d.]+)', ua)
    if m:
        current_version = m.group(1)

    latest_version = settings.get('app_version', '1.0')
    download_url = settings.get('app_update_url', '') or url_for('serve_apk', _external=True)
    is_required = settings.get('app_update_required', False)
    changelog = settings.get('app_update_changelog', '')
    apk_size = settings.get('apk_size', '6.0 MB')

    def parse_version(v):
        parts = v.split('.')
        parts = [int(p) if p.isdigit() else 0 for p in parts]
        while len(parts) < 3:
            parts.append(0)
        return tuple(parts[:3])

    has_update = parse_version(latest_version) > parse_version(current_version)

    return jsonify({
        'has_update': has_update,
        'current_version': current_version,
        'latest_version': latest_version,
        'download_url': download_url,
        'is_required': is_required,
        'changelog': changelog,
        'apk_size': apk_size,
    })


@app.route('/api/online_viewers/<int:item_id>')
@limiter.exempt
@login_required
def online_viewers(item_id):
    stale = datetime.now(timezone.utc) - timedelta(minutes=3)
    try:
        WatchLog.query.filter(WatchLog.last_seen < stale, WatchLog.is_active == True).update({'is_active': False})
        db.session.commit()
    except Exception:
        db.session.rollback()
    online_count = WatchLog.query.filter(
        WatchLog.canal_id == item_id,
        WatchLog.is_active == True
    ).count()
    item = db.session.get(Canal, item_id)
    views = item.view_count if item else 0
    return jsonify({'online': online_count, 'views': views})


@app.route('/api/canais/status', methods=['POST'])
@limiter.exempt
@login_required
def api_canais_status():
    data = request.get_json()
    canal_ids = data.get('canal_ids', [])
    if not canal_ids:
        return jsonify({'epg': {}, 'online': {}})
    now = datetime.now(timezone.utc).replace(tzinfo=None)  # EPG armazenado em UTC
    end_limit = now + timedelta(hours=12)
    all_progs = EPGProgram.query.filter(
        EPGProgram.canal_id.in_(canal_ids),
        EPGProgram.start_time < end_limit,
        EPGProgram.end_time > now - timedelta(hours=1),
    ).order_by(EPGProgram.start_time).all()
    current_programs = {}
    for prog in all_progs:
        cid = prog.canal_id
        if cid and cid not in current_programs and prog.start_time <= now < (prog.end_time or now):
            remaining = ''
            if prog.end_time:
                diff = (prog.end_time - now).total_seconds()
                if diff > 0:
                    h = int(diff // 3600)
                    m = int((diff % 3600) // 60)
                    remaining = f'{h}h {m}m' if h > 0 else f'{m}min'
            current_programs[cid] = {
                'title': prog.title,
                'remaining': remaining,
                'end_brt': to_user_tz(prog.end_time).strftime('%H:%M'),
            }
    if len(current_programs) < len(canal_ids):
        mappings = ChannelEPGMapping.query.filter(ChannelEPGMapping.canal_id.in_(canal_ids)).all()
        mapping_by_canal = {m.canal_id: m for m in mappings}
        epg_channel_ids = [m.channel_id for m in mappings if m.canal_id not in current_programs]
        if epg_channel_ids:
            channel_progs = EPGProgram.query.filter(
                EPGProgram.channel_id.in_(epg_channel_ids),
                EPGProgram.start_time < end_limit,
                EPGProgram.end_time > now - timedelta(hours=1),
            ).order_by(EPGProgram.start_time).all()
            for cid, map_entry in mapping_by_canal.items():
                if cid in current_programs:
                    continue
                for p in channel_progs:
                    if p.channel_id == map_entry.channel_id and p.start_time <= now < (p.end_time or now):
                        remaining = ''
                        if p.end_time:
                            diff = (p.end_time - now).total_seconds()
                            if diff > 0:
                                h = int(diff // 3600)
                                m = int((diff % 3600) // 60)
                                remaining = f'{h}h {m}m' if h > 0 else f'{m}min'
                        current_programs[cid] = {
                            'title': p.title,
                            'remaining': remaining,
                            'end_brt': to_user_tz(p.end_time).strftime('%H:%M'),
                        }
                        break
    online_counts = dict(db.session.query(
        WatchLog.canal_id, db.func.count(WatchLog.id)
    ).filter(
        WatchLog.is_active == True,
        WatchLog.canal_id.in_(canal_ids)
    ).group_by(WatchLog.canal_id).all())
    return jsonify({'epg': current_programs, 'online': online_counts})


@app.route('/api/watched_ids')
@limiter.exempt
def api_watched_ids():
    """IDs dos conteudos que o usuario ja assistiu (selo ✓ ja visto).
    Sempre devolve JSON (mesmo deslogado) para nao quebrar o JS do site/app."""
    ids = []
    try:
        if current_user.is_authenticated:
            rows = db.session.query(WatchLog.canal_id).filter(
                WatchLog.user_id == current_user.id,
                WatchLog.canal_id.isnot(None)
            ).distinct().all()
            ids = [int(r[0]) for r in rows if r[0] is not None]
    except Exception as e:
        log_error(f'api_watched_ids: {e}')
        try:
            db.session.rollback()
        except Exception:
            pass
    resp = jsonify({'ids': ids})
    resp.headers['Cache-Control'] = 'private, max-age=60'
    return resp


@app.route('/api/continue_watching')
@limiter.exempt
@login_required
def continue_watching():
    logs = db.session.query(WatchLog).filter(
        WatchLog.user_id == current_user.id,
        WatchLog.canal_id.isnot(None)
    ).order_by(WatchLog.last_seen.desc()).limit(5).all()
    result = []
    seen = set()
    for log in logs:
        if log.canal_id in seen:
            continue
        seen.add(log.canal_id)
        canal = db.session.get(Canal, log.canal_id)
        if canal:
            result.append({
                'id': canal.id,
                'name': canal.name,
                'tipo': canal.tipo,
                'logo': canal.logo,
                'group_title': canal.group_title,
                'last_seen': brasilia_iso(log.last_seen),
            })
    return jsonify({'items': result})


@app.route('/historico')
@login_required
def historico():
    requested = request.args.get('tipo', 'all').strip().lower()
    category_map = {'canais': 'live', 'filmes': 'movie', 'series': 'series'}
    category = requested if requested in category_map else 'all'
    page = request.args.get('page', 1, type=int)
    per_page = _list_per_page()

    logs_query = WatchLog.query.filter(
        WatchLog.user_id == current_user.id
    ).order_by(WatchLog.last_seen.desc())

    seen = {}
    all_items = []
    for log in logs_query.limit(500).all():
        canal = db.session.get(Canal, log.canal_id) if log.canal_id else None
        tipo = canal.tipo if canal and canal.tipo in ('live', 'movie', 'series') else 'live'
        key = (log.canal_id, log.content_name or '')
        if key in seen:
            continue
        seen[key] = True
        all_items.append({
            'canal_id': log.canal_id,
            'name': log.content_name or (canal.name if canal else 'Desconhecido'),
            'logo': canal.logo if canal else '',
            'last_seen': log.last_seen,
            'group_title': canal.group_title if canal else '',
            'tipo': tipo,
        })

    if user_blocks_adult():
        _blocked_hist = set(get_adult_categories())
        if _blocked_hist:
            all_items = [item for item in all_items if str(item.get('group_title') or '') not in _blocked_hist]

    counts = {
        'all': len(all_items),
        'canais': sum(item['tipo'] == 'live' for item in all_items),
        'filmes': sum(item['tipo'] == 'movie' for item in all_items),
        'series': sum(item['tipo'] == 'series' for item in all_items),
    }
    filtered_items = [item for item in all_items if category == 'all' or item['tipo'] == category_map[category]]
    total = len(filtered_items)
    total_pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, total_pages))
    start = (page - 1) * per_page
    end = start + per_page
    page_items = filtered_items[start:end]
    return render_template('historico.html', items=page_items, category=category, counts=counts,
                          page=page, total_pages=total_pages, total=total)


@app.route('/api/report', methods=['POST'])
@login_required
def report_issue():
    data = request.get_json()
    canal_id = data.get('canal_id')
    issue_type = data.get('issue_type', '').strip()
    valid_types = ['sem_audio', 'nao_pega', 'tela_preta', 'travando', 'offline']
    if not canal_id or issue_type not in valid_types:
        return jsonify({'error': 'Dados inválidos'}), 400
    item = db.session.get(Canal, canal_id)
    if not item:
        return jsonify({'error': 'Conteúdo não encontrado'}), 404
    existing = Report.query.filter(
        Report.user_id == current_user.id,
        Report.canal_id == canal_id,
        Report.status == 'pending'
    ).first()
    if existing:
        return jsonify({'error': 'Você já reportou este conteúdo'}), 400
    report = Report(
        user_id=current_user.id,
        canal_id=canal_id,
        issue_type=issue_type,
        content_name=item.name,
        content_type=item.tipo,
        group_title=item.group_title,
    )
    db.session.add(report)
    db.session.commit()
    return jsonify({'ok': True, 'message': 'Reporte enviado com sucesso!'})


@app.route('/api/report/check/<int:canal_id>')
@login_required
def check_reported(canal_id):
    report = Report.query.filter_by(
        user_id=current_user.id, canal_id=canal_id, status='pending'
    ).first()
    return jsonify({'reported': report is not None})


@app.route('/admin/reports')
@login_required
@admin_required
def admin_reports():
    status_filter = request.args.get('status', 'pending')
    page = request.args.get('page', 1, type=int)
    query = Report.query
    if status_filter != 'all':
        query = query.filter_by(status=status_filter)
    reports = query.order_by(Report.created_at.desc()).paginate(page=page, per_page=20)
    total_pending = Report.query.filter_by(status='pending').count()
    total_resolved = Report.query.filter_by(status='resolved').count()
    total_offline = Report.query.filter_by(status='offline').count()
    return render_template('admin_reports.html', reports=reports,
                           status_filter=status_filter,
                           total_pending=total_pending,
                           total_resolved=total_resolved,
                           total_offline=total_offline)


@app.route('/admin/reports/<int:report_id>/resolve', methods=['POST'])
@login_required
@admin_required
def admin_resolve_report(report_id):
    report = db.session.get(Report, report_id)
    if not report:
        flash('Reporte não encontrado.', 'error')
        return redirect(url_for('admin_reports'))
    note = request.form.get('admin_note', '').strip()
    report.status = 'resolved'
    report.admin_note = note
    report.resolved_at = datetime.now(timezone.utc)
    db.session.commit()
    flash('Reporte marcado como resolvido.', 'success')
    return redirect(url_for('admin_reports'))


@app.route('/admin/reports/<int:report_id>/dismiss', methods=['POST'])
@login_required
@admin_required
def admin_dismiss_report(report_id):
    report = db.session.get(Report, report_id)
    if not report:
        flash('Reporte não encontrado.', 'error')
        return redirect(url_for('admin_reports'))
    report.status = 'dismissed'
    report.resolved_at = datetime.now(timezone.utc)
    db.session.commit()
    flash('Reporte descartado.', 'success')
    return redirect(url_for('admin_reports'))


@app.route('/admin/reports/<int:report_id>/offline', methods=['POST'])
@login_required
@admin_required
def admin_mark_offline(report_id):
    report = db.session.get(Report, report_id)
    if not report:
        flash('Reporte não encontrado.', 'error')
        return redirect(url_for('admin_reports'))
    canal = db.session.get(Canal, report.canal_id)
    if canal:
        canal.is_hidden = True
    report.status = 'offline'
    report.admin_note = 'Conteúdo marcado como offline pelo admin'
    report.resolved_at = datetime.now(timezone.utc)
    db.session.commit()
    flash(f'"{canal.name if canal else "Conteúdo"}" marcado como OFFLINE e oculto.', 'success')
    return redirect(url_for('admin_reports'))


@app.route('/admin/esportes', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_esportes():
    today = datetime.now(BRASILIA_TZ).date()
    if request.method == 'POST':
        event_date_str = request.form.get('event_date', '')
        text = request.form.get('events_text', '')
        try:
            event_date = datetime.strptime(event_date_str, '%Y-%m-%d').date()
        except (ValueError, TypeError):
            event_date = today
        parsed = parse_sports_text(text, event_date)
        count = 0
        for ev in parsed:
            sport_event = SportEvent(
                sport=ev['sport'],
                championship=ev['championship'],
                team1=ev['team1'],
                team2=ev['team2'],
                score=ev['score'],
                event_time=ev['event_time'],
                channels=ev['channels'],
                event_date=ev['event_date'],
            )
            db.session.add(sport_event)
            count += 1
        db.session.commit()
        flash(f'{count} eventos importados com sucesso!', 'success')
        return redirect(url_for('admin_esportes'))
    events = SportEvent.query.order_by(SportEvent.event_date.desc(), SportEvent.sport, SportEvent.event_time).all()
    events_data = []
    for ev in events:
        try:
            ch = json.loads(ev.channels) if ev.channels else []
        except (json.JSONDecodeError, TypeError):
            ch = []
        events_data.append({
            'id': ev.id,
            'event_date': ev.event_date.strftime('%Y-%m-%d'),
            'sport': ev.sport,
            'championship': ev.championship,
            'event_time': ev.event_time,
            'team1': ev.team1,
            'team2': ev.team2,
            'score': ev.score,
            'channels': ch,
        })
    return render_template('admin_esportes.html', events=events, events_data=events_data, today=today)


@app.route('/admin/esportes/delete/<int:event_id>', methods=['POST'])
@login_required
@admin_required
def admin_esportes_delete(event_id):
    event = db.session.get(SportEvent, event_id)
    if event:
        db.session.delete(event)
        db.session.commit()
    return redirect(url_for('admin_esportes'))


@app.route('/admin/esportes/delete_date', methods=['POST'])
@login_required
@admin_required
def admin_esportes_delete_date():
    date_str = request.form.get('date', '')
    try:
        target_date = datetime.strptime(date_str, '%Y-%m-%d').date()
        SportEvent.query.filter(SportEvent.event_date == target_date).delete()
        db.session.commit()
        flash(f'Eventos de {date_str} removidos.', 'success')
    except (ValueError, TypeError):
        flash('Data invalida.', 'error')
    return redirect(url_for('admin_esportes'))


@app.route('/admin/esportes/delete_all', methods=['POST'])
@login_required
@admin_required
def admin_esportes_delete_all():
    SportEvent.query.delete()
    db.session.commit()
    flash('Todos os eventos foram removidos.', 'success')
    return redirect(url_for('admin_esportes'))


@app.route('/admin/esportes/edit/<int:event_id>', methods=['POST'])
@login_required
@admin_required
def admin_esportes_edit(event_id):
    event = db.session.get(SportEvent, event_id)
    if not event:
        flash('Evento nao encontrado.', 'error')
        return redirect(url_for('admin_esportes'))
    event.sport = request.form.get('sport', event.sport)
    event.championship = request.form.get('championship', event.championship)
    event.team1 = request.form.get('team1', event.team1)
    event.team2 = request.form.get('team2', event.team2)
    event.score = request.form.get('score', event.score)
    event.event_time = request.form.get('event_time', event.event_time)
    channels_raw = request.form.get('channels', '')
    if channels_raw:
        channels = [c.strip() for c in channels_raw.split(',') if c.strip()]
        event.channels = json.dumps(channels)
    date_str = request.form.get('event_date', '')
    if date_str:
        try:
            event.event_date = datetime.strptime(date_str, '%Y-%m-%d').date()
        except (ValueError, TypeError):
            pass
    db.session.commit()
    flash('Evento atualizado.', 'success')
    return redirect(url_for('admin_esportes'))


@app.route('/admin/esportes/export')
@login_required
@admin_required
def admin_esportes_export():
    """Exporta agenda do dia como texto organizado para copiar e enviar."""
    date_str = request.args.get('date', '')
    try:
        export_date = datetime.strptime(date_str, '%Y-%m-%d').date()
    except (ValueError, TypeError):
        export_date = datetime.now(BRASILIA_TZ).date()

    events = SportEvent.query.filter_by(event_date=export_date).order_by(
        SportEvent.sport, SportEvent.championship, SportEvent.event_time
    ).all()

    if not events:
        return '', 204

    base_url = os.environ.get('PUBLIC_BASE_URL', 'https://trocoplay.dpdns.org').rstrip('/')
    norm_map, token_map, prefix_map, id_map = get_channel_index()

    weekday_pt = {0: 'Segunda', 1: 'Terca', 2: 'Quarta', 3: 'Quinta',
                  4: 'Sexta', 5: 'Sabado', 6: 'Domingo'}
    month_pt = {1: 'Janeiro', 2: 'Fevereiro', 3: 'Marco', 4: 'Abril',
                5: 'Maio', 6: 'Junho', 7: 'Julho', 8: 'Agosto',
                9: 'Setembro', 10: 'Outubro', 11: 'Novembro', 12: 'Dezembro'}
    wd = weekday_pt[export_date.weekday()]
    mp = month_pt[export_date.month]
    header = f'AGENDA DO DIA — {wd}, {export_date.day} de {mp}\n'

    grouped = {}
    for ev in events:
        grouped.setdefault(ev.sport, []).append(ev)

    sep_line = '=' * 40
    dash_line = '-' * 36
    body_lines = []
    for sport, sport_events in grouped.items():
        body_lines.append('')
        body_lines.append(sep_line)
        body_lines.append('  ' + sport.upper())
        body_lines.append(sep_line)

        by_champ = {}
        for ev in sport_events:
            by_champ.setdefault(ev.championship or 'Sem campeonato', []).append(ev)

        for champ, champ_events in by_champ.items():
            body_lines.append('')
            body_lines.append('  ' + champ)
            body_lines.append('  ' + dash_line)

            for ev in champ_events:
                match_str = ev.team1
                if ev.team2:
                    match_str += f' vs {ev.team2}'
                if ev.score:
                    match_str += f' ({ev.score})'

                body_lines.append(f'  ⏰ {ev.event_time} — {match_str}')

                ch_names = json.loads(ev.channels) if ev.channels else []
                if ch_names:
                    resolved = resolve_event_channels(ev.channels, norm_map, token_map, prefix_map, id_map)
                    ch_parts = []
                    for r in resolved:
                        all_matches = r.get('matches', [])
                        if all_matches:
                            for m in all_matches:
                                ch_url = f'{base_url}/canal/{m["id"]}'
                                ch_parts.append(f'{m["name"]}: {ch_url}')
                        elif r.get('name'):
                            ch_parts.append(r['name'])
                    if ch_parts:
                        body_lines.append('  📺 Canais: ' + ' | '.join(ch_parts))

    full_text = header + '\n'.join(body_lines) + '\n\n📺 Acesse: ' + base_url + '\n'
    return full_text, 200, {'Content-Type': 'text/plain; charset=utf-8'}
@login_required
def check_reported(canal_id):
    report = Report.query.filter_by(
        user_id=current_user.id, canal_id=canal_id, status='pending'
    ).first()
    return jsonify({'reported': report is not None})


@app.route('/api/consume_time', methods=['POST'])
@limiter.exempt
@login_required
@csrf.exempt
def consume_time():
    data = request.get_json(silent=True) or {}
    try:
        seconds = int(float(data.get('seconds', 0) or 0))
    except (TypeError, ValueError):
        seconds = 0
    seconds = max(0, min(seconds, 120))
    if seconds:
        current_user.consume_time(seconds)
    return jsonify({'remaining': current_user.remaining_seconds()}), 200

# ─── Device Session Heartbeat ────────────────────────────────────────────
@app.route('/api/device_heartbeat', methods=['POST'])
@limiter.exempt
@login_required
def device_heartbeat():
    try:
        data = request.get_json(silent=True) or {}
        canal_id = data.get('canal_id')
        token = session.get('persistent_token', '')
        ip = get_client_ip()
        ua = request.headers.get('User-Agent', '')[:500]

        # get_active_stream_row apaga sessao morta (aba/app fechado sem avisar),
        # entao "parar de assistir" tem efeito imediato sem travar o usuario.
        outro = get_active_stream_row(current_user.id)
        if outro is None:
            # Ninguem reproduzindo: registra este device e segue normalmente.
            try:
                stream_start_allowed(
                    current_user.id, token, parse_device_info(ua), '',
                    choose_m3u_source_index(current_user.id), None,
                )
            except Exception:
                db.session.rollback()
            return jsonify({'ok': True, 'can_play': True, 'blocking_device': None})

        if outro.session_token != token:
            return jsonify({'ok': True, 'can_play': False, 'blocking_device': outro.device_info})

        outro.last_heartbeat = datetime.now(timezone.utc)
        db.session.commit()
        return jsonify({'ok': True, 'can_play': True, 'blocking_device': None})
    except Exception:
        db.session.rollback()
        return jsonify({'ok': True, 'can_play': True, 'blocking_device': None})

@app.route('/api/device_stream_start', methods=['POST'])
@limiter.exempt
@login_required
def device_stream_start():
    try:
        data = request.get_json() or {}
        canal_id = data.get('canal_id')
        content_name = data.get('content_name', '')
        token = session.get('persistent_token', '')
        ip = get_client_ip()
        ua = request.headers.get('User-Agent', '')[:500]

        stream_url = ''
        try:
            item = db.session.get(Canal, int(canal_id)) if canal_id else None
            stream_url = item.stream_url if item else ''
        except (TypeError, ValueError):
            pass
        source_index = choose_m3u_source_index(current_user.id, stream_url)
        can_play, block_device = stream_start_allowed(
            current_user.id, token, parse_device_info(ua), content_name, source_index, stream_url
        )
        if can_play:
            reserved_index = get_reserved_source_index(current_user.id, token)
            if reserved_index is not None:
                source_index = reserved_index
        if not can_play:
            return jsonify({
                'ok': False,
                'can_play': False,
                'blocking_device': block_device or 'outro dispositivo',
                'message': 'Esta conta ja esta reproduzindo conteudo em outro dispositivo. E permitido apenas 1 acesso simultaneo por assinatura.',
            })

        log_audit('stream_start', current_user.id, details=f'content={content_name}')
        return jsonify({'ok': True, 'can_play': True})
    except Exception:
        db.session.rollback()
        return jsonify({'ok': True, 'can_play': False, 'blocking_device': None,
                        'message': 'Erro ao verificar dispositivo.'})

@app.route('/api/device_stream_stop', methods=['POST'])
@limiter.exempt
@login_required
def device_stream_stop():
    token = session.get('persistent_token', '')
    stream_stop(current_user.id, token)
    log_audit('stream_stop', current_user.id)
    return jsonify({'ok': True})

@app.route('/api/check_device')
@limiter.exempt
@login_required
def check_device():
    token = session.get('persistent_token', '')
    outro = get_active_stream_row(current_user.id)
    if outro and outro.session_token != token:
        return jsonify({'can_play': False, 'blocking_device': outro.device_info})
    return jsonify({'can_play': True, 'blocking_device': None})

# ─── Notifications ───────────────────────────────────────────────────────
@app.route('/api/notifications')
@limiter.exempt
@login_required
@cached_api(60)  # cache 60 s: badge de nao-lidas muda pouco; corta polling ao banco
def api_notifications():
    notifs = Notification.query.filter_by(user_id=current_user.id).order_by(Notification.created_at.desc()).limit(20).all()
    result = []
    for n in notifs:
        result.append({
            'id': n.id,
            'title': n.title,
            'message': _notification_message(n),
            'is_read': n.is_read,
            'link': n.link,
            'created_at': _safe_iso_or_empty(n.created_at),
        })
    return jsonify({'notifications': result, 'unread_count': get_unread_count(current_user.id)})

@app.route('/api/notifications/read', methods=['POST'])
@limiter.exempt
@login_required
def mark_notifications_read():
    data = request.get_json() or {}
    notif_id = data.get('id')
    if notif_id:
        n = Notification.query.filter_by(id=notif_id, user_id=current_user.id).first()
        if n:
            n.is_read = True
    else:
        Notification.query.filter_by(user_id=current_user.id, is_read=False).update({'is_read': True})
    db.session.commit()
    try:
        cache_invalidate('api_notifications', current_user.get_id())
    except Exception:
        pass
    return jsonify({'ok': True, 'unread_count': get_unread_count(current_user.id)})

@app.route('/api/admin/pending-counts')
@login_required
@admin_required
def admin_pending_counts():
    counts = {}
    items = []

    if has_admin_tab('suporte'):
        open_tickets = SupportTicket.query.filter(SupportTicket.status.in_(['aberto', 'em_atendimento'])).count()
        counts['suporte'] = open_tickets
        if open_tickets > 0:
            recent = SupportTicket.query.filter(SupportTicket.status.in_(['aberto', 'em_atendimento'])).order_by(SupportTicket.updated_at.desc()).limit(5).all()
            for t in recent:
                items.append({
                    'section': 'suporte',
                    'label': f'Chamado #{t.id}: {t.subject[:60]}',
                    'status': t.status,
                    'user_email': t.user.email,
                    'time': t.updated_at.isoformat() if t.updated_at else '',
                    'url': url_for('admin_suporte_ticket', ticket_id=t.id),
                })

    if has_admin_tab('sugestoes'):
        pending_sugs = Suggestion.query.filter_by(status='pendente').count()
        counts['sugestoes'] = pending_sugs
        if pending_sugs > 0:
            recent = Suggestion.query.filter_by(status='pendente').order_by(Suggestion.created_at.desc()).limit(5).all()
            for s in recent:
                counts['sugestoes'] = pending_sugs
                items.append({
                    'section': 'sugestoes',
                    'label': f'Sugestao #{s.id}: {s.titulo[:60]}',
                    'status': s.status,
                    'user_email': s.user.email,
                    'time': _safe_iso_or_empty(s.created_at),
                    'url': url_for('admin_sugestoes'),
                })

    if has_admin_tab('reports'):
        pending_reports = Report.query.filter_by(status='pending').count()
        counts['reports'] = pending_reports
        if pending_reports > 0:
            recent = Report.query.filter_by(status='pending').order_by(Report.created_at.desc()).limit(5).all()
            for r in recent:
                items.append({
                    'section': 'reports',
                    'label': f'Reporte #{r.id}: {r.content_name[:60]}',
                    'status': r.status,
                    'user_email': r.user.email,
                    'time': _safe_iso_or_empty(r.created_at),
                    'url': url_for('admin_reports'),
                })

    if has_admin_tab('afiliacao'):
        pending_payouts = PayoutRequest.query.filter_by(status='pending').count()
        counts['saques'] = pending_payouts
        if pending_payouts > 0:
            recent = PayoutRequest.query.filter_by(status='pending').order_by(PayoutRequest.created_at.desc()).limit(5).all()
            for p in recent:
                items.append({
                    'section': 'saques',
                    'label': f'Saque #{p.id}: R$ {p.amount:.2f}',
                    'status': p.status,
                    'user_email': p.user.email if p.user else '',
                    'time': _safe_iso_or_empty(p.created_at),
                    'url': url_for('admin_afiliados_saques'),
                })

    return jsonify({'counts': counts, 'items': items, 'total': sum(counts.values())})


@app.route('/notificacoes')
@login_required
def notificacoes():
    page = request.args.get('page', 1, type=int)
    per_page = 20
    query = Notification.query.filter_by(user_id=current_user.id).order_by(Notification.created_at.desc())
    total = query.count()
    total_pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, total_pages))
    start = (page - 1) * per_page
    notifs = query.offset(start).limit(per_page).all()
    unread_count = get_unread_count(current_user.id)
    return render_template('notificacoes.html', notifications=notifs, unread_count=unread_count,
                         page=page, total_pages=total_pages, total=total)


@app.route('/api/admin/dashboard-stats')
@login_required
@admin_required
@cached_api(300)  # cache 5 min: estatisticas agregadas; dashboard nao precisa de tempo real
def admin_dashboard_stats():
    from sqlalchemy import func
    now_utc = datetime.now(timezone.utc)
    now_brt = to_user_tz(now_utc)
    today_start = now_brt.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
    week_start = today_start - timedelta(days=today_start.weekday())

    qtd_users = User.query.filter(User.is_admin == False).count()
    qtd_canais = Canal.query.filter_by(tipo='live').count()
    qtd_filmes = Canal.query.filter_by(tipo='movie').count()
    qtd_series = db.session.query(Canal.series_name).filter(
        Canal.tipo == 'series', Canal.series_name != '', Canal.series_name.isnot(None)
    ).distinct().count()
    qtd_payments = Payment.query.count()
    qtd_free = User.query.filter(User.is_paid == False, User.is_admin == False, User.is_banned == False).count()
    qtd_paid = User.query.filter(User.is_paid == True, User.is_admin == False, User.is_banned == False).count()
    qtd_banned = User.query.filter(User.is_banned == True).count()
    qtd_inactive = User.query.filter(User.is_active_user == False, User.is_admin == False, User.is_banned == False).count()

    stale = now_utc - timedelta(minutes=3)
    try:
        WatchLog.query.filter(WatchLog.last_seen < stale, WatchLog.is_active == True).update({'is_active': False})
        db.session.commit()
    except Exception:
        db.session.rollback()
    try:
        # Conta PESSOAS online (nao linhas): um usuario so assiste 1 conteudo.
        online_count = db.session.query(func.count(func.distinct(WatchLog.user_id))).filter(
            WatchLog.is_active == True  # noqa: E712
        ).scalar() or 0
    except Exception:
        db.session.rollback()
        online_count = 0

    qtd_logins_today = AuditLog.query.filter(AuditLog.action == 'login', AuditLog.created_at >= today_start).count()
    qtd_logins_week = AuditLog.query.filter(AuditLog.action == 'login', AuditLog.created_at >= week_start).count()
    qtd_new_users_today = User.query.filter(User.created_at >= today_start).count()
    qtd_new_users_week = User.query.filter(User.created_at >= week_start).count()
    total_revenue = db.session.query(func.coalesce(func.sum(Payment.amount), 0)).filter(Payment.status == 'paid').scalar()
    revenue_today = db.session.query(func.coalesce(func.sum(Payment.amount), 0)).filter(Payment.status == 'paid', Payment.paid_at >= today_start).scalar()
    revenue_week = db.session.query(func.coalesce(func.sum(Payment.amount), 0)).filter(Payment.status == 'paid', Payment.paid_at >= week_start).scalar()
    qtd_paid_today = Payment.query.filter(Payment.status == 'paid', Payment.paid_at >= today_start).count()
    qtd_paid_week = Payment.query.filter(Payment.status == 'paid', Payment.paid_at >= week_start).count()
    qtd_active_today = AuditLog.query.filter(AuditLog.action.in_(['login', 'stream_start']), AuditLog.created_at >= today_start).count()

    _sweep_expired_planhistory()
    plan_counts = {}
    active_types = db.session.query(PlanHistory.plan_type).filter(PlanHistory.status == 'active').distinct().all()
    for (pt,) in active_types:
        plan_counts[pt] = PlanHistory.query.filter(PlanHistory.plan_type == pt, PlanHistory.status == 'active').count()

    popular_raw = db.session.query(Canal.name, Canal.view_count).filter(Canal.view_count > 0).order_by(Canal.view_count.desc()).limit(5).all()
    popular = [{'name': n[:60], 'views': v} for n, v in popular_raw]

    watching = []
    try:
        logs = WatchLog.query.filter(WatchLog.is_active == True).order_by(WatchLog.last_seen.desc()).all()  # noqa: E712
    except Exception:
        logs = []
    _seen_users = set()
    _dup_ids = []
    for log in logs:
        if log.user_id in _seen_users:
            _dup_ids.append(log.id)  # um usuario so assiste 1 coisa por vez
            continue
        _seen_users.add(log.user_id)
        user = db.session.get(User, log.user_id)
        canal = db.session.get(Canal, log.canal_id) if log.canal_id else None
        if user:
            watching.append({
                'user': user.email,
                'content': log.content_name or (canal.name if canal else 'N/A'),
                'since': brasilia_iso(log.started_at),
                'since_brt': to_user_tz(log.started_at).strftime('%H:%M') if log.started_at else '',
            })
    if _dup_ids:
        # Auto-correcao dos registros antigos duplicados que ficaram no banco.
        try:
            WatchLog.query.filter(WatchLog.id.in_(_dup_ids)).update(
                {'is_active': False}, synchronize_session=False)
            db.session.commit()
        except Exception:
            db.session.rollback()

    return jsonify({
        'online_count': online_count,
        'qtd_users': qtd_users,
        'qtd_paid': qtd_paid,
        'qtd_free': qtd_free,
        'qtd_banned': qtd_banned,
        'qtd_inactive': qtd_inactive,
        'qtd_canais': qtd_canais,
        'qtd_filmes': qtd_filmes,
        'qtd_series': qtd_series,
        'total_revenue': round(total_revenue, 2) if total_revenue else 0,
        'revenue_today': round(revenue_today, 2) if revenue_today else 0,
        'revenue_week': round(revenue_week, 2) if revenue_week else 0,
        'qtd_payments': qtd_payments,
        'qtd_active_today': qtd_active_today,
        'qtd_logins_today': qtd_logins_today,
        'qtd_logins_week': qtd_logins_week,
        'qtd_new_users_today': qtd_new_users_today,
        'qtd_new_users_week': qtd_new_users_week,
        'qtd_paid_today': qtd_paid_today,
        'qtd_paid_week': qtd_paid_week,
        'plan_counts': plan_counts,
        'popular': popular,
        'watching': watching,
        'watching_count': len(watching),
    })


def _audit_status(action):
    """Classifica uma acao de auditoria em ok/warn/error para o filtro de Logs."""
    a = (action or '').lower()
    if any(k in a for k in ('fail', 'error', 'ban', 'delete', 'remove', 'recusar', 'reject')):
        return 'error'
    if any(k in a for k in ('expire', 'attempt', 'hide', 'warn', 'block', 'limit')):
        return 'warn'
    return 'ok'


def _brt_bound(date_str, end=False):
    """Converte YYYY-MM-DD (Brasilia) no limite naive-UTC para filtrar created_at."""
    d = datetime.strptime(date_str, '%Y-%m-%d')
    if end:
        d = d.replace(hour=23, minute=59, second=59, microsecond=999999)
    else:
        d = d.replace(hour=0, minute=0, second=0, microsecond=0)
    return d.replace(tzinfo=BRASILIA_TZ).astimezone(timezone.utc).replace(tzinfo=None)


@app.route('/admin/registros')
@login_required
@admin_required
def admin_logs():
    origem = (request.args.get('origem') or 'all').strip()
    status = (request.args.get('status') or '').strip()
    acao = (request.args.get('acao') or '').strip()
    q = (request.args.get('q') or '').strip()
    date_from = (request.args.get('de') or '').strip()
    date_to = (request.args.get('ate') or '').strip()
    try:
        page = max(1, int(request.args.get('page', 1)))
    except (ValueError, TypeError):
        page = 1
    per_page = 50

    rows = []
    total = 0

    # ─── Auditoria ───
    if origem in ('all', 'audit'):
        aq = AuditLog.query
        if acao:
            aq = aq.filter(AuditLog.action.ilike(f'%{acao}%'))
        if date_from:
            try:
                aq = aq.filter(AuditLog.created_at >= _brt_bound(date_from))
            except ValueError:
                pass
        if date_to:
            try:
                aq = aq.filter(AuditLog.created_at <= _brt_bound(date_to, end=True))
            except ValueError:
                pass
        if q:
            like = f'%{q}%'
            aq = aq.outerjoin(User, AuditLog.user_id == User.id).filter(
                db.or_(AuditLog.action.ilike(like), AuditLog.details.ilike(like),
                       AuditLog.ip_address.ilike(like), User.email.ilike(like))
            )
        audit_total = aq.count()
        total += audit_total
        cap = page * per_page
        for r in aq.order_by(AuditLog.created_at.desc()).limit(cap).all():
            r_status = _audit_status(r.action)
            if status and r_status != status:
                continue
            rows.append({
                'created_at': r.created_at,
                'origem': 'Auditoria',
                'conta': r.user.email if r.user else (f'#{r.user_id}' if r.user_id else '-'),
                'action': r.action,
                'status': r_status,
                'message': r.details or '',
                'extra': r.ip_address or '',
            })

    # ─── Fontes M3U ───
    if origem in ('all', 'm3u'):
        mq = M3USourceLog.query
        if status:
            mq = mq.filter(M3USourceLog.status == status)
        if acao:
            mq = mq.filter(M3USourceLog.action.ilike(f'%{acao}%'))
        if date_from:
            try:
                mq = mq.filter(M3USourceLog.created_at >= _brt_bound(date_from))
            except ValueError:
                pass
        if date_to:
            try:
                mq = mq.filter(M3USourceLog.created_at <= _brt_bound(date_to, end=True))
            except ValueError:
                pass
        if q:
            like = f'%{q}%'
            mq = mq.filter(db.or_(M3USourceLog.message.ilike(like),
                                  M3USourceLog.action.ilike(like),
                                  M3USourceLog.source_url.ilike(like)))
        m_total = mq.count()
        total += m_total
        cap = page * per_page
        for r in mq.order_by(M3USourceLog.created_at.desc()).limit(cap).all():
            rows.append({
                'created_at': r.created_at,
                'origem': 'M3U',
                'conta': (f'#{r.source_index + 1}' if r.source_index is not None else '-'),
                'action': r.action,
                'status': r.status or 'ok',
                'message': r.message or '',
                'extra': r.source_url or '',
            })

    def _sort_key(x):
        dt = x['created_at']
        if dt is None:
            return 0.0
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt.timestamp()
    rows.sort(key=_sort_key, reverse=True)
    start = (page - 1) * per_page
    page_rows = rows[start:start + per_page]
    for r in page_rows:
        try:
            r['when'] = to_user_tz(r['created_at']).strftime('%d/%m/%Y %H:%M:%S') if r['created_at'] else ''
        except Exception:
            r['when'] = r['created_at'].strftime('%d/%m/%Y %H:%M:%S') if r['created_at'] else ''
        r.pop('created_at', None)

    total_pages = max(1, (total + per_page - 1) // per_page)
    return render_template('admin_logs.html', rows=page_rows, total=total, page=page,
                           total_pages=total_pages, origem=origem, status_filter=status,
                           acao=acao, q=q, date_from=date_from, date_to=date_to)


# ─── Support Center ──────────────────────────────────────────────────────
@app.route('/suporte')
@login_required
def suporte():
    page = request.args.get('page', 1, type=int)
    per_page = 20
    pagination = SupportTicket.query.filter_by(user_id=current_user.id).order_by(SupportTicket.updated_at.desc()).paginate(page=page, per_page=per_page, error_out=False)
    unread_count = get_unread_count(current_user.id)
    return render_template('suporte.html', tickets=pagination.items, page=page, total_pages=pagination.pages, total=pagination.total, unread_count=unread_count)

@app.route('/suporte/novo', methods=['GET', 'POST'])
@login_required
def suporte_novo():
    if request.method == 'POST':
        subject = request.form.get('subject', '').strip()
        message = request.form.get('message', '').strip()
        if not subject or not message:
            flash('Preencha todos os campos.', 'error')
        else:
            ticket = SupportTicket(user_id=current_user.id, subject=subject)
            db.session.add(ticket)
            db.session.commit()
            msg = SupportMessage(ticket_id=ticket.id, sender_id=current_user.id, message=message)
            db.session.add(msg)
            db.session.commit()
            log_audit('support_ticket_opened', current_user.id, details=f'ticket={ticket.id}')
            flash('Chamado aberto com sucesso!', 'success')
            return redirect(url_for('suporte_ticket', ticket_id=ticket.id))
    return render_template('suporte_novo.html')

@app.route('/suporte/<int:ticket_id>')
@login_required
def suporte_ticket(ticket_id):
    ticket = SupportTicket.query.filter(SupportTicket.id == ticket_id, SupportTicket.user_id == current_user.id).first()
    if not ticket:
        flash('Chamado nao encontrado.', 'error')
        return redirect(url_for('suporte'))
    SupportMessage.query.filter(
        SupportMessage.ticket_id == ticket_id,
        SupportMessage.is_admin == True,
        SupportMessage.read == False,
    ).update({'read': True})
    db.session.commit()
    messages = SupportMessage.query.filter_by(ticket_id=ticket_id).order_by(SupportMessage.created_at).all()
    unread_count = get_unread_count(current_user.id)
    return render_template('suporte_ticket.html', ticket=ticket, messages=messages, unread_count=unread_count)

@app.route('/suporte/<int:ticket_id>/responder', methods=['POST'])
@login_required
def suporte_responder(ticket_id):
    ticket = SupportTicket.query.filter(SupportTicket.id == ticket_id, SupportTicket.user_id == current_user.id).first()
    if not ticket:
        flash('Chamado nao encontrado.', 'error')
        return redirect(url_for('suporte'))
    message = request.form.get('message', '').strip()
    if not message:
        flash('Mensagem nao pode ser vazia.', 'error')
    else:
        msg = SupportMessage(ticket_id=ticket.id, sender_id=current_user.id, is_admin=False, message=message)
        db.session.add(msg)
        ticket.updated_at = datetime.now(timezone.utc)
        if ticket.status in ('respondido', 'resolvido'):
            ticket.status = 'em_atendimento'
        db.session.commit()
        log_audit('support_message_sent', current_user.id, details=f'ticket={ticket.id}')
    return redirect(url_for('suporte_ticket', ticket_id=ticket.id))

@app.route('/suporte/<int:ticket_id>/fechar', methods=['POST'])
@login_required
def suporte_fechar(ticket_id):
    ticket = SupportTicket.query.filter(SupportTicket.id == ticket_id, SupportTicket.user_id == current_user.id).first()
    if ticket:
        ticket.status = 'fechado'
        ticket.updated_at = datetime.now(timezone.utc)
        db.session.commit()
        flash('Chamado fechado.', 'success')
    return redirect(url_for('suporte'))

# ─── Sugestões ─────────────────────────────────────────────────────────
@app.route('/sugestoes')
@login_required
def sugestoes():
    page = request.args.get('page', 1, type=int)
    per_page = 20
    pagination = Suggestion.query.filter_by(user_id=current_user.id).order_by(Suggestion.created_at.desc()).paginate(page=page, per_page=per_page, error_out=False)
    return render_template('sugestoes.html', sugestoes=pagination.items, page=page, total_pages=pagination.pages, total=pagination.total)

@app.route('/sugestoes/nova', methods=['GET', 'POST'])
@login_required
def sugestoes_nova():
    if request.method == 'POST':
        tipo = request.form.get('tipo', '').strip()
        titulo = request.form.get('titulo', '').strip()
        descricao = request.form.get('descricao', '').strip()
        tipos_validos = ['canal', 'funcao', 'melhoria', 'outro']
        if tipo not in tipos_validos:
            flash('Tipo de sugestao invalido.', 'error')
        elif not titulo:
            flash('Titulo e obrigatorio.', 'error')
        else:
            sug = Suggestion(user_id=current_user.id, tipo=tipo, titulo=titulo, descricao=descricao)
            db.session.add(sug)
            db.session.commit()
            log_audit('suggestion_created', current_user.id, details=f'tipo={tipo}, titulo={titulo}')
            flash('Sugestao enviada com sucesso!', 'success')
            return redirect(url_for('sugestoes'))
    return render_template('sugestoes_nova.html')

# ─── Plan History ────────────────────────────────────────────────────────
@app.route('/historico_planos')
@login_required
def historico_planos():
    page = request.args.get('page', 1, type=int)
    per_page = 20
    pagination = PlanHistory.query.filter_by(user_id=current_user.id).order_by(PlanHistory.created_at.desc()).paginate(page=page, per_page=per_page, error_out=False)
    unread_count = get_unread_count(current_user.id)
    return render_template('historico_planos.html', history=pagination.items, page=page, total_pages=pagination.pages, total=pagination.total, unread_count=unread_count)

# ─── Payment History ─────────────────────────────────────────────────────
@app.route('/historico_pagamentos')
@login_required
def historico_pagamentos():
    page = request.args.get('page', 1, type=int)
    per_page = 20
    pagination = Payment.query.filter_by(user_id=current_user.id).order_by(Payment.created_at.desc()).paginate(page=page, per_page=per_page, error_out=False)
    unread_count = get_unread_count(current_user.id)
    return render_template('historico_pagamentos.html', payments=pagination.items, page=page, total_pages=pagination.pages, total=pagination.total, unread_count=unread_count)


@app.route('/api/epg/<int:canal_id>')
@limiter.exempt
@login_required
@cached_api(600)  # cache 10 min: o player faz polling deste endpoint a cada poucos segundos
def api_epg(canal_id):
    mapping = ChannelEPGMapping.query.filter_by(canal_id=canal_id).first()
    if not mapping:
        return jsonify({'current': None, 'next': None, 'programs': []})

    now = datetime.now(timezone.utc).replace(tzinfo=None)  # EPG armazenado em UTC
    end_limit = now + timedelta(hours=12)

    five_min_ago = now - timedelta(minutes=5)
    programs = EPGProgram.query.filter(
        EPGProgram.channel_id == mapping.channel_id,
        EPGProgram.start_time < end_limit,
        EPGProgram.end_time > five_min_ago,
    ).order_by(EPGProgram.start_time).all()

    deduped = []
    for p in programs:
        key = p.title.lower().strip()
        replaced = False
        for i in range(len(deduped) - 1, -1, -1):
            prev = deduped[i]
            if prev.title.lower().strip() == key:
                delta = abs((p.start_time - prev.start_time).total_seconds())
                if delta < 300:
                    p_current = p.start_time <= now < (p.end_time or now + timedelta(hours=1))
                    prev_current = prev.start_time <= now < (prev.end_time or now + timedelta(hours=1))
                    if p_current and not prev_current:
                        deduped[i] = p
                    replaced = True
                break
        if not replaced:
            deduped.append(p)
    programs = deduped

    current = None
    current_ended = None
    next_prog = None
    for p in programs:
        if p.start_time <= now < (p.end_time or now + timedelta(hours=1)):
            current = p
        elif p.start_time <= now and current is None and (p.end_time or now) > five_min_ago:
            if current_ended is None or (p.end_time or now) > (current_ended.end_time or now):
                current_ended = p
        if p.start_time > now and next_prog is None:
            next_prog = p

    if current is None and current_ended is not None:
        current = current_ended

    def fmt(p, is_current=False):
        return {
            'title': p.title,
            'description': clean_epg_description(p.description or ''),
            'start': p.start_time.isoformat() if p.start_time else '',
            'end': p.end_time.isoformat() if p.end_time else '',
            'start_brt': to_user_tz(p.start_time).strftime('%H:%M') if p.start_time else '',
            'end_brt': to_user_tz(p.end_time).strftime('%H:%M') if p.end_time else '',
            'is_current': is_current,
        }

    remaining = ''
    if current and current.end_time and current.end_time > now:
        diff = (current.end_time - now).total_seconds()
        if diff > 0:
            h = int(diff // 3600)
            m = int((diff % 3600) // 60)
            remaining = f'{h}h {m}m' if h > 0 else f'{m} min'

    programs_list = []
    for p in programs:
        if p.end_time and p.end_time <= now:
            continue
        is_now = (current and p.id == current.id)
        programs_list.append(fmt(p, is_current=is_now))

    return jsonify({
        'current': fmt(current, is_current=True) if current else None,
        'next': fmt(next_prog) if next_prog else None,
        'remaining': remaining,
        'programs': programs_list,
    })


@app.route('/conta')
@login_required
def conta():
    if session.get('tv_login'):
        return redirect(url_for('tv_canais'))
    referral_stats = get_referral_stats(current_user.id)
    remaining = current_user.remaining_seconds()
    horas = remaining // 3600
    minutos = (remaining % 3600) // 60
    used = current_user.free_daily_used
    used_h = used // 3600
    used_m = (used % 3600) // 60
    limit_h = current_user.free_daily_limit // 3600
    if current_user.is_paid and current_user.paid_until and current_user.paid_until_utc:
        time_diff = current_user.paid_until_utc - datetime.now(BRASILIA_TZ)
        remaining_days = max(0, time_diff.days)
    else:
        remaining_days = 0
    return render_template('conta.html', now=datetime.now(BRASILIA_TZ),
                           referral_stats=referral_stats,
                           horas=horas, minutos=minutos, remaining=remaining,
                           used_h=used_h, used_m=used_m, limit_h=limit_h,
                           remaining_days=remaining_days)


@app.route('/conta/alterar_senha', methods=['POST'])
@login_required
def alterar_senha():
    current_pw = request.form.get('current_password', '')
    new_pw = request.form.get('new_password', '')
    confirm_pw = request.form.get('confirm_password', '')
    if not current_user.check_password(current_pw):
        flash('Senha atual incorreta.', 'error')
    elif not new_pw or len(new_pw) < 6:
        flash('Nova senha deve ter pelo menos 4 caracteres.', 'error')
    elif new_pw != confirm_pw:
        flash('Senhas nao conferem.', 'error')
    else:
        current_user.set_password(new_pw)
        current_user.token_version += 1
        db.session.commit()
        invalidate_all_sessions(current_user.id)
        token = create_session_token(current_user.id, remember_me=True, ip=get_client_ip(), ua=request.headers.get('User-Agent', '')[:500])
        session['persistent_token'] = token
        log_audit('password_changed', current_user.id)
        flash('Senha alterada com sucesso!', 'success')
    return redirect(url_for('conta'))


@app.route('/conta/alterar_email', methods=['POST'])
@login_required
def alterar_email():
    new_email = request.form.get('new_email', '').strip().lower()
    current_pw = request.form.get('password_confirm', '')
    if not current_user.check_password(current_pw):
        flash('Senha incorreta.', 'error')
    elif not new_email:
        flash('Email nao pode ser vazio.', 'error')
    elif User.query.filter_by(email=new_email).first():
        flash('Email ja esta em uso.', 'error')
    else:
        current_user.email = new_email
        current_user.token_version += 1
        db.session.commit()
        invalidate_all_sessions(current_user.id)
        token = create_session_token(current_user.id, remember_me=True, ip=get_client_ip(), ua=request.headers.get('User-Agent', '')[:500])
        session['persistent_token'] = token
        log_audit('email_changed', current_user.id, details=f'new_email={new_email}')
        flash('Email alterado com sucesso!', 'success')
    return redirect(url_for('conta'))


@app.route('/conta/alterar_whatsapp', methods=['POST'])
@login_required
def alterar_whatsapp():
    whatsapp = re.sub(r'[^0-9]', '', request.form.get('whatsapp', '').strip())
    if not whatsapp or len(whatsapp) < 10:
        flash('Informe um numero de WhatsApp valido (minimo 10 digitos).', 'error')
    else:
        current_user.whatsapp = whatsapp
        db.session.commit()
        log_audit('whatsapp_changed', current_user.id, details=f'whatsapp={whatsapp}')
        flash('WhatsApp atualizado com sucesso!', 'success')
    return redirect(url_for('conta'))


@app.route('/conta/conteudo-adulto', methods=['POST'])
@login_required
def toggle_hide_adult():
    hide = request.form.get('hide_adult') == 'on'
    current_user.hide_adult = hide
    db.session.commit()
    log_audit('hide_adult_changed', current_user.id, details=f'hide_adult={hide}')
    if hide:
        flash('Bloqueio de conteúdo adulto ATIVADO. Categorias adultas foram ocultadas.', 'success')
    else:
        flash('Bloqueio de conteúdo adulto DESATIVADO.', 'success')
    return redirect(url_for('conta'))


@app.route('/api/set_hide_adult', methods=['POST'])
@login_required
def api_set_hide_adult():
    data = request.get_json() or {}
    hide = data.get('hide_adult')
    if isinstance(hide, str):
        hide = hide.lower() in ('1', 'true', 'on', 'yes')
    current_user.hide_adult = bool(hide)
    db.session.commit()
    log_audit('hide_adult_changed', current_user.id, details=f'hide_adult={bool(hide)}')
    return jsonify({'ok': True, 'hide_adult': bool(current_user.hide_adult)})


@app.route('/api/atualizar_whatsapp', methods=['POST'])
@login_required
def api_atualizar_whatsapp():
    whatsapp = re.sub(r'[^0-9]', '', request.form.get('whatsapp', '').strip())
    if not whatsapp or len(whatsapp) < 10:
        return jsonify({'ok': False, 'error': 'Informe um numero de WhatsApp valido (minimo 10 digitos).'}), 400
    current_user.whatsapp = whatsapp
    db.session.commit()
    log_audit('whatsapp_set_popup', current_user.id, details=f'whatsapp={whatsapp}')
    return jsonify({'ok': True})


@app.route('/afiliados')
@login_required
def afiliados():
    settings = load_settings()
    affiliation_disabled = current_user.affiliation_disabled or settings.get('affiliation_disabled_all', False)
    if affiliation_disabled:
        return render_template('afiliados.html', referral_stats=None,
                               referral_link=None, now=datetime.now(timezone.utc),
                               affiliation_disabled=True, wallet=None,
                               commissions=None, payouts=None, referrals=None,
                               config=None, indication_levels=INDICATION_LEVELS,
                               min_withdraw=0.01, network=None)
    referral_stats = get_referral_stats(current_user.id)
    if not current_user.referral_code:
        current_user.referral_code = generate_referral_code()
        db.session.commit()
    referral_link = url_for('register', ref=current_user.referral_code, _external=True)
    wallet = get_or_create_wallet(current_user.id)
    commissions = Commission.query.filter_by(referrer_id=current_user.id).order_by(Commission.created_at.desc()).all()
    payouts = PayoutRequest.query.filter_by(user_id=current_user.id).order_by(PayoutRequest.created_at.desc()).all()
    referrals = Referral.query.filter_by(referrer_id=current_user.id).order_by(Referral.created_at.desc()).all()
    backfilled_codes = False
    for referral in referrals:
        referred_user = db.session.get(User, referral.referred_id)
        if referred_user and not referred_user.referral_code:
            referred_user.referral_code = generate_referral_code()
            backfilled_codes = True
    if backfilled_codes:
        db.session.commit()
    config = get_affiliate_config(current_user.id)
    if not config:
        config = get_affiliate_config()
    cheapest_plan = Plan.query.filter(Plan.is_active == True).order_by(Plan.price).first()
    network = get_referral_network(current_user.id)
    network_by_level = {}
    for n in network:
        network_by_level.setdefault(n['level'], []).append(n)
    return render_template('afiliados.html', referral_stats=referral_stats,
                           referral_link=referral_link, now=datetime.now(timezone.utc),
                           affiliation_disabled=False, wallet=wallet,
                           commissions=commissions, payouts=payouts,
                           referrals=referrals, config=config,
                           cheapest_plan=cheapest_plan,
                           indication_levels=INDICATION_LEVELS,
                           min_withdraw=0.01,
                           network=network, network_by_level=network_by_level)


@app.route('/afiliados/carteira')
@login_required
def afiliados_carteira():
    return redirect(url_for('afiliados'))


@app.route('/afiliados/solicitar-saque', methods=['POST'])
@login_required
def solicitar_saque():
    settings = load_settings()
    affiliation_disabled = current_user.affiliation_disabled or settings.get('affiliation_disabled_all', False)
    if affiliation_disabled:
        flash('Programa de afiliados desativado.', 'error')
        return redirect(url_for('afiliados'))
    amount = request.form.get('amount', '').strip()
    pix_key_type = request.form.get('pix_key_type', '').strip()
    pix_key = request.form.get('pix_key', '').strip()
    if not amount or not pix_key_type or not pix_key:
        flash('Preencha todos os campos.', 'error')
        return redirect(url_for('afiliados'))
    try:
        amount = float(amount)
    except ValueError:
        flash('Valor invalido.', 'error')
        return redirect(url_for('afiliados'))
    if amount < 0.01:
        flash('Valor minimo para saque e R$ 0,01.', 'error')
        return redirect(url_for('afiliados'))
    wallet = get_or_create_wallet(current_user.id)
    if amount > wallet.balance:
        flash('Saldo insuficiente.', 'error')
        return redirect(url_for('afiliados'))
    if pix_key_type not in ('cpf', 'cnpj', 'email', 'phone', 'random'):
        flash('Tipo de chave PIX invalido.', 'error')
        return redirect(url_for('afiliados'))
    payout = PayoutRequest(
        user_id=current_user.id,
        amount=amount,
        pix_key_type=pix_key_type,
        pix_key=pix_key,
        status='pending',
    )
    db.session.add(payout)
    wallet.balance -= amount
    db.session.commit()
    create_notification(current_user.id, 'Saque Solicitado!',
                        f'Solicitacao de saque de R$ {amount:.2f} enviada com sucesso.',
                        link='/afiliados')
    log_audit('payout_requested', current_user.id, details=f'amount={amount}, pix_type={pix_key_type}')
    flash('Solicitacao de saque enviada com sucesso!', 'success')
    return redirect(url_for('afiliados'))


@app.route('/api/afiliados/wallet')
@login_required
def api_afiliados_wallet():
    wallet = get_or_create_wallet(current_user.id)
    commissions = Commission.query.filter_by(referrer_id=current_user.id).order_by(Commission.created_at.desc()).all()
    payouts = PayoutRequest.query.filter_by(user_id=current_user.id).order_by(PayoutRequest.created_at.desc()).all()
    return jsonify({
        'balance': wallet.balance,
        'total_earned': wallet.total_earned,
        'pending_balance': wallet.pending_balance,
        'commissions': [{
            'id': c.id,
            'referred_email': db.session.get(User, c.referred_id).email if db.session.get(User, c.referred_id) else 'N/A',
            'plan_name': c.plan_name,
            'plan_amount': c.plan_amount,
            'commission_amount': c.commission_amount,
            'commission_type': c.commission_type,
            'status': c.status,
            'level': c.level,
            'created_at': _safe_iso(c.created_at),
        } for c in commissions],
        'payouts': [{
            'id': p.id,
            'amount': p.amount,
            'pix_key_type': p.pix_key_type,
            'pix_key': p.pix_key[:4] + '****' if len(p.pix_key) > 4 else '****',
            'status': p.status,
            'rejection_reason': p.rejection_reason,
            'created_at': _safe_iso(p.created_at),
            'paid_at': p.paid_at.isoformat() if p.paid_at else None,
        } for p in payouts],
    })


@app.route('/afiliados/escolher-comissao', methods=['POST'])
@login_required
def afiliados_escolher_comissao():
    choice = request.form.get('commission_choice', '').strip()
    if choice not in ('percentage', 'fixed'):
        flash('Opcao invalida.', 'error')
        return redirect(url_for('afiliados'))
    current_user.affiliate_commission_choice = choice
    db.session.commit()
    flash('Preferencia de comissao salva com sucesso!', 'success')
    return redirect(url_for('afiliados'))


@app.route('/planos')
@login_required
def planos():
    plans = get_active_plans()
    settings = load_settings()
    free_hours = settings.get('free_daily_hours', FREE_DAILY_HOURS_DEFAULT)
    pending_payment = Payment.query.filter(
        Payment.user_id == current_user.id,
        Payment.status == 'pending',
    ).filter(
        db.or_(Payment.expires_at == None, Payment.expires_at > datetime.now(timezone.utc))
    ).order_by(Payment.created_at.desc()).first()
    current_plan_type = None
    if current_user.is_paid and current_user.paid_until:
        ph = PlanHistory.query.filter_by(
            user_id=current_user.id, status='active'
        ).order_by(PlanHistory.created_at.desc()).first()
        if ph:
            current_plan_type = ph.plan_type
    _qc, qtd_filmes, qtd_series = _contagens_catalogo()
    return render_template('planos.html', price=PLAN_MONTHLY_PRICE,
                           plans=plans, qtd_filmes=qtd_filmes, qtd_series=qtd_series,
                           now=datetime.now(timezone.utc),
                           pending_payment=pending_payment,
                           current_plan_type=current_plan_type,
                           free_hours=free_hours,
                           payment_method=settings.get('payment_method', 'whatsapp'))


@app.route('/assinar/<plan_type>')
@login_required
def assinar(plan_type='mensal'):
    user = current_user
    now_utc = datetime.utcnow()
    if user.is_paid and user.paid_until:
        paid_until = user.paid_until
        if hasattr(paid_until, 'replace') and paid_until.tzinfo is not None:
            paid_until = paid_until.replace(tzinfo=None)
        if paid_until and paid_until > now_utc:
            ph = PlanHistory.query.filter_by(user_id=user.id, status='active').order_by(PlanHistory.created_at.desc()).first()
            current_plan = ph.plan_type if ph else None
            if current_plan == plan_type:
                flash('Voce ja possui este plano ativo. Aguarde a expiracao ou renove pela pagina de planos.', 'info')
                return redirect(url_for('planos'))

    plan_obj = Plan.query.filter_by(key=plan_type, is_active=True).first()
    if not plan_obj:
        flash('Plano invalido.', 'error')
        return redirect(url_for('planos'))

    settings = load_settings()
    payment_method = settings.get('payment_method', 'whatsapp')

    if payment_method == 'cakto':
        try:
            offer_ids = json.loads(settings.get('cakto_offer_ids') or '{}')
        except (json.JSONDecodeError, TypeError):
            offer_ids = {}
        offer_id = offer_ids.get(plan_type)
        if not offer_id:
            flash('Pagamento online nao configurado para este plano. Entre em contato com o suporte.', 'error')
            return redirect(url_for('planos'))
        log_audit('cakto_subscribe', current_user.id, details=f'plan={plan_type}')
        return redirect(f'https://pay.cakto.com.br/{offer_id}')
    else:
        msg = (
            f'Olá! Quero assinar o plano {plan_obj.name} (R$ {effective_plan_price(plan_obj):.2f}). '
            f'Meu email: {current_user.email}'
        )
        if current_user.whatsapp:
            msg += f'. Meu WhatsApp: {current_user.whatsapp}'
        wa_url = f'https://wa.me/{settings.get("payment_whatsapp", SUPPORT_WHATSAPP)}?text=' + urlquote(msg)
        log_audit('whatsapp_subscribe', current_user.id, details=f'plan={plan_type}')
        return redirect(wa_url)


@app.route('/api/cakto/create-payment', methods=['POST'])
@login_required
@limiter.exempt
def cakto_create_payment():
    try:
        data = request.get_json() or {}
        plan_type = data.get('plan_type', '')

        user = current_user
        now_utc = datetime.utcnow()
        if user.is_paid and user.paid_until:
            paid_until = user.paid_until
            if hasattr(paid_until, 'replace') and paid_until.tzinfo is not None:
                paid_until = paid_until.replace(tzinfo=None)
            if paid_until and paid_until > now_utc:
                ph = PlanHistory.query.filter_by(user_id=user.id, status='active').order_by(PlanHistory.created_at.desc()).first()
                current_plan = ph.plan_type if ph else None
                if current_plan == plan_type:
                    return jsonify({'ok': False, 'error': 'Voce ja possui este plano ativo. Nao e possivel gerar novo pagamento para o mesmo plano.'}), 400

        settings = load_settings()
        if settings.get('payment_method') != 'cakto':
            return jsonify({'ok': False, 'error': 'Pagamento online nao configurado.'}), 400

        plan_obj = Plan.query.filter_by(key=plan_type, is_active=True).first()
        if not plan_obj:
            return jsonify({'ok': False, 'error': 'Plano invalido.'}), 400

        try:
            offer_ids = json.loads(settings.get('cakto_offer_ids') or '{}')
        except (json.JSONDecodeError, TypeError):
            offer_ids = {}
        offer_id = offer_ids.get(plan_type)
        if not offer_id:
            return jsonify({'ok': False, 'error': 'Oferta nao configurada para este plano.'}), 400

        token = _get_cakto_token()
        if not token:
            return jsonify({'ok': False, 'error': 'Erro ao obter token de pagamento. Verifique as credenciais Cakto no admin.'}), 502

        phone = current_user.whatsapp or ''
        phone = ''.join(c for c in str(phone) if c.isdigit())
        if phone and not phone.startswith('55'):
            phone = '55' + phone
        if len(phone) < 12:
            phone = '5500000000000'

        user_email = current_user.email or ''
        if not user_email or '@' not in user_email:
            return jsonify({'ok': False, 'error': 'Email do usuario invalido.'}), 400

        cpf = ''.join(c for c in str(data.get('cpf', '')) if c.isdigit())
        if len(cpf) != 11 or not validate_cpf(cpf):
            return jsonify({'ok': False, 'error': 'CPF invalido. Verifique os numeros.'}), 400

        customer_name = (data.get('name') or '').strip()
        if len(customer_name) < 3:
            return jsonify({'ok': False, 'error': 'Informe seu nome completo.'}), 400

        phone = current_user.whatsapp or ''
        phone = ''.join(c for c in str(phone) if c.isdigit())
        if phone and not phone.startswith('55'):
            phone = '55' + phone
        if len(phone) < 12:
            phone = '5500000000000'

        fingerprint = hashlib.md5(user_email.encode()).hexdigest()

        idem_key = str(uuid.uuid4())
        payload = {
            'paymentMethod': 'pix',
            'customer': {
                'name': customer_name,
                'email': user_email,
                'phone': phone,
                'fingerprint': fingerprint,
                'docType': 'cpf',
                'docNumber': cpf,
            },
            'items': [{'offerId': offer_id}],
            'pixExpiresIn': 1200,
        }

        print(f'[Cakto] Creating payment for user={user_email}, plan={plan_type}, phone={phone}')
        r = requests.post(
            'https://api.cakto.com.br/public_api/payments/',
            json=payload,
            headers={
                'Authorization': f'Bearer {token}',
                'Content-Type': 'application/json',
                'X-Idempotency-Key': idem_key,
            },
            timeout=15,
        )
        print(f'[Cakto] Response HTTP {r.status_code}: {r.text[:500]}')
        if r.status_code in (200, 201):
            result = r.json()
            order_id = result.get('id', '')
            pix_data = result.get('pix') or {}

            expires_dt = datetime.utcnow() + timedelta(minutes=20)
            print(f'[Cakto] pixExpiresIn=1200, Cakto returned expirationDate={pix_data.get("expirationDate")}, stored={expires_dt} (site limit: 20min)')

            payment = Payment(
                user_id=current_user.id,
                amount=plan_obj.effective_price,
                status='pending',
                plan_type=plan_type,
                plan_days=plan_obj.days,
                charge_id=order_id,
                pix_copy_paste=pix_data.get('qrCode', ''),
                expires_at=expires_dt,
            )
            db.session.add(payment)
            db.session.commit()

            log_audit('cakto_payment_created', current_user.id,
                       details=f'plan={plan_type}, order={order_id}')

            create_notification(
                current_user.id,
                'Pix Gerado!',
                f'Seu QR Code Pix do plano {plan_obj.name} foi gerado. Pague em ate 20 minutos para ativar seu plano.',
                link='/pagamento/' + plan_type,
                send_email=True,
            )

            return jsonify({
                'ok': True,
                'payment_method': 'pix',
                'qr_code': pix_data.get('qrCode', ''),
                'expires_at': expires_dt.isoformat() + 'Z' if expires_dt else '',
                'order_id': order_id,
                'amount': plan_obj.effective_price,
                'plan_name': plan_obj.name,
            })
        else:
            error_detail = r.text[:500] if r.text else f'HTTP {r.status_code} sem corpo'
            try:
                err_body = r.json()
                if isinstance(err_body, dict):
                    parts = []
                    for k, v in err_body.items():
                        if isinstance(v, list):
                            parts.append(f'{k}: {", ".join(str(x) for x in v)}')
                        elif v is not None:
                            parts.append(f'{k}: {v}')
                    error_detail = '; '.join(parts) if parts else r.text[:500]
                elif isinstance(err_body, list):
                    parts = []
                    for item in err_body:
                        if isinstance(item, dict):
                            for field, val in item.items():
                                if isinstance(val, list):
                                    parts.append(f'{field}: {", ".join(str(v) for v in val)}')
                                elif val is not None:
                                    parts.append(f'{field}: {val}')
                    error_detail = '; '.join(parts) if parts else str(err_body)[:500]
            except Exception:
                pass
            print(f'[Cakto] Payment create FAILED: HTTP {r.status_code} - {error_detail}')
            return jsonify({'ok': False, 'error': f'Erro ao criar cobranca: {error_detail}'}), 502
    except Exception as e:
        import traceback
        traceback.print_exc()
        print(f'[Cakto] Payment create exception: {e}')
        return jsonify({'ok': False, 'error': f'Erro interno: {str(e)[:200]}'}), 500


def _expire_pending_payments():
    """Marca como 'expired' pagamentos pendentes cujo tempo expirou e envia notificacoes."""
    try:
        now_utc = datetime.utcnow()
        pending = Payment.query.filter(
            Payment.status == 'pending',
            Payment.expires_at != None,
            Payment.expires_at < now_utc,
        ).all()
        for p in pending:
            p.status = 'expired'
            try:
                create_notification(
                    p.user_id,
                    'Pagamento Expirado',
                    f'Seu pagamento do plano {p.plan_type} expirou. Gere um novo QR Code para assinar.',
                    link='/pagamento/' + (p.plan_type or 'mensal'),
                    send_email=True,
                )
            except Exception:
                pass
            log_audit('pix_expired', p.user_id, details=f'charge={p.charge_id}')
        if pending:
            db.session.commit()
    except Exception:
        db.session.rollback()


@app.route('/pagamento/<plan_type>')
@login_required
def pagamento(plan_type):
    settings = load_settings()
    if settings.get('payment_method') != 'cakto':
        flash('Pagamento online nao configurado.', 'error')
        return redirect(url_for('planos'))

    user = current_user
    now_utc = datetime.utcnow()
    if user.is_paid and user.paid_until:
        paid_until = user.paid_until
        if hasattr(paid_until, 'replace') and paid_until.tzinfo is not None:
            paid_until = paid_until.replace(tzinfo=None)
        if paid_until and paid_until > now_utc:
            ph = PlanHistory.query.filter_by(user_id=user.id, status='active').order_by(PlanHistory.created_at.desc()).first()
            current_plan = ph.plan_type if ph else None
            if current_plan == plan_type:
                flash('Voce ja possui este plano ativo. Aguarde a expiracao ou renove pela pagina de planos.', 'info')
                return redirect(url_for('planos'))

    plan_obj = Plan.query.filter_by(key=plan_type, is_active=True).first()
    if not plan_obj:
        flash('Plano invalido.', 'error')
        return redirect(url_for('planos'))

    try:
        offer_ids = json.loads(settings.get('cakto_offer_ids') or '{}')
    except (json.JSONDecodeError, TypeError):
        offer_ids = {}
    offer_id = offer_ids.get(plan_type)
    if not offer_id:
        flash('Oferta nao configurada para este plano.', 'error')
        return redirect(url_for('planos'))

    now_utc = datetime.utcnow()
    pending = Payment.query.filter(
        Payment.user_id == current_user.id,
        Payment.plan_type == plan_type,
        Payment.status == 'pending',
    ).order_by(Payment.created_at.desc()).first()

    has_active_pending = False
    if pending:
        exp = pending.expires_at
        if exp:
            if hasattr(exp, 'replace') and exp.tzinfo is not None:
                exp = exp.replace(tzinfo=None)
            if exp > now_utc:
                has_active_pending = True

    return render_template(
        'pagamento.html',
        plan=plan_obj,
        plan_type=plan_type,
        offer_id=offer_id,
        pending=pending if has_active_pending else None,
        payment_method_method=settings.get('payment_method'),
    )


@app.route('/payment/<int:payment_id>')
@login_required
def payment_status(payment_id):
    payment = db.session.get(Payment, payment_id)
    if not payment or payment.user_id != current_user.id:
        flash('Pagamento não encontrado.', 'error')
        return redirect(url_for('planos'))
    return render_template('payment_status.html', payment=payment)


@app.route('/api/payment_status/<charge_id>')
@login_required
@limiter.exempt
def api_payment_status(charge_id):
    payment = Payment.query.filter_by(charge_id=charge_id).first()
    if not payment:
        return jsonify({'status': 'not_found'})
    now_utc = datetime.utcnow()
    if payment.status == 'pending':
        exp = payment.expires_at
        if exp and hasattr(exp, 'replace') and exp.tzinfo is not None:
            exp = exp.replace(tzinfo=None)
        if exp and exp < now_utc:
            payment.status = 'expired'
            try:
                db.session.commit()
            except Exception:
                db.session.rollback()
            log_audit('pix_expired', payment.user_id, details=f'charge={charge_id}')
            create_notification(
                payment.user_id,
                'Pagamento Expirado',
                f'Seu pagamento do plano {payment.plan_type} expirou. Gere um novo QR Code para assinar.',
                link='/pagamento/' + payment.plan_type,
                send_email=True,
            )
        else:
            try:
                token = _get_cakto_token()
                if token:
                    r = requests.get(
                        f'https://api.cakto.com.br/public_api/payments/{charge_id}',
                        headers={'Authorization': f'Bearer {token}'},
                        timeout=10,
                    )
                    if r.status_code == 200:
                        cdata = r.json()
                        st = cdata.get('status', '')
                        print(f'[Cakto] Payment status check: charge={charge_id}, cakto_status={st}')
                        if st == 'approved' and payment.status != 'paid':
                            payment.status = 'paid'
                            payment.paid_at = datetime.utcnow()
                            db.session.commit()
                            activate_subscription(payment.user_id, payment.plan_days or 30, payment.id)
                            log_audit('payment_confirmed', payment.user_id, details=f'charge={charge_id}, plan={payment.plan_type}')
            except Exception:
                pass
    now_utc = datetime.utcnow()
    site_expires = (payment.created_at + timedelta(minutes=20)).replace(tzinfo=None) if payment.created_at else None
    if site_expires and site_expires < now_utc:
        site_expires = now_utc
    expires_at = (site_expires.isoformat() + 'Z') if site_expires else None
    return jsonify({
        'status': payment.status,
        'paid_at': payment.paid_at.isoformat() if payment.paid_at else None,
        'expires_at': expires_at,
        'pix_qr_code': payment.pix_copy_paste or '',
    })


@app.route('/webhook/c7', methods=['POST'])
@csrf.exempt
def payment_webhook():
    body_raw = request.get_data(as_text=True)
    data = request.get_json() or {}
    received_sig = request.headers.get('X-C7-Signature', '')
    received_ts = request.headers.get('X-C7-Timestamp', '')
    if not received_sig or not received_ts:
        return jsonify({'error': 'missing signature'}), 400
    expected_sig = hmac.new(C7_API_SECRET.encode(), (received_ts + '.' + body_raw).encode(), hashlib.sha256).hexdigest()
    if received_sig != expected_sig:
        return jsonify({'error': 'invalid signature'}), 403
    if data.get('event') == 'payment.confirmed':
        identifier = (data.get('data') or {}).get('identifier')
        payment = Payment.query.filter_by(charge_id=identifier).first()
        if payment and payment.status == 'pending':
            payment.status = 'paid'
            payment.paid_at = datetime.now(timezone.utc)
            db.session.commit()
            activate_subscription(payment.user_id, payment.plan_days or 30, payment.id)
            log_audit('payment_confirmed_webhook', payment.user_id, details=f'charge={identifier}')
            create_notification(
                payment.user_id,
                'Pagamento Confirmado!',
                f'Seu pagamento foi confirmado. Plano ativo!',
                link='/dashboard'
            )
    return jsonify({'ok': True}), 200


@app.route('/webhook/cakto', methods=['POST'])
@csrf.exempt
@limiter.exempt
def cakto_webhook():
    try:
        raw_body = request.get_data()
        data = request.get_json() or {}
        settings = load_settings()
        webhook_secret = settings.get('cakto_webhook_secret', '')
        if not webhook_secret:
            print('[Cakto] Webhook secret not configured')
            return jsonify({'ok': True}), 200

        received_secret = data.get('secret', '')
        if not hmac.compare_digest(str(received_secret), str(webhook_secret)):
            print('[Cakto] Invalid webhook secret')
            return jsonify({'error': 'invalid secret'}), 403

        ts_header = request.headers.get('X-Cakto-Timestamp', '')
        sig_header = request.headers.get('X-Cakto-Signature', '')
        if ts_header and sig_header:
            try:
                ts_int = int(ts_header)
                if abs(_time.time() - ts_int) > 300:
                    print('[Cakto] Timestamp too old')
                    return jsonify({'error': 'timestamp expired'}), 403
                expected_sig = hmac.new(
                    webhook_secret.encode('utf-8'),
                    (ts_header + '.' + raw_body.decode('utf-8')).encode('utf-8'),
                    hashlib.sha256,
                ).hexdigest()
                if not hmac.compare_digest(sig_header, 'v1=' + expected_sig):
                    print('[Cakto] Signature mismatch')
                    return jsonify({'error': 'invalid signature'}), 403
            except Exception as e:
                print(f'[Cakto] Signature check error: {e}')

        event = data.get('event', '')
        event_data = data.get('data', {})
        order_id = event_data.get('id', '')
        print(f'[Cakto] Webhook event={event}, order={order_id}')

        if event == 'purchase_approved':
            customer = event_data.get('customer', {})
            customer_email = (customer.get('email') or '').strip().lower()
            product = event_data.get('product', {})
            product_id = product.get('id', '')
            amount = float(event_data.get('amount') or event_data.get('baseAmount') or 0)

            user = None
            if customer_email:
                user = User.query.filter_by(email=customer_email).first()
            if not user:
                print(f'[Cakto] User not found for email: {customer_email}')
                return jsonify({'ok': True}), 200

            settings2 = load_settings()
            try:
                offer_ids = json.loads(settings2.get('cakto_offer_ids') or '{}')
            except (json.JSONDecodeError, TypeError):
                offer_ids = {}
            offer_id = (event_data.get('offer') or {}).get('id', '')
            plan_type = 'custom'
            if offer_id:
                for pkey, oid in offer_ids.items():
                    if oid == offer_id:
                        plan_type = pkey
                        break
            if plan_type == 'custom':
                for pkey, pid in offer_ids.items():
                    if pid == product_id:
                        plan_type = pkey
                        break

            plan_obj = Plan.query.filter_by(key=plan_type, is_active=True).first()
            plan_days = plan_obj.days if plan_obj else 30

            existing = PlanHistory.query.filter(
                PlanHistory.user_id == user.id,
                PlanHistory.plan_type == plan_type,
                PlanHistory.status == 'active',
            ).first()
            if existing:
                print(f'[Cakto] Duplicate order {order_id} for user {user.email}')
                return jsonify({'ok': True}), 200

            existing_payment = Payment.query.filter_by(charge_id=order_id).first()
            if existing_payment:
                if existing_payment.status != 'paid':
                    existing_payment.status = 'paid'
                    existing_payment.paid_at = datetime.utcnow()
                    db.session.commit()
                    activate_subscription(user.id, plan_days, existing_payment.id)
                print(f'[Cakto] Payment already exists (updated): order={order_id}, user={user.email}')
                return jsonify({'ok': True}), 200

            payment = Payment(
                user_id=user.id, amount=amount, status='paid',
                plan_type=plan_type, plan_days=plan_days,
                charge_id=order_id,
                paid_at=datetime.utcnow(),
            )
            db.session.add(payment)
            db.session.commit()

            activate_subscription(user.id, plan_days, payment.id)
            log_audit('cakto_payment_confirmed', user.id, details=f'order={order_id}, plan={plan_type}')
            create_notification(
                user.id, 'Pagamento Confirmado!',
                'Seu pagamento foi confirmado. Plano ativo!',
                link='/dashboard'
            )
            print(f'[Cakto] Payment confirmed: user={user.email}, order={order_id}, plan={plan_type}')

        elif event in ('subscription_renewed',):
            customer = event_data.get('customer', {})
            customer_email = (customer.get('email') or '').strip().lower()
            subscription = event_data.get('subscription', {}) or {}
            product = event_data.get('product', {})
            product_id = product.get('id', '')
            amount = float(event_data.get('amount') or 0)

            user = User.query.filter_by(email=customer_email).first() if customer_email else None
            if not user:
                print(f'[Cakto] Renewal: user not found for {customer_email}')
                return jsonify({'ok': True}), 200

            settings2 = load_settings()
            try:
                offer_ids = json.loads(settings2.get('cakto_offer_ids') or '{}')
            except (json.JSONDecodeError, TypeError):
                offer_ids = {}
            offer_id = (event_data.get('offer') or {}).get('id', '')
            plan_type = 'custom'
            if offer_id:
                for pkey, oid in offer_ids.items():
                    if oid == offer_id:
                        plan_type = pkey
                        break
            if plan_type == 'custom':
                for pkey, pid in offer_ids.items():
                    if pid == product_id:
                        plan_type = pkey
                        break

            plan_obj = Plan.query.filter_by(key=plan_type, is_active=True).first()
            plan_days = plan_obj.days if plan_obj else 30

            existing_payment = Payment.query.filter_by(charge_id=order_id).first()
            if existing_payment:
                print(f'[Cakto] Renewal payment already exists: order={order_id}')
                return jsonify({'ok': True}), 200

            payment = Payment(
                user_id=user.id, amount=amount, status='paid',
                plan_type=plan_type, plan_days=plan_days,
                charge_id=order_id,
                paid_at=datetime.utcnow(),
            )
            db.session.add(payment)
            db.session.commit()

            activate_subscription(user.id, plan_days, payment.id)
            log_audit('cakto_subscription_renewed', user.id, details=f'order={order_id}')
            create_notification(
                user.id, 'Assinatura Renovada!',
                'Sua assinatura foi renovada com sucesso!',
                link='/dashboard'
            )
            print(f'[Cakto] Subscription renewed: user={user.email}, order={order_id}')

        elif event in ('subscription_canceled', 'subscription_renewal_refused'):
            customer = event_data.get('customer', {})
            customer_email = (customer.get('email') or '').strip().lower()
            user = User.query.filter_by(email=customer_email).first() if customer_email else None
            if user:
                log_audit('cakto_subscription_' + event.split('_')[-1], user.id, details=f'order={order_id}')
                print(f'[Cakto] Subscription {event}: user={user.email}')

        else:
            print(f'[Cakto] Unhandled event: {event}')

        return jsonify({'ok': True}), 200
    except Exception as e:
        import traceback
        traceback.print_exc()
        print(f'[Cakto] Webhook exception: {e}')
        return jsonify({'ok': True}), 200


@app.route('/pagamento-concluido')
@login_required
def pagamento_concluido():
    return render_template('pagamento_concluido.html')


# ─── Admin Routes ──────────────────────────────────────────────────────────
@app.route('/admin/login', methods=['GET', 'POST'])
@limiter.limit('5 per minute', methods=['POST'])
def admin_login():
    if current_user.is_authenticated and current_user.is_admin:
        return redirect(url_for('admin_panel'))
    if request.method == 'POST':
        email = request.form.get('email', '').strip().lower()
        pw = request.form.get('password', '')
        if ADMIN_EMAIL and ADMIN_PASSWORD and email == ADMIN_EMAIL and pw == ADMIN_PASSWORD:
            user = User.query.filter_by(email=email).first()
            if not user:
                user = User(email=email, is_admin=True, is_paid=True,
                            last_free_reset=datetime.now(timezone.utc).date())
                user.set_password(pw)
                db.session.add(user)
                db.session.commit()
            elif not user.is_admin:
                user.is_admin = True
                user.set_password(pw)
                db.session.commit()
            login_user(user, remember=True)
            session.permanent = True
            flash('Bem-vindo ao painel administrativo!', 'success')
            return redirect(url_for('admin_panel'))
        flash('Credenciais inválidas.', 'error')
    return render_template('admin_login.html')


@app.route('/admin')
@login_required
@admin_required
def admin_panel():
    from sqlalchemy import func

    filter_from = request.args.get('from', '').strip()
    filter_to = request.args.get('to', '').strip()

    date_from = None
    date_to = None
    if filter_from:
        try:
            date_from = datetime.strptime(filter_from, '%Y-%m-%d').replace(tzinfo=timezone.utc)
        except ValueError:
            pass
    if filter_to:
        try:
            date_to = datetime.strptime(filter_to, '%Y-%m-%d').replace(tzinfo=timezone.utc) + timedelta(days=1)
        except ValueError:
            pass

    # Contagens do catalogo com cache: com ~300k linhas em canal o COUNT()
    # distinct de series sozinho levava dezenas de segundos e segurava locks.
    _counts = cached_swrc('admin:catalog_counts', 120, _admin_catalog_counts)
    qtd_users = _counts['qtd_users']
    qtd_canais = _counts['qtd_canais']
    qtd_filmes = _counts['qtd_filmes']
    qtd_series = _counts['qtd_series']
    qtd_payments = _counts['qtd_payments']
    qtd_banned = _counts['qtd_banned']
    qtd_inactive = _counts['qtd_inactive']

    plan_counts = {}
    plan_users = {}
    now_utc = datetime.now(timezone.utc)
    # Varre planos vencidos (comparando no horario certo, Brasilia) ANTES de
    # contar, para que "venceu" saia do painel imediatamente.
    _sweep_expired_planhistory()
    # IDs de usuarios com assinatura ativa via PlanHistory (dinamico)
    active_payer_ids = set()
    # Buscar todos os plan_type distintos com historico ativo
    active_types = db.session.query(PlanHistory.plan_type).filter(
        PlanHistory.status == 'active'
    ).distinct().all()
    plan_type_names = {p.key: p.name for p in Plan.query.all()}
    for (plan_key,) in active_types:
        histories = PlanHistory.query.filter(
            PlanHistory.plan_type == plan_key,
            PlanHistory.status == 'active'
        ).all()
        plan_counts[plan_key] = len(histories)
        uids = [h.user_id for h in histories]
        active_payer_ids.update(uids)
        users_list = User.query.filter(User.id.in_(uids)).all() if uids else []
        uids_to_hist = {h.user_id: h for h in histories}
        # paid_until/expires_at sao gravados em parede de Brasilia: emite ISO
        # com offset -03:00 para o JS converter com new Date() corretamente.
        def _iso_brt(dt_value):
            if not dt_value:
                return None
            br = dt_value if dt_value.tzinfo is not None else dt_value.replace(tzinfo=BRASILIA_TZ)
            return br.astimezone(BRASILIA_TZ).isoformat()
        plan_users[plan_key] = [{'email': u.email, 'created_at': _safe_iso(u.created_at), 'paid_until': _iso_brt(uids_to_hist[u.id].expires_at) if u.id in uids_to_hist and uids_to_hist[u.id].expires_at else _iso_brt(u.paid_until)} for u in users_list]
    # Incluir todos os planos ativos do banco que nao tenham historico ainda
    for plan in Plan.query.filter_by(is_active=True).all():
        if plan.key not in plan_counts:
            plan_counts[plan.key] = 0
            plan_users[plan.key] = []

    # Reconciliar: users com is_paid=True mas sem PlanHistory ativo => desmarcar
    paid_users = User.query.filter(User.is_paid == True, User.is_admin == False, User.is_banned == False).all()
    for u in paid_users:
        if u.id not in active_payer_ids and u.paid_until and u.paid_until.replace(tzinfo=BRASILIA_TZ) < datetime.now(BRASILIA_TZ):
            u.is_paid = False
    try:
        db.session.commit()
    except Exception:
        db.session.rollback()
    # Contagens reconciliadas
    qtd_paid = len(active_payer_ids)
    qtd_free = User.query.filter(User.is_paid == False, User.is_admin == False, User.is_banned == False).count()

    now_utc = datetime.now(timezone.utc)
    today_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
    week_start = today_start - timedelta(days=today_start.weekday())

    qtd_logins_today = AuditLog.query.filter(
        AuditLog.action == 'login',
        AuditLog.created_at >= today_start
    ).count()

    qtd_logins_week = AuditLog.query.filter(
        AuditLog.action == 'login',
        AuditLog.created_at >= week_start
    ).count()

    qtd_new_users_today = User.query.filter(User.created_at >= today_start).count()
    qtd_new_users_week = User.query.filter(User.created_at >= week_start).count()

    revenue_query = Payment.query.filter(Payment.status == 'paid')
    if date_from:
        revenue_query = revenue_query.filter(Payment.paid_at >= date_from)
    if date_to:
        revenue_query = revenue_query.filter(Payment.paid_at < date_to)
    total_revenue = db.session.query(func.coalesce(func.sum(Payment.amount), 0)).filter(
        Payment.status == 'paid'
    ).scalar()
    period_revenue = db.session.query(func.coalesce(func.sum(Payment.amount), 0)).filter(
        Payment.status == 'paid'
    )
    if date_from:
        period_revenue = period_revenue.filter(Payment.paid_at >= date_from)
    if date_to:
        period_revenue = period_revenue.filter(Payment.paid_at < date_to)
    period_revenue = period_revenue.scalar()

    revenue_today = db.session.query(func.coalesce(func.sum(Payment.amount), 0)).filter(
        Payment.status == 'paid', Payment.paid_at >= today_start
    ).scalar()
    revenue_week = db.session.query(func.coalesce(func.sum(Payment.amount), 0)).filter(
        Payment.status == 'paid', Payment.paid_at >= week_start
    ).scalar()

    total_commissions = db.session.query(func.coalesce(func.sum(Commission.commission_amount), 0)).scalar()

    qtd_paid_today = Payment.query.filter(
        Payment.status == 'paid', Payment.paid_at >= today_start
    ).count()
    qtd_paid_week = Payment.query.filter(
        Payment.status == 'paid', Payment.paid_at >= week_start
    ).count()

    qtd_active_today = AuditLog.query.filter(
        AuditLog.action.in_(['login', 'stream_start']),
        AuditLog.created_at >= today_start
    ).count()

    stale = now_utc - timedelta(seconds=90)
    try:
        WatchLog.query.filter(WatchLog.last_seen < stale, WatchLog.is_active == True).update({'is_active': False})
        db.session.commit()
    except Exception:
        db.session.rollback()

    try:
        online_logs = WatchLog.query.filter(WatchLog.is_active == True, WatchLog.last_seen >= stale).order_by(WatchLog.last_seen.desc()).all()
    except Exception:
        db.session.rollback()
        online_logs = []
    # Um usuario assiste UM conteudo por vez: mostra apenas o mais recente de
    # cada um (e ja desativa os duplicados antigos que restaram no banco).
    watching = []
    _seen_users = set()
    _dup_ids = []
    for log in online_logs:
        if log.user_id in _seen_users:
            _dup_ids.append(log.id)
            continue
        _seen_users.add(log.user_id)
        user = db.session.get(User, log.user_id)
        canal = db.session.get(Canal, log.canal_id) if log.canal_id else None
        if user:
            watching.append({
                'user': user.email,
                'content': log.content_name or (canal.name if canal else 'N/A'),
                'since': log.started_at,
            })
    if _dup_ids:
        try:
            WatchLog.query.filter(WatchLog.id.in_(_dup_ids)).update(
                {'is_active': False}, synchronize_session=False)
            db.session.commit()
        except Exception:
            db.session.rollback()
    online_user_ids = set(_seen_users)
    online_users_list = User.query.filter(User.id.in_(online_user_ids)).all() if online_user_ids else []

    popular = db.session.query(
        Canal.name, Canal.group_title, Canal.view_count
    ).filter(Canal.view_count > 0).order_by(Canal.view_count.desc()).limit(10).all()

    referral_totals = db.session.query(
        Referral.referrer_id, db.func.count(Referral.id)
    ).group_by(Referral.referrer_id).all()
    referral_confirmed = db.session.query(
        Referral.referrer_id, db.func.count(Referral.id)
    ).filter(Referral.subscription_confirmed == True).group_by(Referral.referrer_id).all()
    referral_bonus = db.session.query(
        Referral.referrer_id, db.func.count(Referral.id)
    ).filter(Referral.bonus_granted == True).group_by(Referral.referrer_id).all()
    ref_total_map = dict(referral_totals)
    ref_confirmed_map = dict(referral_confirmed)
    ref_bonus_map = dict(referral_bonus)
    ref_balance_map = {}
    ref_earned_map = {}
    ref_commissions_map = {}
    for affiliate_user in User.query.filter(User.is_admin == False).all():
        wallet = AffiliateWallet.query.filter_by(user_id=affiliate_user.id).first()
        ref_balance_map[affiliate_user.id] = float(wallet.balance or 0) if wallet else 0.0
        ref_earned_map[affiliate_user.id] = float(wallet.total_earned or 0) if wallet else 0.0
        ref_commissions_map[affiliate_user.id] = Commission.query.filter_by(referrer_id=affiliate_user.id).count()
    ranking_users = User.query.filter(User.is_admin == False).all()
    ref_link_map = {}
    generated_referral_codes = False
    for u in User.query.all():
        if not u.referral_code:
            u.referral_code = generate_referral_code()
            generated_referral_codes = True
        ref_link_map[u.id] = url_for('register', ref=u.referral_code, _external=True)
    if generated_referral_codes:
        db.session.commit()

    user_search = request.args.get('user_q', '').strip()
    user_query = User.query
    if user_search:
        user_query = user_query.filter(User.email.ilike(f'%{user_search}%'))
    users = user_query.order_by(User.created_at.desc()).all()
    # Force daily usage reset for all users (not just active ones)
    today_date = datetime.now(timezone.utc).date()
    for u in users:
        if u.last_free_reset != today_date:
            u.free_daily_used = 0
            u.last_free_reset = today_date
    db.session.commit()
    payments = Payment.query.order_by(Payment.created_at.desc()).limit(20).all()

    # Ultimo conteudo por usuario em UMA query (antes era 1 WatchLog + 1 Canal
    # por usuario, gerando centenas de queries e segurando o pool do MySQL).
    user_last_watched = {}
    _user_ids = [u.id for u in users]
    if _user_ids:
        try:
            _subq = (
                db.session.query(
                    WatchLog.user_id,
                    func.max(WatchLog.id).label('max_id')
                ).filter(WatchLog.user_id.in_(_user_ids))
                .group_by(WatchLog.user_id)
                .subquery()
            )
            _latest = (
                db.session.query(WatchLog)
                .join(_subq, WatchLog.id == _subq.c.max_id)
                .all()
            )
            _canal_ids = {l.canal_id for l in _latest if l.canal_id}
            _canal_map = {}
            if _canal_ids:
                for c in Canal.query.filter(Canal.id.in_(_canal_ids)).all():
                    _canal_map[c.id] = c
            for last_log in _latest:
                canal = _canal_map.get(last_log.canal_id) if last_log.canal_id else None
                user_last_watched[last_log.user_id] = {
                    'name': last_log.content_name or (canal.name if canal else 'N/A'),
                    'last_seen': last_log.last_seen,
                }
        except Exception:
            db.session.rollback()

    current_user_perms = get_admin_permissions_list(current_user)
    return render_template('admin.html', qtd_users=qtd_users,
                           qtd_canais=qtd_canais, qtd_filmes=qtd_filmes,
                           qtd_series=qtd_series,
                           qtd_payments=qtd_payments,                            users=users,
                           ranking_users=ranking_users,
                           payments=payments, price=PLAN_MONTHLY_PRICE,
                           now=now_utc,
                           online_count=len(online_users_list),
                           online_users=online_users_list,
                           online_user_ids=online_user_ids,
                           watching=watching, popular=popular,
                           user_last_watched=user_last_watched,
                           qtd_pending_reports=Report.query.filter_by(status='pending').count(),
                           qtd_free=qtd_free, qtd_paid=qtd_paid,
                           qtd_banned=qtd_banned, qtd_inactive=qtd_inactive,
                           plan_counts=plan_counts,
                           plan_type_names=plan_type_names,
                           qtd_logins_today=qtd_logins_today,
                           qtd_logins_week=qtd_logins_week,
                           qtd_new_users_today=qtd_new_users_today,
                           qtd_new_users_week=qtd_new_users_week,
                          total_revenue=total_revenue,
                          total_commissions=total_commissions,
                          net_profit=round(float(total_revenue or 0) - float(total_commissions or 0), 2),
                          period_revenue=period_revenue,
                           revenue_today=revenue_today,
                           revenue_week=revenue_week,
                           qtd_paid_today=qtd_paid_today,
                           qtd_paid_week=qtd_paid_week,
                           qtd_active_today=qtd_active_today,
                           filter_from=filter_from, filter_to=filter_to,
                           user_search=user_search,
                           settings=load_settings(),
                         plan_users=plan_users,
                         admin_tabs=ADMIN_TABS,
                         current_user_perms=current_user_perms,
                          has_admin_users_tab=has_admin_tab('admin_users'),
                          ref_total_map=ref_total_map,
                          ref_confirmed_map=ref_confirmed_map,
                          ref_bonus_map=ref_bonus_map,
                          ref_balance_map=ref_balance_map,
                          ref_earned_map=ref_earned_map,
                          ref_commissions_map=ref_commissions_map,
                          ref_link_map=ref_link_map,
                          indication_levels=INDICATION_LEVELS,
                          min_withdraw=0.01)


def _fmt_when(dt_value):
    if not dt_value:
        return ''
    try:
        return to_user_tz(dt_value).strftime('%d/%m/%Y %H:%M:%S')
    except Exception:
        try:
            return dt_value.strftime('%d/%m/%Y %H:%M:%S')
        except Exception:
            return ''


def _fmt_dur(start, end):
    try:
        if start is None or end is None:
            return '-'
        if (start.tzinfo is None) != (end.tzinfo is None):
            start = start.replace(tzinfo=timezone.utc) if start.tzinfo is None else start
            end = end.replace(tzinfo=timezone.utc) if end.tzinfo is None else end
        secs = int((end - start).total_seconds())
        if secs < 0:
            secs = 0
        h, m, s = secs // 3600, (secs % 3600) // 60, secs % 60
        if h:
            return f'{h}h {m:02d}m'
        if m:
            return f'{m}m {s:02d}s'
        return f'{s}s'
    except Exception:
        return '-'


@app.route('/admin/usuarios/<int:user_id>/atividade')
@login_required
@admin_required
def admin_user_history(user_id):
    from sqlalchemy import func, distinct
    user = db.session.get(User, user_id)
    if not user:
        flash('Usuario nao encontrado.', 'error')
        return redirect(url_for('admin_usuarios'))

    sec = (request.args.get('sec') or 'watch').strip()
    if sec not in ('watch', 'fav', 'login'):
        sec = 'watch'
    date_from = (request.args.get('de') or '').strip()
    date_to = (request.args.get('ate') or '').strip()
    try:
        page = max(1, int(request.args.get('page', 1)))
    except (ValueError, TypeError):
        page = 1
    per_page = 30

    def _range(q, col):
        if date_from:
            try:
                q = q.filter(col >= _brt_bound(date_from))
            except ValueError:
                pass
        if date_to:
            try:
                q = q.filter(col <= _brt_bound(date_to, end=True))
            except ValueError:
                pass
        return q

    has_range = bool(date_from or date_to)

    # ─── Estatisticas ───
    w_all = WatchLog.query.filter(WatchLog.user_id == user_id)
    sessions_all = w_all.count()
    sessions_range = _range(w_all, WatchLog.started_at).count()
    dc_q = db.session.query(func.count(func.distinct(WatchLog.content_name))).filter(WatchLog.user_id == user_id)
    dc_q = _range(dc_q, WatchLog.started_at)
    distinct_content = dc_q.scalar() or 0
    active_days = db.session.query(
        func.count(func.distinct(func.date(WatchLog.started_at)))
    ).filter(WatchLog.user_id == user_id)
    active_days = _range(active_days, WatchLog.started_at).scalar() or 0
    fav_total = Favorite.query.filter(Favorite.user_id == user_id).count()
    login_all = AuditLog.query.filter(AuditLog.user_id == user_id, AuditLog.action.ilike('%login%'))
    logins_total = login_all.count()
    logins_range = _range(login_all, AuditLog.created_at).count()
    last_w = WatchLog.query.filter(WatchLog.user_id == user_id).order_by(WatchLog.last_seen.desc()).first()
    last_activity = last_w.last_seen if last_w else None

    # ─── Lista paginada por secao ───
    if sec == 'watch':
        q = WatchLog.query.filter(WatchLog.user_id == user_id)
        q = _range(q, WatchLog.started_at).order_by(WatchLog.started_at.desc())
    elif sec == 'fav':
        q = Favorite.query.filter(Favorite.user_id == user_id)
        q = _range(q, Favorite.created_at).order_by(Favorite.created_at.desc())
    else:
        q = AuditLog.query.filter(AuditLog.user_id == user_id, AuditLog.action.ilike('%login%'))
        q = _range(q, AuditLog.created_at).order_by(AuditLog.created_at.desc())
    pagination = q.paginate(page=page, per_page=per_page, error_out=False)

    items = pagination.items
    canal_map = {}
    ids = {it.canal_id for it in items if getattr(it, 'canal_id', None)}
    if ids:
        for c in Canal.query.filter(Canal.id.in_(ids)).all():
            canal_map[c.id] = c

    rows = []
    for it in items:
        if sec == 'watch':
            canal = canal_map.get(it.canal_id) if it.canal_id else None
            rows.append({
                'title': it.content_name or (canal.name if canal else 'N/A'),
                'group': (canal.group_title if canal else ''),
                'a': _fmt_when(it.started_at),
                'b': _fmt_when(it.last_seen),
                'dur': _fmt_dur(it.started_at, it.last_seen),
                'active': bool(it.is_active),
            })
        elif sec == 'fav':
            canal = canal_map.get(it.canal_id) if it.canal_id else None
            rows.append({
                'title': (canal.name if canal else '(conteudo removido)'),
                'group': (canal.tipo if canal else ''),
                'a': _fmt_when(it.created_at),
                'b': (canal.group_title if canal else ''),
                'dur': '',
                'active': False,
            })
        else:
            rows.append({
                'title': it.action,
                'group': '',
                'a': _fmt_when(it.created_at),
                'b': it.ip_address or '',
                'dur': it.device_info or it.user_agent or '',
                'active': False,
            })

    # preserva filtros ao paginar
    qs = f'sec={sec}' + (f'&de={date_from}' if date_from else '') + (f'&ate={date_to}' if date_to else '')
    current_user_perms = get_admin_permissions_list(current_user)
    return render_template('admin_user_history.html', user=user, sec=sec, rows=rows,
                           pagination=pagination, date_from=date_from, date_to=date_to,
                           has_range=has_range, qs=qs,
                           stats={'sessions_all': sessions_all, 'sessions_range': sessions_range,
                                  'distinct_content': distinct_content, 'active_days': active_days,
                                  'fav_total': fav_total, 'logins_total': logins_total,
                                  'logins_range': logins_range},
                           last_activity=_fmt_when(last_activity),
                           current_user_perms=current_user_perms)


@app.route('/admin/usuarios/ativos')
@login_required
@admin_required
def admin_usuarios_ativos():
    from sqlalchemy import func, distinct
    date_from = (request.args.get('de') or '').strip()
    date_to = (request.args.get('ate') or '').strip()
    try:
        page = max(1, int(request.args.get('page', 1)))
    except (ValueError, TypeError):
        page = 1
    per_page = 50

    base = db.session.query(
        WatchLog.user_id,
        func.count(WatchLog.id).label('sess'),
        func.count(func.distinct(WatchLog.content_name)).label('conteudos'),
        func.max(WatchLog.last_seen).label('last'),
    )
    if date_from:
        try:
            base = base.filter(WatchLog.started_at >= _brt_bound(date_from))
        except ValueError:
            pass
    if date_to:
        try:
            base = base.filter(WatchLog.started_at <= _brt_bound(date_to, end=True))
        except ValueError:
            pass
    base = base.group_by(WatchLog.user_id)

    grouped = base.order_by(func.count(WatchLog.id).desc()).all()
    total = len(grouped)
    total_pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, total_pages))
    start = (page - 1) * per_page
    window = grouped[start:start + per_page]

    uids = [g.user_id for g in window]
    umap = {}
    if uids:
        for u in User.query.filter(User.id.in_(uids)).all():
            umap[u.id] = u

    rows = []
    for g in window:
        u = umap.get(g.user_id)
        rows.append({
            'user': u.email if u else f'#{g.user_id} (excluido)',
            'paid': bool(u.is_paid) if u else False,
            'banned': bool(u.is_banned) if u else False,
            'sess': g.sess,
            'conteudos': g.conteudos or 0,
            'last': _fmt_when(g.last),
            'uid': g.user_id,
        })

    qs = (('de=' + date_from + '&') if date_from else '') + (('ate=' + date_to) if date_to else '')
    current_user_perms = get_admin_permissions_list(current_user)
    return render_template('admin_usuarios_ativos.html', rows=rows, page=page,
                           total=total, total_pages=total_pages, date_from=date_from,
                           date_to=date_to, qs=qs, current_user_perms=current_user_perms)


@app.route('/admin/usuarios')
@login_required
@admin_required
def admin_usuarios():
    from sqlalchemy import func
    now_utc = datetime.now(timezone.utc)
    today_date = now_utc.date()
    stale = now_utc - timedelta(seconds=90)
    try:
        WatchLog.query.filter(WatchLog.last_seen < stale, WatchLog.is_active == True).update({'is_active': False})
        db.session.commit()
    except Exception:
        db.session.rollback()
    online_logs = WatchLog.query.filter(WatchLog.is_active == True, WatchLog.last_seen >= stale).order_by(WatchLog.last_seen.desc()).all()
    online_user_ids = {l.user_id for l in online_logs}
    user_search = request.args.get('user_q', '').strip()
    page = request.args.get('page', 1, type=int)
    per_page = 50
    user_query = User.query
    if user_search:
        user_query = user_query.filter(User.email.ilike(f'%{user_search}%'))
    total = user_query.count()
    total_pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, total_pages))
    start = (page - 1) * per_page
    users = user_query.order_by(User.created_at.desc()).offset(start).limit(per_page).all()
    for u in users:
        if u.last_free_reset != today_date:
            u.free_daily_used = 0
            u.last_free_reset = today_date
    db.session.commit()
    # --- Batch query: last WatchLog per user (N+1 fix) ---
    user_ids = [u.id for u in users]
    user_last_watched = {}
    if user_ids:
        subq = (
            db.session.query(
                WatchLog.user_id,
                func.max(WatchLog.id).label('max_id')
            ).filter(WatchLog.user_id.in_(user_ids))
            .group_by(WatchLog.user_id)
            .subquery()
        )
        latest_logs = (
            db.session.query(WatchLog)
            .join(subq, WatchLog.id == subq.c.max_id)
            .all()
        )
        log_by_user = {l.user_id: l for l in latest_logs}
        # Batch-load referenced Canals
        canal_ids = {l.canal_id for l in latest_logs if l.canal_id}
        canal_map = {}
        if canal_ids:
            for c in Canal.query.filter(Canal.id.in_(canal_ids)).all():
                canal_map[c.id] = c
        for u in users:
            last_log = log_by_user.get(u.id)
            if last_log:
                canal = canal_map.get(last_log.canal_id) if last_log.canal_id else None
                user_last_watched[u.id] = {
                    'name': last_log.content_name or (canal.name if canal else 'N/A'),
                    'last_seen': last_log.last_seen,
                }
    # --- Batch query: referral stats ---
    referral_totals = db.session.query(Referral.referrer_id, db.func.count(Referral.id)).group_by(Referral.referrer_id).all()
    referral_confirmed = db.session.query(Referral.referrer_id, db.func.count(Referral.id)).filter(Referral.subscription_confirmed == True).group_by(Referral.referrer_id).all()
    referral_bonus = db.session.query(Referral.referrer_id, db.func.count(Referral.id)).filter(Referral.bonus_granted == True).group_by(Referral.referrer_id).all()
    ref_total_map = dict(referral_totals)
    ref_confirmed_map = dict(referral_confirmed)
    ref_bonus_map = dict(referral_bonus)
    ref_link_map = {}
    for u in users:
        if not u.referral_code:
            u.referral_code = generate_referral_code()
        ref_link_map[u.id] = url_for('register', ref=u.referral_code, _external=True)
    db.session.commit()
    settings = load_settings()
    current_user_perms = get_admin_permissions_list(current_user)
    return render_template('admin_usuarios.html',
                           users=users, user_search=user_search,
                           online_user_ids=online_user_ids,
                           user_last_watched=user_last_watched,
                           ref_total_map=ref_total_map,
                           ref_confirmed_map=ref_confirmed_map,
                           ref_bonus_map=ref_bonus_map,
                           ref_link_map=ref_link_map,
                           settings=settings,
                           now=now_utc,
                           plan_users={},
                           plans_for_premium=Plan.query.filter_by(is_active=True).order_by(Plan.sort_order).all(),
                           admin_tabs=ADMIN_TABS,
                           current_user_perms=current_user_perms,
                           indication_levels=INDICATION_LEVELS,
                           page=page, total_pages=total_pages, total=total)


@app.route('/admin/pagamentos')
@login_required
@admin_required
def admin_pagamentos():
    filter_from = request.args.get('from', '').strip()
    filter_to = request.args.get('to', '').strip()
    page = request.args.get('page', 1, type=int)
    per_page = 50
    date_from = date_to = None
    if filter_from:
        try:
            date_from = datetime.strptime(filter_from, '%Y-%m-%d').replace(tzinfo=timezone.utc)
        except ValueError:
            pass
    if filter_to:
        try:
            date_to = datetime.strptime(filter_to, '%Y-%m-%d').replace(tzinfo=timezone.utc) + timedelta(days=1)
        except ValueError:
            pass
    q = Payment.query
    if date_from:
        q = q.filter(Payment.paid_at >= date_from)
    if date_to:
        q = q.filter(Payment.paid_at < date_to)
    total = q.count()
    total_pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, total_pages))
    start = (page - 1) * per_page
    payments = q.order_by(Payment.created_at.desc()).offset(start).limit(per_page).all()
    users = User.query.all()
    user_map = {u.id: u for u in users}
    from sqlalchemy import func
    total_revenue = db.session.query(func.coalesce(func.sum(Payment.amount), 0)).filter(Payment.status == 'paid').scalar()
    current_user_perms = get_admin_permissions_list(current_user)
    return render_template('admin_pagamentos.html',
                           payments=payments, user_map=user_map,
                           total_revenue=total_revenue,
                           filter_from=filter_from, filter_to=filter_to,
                           current_user_perms=current_user_perms,
                           page=page, total_pages=total_pages, total=total,
                           now=datetime.now(timezone.utc))


@app.route('/admin/ranking')
@login_required
@admin_required
def admin_ranking():
    referral_totals = db.session.query(Referral.referrer_id, db.func.count(Referral.id)).group_by(Referral.referrer_id).all()
    referral_confirmed = db.session.query(Referral.referrer_id, db.func.count(Referral.id)).filter(Referral.subscription_confirmed == True).group_by(Referral.referrer_id).all()
    referral_bonus = db.session.query(Referral.referrer_id, db.func.count(Referral.id)).filter(Referral.bonus_granted == True).group_by(Referral.referrer_id).all()
    ref_total_map = dict(referral_totals)
    ref_confirmed_map = dict(referral_confirmed)
    ref_bonus_map = dict(referral_bonus)
    ranking_users = User.query.filter(User.is_admin == False).all()
    ref_balance_map = {}
    ref_earned_map = {}
    ref_commissions_map = {}
    for affiliate_user in ranking_users:
        wallet = AffiliateWallet.query.filter_by(user_id=affiliate_user.id).first()
        ref_balance_map[affiliate_user.id] = float(wallet.balance or 0) if wallet else 0.0
        ref_earned_map[affiliate_user.id] = float(wallet.total_earned or 0) if wallet else 0.0
        ref_commissions_map[affiliate_user.id] = Commission.query.filter_by(referrer_id=affiliate_user.id).count()
    ref_link_map = {}
    for u in User.query.all():
        if not u.referral_code:
            u.referral_code = generate_referral_code()
        ref_link_map[u.id] = url_for('register', ref=u.referral_code, _external=True)
    db.session.commit()
    settings = load_settings()
    affiliation_disabled = settings.get('affiliation_disabled_all', False)
    current_user_perms = get_admin_permissions_list(current_user)
    return render_template('admin_ranking.html',
                           ref_total_map=ref_total_map,
                           ref_confirmed_map=ref_confirmed_map,
                           ref_bonus_map=ref_bonus_map,
                           ref_balance_map=ref_balance_map,
                           ref_earned_map=ref_earned_map,
                           ref_commissions_map=ref_commissions_map,
                           ranking_users=ranking_users,
                           ref_link_map=ref_link_map,
                           affiliation_disabled=affiliation_disabled,
                           current_user_perms=current_user_perms,
                           now=datetime.now(timezone.utc))


@app.route('/admin/horas-free')
@login_required
@admin_required
def admin_horas_free():
    settings = load_settings()
    now_utc = datetime.now(timezone.utc)
    today_date = now_utc.date()
    stale = now_utc - timedelta(seconds=90)
    try:
        WatchLog.query.filter(WatchLog.last_seen < stale, WatchLog.is_active == True).update({'is_active': False})
        db.session.commit()
    except Exception:
        db.session.rollback()
    online_logs = WatchLog.query.filter(WatchLog.is_active == True, WatchLog.last_seen >= stale).all()
    online_user_ids = {l.user_id for l in online_logs}
    users = User.query.filter(User.is_admin == False).order_by(User.email).all()
    for u in users:
        if u.last_free_reset != today_date:
            u.free_daily_used = 0
            u.free_daily_reset = today_date
    db.session.commit()
    current_user_perms = get_admin_permissions_list(current_user)
    return render_template('admin_horas_free.html',
                           users=users, settings=settings,
                           online_user_ids=online_user_ids,
                           current_user_perms=current_user_perms,
                           now=now_utc)


@app.route('/admin/users/<int:user_id>/give_premium', methods=['POST'])
@login_required
@admin_required
def give_premium(user_id):
    user = db.session.get(User, user_id)
    if user and not user.is_admin:
        plan_type = request.form.get('plan_type', 'custom')
        if plan_type == 'custom':
            days = int(request.form.get('days', 30))
        else:
            plan = Plan.query.filter_by(key=plan_type, is_active=True).first()
            days = plan.days if plan else 30
        user.is_paid = True
        now = datetime.now(BRASILIA_TZ)
        paid_until = user.paid_until
        if paid_until and paid_until.tzinfo is None:
            paid_until = paid_until.replace(tzinfo=BRASILIA_TZ)
        if paid_until and paid_until > now:
            user.paid_until = paid_until + timedelta(days=days)
        else:
            user.paid_until = now + timedelta(days=days)

        price = 0
        plan_name_display = plan_type
        plan_obj = Plan.query.filter_by(key=plan_type, is_active=True).first()
        if plan_obj:
            price = effective_plan_price(plan_obj)
            plan_name_display = plan_obj.name
        else:
            plan_by_days = Plan.query.filter_by(days=days, is_active=True).first()
            if plan_by_days:
                price = effective_plan_price(plan_by_days)
                plan_name_display = plan_by_days.name
                plan_type = plan_by_days.key
            else:
                plan_name_display = f'Custom ({days} dias)'

        payment = Payment(
            charge_id=f'admin_{uuid.uuid4().hex[:12]}',
            user_id=user_id,
            amount=price,
            plan_type=plan_type,
            plan_days=days,
            status='paid',
            paid_at=now,
            expires_at=user.paid_until,
        )
        db.session.add(payment)
        db.session.flush()

        plan_hist = PlanHistory(
            user_id=user_id, plan_type=plan_type,
            plan_name=plan_name_display,
            started_at=now, expires_at=user.paid_until,
            status='active', source='direct',
            payment_id=payment.id,
        )
        # Garante 1 plano ativo por usuario: as linhas antigas viram 'expired'
        # antes de inserir a nova (senao o painel conta renovacoes a mais).
        _expire_other_active_planhistory(user_id)
        db.session.add(plan_hist)
        db.session.commit()
        credited = grant_referral_commissions(
            user, plan_hist.plan_name, price, payment.id
        ) if price > 0 else 0
        if credited:
            db.session.commit()
        log_audit('admin_grant_premium', user.id, details=f'days={days}, plan={plan_type}, price={price}, commissions={credited}')
        cache_invalidate('admin_dashboard_stats')
        try:
            notify_subscription_activated(user, f'Plano {plan_name_display}', user.paid_until)
        except Exception as e:
            print(f'Error sending admin grant notification for {user.email}: {e}')
        flash(f'Premium ativado para {user.email} (+{days} dias, R${price:.2f}).', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/credit_commissions', methods=['POST'])
@login_required
@admin_required
def admin_credit_commissions(user_id):
    user = db.session.get(User, user_id)
    if not user:
        flash('Usuário não encontrado.', 'error')
        return redirect(url_for('admin_panel'))

    payment = Payment.query.filter_by(user_id=user.id, status='paid').order_by(
        Payment.paid_at.desc(), Payment.id.desc()
    ).first()
    amount = float(payment.amount or 0) if payment else 0.0
    plan_type = (payment.plan_type if payment else '') or 'mensal'
    plan = Plan.query.filter_by(key=plan_type, is_active=True).first()
    if amount <= 0 and plan:
        amount = effective_plan_price(plan)
    plan_name = plan.name if plan else f'Plano {plan_type.capitalize()}'
    payment_id = payment.id if payment else None

    credited = grant_referral_commissions(user, plan_name, amount, payment_id)
    db.session.commit()
    log_audit(
        'admin_credit_commissions', user.id,
        details=f'amount={amount:.2f}, plan={plan_name}, credited={credited}, payment_id={payment_id}'
    )
    if credited:
        flash(f'{credited} comissões creditadas para a rede de {user.email}.', 'success')
    else:
        flash(
            f'Nenhuma comissão foi creditada para {user.email}. '
            'Verifique indicador, bloqueio de afiliação ou se o crédito já foi processado.',
            'error'
        )
    return redirect(url_for('admin_panel'))


@app.route('/admin/affiliates/reconcile', methods=['POST'])
@login_required
@admin_required
def admin_reconcile_affiliates():
    target_email = request.form.get('target_email', '').strip() or None
    stats = reconcile_affiliates(target_email=target_email)
    log_audit('admin_reconcile_affiliates', current_user.id, details=str(stats))
    flash(f'Reconciliação concluída: {stats["lines_created"]} créditos criados.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/remove_premium', methods=['POST'])
@login_required
@admin_required
def remove_premium(user_id):
    user = db.session.get(User, user_id)
    if user:
        action = request.form.get('action', 'remove_all')
        if action == 'subtract_days':
            days = int(request.form.get('days', 0))
            if days > 0 and user.paid_until:
                paid_until = user.paid_until
                if paid_until.tzinfo is None:
                    paid_until = paid_until.replace(tzinfo=BRASILIA_TZ)
                user.paid_until = paid_until - timedelta(days=days)
                now = datetime.now(BRASILIA_TZ).replace(tzinfo=None)
                if user.paid_until < now:
                    user.is_paid = False
                    user.paid_until = None
                    _expire_all_active_planhistory(user.id)
                else:
                    # Ainda ativo: ajusta o expires_at da linha ativa restante.
                    ph = PlanHistory.query.filter_by(user_id=user.id, status='active').order_by(
                        PlanHistory.expires_at.desc()).first()
                    if ph:
                        ph.expires_at = user.paid_until
                db.session.commit()
                log_audit('admin_subtract_premium_days', user.id, details=f'days={days}')
                cache_invalidate('admin_dashboard_stats')
                flash(f'-{days} dias removidos de {user.email}.', 'success')
            else:
                flash('Nenhum dia para remover.', 'error')
        else:
            user.is_paid = False
            user.paid_until = None
            # Tira o plano tbem do historico ativo: senao continuava contando no
            # painel ("removi e nao saiu dai").
            _expire_all_active_planhistory(user.id)
            db.session.commit()
            log_audit('admin_remove_premium', user.id)
            cache_invalidate('admin_dashboard_stats')
            flash(f'Premium removido de {user.email}.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/edit', methods=['POST'])
@login_required
@admin_required
def admin_edit_user(user_id):
    user = db.session.get(User, user_id)
    if not user:
        flash('Usuario nao encontrado.', 'error')
        return redirect(url_for('admin_panel'))
    email = request.form.get('email', '').strip().lower()
    whatsapp = re.sub(r'[^0-9]', '', request.form.get('whatsapp', '').strip())
    new_pw = request.form.get('password', '').strip()
    changed = []
    if email:
        existing = User.query.filter(User.email == email, User.id != user.id).first()
        if existing:
            flash(f'Ja existe um usuario com o email "{email}".', 'error')
            return redirect(url_for('admin_panel'))
        user.email = email
        changed.append('email')
    if whatsapp:
        user.whatsapp = whatsapp
        changed.append('whatsapp')
    if whatsapp == '' and 'whatsapp' in request.form:
        user.whatsapp = None
        changed.append('whatsapp')
    if new_pw:
        if len(new_pw) < 6:
            flash('A senha deve ter pelo menos 6 caracteres.', 'error')
            return redirect(url_for('admin_panel'))
        user.set_password(new_pw)
        invalidate_all_sessions(user.id)
        changed.append('senha')
    db.session.commit()
    log_audit('admin_edit_user', user.id,
              details=f'campos alterados: {", ".join(changed) if changed else "nenhum"}')
    if not changed:
        flash('Nenhum dado informado para alterar.', 'error')
    else:
        flash(f'Dados de {user.email} atualizados ({", ".join(changed)}).', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/ban', methods=['POST'])
@login_required
@admin_required
def ban_user(user_id):
    user = db.session.get(User, user_id)
    if user and not user.is_admin:
        reason = request.form.get('reason', '').strip()
        user.is_banned = True
        user.ban_reason = reason
        invalidate_all_sessions(user.id)
        db.session.commit()
        log_audit('admin_ban', user.id, details=f'reason={reason}')
        flash(f'{user.email} foi banido.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/unban')
@login_required
@admin_required
def unban_user(user_id):
    user = db.session.get(User, user_id)
    if user and not user.is_admin:
        user.is_banned = False
        user.ban_reason = ''
        db.session.commit()
        log_audit('admin_unban', user.id)
        flash(f'{user.email} foi desbanido.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/deactivate', methods=['POST'])
@login_required
@admin_required
def deactivate_user(user_id):
    user = db.session.get(User, user_id)
    if user and not user.is_admin:
        reason = request.form.get('reason', '').strip()
        user.is_active_user = False
        user.delete_reason = reason
        invalidate_all_sessions(user.id)
        db.session.commit()
        log_audit('admin_deactivate', user.id, details=f'reason={reason}')
        flash(f'Acesso de {user.email} desativado.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/activate')
@login_required
@admin_required
def activate_user(user_id):
    user = db.session.get(User, user_id)
    if user and not user.is_admin:
        user.is_active_user = True
        user.delete_reason = ''
        db.session.commit()
        log_audit('admin_activate', user.id)
        flash(f'Acesso de {user.email} ativado.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/adjust_hours', methods=['POST'])
@login_required
@admin_required
def adjust_hours(user_id):
    user = db.session.get(User, user_id)
    if user:
        action = request.form.get('action', '')
        value = float(request.form.get('value', 0))
        if action == 'add':
            user.free_daily_used = max(0, user.free_daily_used - int(value * 3600))
            flash(f'+{value}h adicionadas para {user.email}.', 'success')
        elif action == 'subtract':
            user.free_daily_used = user.free_daily_used + int(value * 3600)
            flash(f'-{value}h removidas de {user.email}.', 'success')
        elif action == 'set':
            hours = float(request.form.get('hours', 4))
            user.free_daily_limit = int(hours * 3600)
            flash(f'Limite de {user.email} alterado para {hours}h/dia.', 'success')
        db.session.commit()
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id):
    user = db.session.get(User, user_id)
    if user and user.id != current_user.id:
        reason = request.form.get('reason', '').strip()
        user.is_banned = True
        user.ban_reason = reason or 'Conta excluida pelo administrador.'
        user.is_active_user = False
        user.delete_reason = reason or 'Conta excluida pelo administrador.'
        invalidate_all_sessions(user.id)
        db.session.commit()
        log_audit('admin_delete_user', user.id, details=f'reason={reason}')
        flash(f'Conta de {user.email} desativada.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/make_admin', methods=['POST'])
@login_required
@admin_required
def make_admin(user_id):
    user = db.session.get(User, user_id)
    if user and user.id != current_user.id:
        user.is_admin = True
        tabs_raw = request.form.get('tabs_json', 'all')
        if tabs_raw == 'all':
            user.admin_permissions = 'all'
        else:
            try:
                parsed = json.loads(tabs_raw)
                if isinstance(parsed, list) and len(parsed) > 0:
                    user.admin_permissions = tabs_raw
                else:
                    user.admin_permissions = 'all'
            except (json.JSONDecodeError, TypeError):
                user.admin_permissions = 'all'
        db.session.commit()
        log_audit('admin_make_admin', user.id, details=f'perms={user.admin_permissions}')
        flash(f'{user.email} agora e administrador.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/remove_admin', methods=['POST'])
@login_required
@admin_required
def remove_admin(user_id):
    user = db.session.get(User, user_id)
    if user and user.id != current_user.id and user.is_admin:
        user.is_admin = False
        user.admin_permissions = None
        db.session.commit()
        log_audit('admin_remove_admin', user.id)
        flash(f'{user.email} nao e mais administrador.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/update_permissions', methods=['POST'])
@login_required
@admin_required
def update_admin_permissions(user_id):
    user = db.session.get(User, user_id)
    if user and user.is_admin and user.id != current_user.id:
        tabs_raw = request.form.get('tabs_json', 'all')
        if tabs_raw == 'all':
            user.admin_permissions = 'all'
        else:
            try:
                parsed = json.loads(tabs_raw)
                if isinstance(parsed, list) and len(parsed) > 0:
                    user.admin_permissions = tabs_raw
                else:
                    user.admin_permissions = 'all'
            except (json.JSONDecodeError, TypeError):
                user.admin_permissions = 'all'
        db.session.commit()
        log_audit('admin_update_permissions', user.id, details=f'perms={user.admin_permissions}')
        flash(f'Permissoes de {user.email} atualizadas.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/users/<int:user_id>/referrals')
@login_required
@admin_required
def admin_user_referrals(user_id):
    referrer = db.session.get(User, user_id)
    if not referrer:
        return jsonify({'error': 'Usuário não encontrado'}), 404
    referrals = Referral.query.filter_by(referrer_id=user_id).order_by(Referral.created_at.desc()).all()
    data = []
    for r in referrals:
        referred = db.session.get(User, r.referred_id)
        ref_link = ''
        if referred and not referred.referral_code:
            referred.referral_code = generate_referral_code()
            db.session.commit()
        if referred and referred.referral_code:
            ref_link = url_for('register', ref=referred.referral_code, _external=True)
        data.append({
            'id': r.id,
            'referred_email': referred.email if referred else 'Desconhecido',
            'referred_id': r.referred_id,
            'referral_code': referred.referral_code if referred else '',
            'referral_link': ref_link,
            'created_at': _safe_iso(r.created_at),
            'subscription_confirmed': r.subscription_confirmed,
            'bonus_granted': r.bonus_granted,
        })
    return jsonify({
        'referrer_email': referrer.email,
        'total': len(data),
        'referrals': data,
    })


@app.route('/admin/users/<int:user_id>/toggle_affiliation', methods=['POST'])
@login_required
@admin_required
def toggle_user_affiliation(user_id):
    user = db.session.get(User, user_id)
    if user:
        user.affiliation_disabled = not user.affiliation_disabled
        db.session.commit()
        status = 'desativada' if user.affiliation_disabled else 'ativada'
        log_audit('admin_toggle_affiliation', user.id, details=f'disabled={user.affiliation_disabled}')
        flash(f'Afilicao {status} para {user.email}.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/affiliation/toggle_all', methods=['POST'])
@login_required
@admin_required
def toggle_all_affiliation():
    settings = load_settings()
    current = settings.get('affiliation_disabled_all', False)
    new_state = not current
    settings['affiliation_disabled_all'] = new_state
    save_settings(settings)
    User.query.update({'affiliation_disabled': new_state}, synchronize_session=False)
    db.session.commit()
    status = 'desativada' if new_state else 'ativada'
    log_audit('admin_toggle_affiliation_all', current_user.id, details=f'disabled={new_state}')
    flash(f'Afilicao {status} para TODOS os usuarios.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/afiliados/gestao')
@login_required
@admin_required
def admin_afiliados_gestao():
    search = request.args.get('q', '').strip()
    page = request.args.get('page', 1, type=int)
    per_page = 50
    query = User.query.filter(User.is_admin.is_(False))
    if search:
        query = query.filter(User.email.ilike(f'%{search}%'))
    total = query.count()
    total_pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, total_pages))
    start = (page - 1) * per_page
    users = query.order_by(User.email).offset(start).limit(per_page).all()
    user_ids = [u.id for u in users]
    wallets = {w.user_id: w for w in AffiliateWallet.query.filter(AffiliateWallet.user_id.in_(user_ids)).all()}
    invited_counts = dict(db.session.query(User.referred_by, db.func.count()).filter(User.referred_by.in_(user_ids)).group_by(User.referred_by).all())
    confirmed_counts = dict(db.session.query(Referral.referrer_id, db.func.count()).filter(Referral.referrer_id.in_(user_ids), Referral.subscription_confirmed == True).group_by(Referral.referrer_id).all())
    commission_counts = dict(db.session.query(Commission.referrer_id, db.func.count()).filter(Commission.referrer_id.in_(user_ids), Commission.status == 'available').group_by(Commission.referrer_id).all())
    rows = []
    for user in users:
        wallet = wallets.get(user.id)
        balance = float(wallet.balance or 0) if wallet else 0.0
        total_earned = float(wallet.total_earned or 0) if wallet else 0.0
        total_invited = invited_counts.get(user.id, 0)
        confirmed = confirmed_counts.get(user.id, 0)
        commissions_count = commission_counts.get(user.id, 0)
        rows.append({
            'user': user,
            'wallet': wallet,
            'balance': balance,
            'total_earned': total_earned,
            'total_invited': total_invited,
            'confirmed': confirmed,
            'commissions_count': commissions_count,
        })
    return render_template('admin_afiliados.html', rows=rows, search=search, page=page, total_pages=total_pages, total=total,
                           total_balance=sum(float(r['balance']) for r in rows),
                           total_earned=sum(float(r['total_earned']) for r in rows),
                           pending=PayoutRequest.query.filter_by(status='pending').count())


@app.route('/admin/afiliados/<int:user_id>/ajustar_saldo', methods=['POST'])
@login_required
@admin_required
def admin_afiliados_ajustar_saldo(user_id):
    user = db.session.get(User, user_id)
    if not user or user.is_admin:
        flash('Afiliado não encontrado.', 'error')
        return redirect(url_for('admin_afiliados_gestao'))
    try:
        amount = float(request.form.get('valor', 0))
    except (TypeError, ValueError):
        amount = 0
    if amount <= 0:
        flash('Informe um valor válido.', 'error')
        return redirect(url_for('admin_afiliados_gestao', q=user.email))
    tipo = request.form.get('tipo', 'adicionar').strip().lower()
    amount = -abs(amount) if tipo == 'retirar' else abs(amount)
    reason = request.form.get('motivo', '').strip()[:200]
    wallet = get_or_create_wallet(user.id)
    new_balance = float(wallet.balance or 0) + amount
    if new_balance < 0:
        flash('Saldo insuficiente para esta retirada.', 'error')
        return redirect(url_for('admin_afiliados_gestao', q=user.email))
    wallet.balance = new_balance
    if amount > 0:
        wallet.total_earned = float(wallet.total_earned or 0) + amount
    db.session.commit()
    log_audit(
        'admin_ajustar_saldo', user.id,
        details=f'valor={amount:+.2f}, motivo={reason}, by={current_user.email}'
    )
    title = 'Saldo Adicionado!' if amount > 0 else 'Saldo Ajustado'
    message = (
        f'R$ {amount:.2f} foram adicionados à sua carteira.' if amount > 0
        else f'R$ {abs(amount):.2f} foram retirados da sua carteira.'
    )
    if reason:
        message += f' Motivo: {reason}'
    create_notification(user.id, title, message, link='/afiliados')
    flash(f'Saldo de {user.email} ajustado em R$ {amount:+.2f}. Novo saldo: R$ {new_balance:.2f}.', 'success')
    return redirect(url_for('admin_afiliados_gestao', q=user.email))


@app.route('/admin/afiliados/aprovar_indicado', methods=['POST'])
@login_required
@admin_required
def admin_afiliados_aprovar_indicado():
    user = None
    raw_id = request.form.get('user_id', '').strip()
    if raw_id:
        try:
            user = db.session.get(User, int(raw_id))
        except (TypeError, ValueError):
            user = None
    if not user:
        email = request.form.get('email', '').strip().lower()
        user = User.query.filter_by(email=email).first() if email else None
    if not user or user.is_admin:
        flash('Convidado não encontrado.', 'error')
        return redirect(url_for('admin_afiliados_gestao'))
    payment = Payment.query.filter_by(user_id=user.id, status='paid').order_by(
        Payment.paid_at.desc(), Payment.id.desc()
    ).first()
    amount = float(payment.amount or 0) if payment else 0.0
    plan_type = (payment.plan_type if payment else '') or 'mensal'
    plan = Plan.query.filter_by(key=plan_type, is_active=True).first()
    if amount <= 0 and plan:
        amount = effective_plan_price(plan)
    plan_name = plan.name if plan else f'Plano {plan_type.capitalize()}'
    credited = grant_referral_commissions(user, plan_name, amount, payment.id if payment else None)
    db.session.commit()
    log_audit(
        'admin_aprovar_indicado', user.id,
        details=f'referrer_id={user.referred_by}, amount={amount:.2f}, credited={credited}, by={current_user.email}'
    )
    referrer = db.session.get(User, user.referred_by) if user.referred_by else None
    if credited:
        flash(f'{credited} comissões creditadas para a rede de {user.email}.', 'success')
    else:
        flash('O indicado não possui indicador, já foi aprovado ou está com a afiliação bloqueada.', 'error')
    return redirect(url_for('admin_afiliados_gestao', q=referrer.email if referrer else ''))


@app.route('/admin/afiliados/reconciliar', methods=['POST'])
@login_required
@admin_required
def admin_afiliados_reconciliar():
    email = request.form.get('email', '').strip().lower()
    stats = reconcile_affiliates(target_email=email or None)
    db.session.commit()
    log_audit(
        'admin_reconciliar_afiliados', current_user.id,
        details=f'email={email or "todos"}, stats={stats}'
    )
    flash(
        f'Reconciliação concluída: {stats.get("users_scanned", 0)} usuários analisados e '
        f'{stats.get("lines_created", 0)} créditos criados.',
        'success'
    )
    return redirect(url_for('admin_afiliados_gestao', q=email))


@app.route('/admin/afiliados/config', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_afiliados_config():
    if request.method == 'POST':
        commission_type = request.form.get('commission_type', 'percentage')
        user_id = request.form.get('user_id', '').strip()
        percentage_value = float(request.form.get('percentage_value', 10))
        if user_id:
            user_id = int(user_id)
            config = AffiliateConfig.query.filter_by(user_id=user_id).first()
            if not config:
                config = AffiliateConfig(user_id=user_id)
                db.session.add(config)
        else:
            config = AffiliateConfig.query.filter_by(user_id=None).first()
            if not config:
                config = AffiliateConfig()
                db.session.add(config)
        config.commission_type = commission_type
        config.percentage_value = percentage_value
        db.session.commit()
        log_audit('admin_affiliate_config', current_user.id,
                  details=f'type={commission_type}, pct={percentage_value}, user_id={user_id or "global"}')
        flash('Configuracao salva com sucesso!', 'success')
        return redirect(url_for('admin_afiliados_config'))
    global_config = AffiliateConfig.query.filter_by(user_id=None).first()
    user_configs = AffiliateConfig.query.filter(AffiliateConfig.user_id.isnot(None)).all()
    user_config_map = {c.user_id: c for c in user_configs}
    users_with_config = [db.session.get(User, c.user_id) for c in user_configs if db.session.get(User, c.user_id)]
    all_users = User.query.filter(User.is_admin == False).order_by(User.email).all()
    return render_template('admin_afiliados_config.html', global_config=global_config,
                           user_config_map=user_config_map, users_with_config=users_with_config,
                           all_users=all_users)


@app.route('/admin/afiliados/saques')
@login_required
@admin_required
def admin_afiliados_saques():
    status_filter = request.args.get('status', 'pending')
    page = request.args.get('page', 1, type=int)
    per_page = 30
    if status_filter not in ('pending', 'approved', 'rejected'):
        status_filter = 'pending'
    query = PayoutRequest.query.filter_by(status=status_filter).order_by(PayoutRequest.created_at.desc())
    total = query.count()
    total_pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, total_pages))
    start = (page - 1) * per_page
    items = query.offset(start).limit(per_page).all()
    total_pending = PayoutRequest.query.filter_by(status='pending').count()
    total_approved = PayoutRequest.query.filter_by(status='approved').count()
    total_rejected = PayoutRequest.query.filter_by(status='rejected').count()
    return render_template('admin_afiliados_saques.html', items=items,
                           status_filter=status_filter,
                           total_pending=total_pending, total_approved=total_approved, total_rejected=total_rejected,
                           page=page, total_pages=total_pages, total=total)


@app.route('/admin/afiliados/saques/<int:payout_id>/aprovar', methods=['POST'])
@login_required
@admin_required
def admin_aprovar_saque(payout_id):
    payout = db.session.get(PayoutRequest, payout_id)
    if not payout or payout.status != 'pending':
        flash('Solicitacao nao encontrada ou ja processada.', 'error')
        return redirect(url_for('admin_afiliados_saques'))
    payout.status = 'approved'
    payout.processed_by = current_user.id
    payout.paid_at = datetime.now(timezone.utc)
    db.session.commit()
    log_audit('payout_approved', payout.user_id, details=f'amount={payout.amount}, processed_by={current_user.email}')
    create_notification(payout.user_id, 'Saque Aprovado!',
                        f'Seu saque de R$ {payout.amount:.2f} foi aprovado e pago.',
                        link='/afiliados/carteira')
    flash('Saque aprovado com sucesso!', 'success')
    return redirect(url_for('admin_afiliados_saques'))


@app.route('/admin/afiliados/saques/<int:payout_id>/recusar', methods=['POST'])
@login_required
@admin_required
def admin_recusar_saque(payout_id):
    payout = db.session.get(PayoutRequest, payout_id)
    if not payout or payout.status != 'pending':
        flash('Solicitacao nao encontrada ou ja processada.', 'error')
        return redirect(url_for('admin_afiliados_saques'))
    reason = request.form.get('reason', '').strip()
    payout.status = 'rejected'
    payout.rejection_reason = reason
    payout.processed_by = current_user.id
    db.session.commit()
    wallet = get_or_create_wallet(payout.user_id)
    wallet.balance += payout.amount
    db.session.commit()
    log_audit('payout_rejected', payout.user_id,
              details=f'amount={payout.amount}, reason={reason}, processed_by={current_user.email}')
    create_notification(payout.user_id, 'Saque Recusado',
                        f'Seu saque de R$ {payout.amount:.2f} foi recusado. Motivo: {reason or "Nao informado"}.',
                        link='/afiliados/carteira')
    flash('Saque recusado.', 'success')
    return redirect(url_for('admin_afiliados_saques'))


@app.route('/admin/afiliados/saques/historico/<int:user_id>')
@login_required
@admin_required
def admin_afiliados_historico_saques(user_id):
    user = db.session.get(User, user_id)
    if not user:
        flash('Usuario nao encontrado.', 'error')
        return redirect(url_for('admin_afiliados_saques'))
    payouts = PayoutRequest.query.filter_by(user_id=user_id).order_by(PayoutRequest.created_at.desc()).all()
    commissions = Commission.query.filter_by(referrer_id=user_id).order_by(Commission.created_at.desc()).all()
    wallet = get_or_create_wallet(user_id)
    total_commissions = sum(float(c.commission_amount or 0) for c in commissions)
    total_paid = sum(float(p.amount or 0) for p in payouts if p.status == 'approved')
    return render_template('admin_afiliados_saques.html', user=user, wallet=wallet,
                           payouts=payouts, commissions=commissions,
                           total_commissions=total_commissions, total_paid=total_paid,
                           items=None, status_filter='pending',
                           total_pending=0, total_approved=0, total_rejected=0,
                           page=1, total_pages=1, total=len(payouts) + len(commissions),
                           historico_mode=True)


@app.route('/admin/users/<int:user_id>/affiliate_config', methods=['POST'])
@login_required
@admin_required
def admin_user_affiliate_config(user_id):
    user = db.session.get(User, user_id)
    if not user:
        flash('Usuario nao encontrado.', 'error')
        return redirect(url_for('admin_afiliados_config'))
    commission_type = request.form.get('commission_type', 'percentage')
    percentage_value = float(request.form.get('percentage_value', 10))
    config = AffiliateConfig.query.filter_by(user_id=user_id).first()
    if not config:
        config = AffiliateConfig(user_id=user_id)
        db.session.add(config)
    config.commission_type = commission_type
    config.percentage_value = percentage_value
    db.session.commit()
    log_audit('admin_user_affiliate_config', current_user.id,
              details=f'user={user.email}, type={commission_type}, pct={percentage_value}')
    flash(f'Configuracao de afiliado salva para {user.email}.', 'success')
    return redirect(url_for('admin_afiliados_config'))


@app.route('/admin/assinantes')
@login_required
@admin_required
def admin_assinantes():
    current_user_perms = get_admin_permissions_list(current_user)
    search = request.args.get('search', '').strip()
    filter_type = request.args.get('filter', 'all')
    page = request.args.get('page', 1, type=int)
    per_page = 50

    query = User.query.filter(User.is_admin == False)

    if search:
        query = query.filter(User.email.ilike(f'%{search}%'))

    if filter_type == 'paid':
        query = query.filter(User.is_paid == True)
    elif filter_type == 'free':
        query = query.filter(User.is_paid == False, User.is_banned == False, User.is_active_user == True)
    elif filter_type == 'never':
        query = query.filter(User.is_paid == False, User.is_banned == False, User.is_active_user == True)
        query = query.filter(~User.id.in_(db.session.query(PlanHistory.user_id).distinct()))
    elif filter_type == 'expired':
        now_br_exp = datetime.now(BRASILIA_TZ)
        active_user_ids = db.session.query(PlanHistory.user_id).filter(
            PlanHistory.status == 'active'
        ).distinct()
        paid_expired_ids = db.session.query(User.id).filter(
            User.is_paid == True,
            User.paid_until < now_br_exp
        ).distinct()
        expired_history_ids = db.session.query(PlanHistory.user_id).filter(
            PlanHistory.status == 'expired'
        ).distinct()
        query = query.filter(
            ~User.id.in_(active_user_ids),
            db.or_(
                User.id.in_(expired_history_ids),
                User.id.in_(paid_expired_ids)
            )
        )
    elif filter_type == 'inactive':
        query = query.filter(User.is_active_user == False)
    elif filter_type == 'banned':
        query = query.filter(User.is_banned == True)

    total = query.count()
    total_pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, total_pages))
    start = (page - 1) * per_page
    users = query.order_by(User.created_at.desc()).offset(start).limit(per_page).all()

    # Batch plan history
    user_ids = [u.id for u in users]
    plan_histories = {}
    if user_ids:
        all_hist = PlanHistory.query.filter(PlanHistory.user_id.in_(user_ids)).order_by(PlanHistory.created_at.desc()).all()
        for h in all_hist:
            if h.user_id not in plan_histories:
                plan_histories[h.user_id] = []
            plan_histories[h.user_id].append(h)

    # Batch payments
    all_payments = Payment.query.filter(Payment.user_id.in_(user_ids)).order_by(Payment.created_at.desc()).all()
    payments_by_user = {}
    for p in all_payments:
        if p.user_id not in payments_by_user:
            payments_by_user[p.user_id] = []
        payments_by_user[p.user_id].append(p)

    # Batch commissions
    all_commissions = Commission.query.filter(Commission.referrer_id.in_(user_ids)).all()
    commissions_by_user = {}
    for c in all_commissions:
        if c.referrer_id not in commissions_by_user:
            commissions_by_user[c.referrer_id] = []
        commissions_by_user[c.referrer_id].append(c)

    # Batch referrals
    ref_totals = db.session.query(Referral.referrer_id, db.func.count(Referral.id)).group_by(Referral.referrer_id).all()
    ref_total_map = dict(ref_totals)

    try:
        today = datetime.now(BRASILIA_TZ).date()
        for u in users:
            if u.last_free_reset != today:
                u.free_daily_used = 0
                u.last_free_reset = today
        db.session.commit()
    except Exception:
        db.session.rollback()

    online_stale = datetime.now(timezone.utc) - timedelta(seconds=90)
    online_logs = WatchLog.query.filter(WatchLog.is_active == True, WatchLog.last_seen >= online_stale).all()
    online_user_ids = {l.user_id for l in online_logs}

    qtd_paid = User.query.filter(User.is_paid == True, User.is_admin == False, User.is_active_user == True).count()
    qtd_free = User.query.filter(User.is_paid == False, User.is_admin == False, User.is_banned == False, User.is_active_user == True).count()
    qtd_banned = User.query.filter(User.is_banned == True, User.is_admin == False).count()
    qtd_inactive = User.query.filter(User.is_active_user == False, User.is_admin == False, User.is_banned == False).count()

    now_br = datetime.now(BRASILIA_TZ)
    active_user_ids_count = db.session.query(PlanHistory.user_id).filter(PlanHistory.status == 'active').distinct()
    qtd_expired_br = User.query.filter(
        User.is_paid == True, User.paid_until < now_br, User.is_admin == False,
        ~User.id.in_(active_user_ids_count)
    ).count()
    expired_only_ids = db.session.query(PlanHistory.user_id).filter(
        PlanHistory.status == 'expired'
    ).distinct()
    qtd_expired_ph = User.query.filter(
        User.id.in_(expired_only_ids), User.is_admin == False,
        ~User.id.in_(active_user_ids_count)
    ).count()
    qtd_expired = max(qtd_expired_br, qtd_expired_ph)

    paid_user_ids = db.session.query(PlanHistory.user_id).distinct()
    qtd_never = User.query.filter(
        User.is_paid == False, User.is_admin == False,
        User.is_banned == False, User.is_active_user == True
    ).filter(~User.id.in_(paid_user_ids)).count()

    user_expiry_info = {}
    for u in users:
        if u.is_paid and u.paid_until:
            pu = u.paid_until
            if pu.tzinfo is None:
                pu = pu.replace(tzinfo=BRASILIA_TZ)
            diff = pu - now_br
            if diff.days > 0:
                user_expiry_info[u.id] = {'expired': False, 'date': pu.strftime('%d/%m/%Y'), 'days': diff.days}
            else:
                user_expiry_info[u.id] = {'expired': True, 'date': '', 'days': 0}
        else:
            user_expiry_info[u.id] = None

    return render_template('admin_assinantes.html',
                           users=users,
                           user_search=search,
                           search=search,
                           filter_type=filter_type,
                           plan_histories=plan_histories,
                           payments_by_user=payments_by_user,
                           commissions_by_user=commissions_by_user,
                           ref_total_map=ref_total_map,
                           current_user_perms=current_user_perms,
                           online_user_ids=online_user_ids,
                           user_expiry_info=user_expiry_info,
                           page=page, total_pages=total_pages, total=total,
                           plan_type_names={p.key: p.name for p in Plan.query.all()},
                           qtd_paid=qtd_paid, qtd_free=qtd_free,
                           qtd_banned=qtd_banned, qtd_inactive=qtd_inactive,
                           qtd_expired=qtd_expired, qtd_never=qtd_never)


@app.route('/admin/email-history')
@login_required
@admin_required
def admin_email_history():
    current_user_perms = get_admin_permissions_list(current_user)
    search = request.args.get('search', '').strip()
    page = request.args.get('page', 1, type=int)
    per_page = 30
    status_filter = request.args.get('status', '')

    query = EmailLog.query.order_by(EmailLog.created_at.desc())

    if search:
        query = query.filter(EmailLog.recipient_email.ilike(f'%{search}%'))
    if status_filter:
        query = query.filter(EmailLog.status == status_filter)

    total = query.count()
    total_pages = max(1, (total + per_page - 1) // per_page)
    page = max(1, min(page, total_pages))
    start = (page - 1) * per_page
    logs = query.offset(start).limit(per_page).all()

    user_ids = [l.recipient_user_id for l in logs if l.recipient_user_id]
    users_map = {}
    if user_ids:
        for u in User.query.filter(User.id.in_(user_ids)).all():
            users_map[u.id] = u

    sent_count = EmailLog.query.filter_by(status='sent').count()
    failed_count = EmailLog.query.filter_by(status='failed').count()

    return render_template('admin_email_history.html',
                           logs=logs,
                           users_map=users_map,
                           search=search,
                           status_filter=status_filter,
                           page=page, total_pages=total_pages, total=total,
                           current_user_perms=current_user_perms,
                           sent_count=sent_count, failed_count=failed_count)

@app.route('/admin/email/<int:id>')
@login_required
@admin_required
def admin_email_detail(id):
    log = EmailLog.query.get_or_404(id)
    return jsonify({
        'id': log.id,
        'subject': log.subject,
        'recipient_email': log.recipient_email,
        'sender': log.sender or log.sender_key,
        'created_at': log.created_at.strftime('%d/%m/%Y %H:%M') if log.created_at else '-',
        'status': log.status,
        'body': log.body
    })

@app.route('/admin/users/bulk_remove_free_hours', methods=['POST'])
@login_required
@admin_required
def bulk_remove_free_hours():
    target = request.form.get('target', 'all_free')
    hours = float(request.form.get('hours', 0))
    if hours <= 0:
        flash('Informe uma quantidade valida de horas.', 'error')
        return redirect(url_for('admin_panel'))
    if target == 'all_free':
        users = User.query.filter(User.is_paid == False, User.is_admin == False, User.is_banned == False).all()
    elif target == 'all':
        users = User.query.filter(User.is_admin == False, User.is_banned == False).all()
    else:
        flash('Alvo invalido.', 'error')
        return redirect(url_for('admin_panel'))
    count = 0
    for u in users:
        u.free_daily_used = min(u.free_daily_limit, u.free_daily_used + int(hours * 3600))
        count += 1
    db.session.commit()
    log_audit('admin_bulk_remove_free_hours', current_user.id, details=f'target={target}, hours={hours}, count={count}')
    flash(f'{hours}h removidas de {count} usuarios.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/export-emails', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_export_emails():
    current_user_perms = get_admin_permissions_list(current_user)
    plans = Plan.query.filter_by(is_active=True).all()

    # GET sem filtro = mostra pagina de selecao
    filtro = request.args.get('filtro', '').strip() or request.form.get('filtro', '').strip()
    contato = (request.args.get('contato', '').strip() or request.form.get('contato', '').strip()).lower()
    formato = (request.args.get('formato', '').strip() or request.form.get('formato', '').strip()).lower()
    plano = (request.args.get('plano', '').strip() or request.form.get('plano', '').strip())

    if not filtro:
        # Mostrar pagina de selecao
        counts = {
            'todos': User.query.filter(User.is_admin == False).count(),
            'free': User.query.filter(User.is_admin == False, User.is_paid == False, User.is_banned == False, User.is_active_user == True).count(),
            'vip': User.query.filter(User.is_admin == False, User.is_paid == True, User.is_banned == False, User.is_active_user == True).count(),
            'ativos': User.query.filter(User.is_admin == False, User.is_banned == False, User.is_active_user == True).count(),
            'inativos': User.query.filter(User.is_admin == False, User.is_active_user == False).count(),
            'banidos': User.query.filter(User.is_admin == False, User.is_banned == True).count(),
        }
        return render_template('admin_export.html', counts=counts, plans=plans, current_user_perms=current_user_perms)

    # Filtro selecionado -> exportar
    query = User.query.filter(User.is_admin == False)

    if filtro == 'todos':
        pass
    elif filtro == 'free':
        query = query.filter(User.is_paid == False, User.is_banned == False, User.is_active_user == True)
    elif filtro == 'vip':
        query = query.filter(User.is_paid == True, User.is_banned == False, User.is_active_user == True)
    elif filtro == 'ativos':
        query = query.filter(User.is_banned == False, User.is_active_user == True)
    elif filtro == 'inativos':
        query = query.filter(User.is_active_user == False)
    elif filtro == 'banidos':
        query = query.filter(User.is_banned == True)
    elif filtro == 'vip_1mes':
        now = datetime.now(BRASILIA_TZ)
        trinta_dias = now + timedelta(days=30)
        query = query.filter(
            User.is_paid == True,
            User.is_banned == False,
            User.is_active_user == True,
            User.paid_until != None,
            User.paid_until <= trinta_dias
        )
    elif filtro == 'plano' and plano:
        history_ids = db.session.query(PlanHistory.user_id).filter(
            PlanHistory.plan_type == plano,
            PlanHistory.status == 'active'
        ).distinct().all()
        user_ids = [r[0] for r in history_ids]
        query = query.filter(User.id.in_(user_ids), User.is_banned == False, User.is_active_user == True)
    else:
        flash('Filtro invalido.', 'error')
        return redirect(url_for('admin_export_emails'))

    users = query.order_by(User.email).all()
    if contato not in ('email', 'whatsapp', 'ambos'):
        contato = 'ambos'

    if contato == 'email':
        headers = ['email']
        rows = [[u.email or ''] for u in users if u.email]
        label = 'emails'
    elif contato == 'whatsapp':
        headers = ['whatsapp']
        rows = [[u.whatsapp or ''] for u in users if u.whatsapp]
        label = 'whatsapp'
    else:
        headers = ['email', 'whatsapp']
        rows = [[u.email or '', u.whatsapp or ''] for u in users if u.email or u.whatsapp]
        label = 'contatos'

    if formato != 'txt':
        formato = 'csv'

    if formato == 'txt':
        if contato == 'ambos':
            content = '\n'.join(f'{email} | {whatsapp}'.strip(' |') for email, whatsapp in rows)
        else:
            content = '\n'.join(row[0] for row in rows)
        mimetype = 'text/plain; charset=utf-8'
        filename = f'{label}_{filtro}.txt'
    else:
        output = StringIO(newline='')
        writer = csv.writer(output)
        writer.writerow(headers)
        writer.writerows(rows)
        content = '\ufeff' + output.getvalue()
        mimetype = 'text/csv; charset=utf-8'
        filename = f'{label}_{filtro}.csv'

    return Response(
        content,
        mimetype=mimetype,
        headers={'Content-Disposition': f'attachment; filename={filename}'})


@app.route('/admin/canais/edit/<int:item_id>', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_edit_canal(item_id):
    item = db.session.get(Canal, item_id)
    if not item:
        flash('Item nao encontrado.', 'error')
        return redirect(url_for('admin_panel'))
    if request.method == 'POST':
        item.name = request.form.get('name', item.name).strip()
        item.logo = request.form.get('logo', item.logo).strip()
        item.group_title = request.form.get('group_title', item.group_title).strip()
        item.stream_url = request.form.get('stream_url', item.stream_url).strip()
        item.description = request.form.get('description', item.description or '').strip()
        item.tvg_id = request.form.get('tvg_id', item.tvg_id or '').strip()
        db.session.commit()
        flash(f'"{item.name}" atualizado.', 'success')
        return redirect(url_for('admin_canais', tipo=request.args.get('tipo', item.tipo)))
    groups = [g[0] for g in db.session.query(Canal.group_title).filter_by(tipo=item.tipo).distinct().order_by(Canal.group_title).all()]
    return render_template('admin_edit.html', item=item)


@app.route('/admin/canais/toggle_hidden/<int:item_id>')
@login_required
@admin_required
def toggle_hidden(item_id):
    item = db.session.get(Canal, item_id)
    if item:
        item.is_hidden = not item.is_hidden
        db.session.commit()
        status = 'oculto' if item.is_hidden else 'visivel'
        flash(f'"{item.name}" agora esta {status}.', 'success')
    return redirect(request.referrer or url_for('admin_canais'))


@app.route('/admin/canais/bulk_action', methods=['POST'])
@login_required
@admin_required
def admin_bulk_canais():
    action = request.form.get('action', '')
    tipo = request.form.get('tipo', 'live').strip().lower()
    if tipo not in ('live', 'movie', 'series'):
        tipo = 'live'
    ids = request.form.getlist('ids')
    if not ids:
        flash('Nenhum item selecionado.', 'error')
        return redirect(url_for('admin_canais', tipo=tipo))
    items = Canal.query.filter(Canal.id.in_([int(i) for i in ids])).all()
    if action == 'hide':
        for item in items:
            item.is_hidden = True
        db.session.commit()
        flash(f'{len(items)} itens ocultados.', 'success')
    elif action == 'show':
        for item in items:
            item.is_hidden = False
        db.session.commit()
        flash(f'{len(items)} itens exibidos.', 'success')
    elif action == 'delete':
        count = len(items)
        for item in items:
            db.session.delete(item)
        db.session.commit()
        invalidate_cache('admin:')
        flash(f'{count} itens excluidos.', 'success')
    return redirect(url_for('admin_canais', tipo=tipo))


@app.route('/admin/canais/delete/<int:item_id>')
@login_required
@admin_required
def admin_delete_canal(item_id):
    item = db.session.get(Canal, item_id)
    if item:
        tipo = request.args.get('tipo', item.tipo or 'live')
        db.session.delete(item)
        db.session.commit()
        invalidate_cache('admin:')
        flash(f'Item removido.', 'success')
        return redirect(url_for('admin_canais', tipo=tipo))
    return redirect(url_for('admin_panel'))


# ─── Diagnostico e status das fontes M3U ───────────────────────────────────
_M3U_STATUS_CACHE = {}
_M3U_STATUS_TTL = 55          # segundos; o painel consulta a cada 60s


def _m3u_to_int(value):
    try:
        return int(float(value))
    except (TypeError, ValueError):
        return None


def _m3u_mask_url(url):
    """Esconde a senha antes de devolver a URL para a tela."""
    raw = str(url or '')
    try:
        parsed = urlparse(raw)
        pairs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
        masked = []
        for key, value in pairs:
            if key.lower() in ('password', 'pass', 'senha', 'pwd') and value:
                masked.append((key, value[:2] + '***' if len(value) > 2 else '***'))
            else:
                masked.append((key, value))
        return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, parsed.path, '', urllib.parse.urlencode(masked), ''))
    except Exception:
        return raw


def _parse_m3u_credentials(source):
    """Extrai host/usuario/senha de uma URL M3U ou Xtream.

    Aceita os formatos mais comuns:
      http://host:port/get.php?username=u&password=p&type=m3u_plus
      http://host:port/player_api.php?username=u&password=p
      http://host:port/get.php/u/p/lista.m3u
      http://host:port/live/u/p/123.m3u8
    """
    try:
        parsed = urlparse(str(source or '').strip())
    except Exception:
        return None
    if not parsed.netloc:
        return None
    params = urllib.parse.parse_qs(parsed.query or '')
    username = (params.get('username') or [''])[0].strip()
    password = (params.get('password') or [''])[0].strip()
    parts = [p for p in (parsed.path or '').split('/') if p]
    if not (username and password):
        for index, part in enumerate(parts):
            if part in ('get.php', 'player_api.php', 'xmltv.php') and len(parts) >= index + 3:
                username, password = parts[index + 1], parts[index + 2]
                break
    if not (username and password) and len(parts) >= 3 and parts[0] in ('live', 'movie', 'series'):
        username, password = parts[1], parts[2]
    if not (username and password):
        return None
    return {
        'username': username,
        'password': password,
        'scheme': parsed.scheme or 'http',
        'netloc': parsed.netloc,
        'hostname': (parsed.hostname or '').lower(),
    }


def _fetch_xtream_account_info(source, force=False):
    """Consulta player_api.php e devolve a situacao da conta.

    Mesmos campos do script de verificacao: auth, status, exp_date,
    active_cons e max_connections. Cache curto para o painel poder
    atualizar a cada minuto sem martelar o provedor.
    """
    creds = _parse_m3u_credentials(source)
    masked = _m3u_mask_url(source)
    if not creds:
        return {'ok': False, 'fonte': masked, 'erro': 'Sem usuario/senha na URL (nao e uma conta Xtream).'}
    key = f"{creds['scheme']}://{creds['netloc']}|{creds['username']}|{creds['password']}"
    now = _time.time()
    cached = _M3U_STATUS_CACHE.get(key)
    if cached and not force and now - cached[0] < _M3U_STATUS_TTL:
        return cached[1]

    info = {
        'ok': False,
        'fonte': masked,
        'host': creds['netloc'],
        'username': creds['username'],
        # Horario de Brasilia (o painel e usado no Brasil).
        'checked_at': (to_brasilia(datetime.now(timezone.utc)) or datetime.now()).strftime('%d/%m/%Y %H:%M:%S'),
        'ts': now,
    }
    api_url = f"{creds['scheme']}://{creds['netloc']}/player_api.php"
    try:
        resp = requests.get(
            api_url,
            params={'username': creds['username'], 'password': creds['password']},
            headers={'User-Agent': 'IPTVSmarters/1.0', 'Accept': 'application/json'},
            timeout=(6, 15),
        )
        info['http_status'] = resp.status_code
        resp.raise_for_status()
        data = resp.json()
        user = data.get('user_info') or {}
        server = data.get('server_info') or {}
        exp_raw = user.get('exp_date')
        exp_dt = None
        days_left = None
        now_utc = datetime.now(timezone.utc)
        if exp_raw not in (None, '', 'null', '0', 0):
            try:
                # O provedor manda o vencimento em epoch UTC; o painel mostra
                # em Brasilia (-03).
                exp_dt = datetime.fromtimestamp(int(float(exp_raw)), tz=timezone.utc)
                days_left = int((exp_dt - now_utc).total_seconds() // 86400)
            except (TypeError, ValueError, OSError):
                exp_dt = None
        exp_br = to_brasilia(exp_dt) if exp_dt else None
        created_raw = user.get('created_at')
        created_br = None
        if created_raw not in (None, '', 'null', '0', 0):
            try:
                created_br = to_brasilia(datetime.fromtimestamp(int(float(created_raw)), tz=timezone.utc))
            except (TypeError, ValueError, OSError):
                created_br = None
        active_cons = _m3u_to_int(user.get('active_cons')) or 0
        max_connections = _m3u_to_int(user.get('max_connections')) or 0
        info.update({
            'ok': True,
            'auth': _m3u_to_int(user.get('auth')),
            'status': (user.get('status') or '?'),
            'exp_date_raw': exp_raw,
            'exp_date': exp_br.strftime('%d/%m/%Y %H:%M') if exp_br else None,
            'days_left': days_left,
            'vencido': bool(exp_dt and exp_dt < now_utc),
            'active_cons': active_cons,
            'max_connections': max_connections,
            'slots_free': max(0, max_connections - active_cons) if max_connections else None,
            'is_trial': str(user.get('is_trial', '')).strip() in ('1', 'True', 'true'),
            'created_at': created_br.strftime('%d/%m/%Y %H:%M') if created_br else None,
            'allowed_output_formats': user.get('allowed_output_formats') or [],
            'server': {
                'url': server.get('url'),
                'port': server.get('port'),
                'https_port': server.get('https_port'),
                'protocol': server.get('server_protocol'),
                'timezone': server.get('timezone'),
                'time_now': server.get('time_now'),
            },
        })
    except Exception as exc:
        info['erro'] = f'{type(exc).__name__}: {str(exc)[:160]}'
    _M3U_STATUS_CACHE[key] = (now, info)
    return info


def _m3u_catalog_summary():
    """Resumo do catalogo atual (cache curto: o painel consulta a cada minuto)."""
    def _build():
        out = {'file_mtime': None, 'file_size': None, 'total': 0,
               'live': 0, 'movie': 0, 'series': 0, 'sync': None}
        try:
            if os.path.exists(M3U_PATH):
                stat = os.stat(M3U_PATH)
                out['file_mtime'] = datetime.fromtimestamp(stat.st_mtime).strftime('%d/%m/%Y %H:%M')
                out['file_size'] = stat.st_size
        except Exception:
            pass
        try:
            rows = db.session.execute(
                text("SELECT tipo, COUNT(*) FROM canal GROUP BY tipo")
            ).fetchall()
            for tipo, qtd in rows:
                if tipo in ('live', 'movie', 'series'):
                    out[tipo] = int(qtd)
                out['total'] += int(qtd)
        except Exception:
            db.session.rollback()
        try:
            progress_file = os.path.join(BASE_DIR, 'progress_m3u.json')
            if os.path.exists(progress_file):
                with open(progress_file, 'r') as fh:
                    progress = json.load(fh)
                out['sync'] = {
                    'phase': progress.get('phase'),
                    'message': progress.get('message'),
                    'running': bool(progress.get('running')),
                    'timestamp': progress.get('timestamp'),
                }
        except Exception:
            pass
        return out
    return cached_query('m3u_catalog_summary', _M3U_STATUS_TTL, _build)


def _test_m3u_source(url, max_bytes=25 * 1024 * 1024):
    """Baixa e analisa uma lista SEM tocar no catalogo.

    Devolve tudo que o painel precisa para dizer se a fonte e compativel:
    status HTTP, tamanho, cabecalho, itens reconhecidos por tipo, amostra
    de canais, teste de reproducao de alguns streams e situacao da conta
    Xtream (quando a URL tem credenciais).

    Baixa apenas uma amostra (1% do arquivo ou 1 MB, o que for menor)
    para nao sobrecarregar o provedor e responder rapido.
    """
    import tempfile
    from concurrent.futures import ThreadPoolExecutor

    started = _time.time()
    result = {
        'ok': False,
        'url': _m3u_mask_url(url),
        'errors': [],
        'warnings': [],
        'valid': False,
    }
    fd, tmp_path = tempfile.mkstemp(prefix='m3u_test_', suffix='.m3u')
    try:
        with os.fdopen(fd, 'wb') as out:
            resp = requests.get(
                url,
                headers={'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18', 'Accept': '*/*'},
                timeout=(10, 40),
                stream=True,
                allow_redirects=True,
            )
            result['http_status'] = resp.status_code
            result['content_type'] = (resp.headers.get('Content-Type') or '').split(';')[0]
            content_length = resp.headers.get('Content-Length')
            if content_length and content_length.isdigit():
                result['content_length'] = int(content_length)
            # Limite do teste: 1% do arquivo (se conhecido) ou 1 MB, o que for menor.
            # Garante minimo de 256 KB para pegar o cabecalho e algumas linhas.
            known_size = int(content_length) if content_length and content_length.isdigit() else 0
            sample_cap = 1 * 1024 * 1024  # 1 MB fixo
            if known_size:
                sample_cap = min(sample_cap, max(256 * 1024, known_size // 100))
            downloaded = 0
            truncated = False
            for chunk in resp.iter_content(chunk_size=131072):
                if not chunk:
                    continue
                downloaded += len(chunk)
                if downloaded > sample_cap:
                    truncated = True
                    break
                out.write(chunk)
            result['bytes'] = downloaded
            result['sample_cap_bytes'] = sample_cap
            if resp.status_code >= 400:
                result['errors'].append(f'O provedor respondeu HTTP {resp.status_code}.')
        if truncated:
            result['truncated'] = True
            result['warnings'].append(
                f'Teste analisou amostra de {downloaded // 1024} KB (1% do arquivo ou 1 MB max): '
                'lista completa nao foi baixada.'
            )
            # Descarta a ultima linha (cortada no meio) para nao contar uma URL quebrada.
            try:
                with open(tmp_path, 'rb+') as fh:
                    fh.seek(0, os.SEEK_END)
                    size = fh.tell()
                    tail_start = max(0, size - 262144)
                    fh.seek(tail_start)
                    tail = fh.read()
                    pos = tail.rfind(b'\n')
                    if pos >= 0:
                        fh.truncate(tail_start + pos + 1)
            except OSError:
                pass
        result['download_ms'] = int((_time.time() - started) * 1000)

        try:
            stats = _validate_m3u_file(tmp_path, minimum=1)
            result.update(stats)
            result['valid'] = True
        except Exception as exc:
            result['errors'].append(str(exc))

        entries_sample = []
        stream_samples = []
        if result['valid']:
            tipos = {'live': 0, 'movie': 0, 'series': 0}
            parsed = 0
            for entry in parse_m3u_fast(tmp_path):
                parsed += 1
                tipo = entry.get('tipo', 'live')
                tipos[tipo] = tipos.get(tipo, 0) + 1
                if len(entries_sample) < 6:
                    entries_sample.append({
                        'name': entry.get('name') or '',
                        'tipo': tipo,
                        'group': entry.get('group_title') or '',
                        'url': _m3u_mask_url(entry.get('stream_url')),
                    })
                if tipos[tipo] == 1 and len(stream_samples) < 3:
                    stream_samples.append(entry)
                # No teste, le apenas o suficiente para confirmar formato e contar tipos.
                # Para contagem exata, use a sincronizacao completa (Admin > M3U > Atualizar).
                if parsed >= 5000:
                    result['warnings'].append('Teste leu 5.000 itens; total real pode ser maior.')
                    break
            result['parsed'] = parsed
            result['tipos'] = tipos
            result['samples'] = entries_sample
            if parsed == 0:
                result['errors'].append('A lista foi baixada mas nenhum item pode ser lido.')

        if stream_samples:
            def _check_stream(entry):
                check = {
                    'tipo': entry.get('tipo'),
                    'nome': entry.get('name'),
                    'url': _m3u_mask_url(entry.get('stream_url')),
                    'ok': False,
                }
                headers = {'User-Agent': entry.get('stream_user_agent') or 'VLC/3.0.18 LibVLC/3.0.18'}
                if entry.get('stream_referrer'):
                    headers['Referer'] = entry['stream_referrer']
                headers['Range'] = 'bytes=0-2047'
                try:
                    resp = requests.get(entry.get('stream_url'), headers=headers,
                                        timeout=(5, 10), stream=True, allow_redirects=True)
                    check['status'] = resp.status_code
                    check['content_type'] = (resp.headers.get('Content-Type') or '').split(';')[0]
                    try:
                        next(resp.iter_content(2048), b'')
                    except Exception:
                        pass
                    resp.close()
                    check['ok'] = resp.status_code in (200, 206)
                except Exception as exc:
                    check['erro'] = f'{type(exc).__name__}: {str(exc)[:140]}'
                return check

            with ThreadPoolExecutor(max_workers=len(stream_samples)) as pool:
                result['streams'] = list(pool.map(_check_stream, stream_samples))
            for check in result['streams']:
                if not check.get('ok'):
                    result['warnings'].append(
                        f"Amostra {check.get('tipo')} nao respondeu OK ({check.get('erro') or check.get('status')})."
                    )

        creds = _parse_m3u_credentials(url)
        if creds:
            result['xtream'] = _fetch_xtream_account_info(url, force=True)
            if not result['xtream'].get('ok'):
                result['warnings'].append(
                    'Conta Xtream nao respondeu: ' + str(result['xtream'].get('erro') or '')
                )

        if not result['valid']:
            result['veredito'] = '❌ Nao compativel: ' + ' '.join(result['errors'])
        elif not result.get('parsed'):
            result['veredito'] = '❌ Nao compativel: nenhum item reconhecido.'
        else:
            total_ok = result['tipos'].get('live', 0) + result['tipos'].get('movie', 0) + result['tipos'].get('series', 0)
            result['ok'] = True
            result['veredito'] = f'✅ Compativel: {total_ok} itens reconhecidos.'
            if result['warnings']:
                result['veredito'] += ' Atencao aos avisos abaixo.'
    except Exception as exc:
        result['errors'].append(f'{type(exc).__name__}: {str(exc)[:200]}')
        result['veredito'] = '❌ Falha no teste: ' + str(exc)[:200]
    finally:
        try:
            os.remove(tmp_path)
        except OSError:
            pass
        result['total_ms'] = int((_time.time() - started) * 1000)
        # Log do teste
        masked = _m3u_mask_url(url)
        creds = _parse_m3u_credentials(url)
        src_idx = None
        if creds:
            settings = load_settings()
            all_sources = get_m3u_sources(settings, include_disabled=True)
            for i, s in enumerate(all_sources):
                if _parse_m3u_credentials(s) and _parse_m3u_credentials(s).get('username') == creds.get('username'):
                    src_idx = i
                    break
        M3USourceLog.log(
            src_idx, masked, 'test',
            'ok' if result.get('ok') else 'error',
            result.get('veredito', ''),
            details={'parsed': result.get('parsed'), 'tipos': result.get('tipos'), 'truncated': result.get('truncated'), 'http_status': result.get('http_status')}
        )
    return result


@app.route('/admin/m3u/test', methods=['POST'])
@login_required
@admin_required
def admin_m3u_test():
    """Testa uma URL M3U sem importar nada no catalogo."""
    payload = request.get_json(silent=True) or {}
    url = (payload.get('url') or request.form.get('url') or '').strip()
    if not url:
        settings = load_settings()
        sources = get_m3u_sources(settings)
        url = sources[0] if sources else os.environ.get('M3U_URL', '').strip()
    if not url:
        return jsonify({'ok': False, 'veredito': 'Informe uma URL M3U para testar.'}), 400
    if not re.match(r'(?i)^https?://', url):
        return jsonify({'ok': False, 'veredito': 'A URL precisa comecar com http:// ou https://'}), 400
    try:
        return jsonify(_test_m3u_source(url))
    except Exception as exc:
        return jsonify({'ok': False, 'veredito': f'Falha no teste: {exc}'}), 500


def _m3u_build_status(force=False):
    """Coleta a situacao de cada credencial M3U/Xtream.

    Retorna ``(sources, accounts)``. Reusado pelo endpoint de status e pela
    semeadura de logs, para nao duplicar a logica de concorrencia/capacidade.
    """
    from concurrent.futures import ThreadPoolExecutor
    settings = load_settings()
    sources = get_m3u_sources(settings)
    if not sources:
        env_url = os.environ.get('M3U_URL', '').strip()
        if env_url:
            sources = [env_url]
    accounts = []
    if sources:
        workers = min(8, len(sources))
        with ThreadPoolExecutor(max_workers=workers) as pool:
            accounts = list(pool.map(
                lambda src: _fetch_xtream_account_info(src, force=force), sources
            ))
    local_usage = _m3u_source_local_usage(sources)
    disabled_list = settings.get('m3u_disabled', [])
    for index, account in enumerate(accounts):
        account['source_index'] = index
        account['local_cons'] = local_usage.get(index, 0)
        health = _m3u_source_health(sources[index], force=force) if index < len(sources) else {}
        account['usable'] = health.get('usable', True)
        account['motivo'] = health.get('reason', '')
        account['cheia'] = bool(health.get('cheia'))
        account['slots_free'] = health.get('slots_free')
        account['disabled'] = index in disabled_list
        limites = [
            v for v in (
                account.get('max_connections') or 0,
                _m3u_source_capacity_limit(health),
            ) if v
        ]
        account['capacity'] = min(limites) if limites else None
    return sources, accounts


def _m3u_status_summary(accounts):
    """Agrega as contas em livres / quase cheias / cheias / problema / desativadas
    e soma as conexoes restantes, para o cabecalho do painel M3U."""
    livre = quase = cheia = problema = desativadas = 0
    restantes = 0
    tem_dados_conexao = False
    unknown_cap = 0
    for account in accounts:
        if account.get('disabled'):
            desativadas += 1
            continue
        usable = account.get('usable', True)
        cheia_flag = bool(account.get('cheia'))
        cap = account.get('capacity')
        free = account.get('slots_free')
        if isinstance(free, int):
            tem_dados_conexao = True
            if usable and not cheia_flag:
                restantes += max(0, free)
        else:
            # slots_free desconhecido (resposta sem numeros): assume vaga livre.
            if usable and not cheia_flag:
                unknown_cap += 1
        if not usable and not cheia_flag:
            problema += 1
        elif cheia_flag:
            cheia += 1
        elif usable:
            # "Quase cheia" tem que ser medida contra o limite DA CONTA: com
            # max_connections=2 (o mais comum no provedor) a regra antiga
            # free <= 2 punha toda conta inteiramente livre dentro de
            # "quase cheias", e o painel mostrava 0 livres.
            low = isinstance(free, int) and (free <= 1 or (cap and free * 2 <= cap))
            if low:
                quase += 1
            else:
                livre += 1
    return {
        'total': len(accounts),
        'livres': livre,
        'quase_cheias': quase,
        'cheias': cheia,
        'problema': problema,
        'desativadas': desativadas,
        'conexoes_restantes': restantes if tem_dados_conexao else None,
        'sem_dado_conexao': unknown_cap,
    }


def _m3u_write_status_logs(sources, accounts):
    for index, account in enumerate(accounts):
        if index < len(sources):
            masked = _m3u_mask_url(sources[index])
            M3USourceLog.log(
                index, masked, 'status',
                'ok' if account.get('usable', True) else 'warn',
                account.get('motivo') or 'Status verificado',
                details={'auth': account.get('auth'), 'status': account.get('status'),
                         'days_left': account.get('days_left'), 'active_cons': account.get('active_cons'),
                         'max_connections': account.get('max_connections'),
                         'slots_free': account.get('slots_free'),
                         'capacidade': account.get('capacity')}
            )


@app.route('/admin/m3u/status')
@login_required
@admin_required
def admin_m3u_status():
    """Situacao de cada credencial M3U/Xtream (atualizada a cada minuto)."""
    force = request.args.get('force', '') in ('1', 'true', 'yes')
    sources, accounts = _m3u_build_status(force=force)
    summary = _m3u_status_summary(accounts)

    # Log da verificacao de status: sempre quando forcado; caso contrario no
    # maximo 1x a cada STATUS_LOG_THROTTLE s. Assim o auto-refresh (60s) preenche
    # a aba "Logs das Fontes" sem inunda-la.
    now = _time.time()
    do_log = bool(force)
    if not do_log and accounts:
        if now - _m3u_status_log_at.get('t', 0.0) >= STATUS_LOG_THROTTLE:
            do_log = True
    if do_log and accounts:
        _m3u_write_status_logs(sources, accounts)
        _m3u_status_log_at['t'] = now

    return jsonify({
        'accounts': accounts,
        'count': len(accounts),
        'summary': summary,
        'catalog': _m3u_catalog_summary(),
        'checked_at': (to_brasilia(datetime.now(timezone.utc)) or datetime.now()).strftime('%d/%m/%Y %H:%M:%S'),
    })


@app.route('/admin/logs')
@login_required
@admin_required
def admin_logs_view():
    """Mostra o final do erro.log (ou app.log) direto no navegador.

    /admin/logs        -> erro.log (erros)
    /admin/logs?f=app  -> app.log  (diario completo)
    """
    nome = 'app' if (request.args.get('f') or '').lower() == 'app' else 'erro'
    arquivo = os.path.join(os.path.dirname(os.path.abspath(__file__)), f'{nome}.log')
    try:
        with open(arquivo, 'r', encoding='utf-8', errors='replace') as fh:
            linhas = fh.readlines()
        conteudo = ''.join(linhas[-800:])
        if not conteudo:
            conteudo = f'({nome}.log vazio — nenhum evento registrado ainda)'
    except FileNotFoundError:
        conteudo = f'({nome}.log ainda nao foi criado; ele aparece no primeiro erro/evento)'
    except Exception as exc:
        conteudo = f'Erro ao ler {nome}.log: {exc!r}'
    return Response(conteudo, mimetype='text/plain; charset=utf-8',
                    headers={'Cache-Control': 'no-store'})


@app.route('/admin/m3u/logs')
@login_required
@admin_required
def admin_m3u_logs():
    """Retorna logs das fontes M3U."""
    source_index = request.args.get('source_index', type=int)
    limit = min(int(request.args.get('limit', 100)), 500)
    _ensure_m3u_log_table()
    # Semeadura na primeira carga: se nunca houve log, roda uma passada de
    # status forcada para a aba nao aparecer vazia logo apos o deploy.
    try:
        if M3USourceLog.query.count() == 0:
            try:
                srcs, accs = _m3u_build_status(force=True)
                if accs:
                    _m3u_write_status_logs(srcs, accs)
                    _m3u_status_log_at['t'] = _time.time()
                else:
                    M3USourceLog.log(None, None, 'status', 'warn',
                                     'Nenhuma credencial M3U configurada', details={})
            except Exception as seed_err:
                M3USourceLog.log(None, None, 'status', 'error',
                                 f'Falha ao coletar status inicial: {seed_err}',
                                 details={'error': str(seed_err)})
    except Exception:
        try:
            db.session.rollback()
        except Exception:
            pass
    logs = M3USourceLog.get_recent(source_index=source_index, limit=limit)
    return jsonify({
        'logs': [
            {
                'id': log.id,
                'source_index': log.source_index,
                'source_url': log.source_url,
                'action': log.action,
                'status': log.status,
                'message': log.message,
                'details': log.details,
                'created_at': (to_brasilia(log.created_at) or log.created_at).strftime('%d/%m/%Y %H:%M:%S')
            }
            for log in logs
        ]
    })


@app.route('/admin/m3u/source/<int:index>/hide', methods=['POST'])
@login_required
@admin_required
def admin_m3u_hide_source(index):
    """Marca uma fonte M3U como oculta (desabilitada) - nao sera usada no proxy/sync."""
    settings = load_settings()
    sources = get_m3u_sources(settings)
    if index < 0 or index >= len(sources):
        return jsonify({'ok': False, 'error': 'Indice invalido'}), 400
    # Adiciona flag de desabilitada nas settings
    disabled = settings.get('m3u_disabled', [])
    if index not in disabled:
        disabled.append(index)
    settings['m3u_disabled'] = disabled
    save_settings(settings)
    masked = _m3u_mask_url(sources[index])
    M3USourceLog.log(index, masked, 'hide', 'ok', 'Fonte ocultada manualmente pelo admin')
    return jsonify({'ok': True, 'message': 'Fonte ocultada'})


@app.route('/admin/m3u/source/<int:index>/unhide', methods=['POST'])
@login_required
@admin_required
def admin_m3u_unhide_source(index):
    """Reabilita uma fonte M3U ocultada."""
    settings = load_settings()
    sources = get_m3u_sources(settings)
    if index < 0 or index >= len(sources):
        return jsonify({'ok': False, 'error': 'Indice invalido'}), 400
    disabled = settings.get('m3u_disabled', [])
    if index in disabled:
        disabled.remove(index)
    settings['m3u_disabled'] = disabled
    save_settings(settings)
    masked = _m3u_mask_url(sources[index])
    M3USourceLog.log(index, masked, 'unhide', 'ok', 'Fonte reabilitada manualmente pelo admin')
    return jsonify({'ok': True, 'message': 'Fonte reabilitada'})


@app.route('/admin/m3u/source/<int:index>/delete', methods=['POST'])
@login_required
@admin_required
def admin_m3u_delete_source(index):
    """Remove permanentemente uma fonte M3U da configuracao."""
    settings = load_settings()
    sources = get_m3u_sources(settings)
    if index < 0 or index >= len(sources):
        return jsonify({'ok': False, 'error': 'Indice invalido'}), 400
    masked = _m3u_mask_url(sources[index])
    # Remove da lista
    m3u_urls = settings.get('m3u_urls', [])
    if index < len(m3u_urls):
        m3u_urls.pop(index)
    settings['m3u_urls'] = m3u_urls
    # Ajusta indices em m3u_disabled (indices maiores que o removido diminuem 1)
    disabled = settings.get('m3u_disabled', [])
    disabled = [i for i in disabled if i != index]
    disabled = [i - 1 if i > index else i for i in disabled]
    settings['m3u_disabled'] = disabled
    save_settings(settings)
    M3USourceLog.log(index, masked, 'delete', 'warn', 'Fonte removida permanentemente pelo admin')
    return jsonify({'ok': True, 'message': 'Fonte removida'})


@app.route('/admin/m3u', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_m3u():
    settings = load_settings()
    if request.method == 'POST':
        raw_m3u_urls = request.form.get('m3u_urls', '').replace(';', '\\n')
        m3u_urls = []
        for value in raw_m3u_urls.splitlines():
            value = value.strip()
            if value and value not in m3u_urls:
                m3u_urls.append(value)
        legacy_m3u_url = request.form.get('m3u_url', settings.get('m3u_url', '')).strip()
        if not m3u_urls and legacy_m3u_url:
            m3u_urls = [legacy_m3u_url]
        settings['m3u_urls'] = m3u_urls
        settings['m3u_url'] = m3u_urls[0] if m3u_urls else legacy_m3u_url
        # EPG agora e derivado automaticamente de TODAS as listas M3U
        # (get.php -> xmltv.php), uma URL por linha. Ao salvar, atualizamos o
        # card de EPG sozinho. So preservamos um EPG manual quando o admin
        # digitou algo DIFERENTE do que ja estava salvo (sobrescrita consciente).
        def _epg_lines(val):
            if isinstance(val, (list, tuple)):
                return [str(x).strip() for x in val if str(x).strip()]
            return [x.strip() for x in re.split(r'[\r\n]+', str(val or '')) if x.strip()]
        prev_lines = _epg_lines(settings.get('epg_url'))
        form_lines = _epg_lines(request.form.get('epg_url'))
        derived_lines = []
        for _src in m3u_urls:
            _d = _derive_epg_url_from_m3u(_src)
            if _d and _d not in derived_lines:
                derived_lines.append(_d)
        if form_lines and form_lines != prev_lines:
            chosen = form_lines          # override manual explicito
        else:
            chosen = derived_lines or prev_lines or form_lines or [EPG_URL]
        settings['epg_url'] = '\n'.join(chosen)
        try:
            hours = float(request.form.get('free_daily_hours', 4))
            hours = max(0.5, min(24, hours))
            if hours != settings.get('free_daily_hours'):
                settings['free_daily_hours'] = hours
                new_limit = int(hours * 3600)
                User.query.filter(User.is_paid == False, User.is_admin == False).update(
                    {'free_daily_limit': new_limit}, synchronize_session=False
                )
                db.session.commit()
        except (ValueError, TypeError):
            pass
        save_settings(settings)
        _reconfigure_scheduler()
        flash('Configuracoes salvas.', 'success')
        return redirect(url_for('admin_m3u'))
    # GET: monta a exibicao de EPG derivando TODAS as listas M3U (uma por linha),
    # para o card ja aparecer completo sem precisar de um novo salvamento. So
    # usamos o valor derivado quando o admin nao tem um override manual claro
    # (ou seja, o valor salvo ja bate com o que derivariamos, ou esta vazio).
    try:
        disp_sources = settings.get('m3u_urls') or [settings.get('m3u_url', '')]
        derived_disp = []
        for _src in disp_sources:
            _d = _derive_epg_url_from_m3u(_src)
            if _d and _d not in derived_disp:
                derived_disp.append(_d)
        stored_disp = settings.get('epg_url') or ''
        stored_lines = [x.strip() for x in re.split(r'[\r\n]+', str(stored_disp)) if x.strip()]
        # override manual = guardamos algo que NAO e derivavel das listas atuais
        manual_override = [l for l in stored_lines if l not in derived_disp]
        if derived_disp and not manual_override:
            settings['epg_url'] = '\n'.join(derived_disp)
    except Exception:
        pass
    return render_template('admin_m3u.html', settings=settings, cpanel_mode=CPANEL_MODE)
@app.route('/admin/m3u/update')
@login_required
@admin_required
def admin_m3u_update():
    """Forca atualizacao M3U via subprocess independente (nao morre com o request)."""
    # Evita disparar varios imports concorrentes (cliques repetidos): varios
    # processos apagando/reinserindo o catalogo travam o banco (Lock wait
    # timeout) e deixam o site fora do ar.
    if _catalog_update_running():
        flash('Ja existe uma atualizacao do catalogo em andamento. Aguarde ela terminar.', 'warning')
        return redirect(url_for('admin_m3u'))
    caminho = _spawn_catalog_update('all')
    flash('Atualizacao completa iniciada em background: M3U, EPG e metadados Xtream. '
          f'Verifique o log bg_m3u.log em alguns minutos. ({caminho})', 'success')
    return redirect(url_for('admin_m3u'))


@app.route('/admin/m3u/kill-orphans')
@login_required
@admin_required
def admin_m3u_kill_orphans():
    """Mata processos de import M3U/EPG orfaos que continuam rodando apos
    restart do site e seguram o banco (Lock wait timeout -> site fora do ar).

    Os processos sao iniciados com start_new_session=True, entao NAO morrem
    quando o Passenger reinicia a aplicacao.
    """
    killed = 0
    try:
        me = os.getpid()
        for pid_dir in glob.glob('/proc/[0-9]*'):
            pid = int(os.path.basename(pid_dir))
            if pid == me:
                continue
            try:
                with open(os.path.join(pid_dir, 'cmdline'), 'rb') as f:
                    cmdline = f.read().replace(b'\x00', b' ').decode('utf-8', 'replace')
            except Exception:
                continue
            if 'run_bg_update.py' not in cmdline:
                continue
            if 'troco_play_app' not in cmdline and BASE_DIR not in cmdline:
                continue
            try:
                os.kill(pid, signal.SIGTERM)
                killed += 1
            except Exception:
                pass
    except Exception as e:
        flash(f'Erro ao encerrar processos: {e}', 'error')
        return redirect(url_for('admin_m3u'))
    # Libera o progresso travado para permitir novo import
    try:
        pf = os.path.join(BASE_DIR, 'progress_m3u.json')
        with open(pf, 'w') as f:
            json.dump({'running': False, 'phase': 'idle',
                       'message': 'Processos orfaos encerrados pelo admin.',
                       'percent': 0, 'cancelled': True,
                       'timestamp': datetime.now().isoformat()}, f)
    except Exception:
        pass
    flash(f'{killed} processo(s) de importacao encerrado(s).', 'success')
    return redirect(url_for('admin_m3u'))


@app.route('/admin/epg/refresh')
@login_required
@admin_required
def admin_epg_refresh():
    """Forca atualizacao EPG via subprocess independente (nao morre com o request)."""
    try:
        python_bin = sys.executable
        _keep_last_bytes(os.path.join(BASE_DIR, 'bg_epg.log'),
                         keep_bytes=2 * 1024 * 1024, max_bytes=4 * 1024 * 1024)
        subprocess.Popen(
            [python_bin, os.path.join(BASE_DIR, 'run_bg_update.py'), 'epg'],
            cwd=BASE_DIR,
            stdout=open(os.path.join(BASE_DIR, 'bg_epg.log'), 'a'),
            stderr=subprocess.STDOUT,
            start_new_session=True,
        )
        flash('Atualizacao EPG iniciada em background. Verifique o log bg_epg.log em alguns minutos.', 'success')
    except Exception as e:
        flash(f'Erro ao iniciar atualizacao EPG: {e}', 'error')
    return redirect(url_for('admin_m3u'))


@app.route('/admin/xtream/enrich')
@login_required
@admin_required
def admin_xtream_enrich():
    """Forca enriquecimento de metadados Xtream via subprocess independente."""
    try:
        python_bin = sys.executable
        subprocess.Popen(
            [python_bin, os.path.join(BASE_DIR, 'run_bg_update.py'), 'xtream'],
            cwd=BASE_DIR,
            stdout=open(os.path.join(BASE_DIR, 'bg_xtream.log'), 'a'),
            stderr=subprocess.STDOUT,
            start_new_session=True,
        )
        flash('Enriquecimento Xtream iniciado em background. Verifique o log bg_xtream.log em alguns minutos.', 'success')
    except Exception as e:
        flash(f'Erro ao iniciar enriquecimento: {e}', 'error')
    return redirect(url_for('admin_m3u'))


@app.route('/admin/xtream/status')
@login_required
@admin_required
def admin_xtream_status():
    """Retorna status do enrichment Xtream em andamento."""
    import os
    pf = os.path.join(BASE_DIR, 'progress_xtream.json')
    try:
        if os.path.exists(pf):
            with open(pf, 'r') as f:
                return jsonify(json.load(f))
        return jsonify({'running': False, 'message': 'Nenhum enrichment em andamento', 'percent': 0})
    except Exception as e:
        return jsonify({'running': False, 'error': str(e), 'percent': 0})


@app.route('/admin/m3u/cancel')
@login_required
@admin_required
def admin_m3u_cancel():
    """Cancela a atualizacao M3U em andamento."""
    pf = os.path.join(BASE_DIR, 'progress_m3u.json')
    try:
        data = {}
        if os.path.exists(pf):
            with open(pf, 'r') as f:
                data = json.load(f)
        data['cancelled'] = True
        data['running'] = False
        data['phase'] = 'cancelled'
        data['message'] = 'Cancelado pelo administrador.'
        with open(pf, 'w') as f:
            json.dump(data, f)
        flash('Atualizacao cancelada.', 'success')
    except Exception:
        flash('Nenhuma atualizacao em andamento.', 'error')
    return redirect(url_for('admin_m3u'))


@app.route('/admin/epg/cancel')
@login_required
@admin_required
def admin_epg_cancel():
    """Cancela a atualizacao EPG em andamento."""
    pf = os.path.join(BASE_DIR, 'progress_epg.json')
    try:
        data = {}
        if os.path.exists(pf):
            with open(pf, 'r') as f:
                data = json.load(f)
        data['cancelled'] = True
        data['running'] = False
        data['phase'] = 'cancelled'
        data['message'] = 'Cancelado pelo administrador.'
        with open(pf, 'w') as f:
            json.dump(data, f)
        flash('EPG cancelado.', 'success')
    except Exception:
        flash('Nenhuma atualizacao em andamento.', 'error')
    return redirect(url_for('admin_m3u'))


@app.route('/api/m3u-progress')
@limiter.exempt
@login_required
@admin_required
@cached_api(30)  # cache 30 s: o admin faz polling deste endpoint; evita reler o arquivo a cada tick
def api_m3u_progress():
    progress_file = os.path.join(BASE_DIR, 'progress_m3u.json')
    if os.path.exists(progress_file):
        try:
            with open(progress_file, 'r') as f:
                data = json.load(f)
            return jsonify(data)
        except Exception:
            pass
    return jsonify({'running': False, 'phase': 'idle', 'message': 'Nenhuma atualizacao em andamento.', 'percent': 0, 'total': 0, 'processed': 0, 'added': 0, 'updated': 0, 'removed': 0, 'error': None})


@app.route('/api/epg-progress')
@limiter.exempt
@login_required
@admin_required
def api_epg_progress():
    progress_file = os.path.join(BASE_DIR, 'progress_epg.json')
    if os.path.exists(progress_file):
        try:
            with open(progress_file, 'r') as f:
                data = json.load(f)
            return jsonify(data)
        except Exception:
            pass
    return jsonify({'running': False, 'phase': 'idle', 'message': 'Nenhuma atualizacao em andamento.', 'percent': 0, 'total': 0, 'processed': 0, 'added': 0, 'updated': 0, 'removed': 0, 'error': None})


@app.route('/admin/m3u/upload', methods=['POST'])
@login_required
@admin_required
def admin_m3u_upload():
    """Upload manual de arquivo M3U (para hospedagens que nao conseguem baixar externamente)."""
    file = request.files.get('m3u_file')
    if not file or not file.filename:
        flash('Nenhum arquivo selecionado.', 'error')
        return redirect(url_for('admin_m3u'))
    filename_lower = file.filename.lower()
    if not (filename_lower.endswith('.m3u') or filename_lower.endswith('.txt') or filename_lower.endswith('.m3u8')):
        flash('Formato invalido. Envie um arquivo .m3u, .txt ou .m3u8.', 'error')
        return redirect(url_for('admin_m3u'))
    try:
        filepath = M3U_PATH
        temp_upload = M3U_PATH + '.upload'
        file.save(temp_upload)
        # Valida ANTES de substituir: um arquivo invalido nao pode apagar o
        # catalogo que ja esta funcionando.
        try:
            _validate_m3u_file(temp_upload)
        except Exception as validation_error:
            try:
                os.remove(temp_upload)
            except OSError:
                pass
            flash(f'Arquivo recusado: {validation_error}', 'error')
            return redirect(url_for('admin_m3u'))
        if os.path.exists(M3U_PATH):
            shutil.copy2(M3U_PATH, M3U_PATH + '.previous')
        os.replace(temp_upload, filepath)
        # Importar em background para nao timeout no cPanel
        _spawn_catalog_update('m3u_import')
        flash('Arquivo M3U salvo! Importacao iniciada em background com EPG e Xtream '
              'em seguida. Verifique o log bg_m3u.log.', 'success')
    except Exception as e:
        flash(f'Erro ao salvar M3U: {e}', 'error')
    return redirect(url_for('admin_m3u'))


@app.route('/admin/epg/upload', methods=['POST'])
@login_required
@admin_required
def admin_epg_upload():
    """Upload manual de arquivo EPG (XMLTV)."""
    file = request.files.get('epg_file')
    if not file or not file.filename:
        flash('Nenhum arquivo selecionado.', 'error')
        return redirect(url_for('admin_m3u'))
    filename_lower = file.filename.lower()
    if not (filename_lower.endswith('.xml') or filename_lower.endswith('.xmltv') or filename_lower.endswith('.gz')):
        flash('Formato invalido. Envie um arquivo .xml ou .xmltv.', 'error')
        return redirect(url_for('admin_m3u'))
    try:
        if filename_lower.endswith('.gz'):
            import gzip
            import tempfile
            temp_gz = os.path.join(BASE_DIR, 'epg_upload.gz')
            file.save(temp_gz)
            with gzip.open(temp_gz, 'rb') as gz_f:
                with tempfile.NamedTemporaryFile(delete=False, suffix='.xml') as tmp:
                    shutil.copyfileobj(gz_f, tmp)
                    temp_path = tmp.name
            try:
                os.remove(temp_gz)
            except OSError:
                pass
        else:
            temp_path = os.path.join(BASE_DIR, 'epg_upload.xml')
            file.save(temp_path)
        root = ET.parse(temp_path).getroot()
        # Parse channels from uploaded file
        channels = {}
        for ch in root.findall('.//channel'):
            ch_id = ch.get('id', '')
            display = ch.find('display-name')
            if display is not None and display.text:
                channels[ch_id] = display.text.strip()
        # Parse programs
        now = datetime.now(timezone.utc)
        cutoff = (now - timedelta(days=2)).replace(tzinfo=None)
        EPGProgram.query.filter(EPGProgram.start_time < cutoff).delete()
        db.session.commit()
        existing = {(r[0], r[1], r[2]) for r in db.session.query(
            EPGProgram.channel_id, EPGProgram.start_time, EPGProgram.title
        ).all()}
        mapping_by_channel = {
            cid: canal_id
            for cid, canal_id in db.session.query(
                ChannelEPGMapping.channel_id, ChannelEPGMapping.canal_id
            ).all()
        }
        count = 0
        batch = []
        for prog in root.findall('.//programme'):
            ch_id = prog.get('channel', '')
            start = parse_xmltv_program_time(prog, 'start')
            end = parse_xmltv_program_time(prog, 'stop')
            title_el = prog.find('title')
            desc_el = prog.find('desc')
            title = title_el.text.strip() if title_el is not None and title_el.text else ''
            desc = desc_el.text.strip() if desc_el is not None and desc_el.text else ''
            desc = clean_epg_description(desc)
            if start and start > cutoff:
                if (ch_id, start, title) in existing:
                    continue
                batch.append(EPGProgram(
                    canal_id=mapping_by_channel.get(ch_id),
                    channel_id=ch_id,
                    title=title,
                    description=desc,
                    start_time=start,
                    end_time=end,
                ))
                count += 1
                if len(batch) >= 500:
                    db.session.add_all(batch)
                    db.session.commit()
                    batch = []
        if batch:
            db.session.add_all(batch)
            db.session.commit()
        # Map channels
        matched = match_epg_to_channels(root)
        db.session.commit()
        try:
            os.remove(temp_path)
        except OSError:
            pass
        flash(f'EPG importado: {len(channels)} canais, {count} programas, {matched} mapeados.', 'success')
    except Exception as e:
        db.session.rollback()
        flash(f'Erro ao importar EPG: {e}', 'error')
    return redirect(url_for('admin_m3u'))


@app.route('/admin/reset_views', methods=['POST'])
@login_required
@admin_required
def admin_reset_views():
    try:
        # UPDATE em massa: carregar o catalogo inteiro em memoria (centenas de
        # milhares de linhas) estoura a RAM do processo Passenger.
        Canal.query.update({'view_count': 0}, synchronize_session=False)
        db.session.commit()
        invalidate_cache('admin:')
        flash('Visualizacoes zeradas!', 'success')
    except Exception as e:
        db.session.rollback()
        flash(f'Erro: {e}', 'error')
    return redirect(url_for('admin_panel'))


@app.route('/admin/reset_stats', methods=['POST'])
@login_required
@admin_required
def admin_reset_stats():
    target = request.form.get('target', '')
    try:
        if target in ('all', 'views'):
            Canal.query.update({'view_count': 0}, synchronize_session=False)
        if target in ('all', 'history'):
            WatchLog.query.delete(synchronize_session=False)
        if target == 'all':
            AuditLog.query.delete(synchronize_session=False)
            PlanHistory.query.delete(synchronize_session=False)
            Payment.query.delete(synchronize_session=False)
        db.session.commit()
        invalidate_cache('admin:')
        if target == 'all':
            flash('Todas as estatisticas, historicos e pagamentos foram zerados!', 'success')
        elif target == 'views':
            flash('Visualizacoes zeradas!', 'success')
        elif target == 'history':
            flash('Historico de visualizacao removido!', 'success')
        else:
            flash('Acao invalida.', 'error')
    except Exception as e:
        db.session.rollback()
        flash(f'Erro: {e}', 'error')
    return redirect(url_for('admin_panel'))


def _reconfigure_scheduler():
    if not SCHEDULER_ENABLED:
        return
    settings = load_settings()
    try:
        scheduler.remove_job('db_backup')
    except Exception:
        pass
    scheduler.add_job(backup_databases, 'interval',
                      seconds=settings.get('backup_interval_seconds', 3600),
                      id='db_backup')


def _admin_canais_grupos(tipo):
    """[(group_title, contagem)] de um tipo, em UMA passada GROUP BY no banco.
    Antes era um COUNT por grupo: com os 38 grupos de serie isso custava ~10 s
    para abrir /admin/canais?tipo=series. O total por grupo nao depende de
    is_hidden, entao so muda quando o M3U e reimportado ou quando o admin apaga
    canais (os dois lados chamam invalidate_cache('admin:'))."""
    return cached_query('admin:canais:grupos:%s' % tipo, 600, lambda: list(
        db.session.query(Canal.group_title, func.count(Canal.id))
        .filter(Canal.tipo == tipo)
        .group_by(Canal.group_title)
        .order_by(Canal.group_title).all()))


def _admin_catalog_totals():
    """Total de canais por tipo numa unica agrupagem (3 COUNTs separados
    varriam as 242 mil linhas do catalogo a cada clique nas abas do painel)."""
    def _conta():
        pares = dict(db.session.query(Canal.tipo, func.count(Canal.id))
                     .filter(Canal.tipo.in_(('live', 'movie', 'series')))
                     .group_by(Canal.tipo).all())
        return {t: pares.get(t, 0) for t in ('live', 'movie', 'series')}
    return cached_query('admin:canais:totais', 600, _conta)


@app.route('/admin/canais')
@login_required
@admin_required
def admin_canais():
    page = request.args.get('page', 1, type=int)
    q = request.args.get('q', '').strip()
    grp = request.args.get('group', '').strip()
    status = request.args.get('status', '').strip()
    tipo = request.args.get('tipo', 'live').strip().lower()
    if tipo not in ('live', 'movie', 'series'):
        tipo = 'live'
    now_utc = datetime.now(timezone.utc)
    query = Canal.query.filter_by(tipo=tipo)
    if q:
        query = query.filter(Canal.name.ilike(f'%{q}%'))
    if grp:
        query = query.filter(Canal.group_title == grp)
    if status == 'hidden':
        query = query.filter(Canal.is_hidden == True)
    elif status == 'visible':
        query = query.filter(Canal.is_hidden == False)
    elif status == 'online':
        query = query.join(ChannelHealth, ChannelHealth.canal_id == Canal.id).filter(ChannelHealth.is_online == True)
    elif status == 'offline':
        query = query.outerjoin(ChannelHealth, ChannelHealth.canal_id == Canal.id).filter(
            db.or_(ChannelHealth.is_online == False, ChannelHealth.canal_id == None)
        )
    canais_list = query.order_by(Canal.group_title, Canal.name).paginate(page=page, per_page=50)

    grupos_e_totais = _admin_canais_grupos(tipo)
    groups = [g for g, _ in grupos_e_totais]
    group_counts = dict(grupos_e_totais)

    total_count = Canal.query.filter_by(tipo=tipo).count()
    hidden_count = Canal.query.filter_by(tipo=tipo, is_hidden=True).count()

    canal_ids = [c.id for c in canais_list.items]
    health_map = {h.canal_id: h for h in ChannelHealth.query.filter(ChannelHealth.canal_id.in_(canal_ids)).all()}
    online_count = ChannelHealth.query.join(Canal, ChannelHealth.canal_id == Canal.id).filter(
        Canal.tipo == tipo, Canal.is_hidden == False, ChannelHealth.is_online == True
    ).count()
    offline_count = total_count - hidden_count - online_count
    catalog_label = {'live': 'Canais', 'movie': 'Filmes', 'series': 'Séries'}[tipo]
    catalog_counts = _admin_catalog_totals()

    return render_template('admin_canais.html', canais=canais_list,
                           search=q, current_group=grp, current_status=status,
                           groups=groups, group_counts=group_counts,
                           total_count=total_count, hidden_count=hidden_count,
                           health_map=health_map, tipo=tipo, catalog_label=catalog_label,
                           catalog_counts=catalog_counts,
                           online_count=online_count, offline_count=offline_count)


# ─── Admin Conteudo Adulto Routes ────────────────────────────────────
@app.route('/admin/conteudo-adulto', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_adult():
    """Nova aba: escolhe quais categorias (filmes, canais e series) sao adultas."""
    settings = load_settings()
    if request.method == 'POST':
        selected = request.form.getlist('adult_categories')
        selected = sorted({s.strip() for s in selected if s and str(s).strip()})
        settings['adult_categories'] = selected
        save_settings(settings)
        invalidate_cache('canais:groups')
        invalidate_cache('filmes:groups')
        invalidate_cache('series:groups')
        log_audit('admin_adult_config', current_user.id, details=f'categories={",".join(selected)}')
        flash(f'Categorias adultas salvas ({len(selected)} selecionada(s)).', 'success')
        return redirect(url_for('admin_adult'))
    adult_selected = set(get_adult_categories())
    # Mesma agrupagem cacheada de /admin/canais: aqui eram 3 GROUP BY gelados a
    # cada abertura da tela (1,2 s em 226 mil linhas de serie).
    cats = {tipo: [{'name': g, 'count': c, 'blocked': g in adult_selected,
                    'suggested': bool(ADULT_CATEGORY_HINT.search(g))}
                   for g, c in _admin_canais_grupos(tipo) if g]
            for tipo in ('live', 'movie', 'series')}
    return render_template('admin_adult.html', cats=cats,
                           adult_selected=sorted(adult_selected),
                           current_user_perms=get_admin_permissions_list(current_user))


@app.route('/admin/planos')
@login_required
@admin_required
def admin_planos():
    plans = Plan.query.order_by(Plan.sort_order, Plan.price).all()
    return render_template('admin_planos.html', plans=plans)


@app.route('/admin/planos/add', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_plan_add():
    if request.method == 'POST':
        key = request.form.get('key', '').strip().lower()
        name = request.form.get('name', '').strip()
        price = float(request.form.get('price', 0))
        days = int(request.form.get('days', 30))
        description = request.form.get('description', '').strip()
        benefits = request.form.get('benefits', '').strip()
        sort_order = int(request.form.get('sort_order', 0))
        is_active = request.form.get('is_active') == 'on'
        promotion_enabled = request.form.get('promotion_enabled') == 'on'
        promotion_label = request.form.get('promotion_label', '').strip()[:80]
        promo_raw = request.form.get('promotion_price', '').strip()
        promotion_price = float(promo_raw) if promo_raw else None
        promotion_until = None
        until_raw = request.form.get('promotion_until', '').strip()
        if until_raw:
            try:
                promotion_until = datetime.strptime(until_raw, '%Y-%m-%dT%H:%M').replace(tzinfo=timezone.utc)
            except ValueError:
                flash('Data de promoção inválida.', 'error')
        if promotion_price is not None and promotion_price < 0:
            flash('Preço promocional inválido.', 'error')
            promotion_price = None
        if Plan.query.filter_by(key=key).first():
            flash('Já existe um plano com essa chave.', 'error')
        else:
            plan = Plan(key=key, name=name, price=price, days=days,
                        description=description, benefits=benefits,
                        sort_order=sort_order, is_active=is_active,
                        promotion_enabled=promotion_enabled,
                        promotion_price=promotion_price,
                        promotion_label=promotion_label,
                        promotion_until=promotion_until)
            db.session.add(plan)
            db.session.commit()
            flash(f'Plano "{name}" criado com sucesso!', 'success')
            return redirect(url_for('admin_planos'))
    return render_template('admin_plan_form.html', plan=None, action='add')


@app.route('/admin/planos/edit/<int:plan_id>', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_plan_edit(plan_id):
    plan = db.session.get(Plan, plan_id)
    if not plan:
        flash('Plano não encontrado.', 'error')
        return redirect(url_for('admin_planos'))
    if request.method == 'POST':
        plan.key = request.form.get('key', plan.key).strip().lower()
        plan.name = request.form.get('name', plan.name).strip()
        plan.price = float(request.form.get('price', plan.price))
        plan.days = int(request.form.get('days', plan.days))
        plan.description = request.form.get('description', plan.description).strip()
        plan.benefits = request.form.get('benefits', plan.benefits).strip()
        plan.sort_order = int(request.form.get('sort_order', plan.sort_order))
        plan.is_active = request.form.get('is_active') == 'on'
        plan.promotion_enabled = request.form.get('promotion_enabled') == 'on'
        plan.promotion_label = request.form.get('promotion_label', '').strip()[:80]
        promo_raw = request.form.get('promotion_price', '').strip()
        plan.promotion_price = float(promo_raw) if promo_raw else None
        until_raw = request.form.get('promotion_until', '').strip()
        if until_raw:
            try:
                plan.promotion_until = datetime.strptime(until_raw, '%Y-%m-%dT%H:%M').replace(tzinfo=timezone.utc)
            except ValueError:
                flash('Data de promoção inválida.', 'error')
        else:
            plan.promotion_until = None
        if plan.promotion_price is not None and plan.promotion_price < 0:
            plan.promotion_price = None
        db.session.commit()
        flash(f'Plano "{plan.name}" atualizado!', 'success')
        return redirect(url_for('admin_planos'))
    return render_template('admin_plan_form.html', plan=plan, action='edit')


@app.route('/admin/planos/delete/<int:plan_id>', methods=['POST'])
@login_required
@admin_required
def admin_plan_delete(plan_id):
    plan = db.session.get(Plan, plan_id)
    if plan:
        db.session.delete(plan)
        db.session.commit()
        flash(f'Plano removido.', 'success')
    return redirect(url_for('admin_planos'))


@app.route('/admin/planos/toggle/<int:plan_id>')
@login_required
@admin_required
def admin_plan_toggle(plan_id):
    plan = db.session.get(Plan, plan_id)
    if plan:
        plan.is_active = not plan.is_active
        db.session.commit()
        status = 'ativado' if plan.is_active else 'desativado'
        flash(f'Plano "{plan.name}" {status}.', 'success')
    return redirect(url_for('admin_planos'))


# ─── Admin Support Routes ───────────────────────────────────────────────
@app.route('/admin/suporte')
@login_required
@admin_required
def admin_suporte():
    status_filter = request.args.get('status', 'all')
    q = request.args.get('q', '').strip()
    page = request.args.get('page', 1, type=int)
    query = SupportTicket.query
    if status_filter != 'all':
        query = query.filter_by(status=status_filter)
    if q:
        query = query.join(User).filter(User.email.ilike(f'%{q}%'))
    tickets = query.order_by(SupportTicket.updated_at.desc()).paginate(page=page, per_page=20)
    total_open = SupportTicket.query.filter_by(status='aberto').count()
    total_attendance = SupportTicket.query.filter_by(status='em_atendimento').count()
    total_closed = SupportTicket.query.filter_by(status='fechado').count()
    return render_template('admin_suporte.html', tickets=tickets,
                           status_filter=status_filter, search=q,
                           total_open=total_open, total_attendance=total_attendance,
                           total_closed=total_closed)

@app.route('/admin/suporte/<int:ticket_id>')
@login_required
@admin_required
def admin_suporte_ticket(ticket_id):
    ticket = db.session.get(SupportTicket, ticket_id)
    if not ticket:
        flash('Chamado nao encontrado.', 'error')
        return redirect(url_for('admin_suporte'))
    if request.method == 'POST':
        pass
    messages = SupportMessage.query.filter_by(ticket_id=ticket_id).order_by(SupportMessage.created_at).all()
    return render_template('admin_suporte_ticket.html', ticket=ticket, messages=messages)

@app.route('/admin/suporte/<int:ticket_id>/responder', methods=['POST'])
@login_required
@admin_required
def admin_suporte_responder(ticket_id):
    ticket = db.session.get(SupportTicket, ticket_id)
    if not ticket:
        flash('Chamado nao encontrado.', 'error')
        return redirect(url_for('admin_suporte'))
    message = request.form.get('message', '').strip()
    if message:
        msg = SupportMessage(ticket_id=ticket.id, sender_id=current_user.id, is_admin=True, message=message)
        db.session.add(msg)
        ticket.status = 'respondido'
        ticket.updated_at = datetime.now(timezone.utc)
        db.session.commit()
        create_notification(ticket.user_id, f'Resposta ao chamado #{ticket.id}', message, link=f'/suporte/{ticket.id}')
        log_audit('support_admin_reply', current_user.id, details=f'ticket={ticket.id}')
    return redirect(url_for('admin_suporte_ticket', ticket_id=ticket.id))

@app.route('/admin/suporte/<int:ticket_id>/status', methods=['POST'])
@login_required
@admin_required
def admin_suporte_status(ticket_id):
    ticket = db.session.get(SupportTicket, ticket_id)
    if ticket:
        new_status = request.form.get('status', 'aberto')
        ticket.status = new_status
        ticket.updated_at = datetime.now(timezone.utc)
        db.session.commit()
        flash(f'Status alterado para {new_status}.', 'success')
    return redirect(url_for('admin_suporte_ticket', ticket_id=ticket.id))


# ─── Admin Sugestões ───────────────────────────────────────────────────
@app.route('/admin/sugestoes')
@login_required
@admin_required
def admin_sugestoes():
    status_filter = request.args.get('status', 'pendente')
    q = request.args.get('q', '').strip()
    page = request.args.get('page', 1, type=int)
    query = Suggestion.query
    if status_filter != 'all':
        query = query.filter_by(status=status_filter)
    if q:
        query = query.join(User).filter(User.email.ilike(f'%{q}%'))
    sugs = query.order_by(Suggestion.created_at.desc()).paginate(page=page, per_page=20)
    total_pendente = Suggestion.query.filter_by(status='pendente').count()
    total_aprovado = Suggestion.query.filter_by(status='aprovado').count()
    total_rejeitado = Suggestion.query.filter_by(status='rejeitado').count()
    total_implementado = Suggestion.query.filter_by(status='implementado').count()
    return render_template('admin_sugestoes.html', sugestoes=sugs,
                           status_filter=status_filter, search=q,
                           total_pendente=total_pendente,
                           total_aprovado=total_aprovado,
                           total_rejeitado=total_rejeitado,
                           total_implementado=total_implementado)

@app.route('/admin/sugestoes/<int:sug_id>/status', methods=['POST'])
@login_required
@admin_required
def admin_sugestao_status(sug_id):
    sug = db.session.get(Suggestion, sug_id)
    if sug:
        novo_status = request.form.get('status', 'pendente')
        admin_note = request.form.get('admin_note', '').strip()
        sug.status = novo_status
        if admin_note:
            sug.admin_note = admin_note
        db.session.commit()
        log_audit('admin_suggestion_status', current_user.id,
                  details=f'sugestao={sug_id}, status={novo_status}')
        create_notification(sug.user_id, f'Sugestao #{sug.id}: {sug.status}',
                            f'Sua sugestao "{sug.titulo}" foi atualizada para "{novo_status}".',
                            link='/sugestoes')
        flash(f'Sugestao #{sug.id} atualizada para "{novo_status}".', 'success')
    return redirect(url_for('admin_sugestoes'))


# ─── Admin Maintenance Routes ──────────────────────────────────────────
@app.route('/admin/manutencao', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_maintenance():
    settings = load_settings()
    if request.method == 'POST':
        action = request.form.get('action', '')
        if action == 'toggle':
            settings['maintenance_enabled'] = not settings.get('maintenance_enabled', False)
            if settings['maintenance_enabled']:
                log_audit('maintenance_on', current_user.id)
                flash('Manutencao ATIVADA.', 'success')
            else:
                log_audit('maintenance_off', current_user.id)
                flash('Manutencao DESATIVADA.', 'success')
        elif action == 'save':
            settings['maintenance_message'] = request.form.get('message', settings.get('maintenance_message', '')).strip()
            settings['maintenance_start'] = request.form.get('start', '').strip()
            settings['maintenance_end'] = request.form.get('end', '').strip()
            settings['maintenance_enabled'] = request.form.get('enabled') == 'on'
            settings['maintenance_immediate'] = request.form.get('immediate') == 'on'
            settings['maintenance_category'] = request.form.get('category', 'all') or 'all'
            # Secoes granulares
            sections = []
            for s in ('canais', 'filmes', 'series', 'tv_canais', 'tv_filmes', 'tv_series', 'app_mobile', 'app_tv'):
                if request.form.get(f'section_{s}') == 'on':
                    sections.append(s)
            settings['maintenance_sections'] = sections
            # Plataformas alvo
            platforms = []
            for p in ('site', 'app_mobile', 'app_tv', 'celular'):
                if request.form.get(f'platform_{p}') == 'on':
                    platforms.append(p)
            settings['maintenance_platforms'] = platforms
            # Paginas explicitas (rotas/endpoint keys)
            pages = [s.strip() for s in (request.form.get('pages', '') or '').split(',') if s.strip()]
            settings['maintenance_pages'] = pages
            flash('Configuracoes de manutencao salvas.', 'success')
            log_audit('maintenance_config', current_user.id, details=f'enabled={settings["maintenance_enabled"]}, cat={settings["maintenance_category"]}, sections={sections}, platforms={platforms}')
        save_settings(settings)
        return redirect(url_for('admin_maintenance'))
    # Contexto para o template
    maintenance_pages = ','.join(settings.get('maintenance_pages', []) or [])
    return render_template('admin_maintenance.html', settings=settings,
                           maintenance_pages=maintenance_pages,
                           maintenance_categories=[
                               ('all', 'Todas as abas (global)'),
                               ('site', 'Site (apenas abas web)'),
                               ('app', 'App (apenas APIs/endpoints mobile+TV)'),
                               ('canais', 'Categoria: Canais'),
                               ('filmes', 'Categoria: Filmes'),
                               ('series', 'Categoria: Series'),
                               ('esportes', 'Categoria: Esportes'),
                           ])


# ─── Admin Videos (intro) ──────────────────────────────────────────
def _extract_youtube_id(url):
    """Extrai o ID de 11 caracteres de qualquer formato de URL do YouTube."""
    if not url:
        return ''
    s = url.strip()
    # youtu.be/<id>
    m = re.search(r'youtu\.be/([A-Za-z0-9_-]{11})', s)
    if m:
        return m.group(1)
    # youtube.com/embed/<id>, /v/<id>, /shorts/<id>, /watch?v=<id>
    m = re.search(r'youtube\.com/(?:embed|v|shorts)/([A-Za-z0-9_-]{11})', s)
    if m:
        return m.group(1)
    m = re.search(r'[?&]v=([A-Za-z0-9_-]{11})', s)
    if m:
        return m.group(1)
    # bare ID
    if re.fullmatch(r'[A-Za-z0-9_-]{11}', s):
        return s
    return ''


@app.route('/admin/videos', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_videos():
    videos = get_admin_videos()
    if request.method == 'POST':
        action = request.form.get('action', '')
        if action == 'create':
            url = request.form.get('youtube_url', '').strip()
            url_or_id = request.form.get('youtube_id', '').strip()
            yid = _extract_youtube_id(url) or _extract_youtube_id(url_or_id)
            title = request.form.get('title', '').strip() or 'Apresentação Troço Play'
            description = request.form.get('description', '').strip()
            locations = request.form.getlist('locations')
            sort_order = int(request.form.get('sort_order', '0') or 0)
            is_active = request.form.get('is_active', 'on') == 'on'
            autoplay = request.form.get('autoplay', '') == 'on'
            show_controls = request.form.get('show_controls', 'on') == 'on'
            if not yid:
                flash('Informe um link ou ID valido do YouTube.', 'error')
            else:
                existing = VideoIntro.query.filter_by(youtube_id=yid).first()
                if existing:
                    flash('Já existe um vídeo com esse ID. Edite o existente.', 'error')
                else:
                    v = VideoIntro(
                        title=title,
                        description=description,
                        youtube_id=yid,
                        locations=','.join(locations),
                        sort_order=sort_order,
                        is_active=is_active,
                        autoplay=autoplay,
                        show_controls=show_controls,
                    )
                    db.session.add(v)
                    db.session.commit()
                    log_audit('admin_video_create', current_user.id, details=f'id={yid} title={title} locs={locations}')
                    flash('Vídeo adicionado com sucesso.', 'success')
            return redirect(url_for('admin_videos'))
        elif action == 'edit':
            vid = request.form.get('id', type=int)
            v = db.session.get(VideoIntro, vid) if vid else None
            if not v:
                flash('Vídeo não encontrado.', 'error')
            else:
                url = request.form.get('youtube_url', '').strip()
                url_or_id = request.form.get('youtube_id', '').strip()
                yid = _extract_youtube_id(url) or _extract_youtube_id(url_or_id) or v.youtube_id
                v.title = request.form.get('title', v.title).strip() or v.title
                v.description = request.form.get('description', '').strip()
                v.youtube_id = yid
                v.locations = ','.join(request.form.getlist('locations'))
                try:
                    v.sort_order = int(request.form.get('sort_order', '0') or 0)
                except (TypeError, ValueError):
                    pass
                v.is_active = request.form.get('is_active', '') == 'on'
                v.autoplay = request.form.get('autoplay', '') == 'on'
                v.show_controls = request.form.get('show_controls', '') == 'on'
                db.session.commit()
                log_audit('admin_video_edit', current_user.id, details=f'id={vid} youtube_id={yid}')
                flash('Vídeo atualizado.', 'success')
            return redirect(url_for('admin_videos'))
        elif action == 'delete':
            vid = request.form.get('id', type=int)
            v = db.session.get(VideoIntro, vid) if vid else None
            if v:
                db.session.delete(v)
                db.session.commit()
                log_audit('admin_video_delete', current_user.id, details=f'id={vid}')
                flash('Vídeo removido.', 'success')
            return redirect(url_for('admin_videos'))
        elif action == 'toggle':
            vid = request.form.get('id', type=int)
            v = db.session.get(VideoIntro, vid) if vid else None
            if v:
                v.is_active = not v.is_active
                db.session.commit()
                log_audit('admin_video_toggle', current_user.id, details=f'id={vid} active={v.is_active}')
                flash(f'Vídeo {"ativado" if v.is_active else "ocultado"}.', 'success')
            return redirect(url_for('admin_videos'))
    return render_template('admin_videos.html', videos=videos, video_locations=VIDEO_LOCATIONS)


@app.route('/admin/enviar-email', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_enviar_email():
    """Interface para envio de e-mails em massa pelo admin."""
    recent = QueuedEmail.query.order_by(QueuedEmail.created_at.desc()).limit(20).all()
    stats = {
        'total': QueuedEmail.query.count(),
        'pending': QueuedEmail.query.filter_by(status='pending').count(),
        'sending': QueuedEmail.query.filter_by(status='sending').count(),
        'sent': QueuedEmail.query.filter_by(status='sent').count(),
        'failed': QueuedEmail.query.filter_by(status='failed').count(),
    }
    counts = {
        'all': User.query.count(),
        'free': User.query.filter_by(is_paid=False, is_banned=False, is_active_user=True).count(),
        'paid': User.query.filter_by(is_paid=True, is_banned=False, is_active_user=True).count(),
        'banned': User.query.filter_by(is_banned=True).count(),
        'inactive': User.query.filter_by(is_active_user=False).count(),
    }
    # Filtra apenas remetentes com email configurado
    email_senders = [(k, label, email) for k, label, email in EMAIL_SENDERS if email]

    if request.method == 'POST':
        action = request.form.get('action', '')
        subject = request.form.get('subject', '').strip()
        body = request.form.get('body', '').strip()
        rtype = request.form.get('recipients_type', 'custom')
        r_emails = request.form.get('recipients_emails', '').strip()
        delay = max(2, min(30, int(request.form.get('delay_seconds', 5) or 5)))
        scheduled = request.form.get('scheduled_at', '').strip()
        test_email = request.form.get('test_email', '').strip()
        sender_key = request.form.get('sender_email', 'default')

        if action == 'preview':
            if not subject or not body:
                flash('Preencha título e mensagem para visualizar.', 'error')
            else:
                flash(f'Prévia gerada para: {subject}', 'success')
            return render_template('admin_enviar_email.html',
                                   stats=stats, counts=counts, recent=recent,
                                   email_senders=email_senders,
                                   form=dict(subject=subject, body=body, rtype=rtype,
                                             r_emails=r_emails, delay=delay,
                                             scheduled=scheduled, sender_key=sender_key),
                                   preview=True,
                                   preview_subject=subject, preview_body=body)

        elif action == 'test':
            if not test_email or not subject or not body:
                flash('Informe um e-mail de teste, título e mensagem.', 'error')
            else:
                sender, username, password = _get_sender_creds(sender_key)
                sent = send_user_email(
                    test_email,
                    f'[TESTE] {subject}',
                    body,
                    sender=sender,
                    username=username,
                    password=password
                )
                if sent:
                    flash(f'E-mail de teste enviado para {test_email}.', 'success')
                else:
                    flash(f'Falha ao enviar teste para {test_email}. Verifique a configuração SMTP.', 'error')
            return render_template('admin_enviar_email.html',
                                   stats=stats, counts=counts, recent=recent,
                                   email_senders=email_senders,
                                   form=dict(subject=subject, body=body, rtype=rtype,
                                             r_emails=r_emails, delay=delay,
                                             scheduled=scheduled, sender_key=sender_key))

        elif action == 'send' or action == 'schedule':
            if not subject or not body:
                flash('Título e mensagem são obrigatórios.', 'error')
                return redirect(url_for('admin_enviar_email'))

            # Agendar ou enviar imediatamente
            sched_dt = None
            if action == 'schedule' and scheduled:
                try:
                    sched_dt = datetime.fromisoformat(scheduled).replace(tzinfo=timezone.utc)
                except (ValueError, TypeError):
                    flash('Data/hora de agendamento inválida.', 'error')
                    return redirect(url_for('admin_enviar_email'))

            # Contar destinatários
            q = User.query.filter_by(is_active_user=True, is_banned=False)
            if rtype == 'all':
                recipients = [u.email for u in q.all() if u.email]
            elif rtype == 'free':
                recipients = [u.email for u in q.filter_by(is_paid=False).all() if u.email]
            elif rtype == 'paid':
                recipients = [u.email for u in q.filter_by(is_paid=True).all() if u.email]
            elif rtype == 'banned':
                recipients = [u.email for u in User.query.filter_by(is_banned=True).all() if u.email]
            elif rtype == 'inactive':
                recipients = [u.email for u in User.query.filter_by(is_active_user=False).all() if u.email]
            else:  # custom
                emails = [e.strip() for e in r_emails.split(',') if e.strip() and '@' in e.strip()]
                recipients = emails

            if not recipients:
                flash('Nenhum destinatário encontrado para os critérios selecionados.', 'error')
                return redirect(url_for('admin_enviar_email'))

            # Criar registro na fila
            qe = QueuedEmail(
                subject=subject,
                body=body,
                recipients_type=rtype,
                recipients_emails=','.join(recipients) if rtype == 'custom' else '',
                sender_email=sender_key,
                status='pending' if sched_dt else 'sending',
                scheduled_at=sched_dt,
                total_recipients=len(recipients),
                delay_seconds=delay,
                created_by=current_user.id,
            )
            db.session.add(qe)
            db.session.commit()
            log_audit('admin_email_queued', current_user.id,
                      details=f'id={qe.id} subject={subject} recipients={len(recipients)} sender={sender_key}')

            if not sched_dt:
                import threading
                def _send_bulk(qid):
                    with app.app_context():
                        qe_obj = db.session.get(QueuedEmail, qid)
                        if not qe_obj:
                            return
                        qe_obj.status = 'sending'
                        db.session.commit()
                        sender, username, password = _get_sender_creds(qe_obj.sender_email or 'default')
                        recipients_list = [e.strip() for e in qe_obj.recipients_emails.split(',') if e.strip()]
                        if qe_obj.recipients_type != 'custom':
                            q = User.query.filter_by(is_active_user=True, is_banned=False)
                            if qe_obj.recipients_type == 'free':
                                q = q.filter_by(is_paid=False)
                            elif qe_obj.recipients_type == 'paid':
                                q = q.filter_by(is_paid=True)
                            elif qe_obj.recipients_type == 'banned':
                                q = User.query.filter_by(is_banned=True)
                            elif qe_obj.recipients_type == 'inactive':
                                q = User.query.filter_by(is_active_user=False)
                            recipients_list = [u.email for u in q.all() if u.email]
                        errs = []
                        for i, email in enumerate(recipients_list):
                            try:
                                send_user_email(
                                    email,
                                    qe_obj.subject,
                                    qe_obj.body,
                                    sender=sender,
                                    username=username,
                                    password=password,
                                    sender_key=qe_obj.sender_email or 'default',
                                    created_by=qe_obj.created_by
                                )
                            except Exception as ex:
                                errs.append(f'{email}: {ex}')
                            if i < len(recipients_list) - 1:
                                _time.sleep(qe_obj.delay_seconds)
                            qe_obj.sent_count = i + 1 - len(errs)
                            qe_obj.failed_count = len(errs)
                            db.session.commit()
                        qe_obj.status = 'sent' if errs and len(errs) == len(recipients_list) else 'sent'
                        if errs and len(errs) < len(recipients_list):
                            qe_obj.status = 'sent'
                        elif not errs:
                            qe_obj.status = 'sent'
                        else:
                            qe_obj.status = 'failed'
                        qe_obj.sent_at = datetime.now(timezone.utc)
                        qe_obj.error_log = '\n'.join(errs[-50:])
                        db.session.commit()
                        log_audit('admin_email_sent', current_user.id,
                                  details=f'id={qid} sent={qe_obj.sent_count} failed={qe_obj.failed_count}')

                threading.Thread(target=_send_bulk, args=(qe.id,), daemon=True).start()
                flash(f'Enviando e-mail para {len(recipients)} destinatários em segundo plano. {delay}s entre envios.', 'success')
            else:
                flash(f'E-mail agendado para {sched_dt.strftime("%d/%m/%Y %H:%M")} ({len(recipients)} destinatários).', 'success')
            return redirect(url_for('admin_enviar_email'))

        elif action == 'cancel':
            qid = request.form.get('id', type=int)
            qe = db.session.get(QueuedEmail, qid) if qid else None
            if qe and qe.status in ('pending', 'sending'):
                qe.status = 'cancelled'
                db.session.commit()
                flash('Envio cancelado.', 'success')
            return redirect(url_for('admin_enviar_email'))

    return render_template('admin_enviar_email.html',
                           stats=stats, counts=counts, recent=recent,
                           email_senders=email_senders,
                           form=dict(subject='', body='', rtype='custom',
                                     r_emails='', delay=5, scheduled='',
                                     sender_key='default'),
                           preview=False)


@app.route('/admin/update-app', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_update_app():
    settings = load_settings()
    if request.method == 'POST':
        settings['app_version'] = request.form.get('app_version', '1.0').strip()
        settings['app_update_required'] = request.form.get('app_update_required') == 'on'
        settings['app_update_url'] = request.form.get('app_update_url', '').strip()
        settings['app_update_changelog'] = request.form.get('app_update_changelog', '').strip()
        settings['apk_size'] = request.form.get('apk_size', '6.0 MB').strip()
        settings['app_release_date'] = request.form.get('app_release_date', '').strip()
        save_settings(settings)
        log_audit('admin_update_app', current_user.id, details=f'version={settings["app_version"]}')
        flash('Configuracoes de atualizacao salvas.', 'success')
        return redirect(url_for('admin_update_app'))
    return render_template('admin_update_app.html', settings=settings)


@app.route('/admin/whatsapp', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_whatsapp():
    settings = load_settings()
    if request.method == 'POST':
        payment = re.sub(r'[^0-9]', '', request.form.get('payment_whatsapp', '').strip())
        support = re.sub(r'[^0-9]', '', request.form.get('support_whatsapp', '').strip())
        if not payment or not support or len(payment) < 10 or len(support) < 10:
            flash('Informe numeros de WhatsApp validos (minimo 10 digitos).', 'error')
        else:
            settings['payment_whatsapp'] = payment
            settings['support_whatsapp'] = support
            save_settings(settings)
            log_audit('admin_whatsapp', current_user.id, details=f'payment={payment}, support={support}')
            flash('Numeros de WhatsApp atualizados com sucesso!', 'success')
            return redirect(url_for('admin_whatsapp'))
    return render_template('admin_whatsapp.html', settings=settings)


@app.route('/admin/cakto', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_cakto():
    settings = load_settings()
    plans = Plan.query.filter_by(is_active=True).order_by(Plan.sort_order).all()
    try:
        offer_ids = json.loads(settings.get('cakto_offer_ids') or '{}')
    except (json.JSONDecodeError, TypeError):
        offer_ids = {}
    if request.method == 'POST':
        settings['payment_method'] = request.form.get('payment_method', 'whatsapp')
        settings['payment_whatsapp'] = re.sub(r'[^0-9]', '', request.form.get('payment_whatsapp', '').strip())
        settings['support_whatsapp'] = re.sub(r'[^0-9]', '', request.form.get('support_whatsapp', '').strip())
        settings['cakto_client_id'] = request.form.get('cakto_client_id', '').strip()
        settings['cakto_client_secret'] = request.form.get('cakto_client_secret', '').strip()
        settings['cakto_webhook_secret'] = request.form.get('cakto_webhook_secret', '').strip()
        new_offer_ids = {}
        for p in plans:
            val = request.form.get(f'cakto_offer_{p.key}', '').strip()
            if val:
                new_offer_ids[p.key] = val
        settings['cakto_offer_ids'] = json.dumps(new_offer_ids)
        save_settings(settings)
        log_audit('admin_cakto', current_user.id, details=f"method={settings['payment_method']}")
        flash('Configuracoes de pagamento salvas com sucesso!', 'success')
        return redirect(url_for('admin_cakto'))
    return render_template('admin_cakto.html', settings=settings, plans=plans, offer_ids=offer_ids)


@app.route('/admin/cakto/test', methods=['POST'])
@login_required
@admin_required
def admin_cakto_test():
    settings = load_settings()
    cid = request.json.get('client_id') or settings.get('cakto_client_id', '') if request.is_json else settings.get('cakto_client_id', '')
    csecret = request.json.get('client_secret') or settings.get('cakto_client_secret', '') if request.is_json else settings.get('cakto_client_secret', '')
    if not cid or not csecret:
        return jsonify({'ok': False, 'error': 'Client ID e Client Secret sao obrigatorios.'})
    try:
        r = requests.post(
            'https://api.cakto.com.br/public_api/token/',
            data={'client_id': cid, 'client_secret': csecret},
            headers={'Content-Type': 'application/x-www-form-urlencoded'},
            timeout=10,
        )
        if r.status_code == 200:
            data = r.json()
            if data.get('access_token'):
                return jsonify({'ok': True})
        return jsonify({'ok': False, 'error': f'HTTP {r.status_code}: {r.text[:200]}'})
    except Exception as e:
        return jsonify({'ok': False, 'error': str(e)})


@app.route('/admin/free_hours', methods=['POST'])
@login_required
@admin_required
def admin_free_hours():
    settings = load_settings()
    try:
        hours = float(request.form.get('free_daily_hours', 4))
        hours = max(0.5, min(24, hours))
    except (ValueError, TypeError):
        hours = 4
    settings['free_daily_hours'] = hours
    save_settings(settings)
    new_limit = int(hours * 3600)
    User.query.filter(User.is_paid == False, User.is_admin == False).update(
        {'free_daily_limit': new_limit}, synchronize_session=False
    )
    db.session.commit()
    log_audit('admin_free_hours', current_user.id, details=f'hours={hours}')
    flash(f'Horas free definidas para {hours}h/dia para todos os usuarios free.', 'success')
    return redirect(url_for('admin_panel'))


@app.route('/admin/notificar', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_notify():
    settings = load_settings()
    if request.method == 'POST':
        target = request.form.get('target', 'all')
        title = request.form.get('title', '').strip()
        message = request.form.get('message', '').strip()
        link = request.form.get('link', '').strip()
        channel = request.form.get('channel', 'both')
        send_email = channel in ('both', 'email')
        save_to_db = channel in ('both', 'notification')
        if not title or not message:
            flash('Titulo e mensagem sao obrigatorios.', 'error')
        else:
            if target == 'all':
                users = User.query.filter(User.is_banned == False).all()
                count = 0
                for u in users:
                    create_notification(u.id, title, message, link, send_email=send_email, save_to_db=save_to_db)
                    count += 1
                log_audit('notify_all', current_user.id, details=f'count={count}, title={title}, channel={channel}')
                flash(f'Notificacao enviada para {count} usuarios.', 'success')
            elif target == 'premium':
                users = User.query.filter(User.is_paid == True, User.is_banned == False).all()
                count = 0
                for u in users:
                    create_notification(u.id, title, message, link, send_email=send_email, save_to_db=save_to_db)
                    count += 1
                log_audit('notify_premium', current_user.id, details=f'count={count}, title={title}, channel={channel}')
                flash(f'Notificacao enviada para {count} usuarios premium.', 'success')
            elif target == 'free':
                users = User.query.filter(User.is_paid == False, User.is_admin == False, User.is_banned == False).all()
                count = 0
                for u in users:
                    create_notification(u.id, title, message, link, send_email=send_email, save_to_db=save_to_db)
                    count += 1
                log_audit('notify_free', current_user.id, details=f'count={count}, title={title}, channel={channel}')
                flash(f'Notificacao enviada para {count} usuarios free.', 'success')
            elif target == 'specific':
                email_addr = request.form.get('email', '').strip().lower()
                user = User.query.filter_by(email=email_addr).first()
                if user:
                    create_notification(user.id, title, message, link, send_email=send_email, save_to_db=save_to_db)
                    log_audit('notify_user', current_user.id, details=f'to={email_addr}, title={title}, channel={channel}')
                    flash(f'Notificacao enviada para {email_addr}.', 'success')
                else:
                    flash(f'Usuario "{email_addr}" nao encontrado.', 'error')
            return redirect(url_for('admin_notify'))
    total_users = User.query.filter(User.is_banned == False).count()
    total_paid = User.query.filter(User.is_paid == True, User.is_banned == False).count()
    total_free = User.query.filter(User.is_paid == False, User.is_admin == False, User.is_banned == False).count()
    return render_template('admin_notify.html',
                           total_users=total_users, total_paid=total_paid,
                           total_free=total_free)


# ─── Admin Terms Acceptance ───────────────────────────────────────────────
@app.route('/admin/termos')
@login_required
@admin_required
def admin_terms():
    page = request.args.get('page', 1, type=int)
    q = request.args.get('q', '').strip()
    query = TermsAcceptance.query.join(User)
    if q:
        query = query.filter(User.email.ilike(f'%{q}%'))
    acceptances = query.order_by(TermsAcceptance.accepted_at.desc()).paginate(page=page, per_page=30)
    return render_template('admin_termos.html', acceptances=acceptances, search=q)


@app.route('/admin/termos/<int:acceptance_id>/download')
@login_required
@admin_required
def admin_terms_download(acceptance_id):
    acceptance = db.session.get(TermsAcceptance, acceptance_id)
    if not acceptance:
        flash('Registro nao encontrado.', 'error')
        return redirect(url_for('admin_terms'))

    user = acceptance.user
    br_dt = to_brasilia(acceptance.accepted_at)
    date_str = br_dt.strftime('%d/%m/%Y %H:%M:%S') if br_dt else '-'

    content = f"""================================================================================
                    TROÇO PLAY - COMPROVANTE DE ACEITE DOS TERMOS
================================================================================

DADOS DO USUARIO:
  ID: #{user.id}
  Email: {user.email}
  Cadastro: {to_brasilia(user.created_at).strftime('%d/%m/%Y %H:%M') if user.created_at else '-'}

DADOS DA ACEITACAO:
  Data/Hora: {date_str}
  Endereco IP: {acceptance.ip_address or '-'}
  Dispositivo: {acceptance.device_info or '-'}
  User-Agent: {acceptance.user_agent or '-'}
  Versao dos Termos: {acceptance.terms_version or '1.0'}

TERMOS ACEITOS:
  [X] Termos de Uso
  [X] Politica de Privacidade
  [X] Termos de Servico
  [X] Programa de Afiliados

================================================================================
Este documento comprova que o usuario acima aceitou os Termos de Uso,
Politica de Privacidade, Termos de Servico e Programa de Afiliados
do Troço Play na data e hora indicadas.

Documento gerado automaticamente em {datetime.now(timezone.utc).strftime('%d/%m/%Y %H:%M:%S')}.
================================================================================
"""
    filename = f'termos_aceite_{user.id}_{acceptance.id}.txt'
    return Response(
        content,
        mimetype='text/plain; charset=utf-8',
        headers={'Content-Disposition': f'attachment; filename={filename}'}
    )


@app.route('/admin/termos/editar')
@login_required
@admin_required
def admin_terms_edit_list():
    terms = Term.query.order_by(Term.id).all()
    return render_template('admin_editar_termos.html', terms=terms)


@app.route('/admin/termos/editar/<slug>', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_terms_edit(slug):
    term = Term.query.filter_by(slug=slug).first()
    if not term:
        flash('Termo nao encontrado.', 'error')
        return redirect(url_for('admin_terms_edit_list'))
    if request.method == 'POST':
        title = request.form.get('title', '').strip()
        raw_content = request.form.get('content', '').strip()
        version = request.form.get('version', '').strip() or term.version
        allowed_tags = ['h1','h2','h3','h4','h5','h6','p','br','ul','ol','li','strong','em','a','u','s','span','div','blockquote','pre','code','hr','table','thead','tbody','tr','th','td']
        allowed_attrs = {'a': ['href','target','rel'], 'span': ['style'], 'div': ['style'], 'th': ['style','colspan','rowspan'], 'td': ['style','colspan','rowspan']}
        if HAS_BLEACH:
            content = bleach.clean(raw_content, tags=allowed_tags, attributes=allowed_attrs, strip=True)
        else:
            content = raw_content
        if not title:
            flash('O titulo e obrigatorio.', 'error')
        else:
            term.title = title
            term.content = content
            term.version = version
            term.updated_at = datetime.now(timezone.utc)
            db.session.commit()
            flash(f'"{term.title}" atualizado com sucesso!', 'success')
            return redirect(url_for('admin_terms_edit_list'))
    return render_template('admin_editar_termos.html', term=term, terms=None)


@app.route('/admin/cookies')
@login_required
@admin_required
def admin_cookies():
    page = request.args.get('page', 1, type=int)
    q = request.args.get('q', '').strip()
    query = CookieConsent.query.outerjoin(User)
    if q:
        query = query.filter(User.email.ilike(f'%{q}%'))
    consents = query.order_by(CookieConsent.consented_at.desc()).paginate(page=page, per_page=30)
    return render_template('admin_cookies.html', consents=consents, search=q)


@app.route('/admin/cookies/<int:consent_id>/download')
@login_required
@admin_required
def admin_cookies_download(consent_id):
    consent = db.session.get(CookieConsent, consent_id)
    if not consent:
        flash('Registro nao encontrado.', 'error')
        return redirect(url_for('admin_cookies'))

    user = consent.user
    br_dt = to_brasilia(consent.consented_at)
    date_str = br_dt.strftime('%d/%m/%Y %H:%M:%S') if br_dt else '-'

    content = f"""================================================================================
              TROÇO PLAY - COMPROVANTE DE CONSENTIMENTO DE COOKIES
================================================================================

DADOS DO USUARIO:
  {'  ID: #' + str(user.id) if user else '  Visitante (nao logado)'}
  {'  Email: ' + user.email if user else ''}

DADOS DO CONSENTIMENTO:
  Data/Hora: {date_str}
  Endereco IP: {consent.ip_address or '-'}
  Dispositivo: {consent.device_info or '-'}
  User-Agent: {consent.user_agent or '-'}
  Token: {consent.consent_token[:16]}...

================================================================================
Este documento comprova que o {'usuario ' + user.email if user else 'visitante'}
consentiu com o uso de cookies na plataforma Troço Play na data e hora indicadas.

Documento gerado automaticamente em {datetime.now(timezone.utc).strftime('%d/%m/%Y %H:%M:%S')}.
================================================================================
"""
    filename = f'cookies_consentimento_{consent.id}.txt'
    return Response(
        content,
        mimetype='text/plain; charset=utf-8',
        headers={'Content-Disposition': f'attachment; filename={filename}'}
    )


@app.route('/admin/user/export-consent')
@login_required
@admin_required
def admin_user_consent_export():
    email = request.args.get('email', '').strip().lower()
    export_type = request.args.get('type', 'all').strip().lower()
    if not email:
        flash('Informe o email do usuario.', 'error')
        return redirect(url_for('admin_terms'))

    user = User.query.filter_by(email=email).first()
    if not user:
        flash(f'Usuario "{email}" nao encontrado.', 'error')
        return redirect(url_for('admin_terms'))

    include_terms = export_type in ('all', 'terms')
    include_cookies = export_type in ('all', 'cookies')

    terms = []
    cookies = []
    if include_terms:
        terms = TermsAcceptance.query.filter_by(user_id=user.id).order_by(TermsAcceptance.accepted_at.desc()).all()
    if include_cookies:
        cookies = CookieConsent.query.filter_by(user_id=user.id).order_by(CookieConsent.consented_at.desc()).all()

    lines = []
    lines.append('=' * 80)
    if export_type == 'terms':
        lines.append('  TROÇO PLAY - EXPORTACAO DE TERMOS DE USO')
    elif export_type == 'cookies':
        lines.append('  TROÇO PLAY - EXPORTACAO DE CONSENTIMENTO DE COOKIES')
    else:
        lines.append('  TROÇO PLAY - EXPORTACAO DE CONSENTIMENTOS')
    lines.append('=' * 80)
    lines.append(f'')
    lines.append(f'  Usuario: {user.email} (ID: #{user.id})')
    lines.append(f'  Cadastro: {to_brasilia(user.created_at).strftime("%d/%m/%Y %H:%M") if user.created_at else "-"}')
    lines.append(f'')
    if include_terms:
        lines.append(f'  Total de aceitacoes de termos: {len(terms)}')
    if include_cookies:
        lines.append(f'  Total de consentimentos de cookies: {len(cookies)}')
    lines.append(f'')
    lines.append('=' * 80)
    lines.append(f'')

    if include_terms:
        if terms:
            lines.append('--- TERMOS DE USO ---')
            lines.append(f'')
            for i, t in enumerate(terms, 1):
                br = to_brasilia(t.accepted_at)
                dt = br.strftime('%d/%m/%Y %H:%M:%S') if br else '-'
                lines.append(f'  Registro #{i} (ID: {t.id}):')
                lines.append(f'    Data/Hora: {dt}')
                lines.append(f'    IP: {t.ip_address or "-"}')
                lines.append(f'    Dispositivo: {t.device_info or "-"}')
                lines.append(f'    User-Agent: {t.user_agent or "-"}')
                lines.append(f'    Versao: {t.terms_version or "1.0"}')
                lines.append(f'    Termos Aceitos:')
                lines.append(f'      [{"X" if t.accepted_terms else " "}] Termos de Uso')
                lines.append(f'      [{"X" if t.accepted_privacy else " "}] Politica de Privacidade')
                lines.append(f'      [{"X" if t.accepted_service else " "}] Termos de Servico')
                lines.append(f'      [{"X" if t.accepted_affiliates else " "}] Programa de Afiliados')
                lines.append(f'')
        else:
            lines.append('--- TERMOS DE USO ---')
            lines.append(f'  Nenhum registro encontrado.')
            lines.append(f'')

    if include_cookies:
        if cookies:
            lines.append('--- COOKIES ---')
            lines.append(f'')
            for i, c in enumerate(cookies, 1):
                br = to_brasilia(c.consented_at)
                dt = br.strftime('%d/%m/%Y %H:%M:%S') if br else '-'
                lines.append(f'  Registro #{i} (ID: {c.id}):')
                lines.append(f'    Data/Hora: {dt}')
                lines.append(f'    IP: {c.ip_address or "-"}')
                lines.append(f'    Dispositivo: {c.device_info or "-"}')
                lines.append(f'    User-Agent: {c.user_agent or "-"}')
                lines.append(f'    Token: {(c.consent_token or "-")[:16]}...')
                lines.append(f'')
        else:
            lines.append('--- COOKIES ---')
            lines.append(f'  Nenhum registro encontrado.')
            lines.append(f'')

    lines.append('=' * 80)
    lines.append('  Documento gerado automaticamente em')
    lines.append(f'  {datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S")} UTC.')
    lines.append('=' * 80)

    content = '\n'.join(lines)
    type_tag = {'all': 'consentimentos', 'terms': 'termos', 'cookies': 'cookies'}.get(export_type, 'consentimentos')
    filename = f'{type_tag}_{user.id}_{user.email.replace("@","_")}.txt'

    return Response(
        content,
        mimetype='text/plain; charset=utf-8',
        headers={'Content-Disposition': f'attachment; filename={filename}'}
    )


@app.route('/api/accept_cookies', methods=['POST'])
def api_accept_cookies():
    ip = get_client_ip()
    ua = request.headers.get('User-Agent', '')[:500]
    device = parse_device_info(ua)
    consent_token = secrets.token_hex(32)
    user_id = current_user.id if current_user.is_authenticated else None
    consent = CookieConsent(
        user_id=user_id, ip_address=ip, device_info=device,
        user_agent=ua, consent_token=consent_token,
    )
    db.session.add(consent)
    db.session.commit()
    return jsonify({'ok': True, 'consent_token': consent_token})


# ─── Admin Social CRUD ──────────────────────────────────────────────────────────

DEFAULT_SOCIAL_LINKS = [
    {'name': 'Telegram', 'url': 'https://t.me/TrocoPlayCanal',
     'icon_svg': '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.127.087.497.087.497l-1.716 8.176a.667.667 0 0 1-.266.385.604.604 0 0 1-.453.044c-.056-.016-1.327-.524-2.579-1.226-.337-.19-.344-.362-.07-.56.182-.132 1.128-1.012 1.748-1.6.02-.02.035-.038.043-.05.1-.123.07-.257-.036-.35a1.23 1.23 0 0 0-.261-.148c-.4-.19-3.744-2.285-3.828-2.39-.09-.11-.112-.232-.014-.368.084-.117.24-.168.379-.152.178.02 2.341.718 3.79 1.22.449.156.824.28 1.143.377.244.074.43.131.568.175.089.028.159.05.21.067.033.01.06.018.08.025.06.02.108.038.142.047.085.024.142.04.172.056.05.026.076.055.076.055s-.005.012-.02.036c-.008.012-.022.025-.04.04-.13.12-2.606 2.226-3.483 2.96-.04.034-.071.06-.09.077-.09.08-.176.158-.176.158s.006.002.018.008l.488.164c.766.248 2.09.678 2.564.823.276.085.5.124.647.142.28.034.434.017.53-.054.177-.13.402-.544.56-.999.076-.22.22-.657.256-.844.048-.23.099-.51.15-.806.228-1.326.456-2.656.683-3.985.064-.38.122-.726.175-1.04.028-.167.054-.322.078-.47.5-.12.513-.128.547-.147z"/></svg>', 'sort_order': 1},
    {'name': 'WhatsApp', 'url': 'https://whatsapp.com/channel/0029VbDXL6I2ZjCsE2xLgC2I',
     'icon_svg': '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>', 'sort_order': 2},
    {'name': 'Instagram', 'url': 'https://www.instagram.com/troco_play',
     'icon_svg': '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/></svg>', 'sort_order': 3},
]


@app.route('/admin/social')
@login_required
@admin_required
def admin_social():
    links = SocialLink.query.order_by(SocialLink.sort_order).all()
    return render_template('admin_social.html', links=links)


@app.route('/admin/social/add', methods=['POST'])
@login_required
@admin_required
def admin_social_add():
    name = request.form.get('name', '').strip()
    url = request.form.get('url', '').strip()
    icon_svg = request.form.get('icon_svg', '').strip()
    sort_order = request.form.get('sort_order', 0, type=int)
    if not name or not url:
        flash('Nome e URL sao obrigatorios.', 'error')
        return redirect(url_for('admin_social'))
    link = SocialLink(name=name, url=url, icon_svg=icon_svg, sort_order=sort_order)
    db.session.add(link)
    db.session.commit()
    flash(f'Rede social "{name}" adicionada!', 'success')
    return redirect(url_for('admin_social'))


@app.route('/admin/social/edit/<int:link_id>', methods=['POST'])
@login_required
@admin_required
def admin_social_edit(link_id):
    link = db.session.get(SocialLink, link_id)
    if not link:
        flash('Link nao encontrado.', 'error')
        return redirect(url_for('admin_social'))
    link.name = request.form.get('name', link.name).strip()
    link.url = request.form.get('url', link.url).strip()
    link.icon_svg = request.form.get('icon_svg', link.icon_svg).strip()
    link.sort_order = request.form.get('sort_order', link.sort_order, type=int)
    link.is_active = request.form.get('is_active') == 'on'
    db.session.commit()
    flash(f'"{link.name}" atualizado!', 'success')
    return redirect(url_for('admin_social'))


@app.route('/admin/social/delete/<int:link_id>', methods=['POST'])
@login_required
@admin_required
def admin_social_delete(link_id):
    link = db.session.get(SocialLink, link_id)
    if link:
        db.session.delete(link)
        db.session.commit()
        flash('Link removido.', 'success')
    return redirect(url_for('admin_social'))


@app.route('/admin/social/toggle/<int:link_id>')
@login_required
@admin_required
def admin_social_toggle(link_id):
    link = db.session.get(SocialLink, link_id)
    if link:
        link.is_active = not link.is_active
        db.session.commit()
        status = 'ativado' if link.is_active else 'desativado'
        flash(f'"{link.name}" {status}.', 'success')
    return redirect(url_for('admin_social'))


# ─── Admin Backup ───────────────────────────────────────────────────────────
@app.route('/admin/backup', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_backup():
    if request.method == 'POST':
        action = request.form.get('action', '')
        if action == 'force':
            backup_databases()
            flash('Backup forcado realizado com sucesso!', 'success')
        elif action == 'set_interval':
            interval = request.form.get('interval', 60, type=int)
            if interval < 10:
                interval = 10
            settings = load_settings()
            settings['backup_interval_seconds'] = interval
            save_settings(settings)
            _reconfigure_scheduler()
            flash(f'Intervalo de backup alterado para {interval} segundos.', 'success')
        return redirect(url_for('admin_backup'))

    settings = load_settings()
    interval = settings.get('backup_interval_seconds', 3600)
    os.makedirs(BACKUP_DIR, exist_ok=True)

    backups = []
    for f in sorted(os.listdir(BACKUP_DIR), reverse=True):
        fp = os.path.join(BACKUP_DIR, f)
        if not os.path.isfile(fp):
            continue
        size = os.path.getsize(fp)
        mtime = datetime.fromtimestamp(os.path.getmtime(fp))
        if f.startswith('troco_play_'):
            ftype = 'db'
        elif f.startswith('settings_'):
            ftype = 'settings'
        elif f.startswith('canais_'):
            ftype = 'm3u'
        else:
            ftype = 'other'
        backups.append({'filename': f, 'size': size, 'modified': mtime, 'type': ftype})

    return render_template('admin_backup.html', interval=interval, backups=backups)


@app.route('/admin/backup/download/<filename>')
@login_required
@admin_required
def admin_backup_download(filename):
    filepath = os.path.join(BACKUP_DIR, filename)
    if not os.path.exists(filepath) or not filepath.startswith(os.path.abspath(BACKUP_DIR)):
        flash('Arquivo nao encontrado.', 'error')
        return redirect(url_for('admin_backup'))
    return send_file(filepath, as_attachment=True, download_name=filename)


@app.route('/admin/backup/delete/<filename>', methods=['POST'])
@login_required
@admin_required
def admin_backup_delete(filename):
    filepath = os.path.join(BACKUP_DIR, filename)
    if os.path.exists(filepath) and filepath.startswith(os.path.abspath(BACKUP_DIR)):
        os.remove(filepath)
        flash(f'{filename} excluido.', 'success')
    return redirect(url_for('admin_backup'))


# ─── Init ──────────────────────────────────────────────────────────────────
def ensure_schema():
    """Idempotent schema migration. Safe to run on every startup.
    Never drops data. Only adds missing tables/columns."""
    from sqlalchemy import text, inspect as sa_inspect
    inspector = sa_inspect(db.engine)
    existing_tables = set(inspector.get_table_names())

    for table_name, model in db.metadata.tables.items():
        if table_name not in existing_tables:
            model.create(db.engine)
            print(f'[Migration] Tabela "{table_name}" criada.', flush=True)

    for table_name, model in db.metadata.tables.items():
        if table_name not in existing_tables:
            continue
        try:
            db_cols = {c['name'] for c in inspector.get_columns(table_name)}
        except Exception:
            continue
        for col in model.columns:
            if col.name not in db_cols:
                col_type = str(col.type.compile(db.engine.dialect)).upper()
                if hasattr(col.type, 'length') and col.type.length:
                    col_type = f"{col.type.__class__.__name__.upper()}({col.type.length})"
                is_datetime = 'DATETIME' in col_type or 'TIMESTAMP' in col_type
                default_clause = ''
                if is_datetime:
                    default_clause = 'DEFAULT NULL'
                elif col.default is not None and hasattr(col.default, 'arg'):
                    val = col.default.arg
                    if isinstance(val, bool):
                        default_clause = f"DEFAULT {1 if val else 0}"
                    elif isinstance(val, (int, float)):
                        default_clause = f"DEFAULT {val}"
                    else:
                        default_clause = f"DEFAULT '{val}'"
                nullable = '' if col.nullable else 'NOT NULL'
                try:
                    # Backticks: `user` e outras palavras reservadas do MySQL
                    # quebram o ALTER sem quoting (falha silenciosa -> 500 depois).
                    db.session.execute(text(
                        f"ALTER TABLE `{table_name}` ADD COLUMN `{col.name}` {col_type} {default_clause} {nullable}"
                    ))
                    print(f'[Migration] Coluna "{table_name}.{col.name}" adicionada.', flush=True)
                except Exception as e:
                    if 'duplicate column' not in str(e).lower():
                        print(f'[Migration] Erro ao adicionar {table_name}.{col.name}: {e}', flush=True)
    try:
        db.session.commit()
    except Exception:
        db.session.rollback()

    try:
        db.session.execute(text('CREATE UNIQUE INDEX IF NOT EXISTS uq_user_streaming_source_index ON user_streaming (source_index)'))
        db.session.commit()
    except Exception as e:
        db.session.rollback()
        print(f'[Migration] Índice de fontes M3U não criado: {e}', flush=True)

    try:
        existing_canal_indexes = {idx['name'] for idx in sa_inspect(db.engine).get_indexes('canal')}
        for index_name, columns in {
            'ix_canal_vod_visibility_series': '(tipo, is_hidden, series_name)',
            'ix_canal_vod_visibility_group': '(tipo, is_hidden, group_title)',
            # "Mais Assistidos" e o top do painel ordenam por view_count: sem
            # indice isso varria as 240 mil linhas do catalogo ( ate 1,5 s ).
            'ix_canal_view_count': '(view_count)',
        }.items():
            if index_name not in existing_canal_indexes:
                db.session.execute(text(f'CREATE INDEX {index_name} ON canal {columns}'))
        db.session.commit()
    except Exception as e:
        db.session.rollback()
        print(f'[Migration] Índices VOD não criados: {e}', flush=True)

    try:
        for tbl, col in [('payment', 'expires_at'), ('payment', 'paid_at'),
                         ('user', 'paid_until'), ('user', 'last_free_reset'),
                         ('session_token', 'expires_at'), ('session_token', 'created_at'),
                         ('active_session', 'started_at'), ('active_session', 'last_heartbeat'),
                         ('plan_history', 'started_at'), ('plan_history', 'expires_at'),
                         ('audit_log', 'created_at'), ('notification', 'created_at'),
                         ('support_ticket', 'created_at'), ('support_ticket', 'updated_at'),
                         ('support_message', 'created_at'), ('watch_log', 'started_at'),
                         ('watch_log', 'last_seen'), ('report', 'created_at'),
                         ('report', 'resolved_at'), ('canal', 'data_added'),
                         ('suggestion', 'created_at')]:
            try:
                db.session.execute(text(
                    f"UPDATE `{tbl}` SET `{col}` = NULL WHERE `{col}` = ''"
                ))
            except Exception:
                pass
        db.session.commit()
    except Exception:
        db.session.rollback()

    # Fix NULL is_hidden values (MySQL DEFAULT NULL vs SQLAlchemy False)
    try:
        db.session.execute(text('UPDATE canal SET is_hidden = 0 WHERE is_hidden IS NULL'))
        db.session.commit()
    except Exception:
        db.session.rollback()

    # App metadata: sincroniza settings.json com o APK empacotado, uma
    # unica vez por versao (sem mascarar nem reverter edicoes do admin).
    try:
        settings_data = load_settings()
        changed = False
        if settings_data.get('app_version') != APK_META['app_version']:
            for key, val in APK_META.items():
                if settings_data.get(key) != val:
                    settings_data[key] = val
                    changed = True
        for key, val in {'update_interval_minutes': 15, 'epg_update_interval_minutes': 30}.items():
            if settings_data.get(key) != val:
                settings_data[key] = val
                changed = True
        # Force m3u_plus format for ALL M3U URLs (logos, categories, descriptions)
        def _ensure_m3u_plus(url):
            if not url or not url.strip():
                return url
            u = url.strip()
            if 'type=m3u_plus' in u:
                return u
            if 'type=m3u' in u:
                return u.replace('type=m3u', 'type=m3u_plus')
            sep = '&' if '?' in u else '?'
            return f'{u}{sep}type=m3u_plus'
        m3u_url = settings_data.get('m3u_url', '')
        new_m3u_url = _ensure_m3u_plus(m3u_url)
        if new_m3u_url != m3u_url:
            settings_data['m3u_url'] = new_m3u_url
            changed = True
        m3u_urls = settings_data.get('m3u_urls', [])
        if m3u_urls:
            new_urls = [_ensure_m3u_plus(u) for u in m3u_urls]
            if new_urls != m3u_urls:
                settings_data['m3u_urls'] = new_urls
                changed = True
        if changed:
            save_settings(settings_data)
            print(f"[Migration] settings.json atualizado (app v{APK_META['app_version']}, m3u_plus).", flush=True)
    except Exception as e:
        print(f'[Migration] Erro ao atualizar settings.json: {e}', flush=True)

    # Ensure session is clean before querying
    try:
        db.session.rollback()
    except Exception:
        pass

    # ── Varreduras de catalogo (logos ruins + mappings EPG órfãos) ──
    # As duas percorrem a tabela canal inteira e custam ~7 s de CPU em cada
    # boot; só fazem sentido depois que o catálogo mudou, então rodam uma vez
    # por assinatura. Enquanto o catálogo é o mesmo, o worker já nasce pronto
    # para responder o primeiro clique.
    if _boot_sweeps_ja_rodados():
        print('[Migration] Logos e mappings EPG já conferidos neste catálogo.', flush=True)
    else:
        try:
            from sqlalchemy import text as _text
            # Remove logos that are just filenames (no http)
            db.session.execute(_text(
                "UPDATE canal SET logo = '' WHERE logo IS NOT NULL AND logo != '' AND logo NOT LIKE 'http%'"
            ))
            db.session.commit()
            print('[Migration] Logos sanitizados (filenames removidos).', flush=True)
        except Exception as e:
            db.session.rollback()
            print(f'[Migration] Erro ao sanitizar logos: {e}', flush=True)

        # ── Fix EPG mapping: delete orphan mappings (canal was deleted by M3U reimport) ──
        try:
            from sqlalchemy import text as _text
            # Subquery funciona em MySQL e SQLite (o DELETE ... USING/JOIN acima
            # so existe no MySQL e falhava no ambiente local).
            db.session.execute(_text(
                'DELETE FROM channel_epg_mapping '
                'WHERE canal_id IS NOT NULL AND canal_id NOT IN (SELECT id FROM canal)'
            ))
            db.session.commit()
            print('[Migration] Mappings EPG órfãos removidos.', flush=True)
        except Exception as e:
            db.session.rollback()
            print(f'[Migration] Erro ao limpar mappings EPG: {e}', flush=True)
        _marcar_boot_sweeps()

    # ── EPG: normalizar schema de horario (UTC) e reimportar em background ──
    # A partir de v2026-09 o EPG e armazenado em UTC. Se o banco ainda tiver
    # dados do schema antigo (horario de Brasilia), limpa tudo e reimporta
    # automaticamente para eliminar o desvio de 3h de uma vez.
    # utc-v8: forca UMA limpeza + reimportacao final no proximo boot com o
    # parser corrigido (04/09): o provedor envia wall-clock UTC rotulado
    # -0300; o parser agora IGNORA o offset (antes aplicava e gravava tudo
    # 3h atrasado). Garante que nenhuma linha antiga permaneça no banco.
    try:
        settings_data = load_settings()
        epg_schema_ok = settings_data.get('epg_schema', '') == 'utc-v8'
        if not epg_schema_ok:
            settings_data['epg_schema'] = 'utc-v8'
            save_settings(settings_data)
        epg_count = EPGProgram.query.count()
        needs_reimport = (epg_count == 0) or (not epg_schema_ok and epg_count > 0)
        if needs_reimport:
            if epg_count > 0 and not epg_schema_ok:
                try:
                    EPGProgram.query.delete()
                    db.session.commit()
                    print('[Migration] EPG antigo removido — reimportando com horario UTC.', flush=True)
                except Exception as wexc:
                    db.session.rollback()
                    print(f'[Migration] Erro ao limpar EPG antigo: {wexc}', flush=True)
            # No cPanel NAO reimportamos EPG no boot: com catalogo grande isso
            # trava/estoura memoria do processo Passenger e derruba o site.
            if os.environ.get('CPANEL_MODE', '').lower() in {'1', 'true', 'yes'}:
                print('[Migration] Reimportacao EPG no boot desativada no cPanel; use Admin > M3U/EPG ou o Cron.', flush=True)
            else:
                import threading
                def _bg_epg():
                    with app.app_context():
                        try:
                            result = update_epg()
                            print(f'[BG-EPG] Reimportado: {result}', flush=True)
                        except Exception as e:
                            print(f'[BG-EPG] Erro: {e}', flush=True)
                threading.Thread(target=_bg_epg, daemon=True).start()
                print('[Migration] Reimportacao EPG iniciada em background (schema UTC).', flush=True)
    except Exception as e:
        db.session.rollback()
        print(f'[Migration] Aviso EPG: {e}', flush=True)

    # ── EPG: dedupe unico no banco herdado (duplicatas/sobreposicoes da fonte) ──
    try:
        s2 = load_settings()
        if s2.get('epg_clean') != 'v2':
            s2['epg_clean'] = 'v2'
            save_settings(s2)
            from types import SimpleNamespace as _NS2
            _removed = 0
            _ch_ids = [r[0] for r in db.session.query(EPGProgram.channel_id).distinct().all()]
            for _cid in _ch_ids:
                _rows = EPGProgram.query.filter_by(channel_id=_cid).all()
                if len(_rows) <= 1:
                    continue
                _items = [_NS2(id=r.id, title=r.title, start_time=r.start_time, end_time=r.end_time) for r in _rows]
                _kept = _dedupe_programs(_items)
                if len(_kept) < len(_items):
                    _keep_ids = {k.id for k in _kept}
                    for _r in _rows:
                        if _r.id not in _keep_ids:
                            db.session.delete(_r)
                            _removed += 1
            db.session.commit()
            if _removed:
                print(f'[Migration] EPG: {_removed} duplicatas/sobreposicoes removidas do banco.', flush=True)
    except Exception as e:
        db.session.rollback()
        print(f'[Migration] Aviso dedupe EPG: {e}', flush=True)

    if not Term.query.first():
        for slug, data in DEFAULT_TERMS.items():
            db.session.add(Term(slug=slug, title=data['title'], content=data['content'], version='1.0'))
        db.session.commit()
        print('[Seed] Termos legais padrao inseridos.', flush=True)

    if not SocialLink.query.first():
        for data in DEFAULT_SOCIAL_LINKS:
            db.session.add(SocialLink(name=data['name'], url=data['url'], icon_svg=data['icon_svg'], sort_order=data['sort_order']))
        db.session.commit()
        print('[Seed] Links de redes sociais padrao inseridos.', flush=True)

    # ── Seed video institucional (intro) padrão ──
    try:
        if VideoIntro.query.count() == 0:
            v = VideoIntro(
                title='Bem-vindo ao Troço Play',
                description='Conheça o Troço Play, sua plataforma IPTV favorita.',
                youtube_id='XBLqbXjTY08',
                locations='landing,login,register',
                sort_order=0,
                is_active=True,
                autoplay=False,
                show_controls=True,
            )
            db.session.add(v)
            db.session.commit()
            print('[Seed] Video institucional (intro) padrao inserido.', flush=True)
    except Exception as e:
        db.session.rollback()
        print(f'[Seed] Aviso video intro: {e}', flush=True)


with app.app_context():
    db.create_all()
    # Inicializar arquivo de progresso Xtream
    try:
        pf = os.path.join(BASE_DIR, 'progress_xtream.json')
        if not os.path.exists(pf):
            with open(pf, 'w') as f:
                json.dump({'running': False, 'message': 'Pronto', 'percent': 0}, f)
    except Exception:
        pass
    if not DATABASE_URL or DATABASE_URL.startswith('sqlite'):
        try:
            db.session.execute(db.text('PRAGMA journal_mode=WAL'))
            db.session.execute(db.text('PRAGMA busy_timeout=5000'))
            db.session.commit()
        except Exception:
            pass
    ensure_schema()
    if os.environ.get('CPANEL_MODE', '').lower() in {'1', 'true', 'yes'}:
        print('[Migration] Saneamento no boot desativado no cPanel.', flush=True)
    elif _catalog_ja_saneado():
        # O saneamento ja roda logo apos cada importacao do M3U; varrer as
        # 242 mil linhas de novo a cada boot deixava o worker do Passenger
        # minutos em 100% de CPU antes de responder o primeiro clique.
        print('[Migration] Catalogo ja saneado (mesma assinatura): nada a varrer.', flush=True)
    else:
        repaired_titles = repair_existing_catalog_metadata()
        if repaired_titles:
            print(f'[Migration] Catálogo saneado: {repaired_titles} alterações.', flush=True)

    if not Plan.query.first():
        print('[Boot] Nenhum plano encontrado no banco. Execute migrate_plans.sql ou crie via Admin > Planos.', flush=True)

    if ADMIN_EMAIL and ADMIN_PASSWORD and not User.query.filter_by(email=ADMIN_EMAIL).first():
        admin = User(email=ADMIN_EMAIL, is_admin=True,
                     is_paid=True, last_free_reset=datetime.now(timezone.utc).date())
        admin.set_password(ADMIN_PASSWORD)
        db.session.add(admin)
        db.session.commit()
    elif not ADMIN_EMAIL or not ADMIN_PASSWORD:
        print('ADMIN_EMAIL/ADMIN_PASSWORD não configurados; bootstrap administrativo ignorado.', flush=True)

    total = Canal.query.count()
    if total == 0 and os.path.exists(M3U_PATH):
        if os.environ.get('CPANEL_MODE', '').lower() in {'1', 'true', 'yes'}:
            print('[Bootstrap] Catálogo vazio no cPanel; importação automática no boot desativada. Execute a atualização M3U pelo painel ou Cron.', flush=True)
        else:
            print(f'DB vazio. Importando {M3U_PATH}...', flush=True)
            c = import_m3u_full_sync(M3U_PATH)
            print(f'Importados {c["added"]} itens.', flush=True)

    # ── Auto-reparo de categorias / classificacao (group_title + tipo) ──
    # v2: restaurava grupos reais quando tudo era CANAIS/FILMES/SÉRIES.
    # v3: re-sincroniza UMA vez para reclassificar canais ao vivo (HBO,
    #     TELECINE, grupos "FILMES & SERIES" etc) que a versao antiga
    #     mandava para a aba de filmes/series por causa do nome do grupo.
    try:
        s3 = load_settings()
        if s3.get('m3u_groups') != 'v3':
            s3['m3u_groups'] = 'v3'
            save_settings(s3)
            if total > 0:
                # No cPanel NAO re-sincronizamos M3U no boot: baixar/importar
                # ~300k itens dentro do processo do site deixa a app sem
                # responder e o Passenger passa a devolver 500 em tudo.
                if os.environ.get('CPANEL_MODE', '').lower() in {'1', 'true', 'yes'}:
                    # Reparo barato (sem baixar M3U): reagrupa episodios que
                    # viraram series separadas pelo regex antigo de SxxExx.
                    try:
                        if s3.get('series_regroup') != 'v1':
                            s3['series_regroup'] = 'v1'
                            save_settings(s3)
                            repair_split_series_episodes()
                    except Exception as _rexc:
                        db.session.rollback()
                        print(f'[Migration] Aviso reparo de series: {_rexc}', flush=True)
                    print('[Migration] Re-sync M3U no boot desativada no cPanel; use Admin > M3U/EPG ou o Cron.', flush=True)
                else:
                    print('[Migration] Classificacao nova (URL live); re-sincronizando M3U em background...', flush=True)
                    import threading as _th
                    def _bg_m3u():
                        with app.app_context():
                            try:
                                update_m3u_from_url()
                                print('[BG-M3U] Re-sync concluido (categorias/classificacao corrigidas).', flush=True)
                            except Exception as e:
                                print(f'[BG-M3U] Erro: {e}', flush=True)
                    _th.Thread(target=_bg_m3u, daemon=True).start()
    except Exception as e:
        db.session.rollback()
        print(f'[Migration] Aviso auto-reparo M3U: {e}', flush=True)


def _expire_pending_payments_loop():
    """Roda em background, expira pagamentos pendentes a cada 60s e envia notificacoes."""
    while True:
        time.sleep(60)
        try:
            with app.app_context():
                _expire_pending_payments()
        except Exception:
            pass


_expire_thread = threading.Thread(target=_expire_pending_payments_loop, daemon=True)
_expire_thread.start()

# ─── Status do sistema (modulo separado) ────────────────────────────────
# O modulo precisa dos nomes deste app (models, config, helpers). Em vez de um
# app factory, encaminhamos tudo como atributos e registramos as rotas aqui.
import status_module as _status_mod

for _name, _value in (
    ('db', db),
    ('APP', app),
    ('BASE_DIR', BASE_DIR),
    ('BRASILIA_TZ', BRASILIA_TZ),
    ('APP_START_TS', _APP_START_TS),
    ('Canal', Canal),
    ('ChannelHealth', ChannelHealth),
    ('EPGProgram', EPGProgram),
    ('ChannelEPGMapping', ChannelEPGMapping),
    ('M3USourceLog', M3USourceLog),
    ('WatchLog', WatchLog),
    ('ActiveSession', ActiveSession),
    ('User', User),
    ('Payment', Payment),
    ('Notification', Notification),
    ('SupportTicket', SupportTicket),
    ('load_settings', load_settings),
    ('detect_platform', detect_platform),
    ('apply_adult_block', apply_adult_block),
    ('user_blocks_adult', user_blocks_adult),
    ('_naive_as_utc', _naive_as_utc),
    ('_cron_auth', _cron_auth),
    ('status_maintenance_state', _status_maintenance_state),
):
    setattr(_status_mod, _name, _value)

_status_mod.STATUS_HISTORY_PATH = os.path.join(BASE_DIR, 'instance', 'status_history.jsonl')
_status_mod.CRON_STATE_PATH = os.path.join(BASE_DIR, 'instance', 'cron_state.json')

app.add_url_rule('/status', endpoint='status', view_func=_status_mod.status_page,
                 methods=['GET'])
app.add_url_rule('/api/status', endpoint='api_status', view_func=_status_mod.api_status,
                 methods=['GET'])
# Painel tecnico: o cliente ve so a projecao simples de /api/status; aqui fica
# tudo (servicos, cron, latencia, arquivos, logs) e so administrador acessa.
app.add_url_rule('/admin/status', endpoint='admin_status',
                 view_func=admin_required(_status_mod.admin_status_page), methods=['GET'])
app.add_url_rule('/api/admin/status', endpoint='api_admin_status',
                 view_func=admin_required(_status_mod.api_admin_status), methods=['GET'])
app.add_url_rule('/cron/status-sample', endpoint='cron_status_sample',
                 view_func=_status_mod.cron_status_sample, methods=['GET'])
del _name, _value

_record_cron_run = _status_mod._record_cron_run
_cron_state = _status_mod._cron_state
_status_sample_fn = _status_mod.status_sample_tick


def _warm_series_caches():
    """Series sempre prontas no primeiro clique.

    Agrupar as 226 mil linhas do catalogo e contar series distintas custa
    ~2 s no banco frio; o resultado fica 15 min em memoria. Sem este laço o
    primeiro clique depois de cada pausa do calculo pagava o preço gelado (é o
    "abre devagar" que o usuario sente ao voltar ao site). Rodamos em segundo
    plano pouco antes de o cache vencer, no mesmo ritmo de 8 em 8 minutos."""
    import time as _wtime
    while True:
        _w0 = _wtime.time()
        try:
            _wtime.sleep(1)
            with app.app_context():
                _contagens_catalogo()
                cached_swrc('admin:catalog_counts', 120, _admin_catalog_counts)
                _series_total('', '', 0)
                for _pp in (30, 10):
                    _paged = _series_listing('', '', '', 1, _pp, False)
                    # Capas da 1a pagina tambem: a consulta em lote das 30 series
                    # é a parte mais cara de abrir /series com o banco gelado.
                    _series_representative_logos([s.name for s in _paged.items])
                cached_query('series:groups', 600, _series_groups_raw)
            print('[Warmup] Series prontas em %d ms.' % int((_wtime.time() - _w0) * 1000), flush=True)
        except Exception as exc:
            print('[Warmup] Series seguem geladas no primeiro clique: %s' % exc, flush=True)
        _wtime.sleep(480)


import threading as _warm_th
_warm_th.Thread(target=_warm_series_caches, daemon=True).start()


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5001, debug=False, use_reloader=False, threaded=True)
