#!/usr/bin/env python3
"""
Modulo "Funil WhatsApp PX3".

Puxa conversas WhatsApp do Linkia PX3 (GHL) e monta o funil:
chegou -> conversou -> reuniao marcada -> venda -> perdido.

Classificacao em 2 camadas (SEM IA na v1):
  1) opportunity + pipeline stage do GHL (dado explicito da vendedora).
  2) heuristica de palavras-chave nas ultimas mensagens.

Expoe:
  - buscar_conversas_whatsapp(headers_conv, location_id, since_ms, until_ms)
  - buscar_mensagens(conv_id, headers_conv)
  - classificar_conversa(conv, pipelines_meta, msgs_cache=None, headers_conv=None)
  - montar_funil(conversas, pipelines_meta, inicio_br, fim_br, enriquecer_msgs=True,
                 headers_conv=None)
"""
from __future__ import annotations

import re
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from typing import Iterable

import requests


TZ_BR = ZoneInfo("America/Sao_Paulo")
TZ_UTC = ZoneInfo("UTC")

GHL_BASE = "https://services.leadconnectorhq.com"

# --------------------------------------------------------------
# Classificacao por STAGE (dado da vendedora)
# --------------------------------------------------------------
# Checagem feita por substring no nome do stage (lowercase, sem acento).
STAGE_TOKENS_VENDA = (
    "ganhamos", "ganho", "ganha", "comprou", "venda", "vendido", "fechad",
    "pago", "cliente", "ganhou",
)
STAGE_TOKENS_PERDIDO = (
    "perdid", "perdemos", "perda", "desqualific", "invalid", "nao vai",
    "nao responderam", "sem resposta",
)
STAGE_TOKENS_REUNIAO = (
    "reuniao", "reuniao diagnostico", "diagnostico", "agendad", "agendou",
    "agendamento", "demo agendada", "call", "meeting",
    "qualificado",  # qualificado = ja esta avancado o suficiente p/ marcar reuniao
    "qualificacao", "proposta", "negocia", "com consultor",
)
STAGE_TOKENS_CONVERSANDO = (
    "em conversa", "conversando", "respondeu mensagem", "contatado",
)

# Stages que indicam SO que o lead recebeu disparo (ainda nao respondeu).
# NAO classifica como "conversou" automaticamente, so se tiver inbound real.
STAGE_TOKENS_RECEBEU_SO = (
    "novo lead", "recebeu a mensagem", "recebeu mensagem", "novo contato",
    "novo", "lead",
)

# Status GHL (opp.status)
STATUS_WON = "won"
STATUS_LOST = "lost"
STATUS_ABANDONED = "abandoned"
STATUS_OPEN = "open"


# --------------------------------------------------------------
# Heuristica por mensagem (fallback quando nao ha opp)
# --------------------------------------------------------------
MSG_TOKENS_REUNIAO = (
    "agendei", "agendamos", "marquei", "marcamos", "agendado", "marcado",
    "amanha as", "hoje as", "zoom", "google meet", "meet.google",
    "reuniao marcada", "podemos marcar", "vamos marcar",
    "te chamo as", "te ligo as", "ligamos as",
)
MSG_TOKENS_VENDA = (
    "paguei", "pagamento feito", "paguei agora", "comprovante",
    "fiz o pix", "pix feito", "assinei", "fechei", "comprei",
    "sou cliente", "assinatura ok",
)

# Dias pra considerar "perdido/abandonado" (sem resposta)
DIAS_ABANDONO = 14


# ============================================================
# Fetch conversas WhatsApp (paginado via startAfterDate)
# ============================================================
def buscar_conversas_whatsapp(
    headers_conv: dict,
    location_id: str,
    since_ms: int,
    until_ms: int,
    max_pages: int = 30,
    page_size: int = 100,
    logger=None,
) -> list[dict]:
    """
    Pagina /conversations/search com lastMessageType=TYPE_WHATSAPP, ordenado
    por last_message_date desc. Pega SO as conversas cujo `dateAdded`
    (criacao da conversa / chegada do lead) esta dentro de [since_ms, until_ms].

    Como o sort e por last_message_date, a gente para quando `lastMessageDate`
    cair abaixo de since_ms E ao mesmo tempo `dateAdded` tambem (margem de
    seguranca: paginamos ate `lastMessageDate < since_ms - 90d` ou acabar).

    Dedupe por conversation id.
    """
    convs: list[dict] = []
    vistos: set[str] = set()
    cursor = None
    paginas = 0
    safety_floor_ms = max(0, since_ms - 90 * 24 * 3600 * 1000)  # 90d antes

    while paginas < max_pages:
        paginas += 1
        params = {
            "locationId": location_id,
            "limit": page_size,
            "lastMessageType": "TYPE_WHATSAPP",
            "sortBy": "last_message_date",
            "sort": "desc",
        }
        if cursor:
            params["startAfterDate"] = cursor

        try:
            r = requests.get(
                f"{GHL_BASE}/conversations/search",
                headers=headers_conv,
                params=params,
                timeout=30,
            )
            # SEMPRE consumir o body (gotcha GHL)
            try:
                data = r.json()
            except Exception:
                data = {}
            if r.status_code == 429:
                if logger:
                    logger.warning("funil_whatsapp: 429, aguardando 2s")
                time.sleep(2)
                continue
            if r.status_code != 200:
                if logger:
                    logger.warning(
                        f"funil_whatsapp: {r.status_code} params={params} data={str(data)[:200]}"
                    )
                break
        except Exception as e:
            if logger:
                logger.warning(f"funil_whatsapp: erro request {e}")
            break

        page_convs = data.get("conversations") or []
        if not page_convs:
            break

        last_message_date_min = None
        for c in page_convs:
            cid = c.get("id")
            if not cid or cid in vistos:
                continue
            vistos.add(cid)
            convs.append(c)
            lmd = int(c.get("lastMessageDate") or 0)
            if last_message_date_min is None or lmd < last_message_date_min:
                last_message_date_min = lmd

        # cursor = sort[0] do ultimo item
        last_item = page_convs[-1]
        sort_arr = last_item.get("sort") or []
        new_cursor = sort_arr[0] if sort_arr else None
        if not new_cursor or new_cursor == cursor:
            break
        cursor = new_cursor

        # Se a pagina ja esta MUITO abaixo do piso, para
        if last_message_date_min and last_message_date_min < safety_floor_ms:
            break

        time.sleep(0.1)

    return convs


def buscar_mensagens(conv_id: str, headers_conv: dict, limit: int = 20, timeout: int = 15) -> list[dict]:
    """
    GET /conversations/{id}/messages -> ultimas N mensagens.
    Retorna lista ou [] em erro.
    """
    try:
        r = requests.get(
            f"{GHL_BASE}/conversations/{conv_id}/messages",
            headers=headers_conv,
            params={"limit": limit},
            timeout=timeout,
        )
        try:
            data = r.json()
        except Exception:
            data = {}
        if r.status_code != 200:
            return []
    except Exception:
        return []
    # GHL retorna {"messages": {"messages": [...]}} as vezes, as vezes direto
    msgs = data.get("messages")
    if isinstance(msgs, dict):
        msgs = msgs.get("messages") or []
    return msgs or []


# ============================================================
# Classificacao
# ============================================================
def _norm(s: str) -> str:
    if not s:
        return ""
    # strip acentos simples
    repl = (("á","a"),("ã","a"),("â","a"),("à","a"),("é","e"),("ê","e"),
            ("í","i"),("ó","o"),("ô","o"),("õ","o"),("ú","u"),("ç","c"),
            ("Á","a"),("Ã","a"),("Â","a"),("É","e"),("Ê","e"),("Í","i"),
            ("Ó","o"),("Ô","o"),("Õ","o"),("Ú","u"),("Ç","c"))
    out = s
    for a, b in repl:
        out = out.replace(a, b)
    return out.lower().strip()


def _match_any(txt: str, tokens: Iterable[str]) -> bool:
    t = _norm(txt)
    if not t:
        return False
    for tok in tokens:
        if tok in t:
            return True
    return False


def _stage_name_from_opp(opp: dict, pipelines_meta: dict) -> str:
    """pipelines_meta = {pipeline_id: {name, stages_map: {stage_id: name}}}"""
    pid = opp.get("pipelineId")
    sid = opp.get("pipelineStageId")
    pm = (pipelines_meta or {}).get(pid) or {}
    smap = pm.get("stages_map") or {}
    return smap.get(sid, "")


def _classifica_por_opps(opps: list[dict], pipelines_meta: dict) -> tuple[str, str]:
    """
    Retorna (categoria, motivo) baseado nas opportunities.
    categoria in {venda, perdido, reuniao, conversou, None}
    motivo = string curta (nome do stage + status).

    Regra de prioridade entre MULTIPLAS opps:
      venda > reuniao > perdido > conversou > nada
      (um lead que ja comprou NAO conta como perdido mesmo se tiver outra opp
      perdida; e uma reuniao avanca mais que um "perdido" em outra pipeline).
    """
    if not opps:
        return None, ""

    melhor = None  # (rank, categoria, motivo)
    RANK = {"venda": 4, "reuniao": 3, "perdido": 2, "conversou": 1}

    for opp in opps:
        if not isinstance(opp, dict):
            continue
        status = (opp.get("status") or "").lower()
        stage_nome = _stage_name_from_opp(opp, pipelines_meta)
        stage_norm = _norm(stage_nome)
        pipe_name = ((pipelines_meta or {}).get(opp.get("pipelineId")) or {}).get("name") or ""

        cat = None
        if status == STATUS_WON or _match_any(stage_norm, STAGE_TOKENS_VENDA):
            cat = "venda"
        elif _match_any(stage_norm, STAGE_TOKENS_REUNIAO):
            cat = "reuniao"
        elif status in (STATUS_LOST, STATUS_ABANDONED) or _match_any(stage_norm, STAGE_TOKENS_PERDIDO):
            cat = "perdido"
        elif _match_any(stage_norm, STAGE_TOKENS_CONVERSANDO):
            cat = "conversou"

        if cat is None:
            continue

        rank = RANK.get(cat, 0)
        motivo = f"{pipe_name} / {stage_nome or '?'} ({status})"
        if melhor is None or rank > melhor[0]:
            melhor = (rank, cat, motivo)

    if melhor:
        return melhor[1], melhor[2]
    return None, ""


def _classifica_por_tags(tags: list) -> tuple[str, str]:
    tgs = [_norm(t) for t in (tags or []) if isinstance(t, str)]
    if not tgs:
        return None, ""
    blob = " ".join(tgs)
    if any(t in blob for t in ("cliente", "vendido", "venda-feita", "venda_feita")):
        return "venda", "tag:cliente/vendido"
    if any(t in blob for t in ("reuniao-marcada", "agendad", "reuniao_marcada")):
        return "reuniao", "tag:reuniao"
    if any(t in blob for t in ("perdid", "desqualific")):
        return "perdido", "tag:perdido"
    return None, ""


def _classifica_por_mensagens(msgs: list[dict]) -> tuple[str, str]:
    """
    Olha as ultimas ~5 mensagens (as mais recentes vem primeiro no GHL).
    """
    if not msgs:
        return None, ""
    # Concatena os bodies das 5 ultimas
    corpos = []
    for m in msgs[:5]:
        body = m.get("body") or m.get("content") or ""
        if body:
            corpos.append(body)
    blob = " ".join(corpos)
    if _match_any(blob, MSG_TOKENS_VENDA):
        return "venda", "msg: palavra de venda"
    if _match_any(blob, MSG_TOKENS_REUNIAO):
        return "reuniao", "msg: palavra de reuniao"
    return None, ""


def _has_inbound(conv: dict) -> bool:
    """
    'Conversou' = o LEAD respondeu DEPOIS que a conversa foi criada
    (dateAdded). Essa e a leitura correta no GHL de prospeccao ativa.

    GHL mantem `lastInboundWhatsappMessageDate` historico (de qualquer resposta
    passada do contato), por isso NAO basta checar se o campo existe. Precisa
    estar temporalmente dentro desta janela de atendimento.
    """
    inbound_ts = int(conv.get("lastInboundWhatsappMessageDate") or 0)
    date_added = int(conv.get("dateAdded") or 0)

    if not inbound_ts:
        return False
    # Margem de 60s pra absorver ruido de criacao x primeira msg.
    if date_added and inbound_ts >= (date_added - 60_000):
        return True
    return False


def _dias_desde(ts_ms: int, agora_ms: int) -> float:
    if not ts_ms:
        return 1e9
    return max(0.0, (agora_ms - ts_ms) / (1000.0 * 86400.0))


def classificar_conversa(
    conv: dict,
    pipelines_meta: dict,
    msgs_cache: dict | None = None,
    headers_conv: dict | None = None,
    enriquecer_msgs: bool = False,
    agora_ms: int | None = None,
) -> dict:
    """
    Retorna {
      categoria: chegou|conversou|reuniao|venda|perdido,
      motivo: str,
      conversou_flag: bool,
    }

    Regra:
      - Default: 'chegou' (todo lead com conversa WhatsApp conta no topo do funil)
      - Se tem opp: usa classificacao por stage.
      - Se nao tem opp: tenta por tags, depois por mensagens (opcional).
      - Se nao tem opp, nao tem resposta do lead E ultima msg >14d atras -> 'perdido'.
    """
    agora_ms = agora_ms or int(time.time() * 1000)

    conversou = _has_inbound(conv)

    # 1) Classificacao por opportunity (prioridade maxima)
    opps = conv.get("opportunities") or []
    cat, motivo = _classifica_por_opps(opps, pipelines_meta)
    if cat in ("venda", "reuniao"):
        # Reuniao marcada ou venda implicam que o lead respondeu em algum
        # momento, mesmo que o inbound GHL nao capture (ex: resposta via
        # telefone). Marca conversou = True.
        return {"categoria": cat, "motivo": motivo, "conversou_flag": True}
    if cat == "perdido":
        return {"categoria": cat, "motivo": motivo, "conversou_flag": conversou}

    # 2) Tags
    cat_t, motivo_t = _classifica_por_tags(conv.get("tags"))
    if cat_t in ("venda", "reuniao", "perdido"):
        return {"categoria": cat_t, "motivo": motivo_t, "conversou_flag": conversou}

    # 3) Mensagens (opcional, custo de 1 request GHL por conversa)
    if enriquecer_msgs and headers_conv:
        cid = conv.get("id")
        msgs = None
        if msgs_cache is not None and cid in msgs_cache:
            msgs = msgs_cache[cid]
        else:
            msgs = buscar_mensagens(cid, headers_conv, limit=10)
            if msgs_cache is not None:
                msgs_cache[cid] = msgs
            time.sleep(0.1)  # rate limit guard
        cat_m, motivo_m = _classifica_por_mensagens(msgs or [])
        if cat_m in ("venda", "reuniao"):
            return {"categoria": cat_m, "motivo": motivo_m, "conversou_flag": conversou}

    # 4) Perdido por inatividade (>14d sem mensagem alguma)
    last_msg_ms = int(conv.get("lastMessageDate") or 0)
    if last_msg_ms and _dias_desde(last_msg_ms, agora_ms) > DIAS_ABANDONO:
        # Se nao houve resposta do lead, e perdido-abandono.
        if not conversou:
            return {"categoria": "perdido", "motivo": f"sem resposta >{DIAS_ABANDONO}d", "conversou_flag": False}

    # 5) Default: conversou se o lead respondeu de verdade (inbound real)
    if conversou:
        return {"categoria": "conversou", "motivo": motivo or "lead respondeu", "conversou_flag": True}

    # 6) Tem opp mas so "recebeu mensagem" / stage de disparo = ainda e CHEGOU
    return {"categoria": "chegou", "motivo": motivo or "disparo sem resposta", "conversou_flag": False}


# ============================================================
# Montagem do funil
# ============================================================
def _fmt_data_br(ts_ms) -> str:
    if not ts_ms:
        return ""
    try:
        dt = datetime.fromtimestamp(int(ts_ms) / 1000, tz=TZ_BR)
        return dt.strftime("%d/%m %H:%M")
    except Exception:
        return ""


def _sanitize_phone(raw) -> str:
    if not raw:
        return ""
    digits = "".join(ch for ch in str(raw) if ch.isdigit())
    if not digits:
        return ""
    if len(digits) in (10, 11):
        digits = "55" + digits
    return digits


def montar_funil(
    conversas: list[dict],
    pipelines_meta: dict,
    inicio_ms: int,
    fim_ms: int,
    enriquecer_msgs: bool = False,
    headers_conv: dict | None = None,
    location_id: str | None = None,
) -> dict:
    """
    conversas: lista BRUTA de /conversations/search (filtra aqui por dateAdded no periodo).
    pipelines_meta: {pipeline_id: {name, stages_map}}
    """
    agora_ms = int(time.time() * 1000)
    msgs_cache: dict = {}

    por_cat = {"chegou": 0, "conversou": 0, "reuniao": 0, "venda": 0, "perdido": 0}
    por_vendedora: dict[str, dict] = {}
    leads_detalhes: list[dict] = []

    for c in conversas or []:
        dt_added = int(c.get("dateAdded") or 0)
        # Filtro de janela por dateAdded (quando o lead CHEGOU)
        if not dt_added:
            continue
        if dt_added < inicio_ms or dt_added > fim_ms:
            continue

        # Classifica
        cls = classificar_conversa(
            c, pipelines_meta,
            msgs_cache=msgs_cache,
            headers_conv=headers_conv,
            enriquecer_msgs=enriquecer_msgs,
            agora_ms=agora_ms,
        )
        cat = cls["categoria"]

        # Topo do funil: TODO lead conta como chegou
        por_cat["chegou"] += 1
        # Conversou: todo lead que respondeu (independente de ter avancado mais)
        if cls["conversou_flag"] or cat in ("conversou", "reuniao", "venda"):
            por_cat["conversou"] += 1
        if cat == "reuniao":
            por_cat["reuniao"] += 1
        elif cat == "venda":
            por_cat["venda"] += 1
        elif cat == "perdido":
            por_cat["perdido"] += 1

        # Breakdown por vendedora (assignedTo da conversa; se vazio, "sem atribuicao")
        # Preferir primeira entrada em followers (GHL usa followers pra atribuicao
        # em conversa WhatsApp mais confiavel que assignedTo).
        vendedora_id = ""
        followers = c.get("followers") or []
        if followers and isinstance(followers, list):
            vendedora_id = str(followers[0])
        if not vendedora_id:
            vendedora_id = c.get("assignedTo") or ""
        vendedora_id = vendedora_id or "sem_atribuicao"

        v = por_vendedora.setdefault(vendedora_id, {
            "ghl_id": vendedora_id,
            "chegou": 0, "conversou": 0, "reuniao": 0, "venda": 0, "perdido": 0,
        })
        v["chegou"] += 1
        if cls["conversou_flag"] or cat in ("conversou", "reuniao", "venda"):
            v["conversou"] += 1
        if cat == "reuniao":
            v["reuniao"] += 1
        elif cat == "venda":
            v["venda"] += 1
        elif cat == "perdido":
            v["perdido"] += 1

        # Detalhe do lead pra tabela
        nome = (
            (c.get("contactName") or "").strip()
            or (c.get("fullName") or "").strip()
            or "Sem nome"
        )
        phone_raw = c.get("phone") or ""
        phone_digits = _sanitize_phone(phone_raw)

        linkia_url = ""
        if location_id and c.get("contactId"):
            linkia_url = (
                f"https://app.linkia.app/v2/location/{location_id}/contacts/detail/"
                f"{c.get('contactId')}"
            )

        leads_detalhes.append({
            "conv_id": c.get("id") or "",
            "contact_id": c.get("contactId") or "",
            "nome": nome,
            "phone_raw": phone_raw,
            "phone_digits": phone_digits,
            "wa_url": f"https://wa.me/{phone_digits}" if phone_digits else "",
            "data_entrada_ms": dt_added,
            "data_entrada_br": _fmt_data_br(dt_added),
            "ultima_msg_ms": int(c.get("lastMessageDate") or 0),
            "ultima_msg_br": _fmt_data_br(c.get("lastMessageDate")),
            "estagio": cat,
            "motivo": cls["motivo"],
            "vendedora_id": vendedora_id,
            "linkia_url": linkia_url,
        })

    # Taxas (sem divisao por zero)
    def _pct(num, den):
        if not den:
            return 0.0
        return round(100.0 * num / den, 1)

    taxas = {
        "chegou_para_conversou": _pct(por_cat["conversou"], por_cat["chegou"]),
        "conversou_para_reuniao": _pct(por_cat["reuniao"], por_cat["conversou"]),
        "reuniao_para_venda": _pct(por_cat["venda"], por_cat["reuniao"]),
        "chegou_para_venda": _pct(por_cat["venda"], por_cat["chegou"]),
        "pct_perdido": _pct(por_cat["perdido"], por_cat["chegou"]),
    }

    # Lista de vendedoras ordenada por volume
    vendedoras_lista = sorted(
        por_vendedora.values(),
        key=lambda v: v["chegou"],
        reverse=True,
    )

    # Ordena leads por data mais recente
    leads_detalhes.sort(key=lambda r: r.get("data_entrada_ms", 0), reverse=True)

    return {
        "chegou": por_cat["chegou"],
        "conversou": por_cat["conversou"],
        "reuniao": por_cat["reuniao"],
        "venda": por_cat["venda"],
        "perdido": por_cat["perdido"],
        "taxas": taxas,
        "por_vendedora": vendedoras_lista,
        "leads": leads_detalhes,
    }
