from pathlib import Path
import os
from datetime import datetime, timezone
import html
import json
from uuid import UUID
from hmac import compare_digest
from typing import Optional
from sqlalchemy import text
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse, Response
from starlette.concurrency import run_in_threadpool
from app.admin_queries import list_action_runs, list_business_events
from app.integration_outbox_service import get_outbox_item, list_outbox, set_outbox_status
from app.config import is_production_like_env, settings
from app.preparation_service import prepare_task as run_task_preparation
from app.preparation_view_model import build_preparation_view_model
from app.workflow_guard import OperationActionBlocked, get_workflow_action_plan
from app.odoo_service import (
    test_odoo_connection,
    sync_odoo_products,
    get_odoo_product_snapshot,
    sync_opportunity_odoo_status,
)
from app.operation_service import get_operation_snapshot, operation_next_steps, register_operation_action
from app.operations_service import get_operations_summary, get_system_health_summary, list_unified_opportunity_timeline
from app.communication_service import (
    classification_action,
    create_timeline_event,
    get_communication,
    get_communications_summary,
    link_communication_to_customer,
    link_communication_to_opportunity,
    list_communications,
    list_communications_for_opportunity,
    set_communication_status,
)
from app.task_service import complete_task, complete_task_with_note, get_admin_dashboard_metrics, get_customer_profile, get_system_health_metrics, get_task_detail, get_latest_task_preparation, list_admin_recent_raw_events, list_admin_recent_tasks, list_customer_messages, list_customer_opportunity_mappings, list_customer_task_history, list_customer_tasks, list_tasks, skip_task, reclassify_task
from app.opportunity_service import (
    OPPORTUNITY_BOARD_COLUMNS,
    OPPORTUNITY_STAGE_LABELS,
    get_opportunity,
    list_opportunities,
    list_opportunity_events,
    list_opportunity_tasks,
    set_opportunity_stage,
    stage_label,
)
from app.product_service import (
    add_opportunity_item,
    create_product,
    delete_opportunity_item,
    get_product,
    list_opportunity_items,
    list_product_categories,
    list_products,
    set_product_active,
    update_opportunity_item,
    update_product,
)
from app.admin_ui.components import kpi_card
from app.admin_ui.layout import layout
from app.admin_ui.styles import ADMIN_UI_V451_CSS
# Route handlers moved to app.admin_ui.pages.* in v4.7.2. ADMIN_UI_CSS moved to app.admin_ui.styles. Já existe documento atual. A associação direta fica bloqueada
def require_admin_access(request: Request) -> None:
    """Proteção opcional da UI admin.
    Se CLIENTFLOW_ADMIN_TOKEN estiver vazio, mantém compatibilidade local.
    Em produção deve ser definido e enviado em X-ClientFlow-Admin-Token,
    cookie clientflow_admin_token, ou query param admin_token atrás de HTTPS/proxy.
    """
    expected = (settings.clientflow_admin_token or "").strip()
    if not expected:
        if is_production_like_env():
            raise HTTPException(status_code=503, detail="admin auth not configured")
        return
    received = (
        request.headers.get("X-ClientFlow-Admin-Token")
        or request.cookies.get("clientflow_admin_token")
        or (request.query_params.get("admin_token") if not is_production_like_env() else None)
        or ""
    ).strip()
    if not received or not compare_digest(received, expected):
        raise HTTPException(status_code=401, detail="admin auth required")
router = APIRouter(prefix="", tags=["admin"], dependencies=[Depends(require_admin_access)])
def esc(value) -> str:
    return html.escape(str(value or ""))
def chatwoot_conversation_url(conversation_id: object) -> str:
    conversation_id = str(conversation_id or "").strip()
    if not conversation_id:
        return ""
    public_url = (
        getattr(settings, "chatwoot_public_url", "")
        or getattr(settings, "chatwoot_base_url", "")
        or ""
    ).rstrip("/")
    account_id = str(getattr(settings, "chatwoot_account_id", "") or "").strip()
    if not public_url or not account_id:
        return ""
    return f"{public_url}/app/accounts/{account_id}/conversations/{conversation_id}"
def chatwoot_button(conversation_id: object, label: str = "Abrir Chatwoot") -> str:
    href = chatwoot_conversation_url(conversation_id)
    if not href:
        return ""
    return f'<a class="btn btn-sm btn-outline-primary" href="{esc(href)}" target="_blank" rel="noopener"><i class="bi bi-chat-dots"></i> {esc(label)}</a>'
def shell_output(cmd: list[str], *, timeout: int = 8) -> str:
    try:
        import subprocess
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
        )
        output = (result.stdout or "") + (result.stderr or "")
        return output.strip()
    except Exception as e:
        return f"erro ao executar {' '.join(cmd)}: {e!r}"
def status_pill(ok: bool, label: str) -> str:
    cls = "pill-ok" if ok else "pill-bad"
    return f'<span class="system-pill {cls}">{esc(label)}</span>'
def latest_backup_info() -> dict:
    backup_dir = Path(os.getenv("CLIENTFLOW_BACKUP_DIR", "./backups/clientflow"))
    files = sorted(
        backup_dir.glob("clientflow-*.sql.gz"),
        key=lambda x: x.stat().st_mtime if x.exists() else 0,
        reverse=True,
    )
    if not files:
        return {
            "exists": False,
            "file": "",
            "size": "",
            "mtime": "",
        }
    f = files[0]
    stat = f.stat()
    return {
        "exists": True,
        "file": str(f),
        "size": f"{stat.st_size / 1024:.1f} KB",
        "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"),
    }
def done_note_options_html_for(action_code: str) -> str:
    done_note_templates = {
        "SEND_INFO": [
            "Informação enviada ao cliente no Chatwoot.",
            "Cliente informado por email.",
            "Informação comercial enviada; aguardar resposta.",
        ],
        "SEND_QUOTE": [
            "Proposta/cotação enviada ao cliente.",
            "Cotação enviada no Chatwoot.",
            "Proposta enviada; aguardar confirmação do cliente.",
        ],
        "SEND_PROFORMA": [
            "Orçamento para pagamento enviado ao cliente.",
            "Orçamento enviado; aguardar pagamento/confirmação.",
        ],
        "SEND_INVOICE": [
            "Fatura enviada ao cliente.",
            "Cliente informado do envio da fatura.",
        ],
        "CONFIRM_PAYMENT": [
            "Pagamento confirmado.",
            "Comprovativo validado; processo segue para operações se aplicável.",
        ],
        "VALIDATE_PHYSICAL_ORDER": ["Encomenda física validada e pronta para expedição.", "Picking e produtos conferidos; pode avançar para envio."], "SUPPORT": [
            "Pedido de suporte respondido ou encaminhado.",
            "Cliente informado; suporte vai acompanhar o caso.",
            "Pedido encaminhado para análise.",
        ],
        "REMOVE_FROM_LIST": [
            "Contacto removido da lista.",
            "Pedido de remoção tratado.",
        ],
        "REVIEW_MANUALLY": [
            "Caso revisto manualmente.",
            "Sem ação automática; tratado manualmente.",
        ],
        "MARK_NO_INTEREST": [
            "Marcado sem interesse atual.",
            "Cliente informou que não tem necessidade atual.",
            "Oportunidade encerrada/sem seguimento comercial por agora.",
        ],
        "FOLLOW_UP_QUOTE": [
            "Follow-up do orçamento feito; aguardar resposta.",
            "Cliente contactado sobre a proposta.",
            "Follow-up feito e sem resposta imediata.",
        ],
        "FOLLOW_UP_PROFORMA": [
            "Follow-up do orçamento para pagamento feito; aguardar pagamento/confirmação.",
            "Cliente contactado sobre o orçamento para pagamento.",
        ],
        "FOLLOW_UP_PAYMENT": [
            "Follow-up de pagamento feito; aguardar comprovativo/confirmação.",
            "Cliente contactado sobre pagamento pendente.",
        ],
        "FOLLOW_UP_CUSTOMER_REVIEW": [
            "Follow-up da informação enviada feito; aguardar decisão do cliente.",
            "Cliente contactado para perceber se pretende orçamento.",
        ],
        "FOLLOW_UP_GENERIC": [
            "Follow-up manual feito; aguardar resposta.",
            "Cliente contactado manualmente.",
        ],
        "NO_ACTION": [
            "Sem ação necessária.",
        ],
        "IGNORE_SPAM": [
            "Mensagem ignorada como spam.",
        ],
    }
    default_done_notes = [
        "Tarefa concluída.",
        "Cliente informado no Chatwoot.",
        "Pedido tratado manualmente.",
    ]
    templates = done_note_templates.get(action_code or "", default_done_notes)
    return "".join(
        f'<option value="{esc(option)}">{esc(option)}</option>'
        for option in templates
    )
def suggested_reply_for_task(task: dict) -> str:
    action_code = task.get("action_code") or ""
    metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {}
    if not metadata and isinstance(task.get("metadata"), str):
        try:
            metadata = json.loads(task.get("metadata") or "{}")
        except Exception:
            metadata = {}
    if metadata.get("suggested_message"):
        return str(metadata.get("suggested_message") or "")
    customer_name = task.get("customer_name") or ""
    first_name = str(customer_name).strip().split(" ")[0] if customer_name else ""
    greeting = f"Olá {first_name}," if first_name and first_name.lower() not in ["cliente", "desconhecido"] else "Olá,"
    closing = "Obrigado,\nEquipa BLIF"
    templates = {
        "SEND_INFO": f"""{greeting}
Obrigado pelo seu contacto.
Segue informação sobre os nossos carregadores para veículos elétricos. Podemos ajudar com a escolha do modelo mais adequado, disponibilidade, condições de entrega e instalação.
Caso pretenda, envie-nos por favor:
- tipo de viatura;
- local de instalação;
- potência disponível;
- se pretende carregador monofásico ou trifásico.
{closing}""",
        "SEND_QUOTE": f"""{greeting}
Obrigado pelo seu pedido.
Vamos preparar/enviar a proposta para o carregador solicitado, incluindo preço, disponibilidade e condições de entrega.
Se ainda não tiver indicado, confirme por favor:
- modelo pretendido;
- quantidade;
- morada/localidade para entrega;
- dados para faturação, se desejar avançar.
{closing}""",
        "SEND_PROFORMA": f"""{greeting}
Podemos enviar o orçamento para pagamento.
Para isso, envie por favor os dados de faturação:
- nome/empresa;
- NIF;
- morada;
- email para envio;
- produto/quantidade pretendida.
{closing}""",
        "SEND_INVOICE": f"""{greeting}
Obrigado pela confirmação.
Vamos enviar a fatura conforme solicitado. Caso ainda não tenha enviado os dados de faturação, envie por favor:
- nome/empresa;
- NIF;
- morada;
- email.
{closing}""",
        "CONFIRM_PAYMENT": f"""{greeting}
Obrigado pelo envio da informação/comprovativo.
Vamos confirmar o pagamento e dar seguimento ao processo. Se for aplicável, encaminhamos também a encomenda para preparação/envio.
Assim que tivermos atualização, informamos.
{closing}""",
        "SUPPORT": f"""{greeting}
Obrigado pelo contacto.
Vamos encaminhar o seu pedido para suporte. Para ajudar na análise, envie por favor, se aplicável:
- modelo do carregador/equipamento;
- descrição do pedido/problema;
- fotos/vídeos, se possível;
- morada/local de instalação, entrega ou recolha;
- contacto telefónico.
{closing}""",
        "REMOVE_FROM_LIST": f"""{greeting}
Confirmamos que vamos tratar o pedido de remoção da lista de contactos.
{closing}""",
        "MARK_NO_INTEREST": f"""{greeting}
Obrigado pela informação.
Ficamos ao dispor caso no futuro venham a integrar veículos elétricos na frota ou necessitem de soluções de carregamento.
{closing}""",
        "REVIEW_MANUALLY": f"""{greeting}
Obrigado pela sua mensagem.
Vamos analisar o pedido internamente e responder assim que possível.
{closing}""",
    }
    return templates.get(action_code, f"""{greeting}
Obrigado pela sua mensagem.
Vamos analisar o pedido e responder assim que possível.
{closing}""")
ACTION_UI_LABELS = {
    "SEND_INFO": "Enviar informação",
    "SEND_QUOTE": "Enviar orçamento",
    "SEND_PROFORMA": "Enviar orçamento para pagamento",
    "SEND_INVOICE": "Enviar fatura",
    "CONFIRM_PAYMENT": "Confirmar pagamento",
    "SUPPORT": "Tratar suporte",
    "REMOVE_FROM_LIST": "Remover da lista",
    "MARK_NO_INTEREST": "Marcar sem interesse",
    "IGNORE_SPAM": "Ignorar spam",
    "REVIEW_MANUALLY": "Rever manualmente",
    "NO_ACTION": "Sem ação",
    "FOLLOW_UP_QUOTE": "Follow-up orçamento",
    "FOLLOW_UP_PROFORMA": "Follow-up pagamento",
    "FOLLOW_UP_PAYMENT": "Follow-up pagamento",
    "FOLLOW_UP_CUSTOMER_REVIEW": "Follow-up informação",
    "FOLLOW_UP_GENERIC": "Follow-up manual",
}
ACTION_HINTS = {
    "SEND_INFO": "Enviar informação geral e pedir os dados mínimos para recomendar o carregador certo.",
    "SEND_QUOTE": "Preparar/enviar orçamento com preço, disponibilidade, condições de entrega e dados necessários para avançar.",
    "SEND_PROFORMA": "Confirmar dados e enviar orçamento para pagamento.",
    "SEND_INVOICE": "Confirmar dados de faturação e enviar a fatura solicitada.",
    "CONFIRM_PAYMENT": "Validar pagamento/comprovativo e encaminhar para preparação/envio se aplicável.",
    "SUPPORT": "Responder ao pedido e recolher informação técnica mínima para análise.",
    "REMOVE_FROM_LIST": "Confirmar remoção do contacto de comunicações futuras.",
    "MARK_NO_INTEREST": "Cliente indicou ausência de interesse atual/necessidade após divulgação; não é o mesmo que oportunidade perdida por preço ou funcionalidades.",
    "IGNORE_SPAM": "Ignorar a mensagem e não criar seguimento comercial.",
    "REVIEW_MANUALLY": "Analisar manualmente porque a intenção não ficou suficientemente clara.",
    "NO_ACTION": "Não é necessária ação operacional.",
    "FOLLOW_UP_QUOTE": "Confirmar manualmente se o cliente recebeu a proposta e se tem dúvidas.",
    "FOLLOW_UP_PROFORMA": "Confirmar manualmente receção do orçamento/dados de pagamento.",
    "FOLLOW_UP_PAYMENT": "Confirmar manualmente pagamento/comprovativo pendente.",
    "FOLLOW_UP_CUSTOMER_REVIEW": "Confirmar manualmente se a informação enviada foi suficiente e se quer orçamento.",
    "FOLLOW_UP_GENERIC": "Contactar o cliente conforme contexto da oportunidade.",
}
ACTION_MISSING_HINTS = {
    "SEND_INFO": ["potência pretendida", "tipo de instalação", "localidade", "contacto telefónico"],
    "SEND_QUOTE": ["modelo/produto", "quantidade", "morada/localidade", "dados de faturação se avançar"],
    "SEND_PROFORMA": ["nome/empresa", "NIF", "morada fiscal", "email de faturação", "produto/quantidade"],
    "SEND_INVOICE": ["nome/empresa", "NIF", "morada fiscal", "email de faturação"],
    "CONFIRM_PAYMENT": ["valor recebido", "referência/comprovativo", "morada de entrega", "contacto para entrega"],
    "SUPPORT": ["modelo", "descrição do problema", "fotos/vídeos", "local de instalação", "telefone"],
    "MARK_NO_INTEREST": ["motivo", "se é apenas falta de interesse atual", "se deve manter contacto para futuro"],
    "FOLLOW_UP_QUOTE": ["confirmar receção", "dúvidas técnicas", "se pretende avançar"],
    "FOLLOW_UP_PROFORMA": ["confirmar receção", "pagamento", "dados pendentes"],
    "FOLLOW_UP_PAYMENT": ["comprovativo", "previsão de pagamento", "pendências"],
    "FOLLOW_UP_CUSTOMER_REVIEW": ["interesse atual", "necessidade de proposta", "dúvidas"],
    "FOLLOW_UP_GENERIC": ["contexto", "próxima decisão", "prazo de resposta"],
}
PIPELINE_STEPS = [
    ("NEW_LEAD", "Novo pedido"),
    ("INFO_SENT", "Info enviada"),
    ("QUOTE_SENT", "Proposta"),
    ("PAYMENT_CONFIRMED", "Pagamento"),
    ("ODOO_ORDER_CREATED", "Odoo"),
    ("IN_PRODUCTION", "Produção"),
    ("READY_TO_SHIP", "Pronto"),
    ("SHIPMENT_CREATED", "Envio"),
    ("WON", "Concluído"),
    ("NO_INTEREST", "Sem interesse"),
]
def action_label(code: str) -> str:
    code = str(code or "").strip().upper()
    return ACTION_UI_LABELS.get(code, code or "Tarefa")
def compact_text(value, limit: int = 120) -> str:
    text = " ".join(str(value or "").split())
    if len(text) > limit:
        return text[: max(0, limit - 1)].rstrip() + "…"
    return text
def fmt_dt(value) -> str:
    """Formata datas/timestamps para leitura rápida na UI."""
    if not value:
        return "—"
    try:
        if isinstance(value, str):
            dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
        else:
            dt = value
        if getattr(dt, "tzinfo", None) is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt.strftime("%Y-%m-%d %H:%M")
    except Exception:
        return compact_text(value, 32)
def humanize_task_detail(value) -> str:
    detail = str(value or "").strip()
    lower = detail.casefold()
    if "resposta llm inválida" in lower or ("invalid" in lower and "action_code" in lower):
        return "Classificação da mensagem falhou. Rever no Chatwoot e escolher a ação correta."
    if "limpo manualmente" in lower or "resolvido manualmente" in lower:
        return "Item já limpo manualmente. Deve ficar no histórico, não na fila diária."
    return detail
def task_next_action_text(task: dict) -> str:
    note = compact_text(humanize_task_detail(task.get("note") or task.get("action") or ""), 120)
    if note:
        return note
    code = str(task.get("action_code") or "")
    return action_label(code)
def opportunity_contact_name(opportunity: dict) -> str:
    return str(
        opportunity.get("customer_name")
        or opportunity.get("customer_email")
        or opportunity.get("contact_id")
        or "Cliente"
    ).strip()
def opportunity_customer_name(opportunity: dict) -> str:
    # Preferir a ficha fiscal ligada, porque é ela que será usada para Jasmin,
    # faturas e envios. O contacto original continua visível como origem.
    return str(
        opportunity.get("linked_customer_name")
        or opportunity.get("customer_name")
        or opportunity.get("customer_email")
        or opportunity.get("contact_id")
        or "Cliente"
    ).strip()
def _norm_customer_text(value: object) -> str:
    return " ".join(str(value or "").strip().casefold().split())
def _customer_name_tokens(value: object) -> set[str]:
    text = _norm_customer_text(value)
    for ch in "-_,.;:/()[]{}+&|\n\t":
        text = text.replace(ch, " ")
    legal_suffixes = {
        "lda", "ltd", "sa", "s", "a", "unipessoal", "sociedade", "limitada",
        "empresa", "companhia", "pt", "portugal", "the", "and", "e", "de", "da", "do",
        "das", "dos", "para", "com", "ao", "aos", "as", "os",
    }
    tokens: set[str] = set()
    for token in text.split():
        token = token.strip()
        if len(token) < 3 or token in legal_suffixes or "@" in token:
            continue
        tokens.add(token)
        # Prefixes reduce false positives with short/truncated labels such as
        # "Riotec elec" vs "Riotec - Electricidade, ...".
        if len(token) >= 4:
            tokens.add(token[:4])
    return tokens
def opportunity_customer_mismatch(opportunity: dict) -> bool:
    """Nome de contacto ≠ cliente fiscal não é um erro fiável.
    Ex.: contacto "Bruno Oliveira" pode representar a empresa fiscal
    "Nortuflex"; "Riotec elec" pode ser abreviação da entidade fiscal.
    A validação crítica deve focar NIF/morada/documentos, não semelhança de nomes.
    """
    return False
def opportunity_customer_context_html(opportunity: dict) -> str:
    linked = opportunity.get("linked_customer_name")
    original = opportunity.get("customer_name") or opportunity.get("customer_email")
    tax_id = opportunity.get("linked_customer_tax_id")
    linked_email = opportunity.get("linked_customer_email")
    if linked:
        html = f'<div class="small text-secondary text-truncate">Cliente fiscal: {esc(linked)}'
        if tax_id:
            html += f' · NIF {esc(tax_id)}'
        html += '</div>'
        if opportunity_customer_mismatch(opportunity):
            html += f'<div class="small text-warning fw-semibold text-truncate">Contacto original: {esc(original or "—")}</div>'
        elif linked_email:
            html += f'<div class="small text-secondary text-truncate">{esc(linked_email)}</div>'
        return html
    return f'<div class="small text-secondary text-truncate">{esc(original or "Sem cliente fiscal associado")}</div>'
def opportunity_next_action_text(opportunity: dict) -> str:
    pending_count = int(opportunity.get("pending_task_count") or 0)
    stage = str(opportunity.get("stage") or "NEW_LEAD")
    last_action = str(opportunity.get("last_action_code") or "")
    if pending_count:
        return "Concluir tarefa pendente"
    by_stage = {
        "NEW_LEAD": "Qualificar pedido",
        "INFO_REQUESTED": "Aguardar dados do cliente",
        "INFO_SENT": "Confirmar interesse",
        "QUOTE_REQUESTED": "Preparar proposta",
        "QUOTE_SENT": "Acompanhar decisão",
        "PROFORMA_REQUESTED": "Preparar orçamento para pagamento",
        "PROFORMA_SENT": "Aguardar pagamento",
        "INVOICE_REQUESTED": "Emitir fatura",
        "INVOICE_SENT": "Aguardar pagamento",
        "WAITING_PAYMENT": "Confirmar pagamento",
        "PAYMENT_CONFIRMED": "Preparar encomenda",
        "ORDER_PREPARATION": "Preparar material/envio",
        "ODOO_ORDER_CREATED": "Validar estado Odoo",
        "IN_PRODUCTION": "Aguardar WH/OUT",
        "READY_TO_SHIP": "Enviar encomenda",
        "INVOICED": "Enviar encomenda",
        "SHIPMENT_CREATED": "Concluir oportunidade",
        "SHIPPED": "Concluir oportunidade",
        "TRACKING_SENT": "Concluir oportunidade",
        "DELIVERED": "Fechar oportunidade",
        "WON": "Concluída",
        "LOST": "Perdida",
        "NO_INTEREST": "Sem interesse",
        "REVIEW": "Rever manualmente",
        "ARCHIVED": "Arquivada",
    }
    if last_action:
        return by_stage.get(stage, action_label(last_action))
    return by_stage.get(stage, "Acompanhar oportunidade")
def opportunity_priority_chip(opportunity: dict) -> str:
    pending = int(opportunity.get("pending_task_count") or 0)
    stage = str(opportunity.get("stage") or "")
    if pending:
        return '<span class="cf-chip cf-chip-orange">Requer ação</span>'
    if stage in {"WAITING_PAYMENT", "PAYMENT_CONFIRMED", "ORDER_PREPARATION", "READY_TO_SHIP"}:
        return '<span class="cf-chip cf-chip-blue">Prioritária</span>'
    if stage in {"WON", "LOST", "NO_INTEREST", "ARCHIVED"}:
        return '<span class="cf-chip cf-chip-gray">Fechada</span>'
    return '<span class="cf-chip cf-chip-gray">Normal</span>'
def is_uuid_text(value: object) -> bool:
    try:
        UUID(str(value or ""))
        return True
    except Exception:
        return False
def metadata_dict(value) -> dict:
    if isinstance(value, dict):
        return value
    if isinstance(value, str) and value.strip():
        try:
            parsed = json.loads(value)
            return parsed if isinstance(parsed, dict) else {}
        except Exception:
            return {}
    return {}
def opportunity_id_from_task(task: dict) -> str:
    meta = metadata_dict(task.get("metadata"))
    return str(task.get("opportunity_id") or meta.get("opportunity_id") or "").strip()
def customer_display(task: dict) -> str:
    return str(task.get("customer_name") or task.get("customer_email") or task.get("contact_id") or task.get("customer_id") or "Cliente").strip()
def action_recommendation_html(action_code: str) -> str:
    action_code = str(action_code or "").upper()
    hint = ACTION_HINTS.get(action_code, "Executar a ação indicada e atualizar o estado da tarefa.")
    missing = ACTION_MISSING_HINTS.get(action_code, [])
    missing_html = ""
    if missing:
        missing_html = "<div class='small text-secondary mt-2'>Pedir se faltar: " + ", ".join(esc(x) for x in missing) + ".</div>"
    return f"""
    <div class="cf-action-focus">
      <div class="fw-bold">{esc(hint)}</div>
      {missing_html}
    </div>
    """
def stage_progress_html(current_stage: str) -> str:
    rank = OPPORTUNITY_STAGE_LABELS
    current = str(current_stage or "NEW_LEAD")
    stage_order = [x[0] for x in PIPELINE_STEPS]
    current_index = 0
    for idx, stage in enumerate(stage_order):
        if stage == current:
            current_index = idx
            break
        if stage in {"NEW_LEAD", "INFO_SENT", "QUOTE_SENT", "WAITING_PAYMENT", "ORDER_PREPARATION", "SHIPPED", "WON"}:
            pass
    # aproxima estados intermédios para o passo visual mais próximo
    stage_to_step = {
        "NEW_LEAD": 0,
        "INFO_REQUESTED": 0,
        "INFO_SENT": 1,
        "QUOTE_REQUESTED": 1,
        "QUOTE_SENT": 2,
        "PROFORMA_REQUESTED": 2,
        "PROFORMA_SENT": 3,
        "INVOICE_REQUESTED": 2,
        "INVOICE_SENT": 3,
        "WAITING_PAYMENT": 3,
        "PAYMENT_CONFIRMED": 4,
        "ODOO_ORDER_CREATED": 5,
        "ORDER_PREPARATION": 5,
        "IN_PRODUCTION": 6,
        "READY_TO_SHIP": 7,
        "INVOICED": 7,
        "SHIPMENT_CREATED": 8,
        "SHIPPED": 8,
        "TRACKING_SENT": 8,
        "DELIVERED": 9,
        "WON": 9,
        "LOST": 9,
        "NO_INTEREST": 9,
        "REVIEW": 0,
    }
    current_index = stage_to_step.get(current, 0)
    items = ""
    for idx, (stage, label) in enumerate(PIPELINE_STEPS):
        css = "done" if idx < current_index else ("active" if idx == current_index else "")
        items += f"<div class='cf-step {css}'><span></span><small>{esc(label)}</small></div>"
    return f"<div class='cf-stage-progress'>{items}</div>"
def opportunity_quick_actions_html(opportunity_id: str) -> str:
    return ""
def operation_status_badge(status: str) -> str:
    value = str(status or "not_created").strip()
    normalized = {
        "0": "draft",
        "1": "open",
        "2": "completed",
        "3": "closed",
        "open": "open",
        "completed": "completed",
        "complete": "completed",
        "closed": "closed",
        "converted": "converted",
    }.get(value.casefold(), value.casefold())
    cls = {
        "not_created": "cf-chip-gray", "pending": "cf-chip-orange", "processing": "cf-chip-purple", "created": "cf-chip-blue",
        "open": "cf-chip-blue", "draft": "cf-chip-gray", "completed": "cf-chip-green", "closed": "cf-chip-gray",
        "converted": "cf-chip-green", "issued": "cf-chip-green", "confirmed": "cf-chip-green", "validated": "cf-chip-green",
        "in_progress": "cf-chip-orange", "sent": "cf-chip-green", "delivered": "cf-chip-green", "failed": "cf-chip-red",
        "blocked": "cf-chip-red", "dry_run": "cf-chip-gray", "ignored": "cf-chip-gray", "cancelled": "cf-chip-gray",
    }.get(normalized, "cf-chip-gray")
    label = {
        "not_created": "Não criado", "pending": "Pendente", "processing": "A processar", "created": "Criado", "issued": "Emitida",
        "open": "Aberto", "draft": "Rascunho", "completed": "Concluído", "closed": "Fechado", "converted": "Convertido",
        "confirmed": "Confirmado", "validated": "Validado", "in_progress": "Em curso", "sent": "Processado",
        "delivered": "Entregue", "failed": "Falhou", "blocked": "Bloqueado", "dry_run": "Dry-run", "ignored": "Ignorado", "cancelled": "Cancelado",
    }.get(normalized, value)
    return f'<span class="cf-chip {esc(cls)}">{esc(label)}</span>'
def commercial_document_display_number(doc: dict | None, *, fallback: str = "sem número") -> str:
    """Return a human commercial document number without exposing UUIDs.
    Imported Jasmin documents can temporarily have only a UUID/internal id. That
    is useful for diagnostics, but confusing and unsafe as a commercial number.
    """
    doc = doc or {}
    parts = " ".join([
        str(doc.get("document_type") or "").strip(),
        str(doc.get("serie") or "").strip(),
        str(doc.get("series_number") or "").strip(),
    ]).strip()
    for value in (doc.get("document_number"), parts, doc.get("external_name"), doc.get("external_ref")):
        text_value = str(value or "").strip()
        if text_value and not is_uuid_text(text_value):
            return text_value
    return fallback
def should_hide_regressive_quotation_hint(opportunity: dict | None, snapshot: dict | None, next_action: dict | None) -> bool:
    """Avoid suggesting quote creation in later commercial/fulfilment phases."""
    opportunity = opportunity or {}
    snapshot = snapshot or {}
    next_action = next_action or {}
    action_key = str(next_action.get("action_key") or "").strip()
    label = str(next_action.get("label") or "").strip().casefold()
    if action_key != "jasmin_quotation" and "criar orçamento" not in label:
        return False
    stage = str(opportunity.get("stage") or "").strip().upper()
    late_stages = {
        "QUOTE_SENT", "PROFORMA_SENT", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED",
        "ORDER_CONFIRMED", "IN_PRODUCTION", "READY_TO_SHIP", "SHIPMENT_CREATED", "SHIPPED",
        "TRACKING_SENT", "DELIVERED", "WON", "LOST", "NO_INTEREST",
    }
    if stage in late_stages:
        return True
    document_keys = {"jasmin_quotation", "jasmin_proforma", "jasmin_invoice"}
    for card in snapshot.get("cards") or []:
        key = str(card.get("key") or "").strip()
        status = str(card.get("status") or "").strip().lower()
        if key in document_keys and status not in {"", "not_created", "failed", "blocked", "cancelled", "ignored"}:
            return True
    return False
def operation_cockpit_html(opportunity_id: str, opportunity: dict, snapshot: dict) -> str:
    plan = get_workflow_action_plan(opportunity_id)
    pending_tasks = int(opportunity.get("pending_task_count") or 0)
    stage_upper = str(opportunity.get("stage") or "").upper()
    status_lower = str(opportunity.get("status") or "").lower()
    terminal_stage = status_lower == "closed" or stage_upper in {"WON", "LOST", "NO_INTEREST", "DELIVERED"}
    if terminal_stage:
        # Terminal opportunities should not look actionable just because an old
        # follow-up task counter was denormalized before cleanup.
        pending_tasks = int(opportunity.get("pending_task_count") or 0)
    invoice_document = None
    quotation_document = None
    try:
        from app.commercial_service import list_commercial_documents
        commercial_documents = list_commercial_documents(opportunity_id=opportunity_id, limit=30)
        invoice_candidates = [
            doc for doc in commercial_documents
            if str(doc.get("document_kind") or "").lower() == "invoice"
            and str(doc.get("status") or "").lower() not in {"cancelled", "failed", "rejected"}
            and str(doc.get("role") or "current") not in {"historical", "superseded"}
        ]
        quotation_candidates = [
            doc for doc in commercial_documents
            if str(doc.get("document_kind") or "").lower() in {"quotation", "quote", "proforma"}
            and str(doc.get("status") or "").lower() not in {"cancelled", "failed", "rejected"}
            and str(doc.get("role") or "current") not in {"superseded"}
        ]
        invoice_document = next((doc for doc in invoice_candidates if bool(doc.get("is_primary", False))), invoice_candidates[0] if invoice_candidates else None)
        quotation_document = next((doc for doc in quotation_candidates if bool(doc.get("is_primary", False))), quotation_candidates[0] if quotation_candidates else None)
    except Exception:
        invoice_document = None
        quotation_document = None
    cards = [dict(card) for card in (snapshot.get("cards") or [])]
    def ensure_card(key: str, *, label: str, status: str, status_label: str, external_name: str = "", external_url: str = "") -> None:
        non_empty_bad = {"", "not_created", "failed", "blocked", "cancelled", "ignored", "not_found", "unknown"}
        for card in cards:
            if str(card.get("key") or "") == key:
                if str(card.get("status") or "").lower() in non_empty_bad:
                    card["status"] = status
                    card["status_label"] = status_label
                    if external_name:
                        card["external_name"] = external_name
                    if external_url:
                        card["external_url"] = external_url
                return
        cards.append({
            "key": key,
            "label": label,
            "status": status,
            "status_label": status_label,
            "external_name": external_name,
            "external_url": external_url,
        })
    def card_by_key(key: str) -> dict:
        for card in cards:
            if str(card.get("key") or "") == key:
                return card
        return {}
    def payload_dict(value) -> dict:
        if isinstance(value, dict):
            return value
        if isinstance(value, str) and value.strip():
            try:
                parsed = json.loads(value)
                return parsed if isinstance(parsed, dict) else {}
            except Exception:
                return {}
        return {}
    def physical_whout_done() -> bool:
        card = card_by_key("odoo_physical_status")
        payload = payload_dict(card.get("payload"))
        status = str(card.get("status") or payload.get("physical_status") or payload.get("status") or "").strip().lower()
        pickings = payload.get("outgoing_pickings") or payload.get("pickings") or []
        states = {
            str(p.get("state") or "").strip().lower()
            for p in pickings
            if isinstance(p, dict) and str(p.get("state") or "").strip()
        }
        return (
            bool(payload.get("delivery_done"))
            or status in {"done", "shipped", "delivered", "validated"}
            or (bool(states) and states <= {"done", "cancel"} and "done" in states)
        )
    def physical_whout_ready() -> bool:
        card = card_by_key("odoo_physical_status")
        payload = payload_dict(card.get("payload"))
        status = str(card.get("status") or payload.get("physical_status") or payload.get("status") or "").strip().lower()
        pickings = payload.get("outgoing_pickings") or payload.get("pickings") or []
        states = {
            str(p.get("state") or "").strip().lower()
            for p in pickings
            if isinstance(p, dict) and str(p.get("state") or "").strip()
        }
        if "assigned" in states and "done" not in states:
            return False
        return (
            bool(payload.get("ready_to_ship") or payload.get("delivery_ready"))
            or status in {"ready_to_ship", "ready", "validated"}
        )
    if quotation_document:
        ensure_card(
            "jasmin_quotation",
            label="Orçamento",
            status="created",
            status_label="Associado",
            external_name=str(quotation_document.get("document_number") or quotation_document.get("external_id") or ""),
            external_url=str(quotation_document.get("external_url") or ""),
        )
    if invoice_document:
        ensure_card(
            "jasmin_invoice",
            label="Fatura",
            status="issued",
            status_label="Emitida",
            external_name=str(invoice_document.get("document_number") or invoice_document.get("external_id") or ""),
            external_url=str(invoice_document.get("external_url") or ""),
        )
    whout_done = physical_whout_done()
    whout_ready = physical_whout_ready()

    # Do not infer a green Odoo sale from invoice/payment/stage alone.
    # Sale evidence must come from the linked Odoo snapshot itself.
    # WH/MO/production is technical Odoo detail only.

    physical_validation_card = card_by_key("physical_validation")
    physical_validation_status = str(
        physical_validation_card.get("status") or ""
    ).strip().lower()
    physical_validated = physical_validation_status in {
        "validated",
        "done",
        "completed",
    }

    # A fase comercial, uma fatura emitida ou um picking apenas atribuído/pronto
    # não constituem validação física. A validação exige evidência explícita ou
    # WH-OUT concluído.
    if physical_validated or whout_done:
        ensure_card(
            "physical_validation",
            label="Validado",
            status="validated",
            status_label="Validado",
        )
    if whout_done:
        ensure_card("odoo_physical_status", label="Estado físico Odoo", status="shipped", status_label="Expedida")
        ensure_card("packlink_shipment", label="Envio", status="done", status_label="Concluído no Odoo")
    elif whout_ready:
        ensure_card("odoo_physical_status", label="Estado físico Odoo", status="ready_to_ship", status_label="Pronta para despacho")
    has_invoice_card = any(
        str(card.get("key") or "") == "jasmin_invoice"
        and str(card.get("status") or "").lower() not in {"", "not_created", "failed", "blocked", "cancelled", "ignored"}
        for card in cards
    )
    next_action = plan.get("next_action") or {}
    next_kind = str(next_action.get("kind") or "")
    workflow_label = next_action.get("label") or "Sem ação"
    workflow_reason = next_action.get("reason") or ""
    physical_reason = plan.get("physical_reason") or ""
    physical_next = plan.get("physical_next_action") or ""
    central_next_action = opportunity.get("clientflow_next_action") if isinstance(opportunity, dict) else None
    if not isinstance(central_next_action, dict):
        central_next_action = {}
    central_action_code = str(central_next_action.get("action_code") or "").upper()
    def _central_action_button_html(action_code: str, label: str, target_url: str | None) -> str:
        action_code = str(action_code or "").upper()
        label = str(label or "Continuar")
        target_url = str(target_url or "").strip()
        if action_code == "CLOSE_OPPORTUNITY":
            return (
                f'<form method="post" action="/opportunities/{esc(opportunity_id)}/operations/delivered">'
                '<input type="hidden" name="external_name" value="Oportunidade concluída">'
                '<input type="hidden" name="note" value="Fatura enviada, pagamento confirmado e Odoo/WH-OUT concluído.">'
                f'<button class="btn btn-success btn-sm" type="submit">{esc(label)}</button>'
                '</form>'
            )
        if action_code == "PREPARE_ORDER":
            return (
                f'<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/link-sale" class="d-flex flex-wrap gap-2 align-items-center">'
                '<input type="text" class="form-control form-control-sm" style="max-width:180px" name="sale_ref" placeholder="Venda Odoo ex.: S00308" required>'
                f'<button class="btn btn-primary btn-sm" type="submit">{esc(label)}</button>'
                '</form>'
            )
        if action_code in {"WAIT_PRODUCTION", "NO_ACTION"}:
            return '<span class="badge bg-warning text-dark">Aguardar</span>'
        if not target_url:
            target_url = f"/opportunities/{opportunity_id}#operacao"
        return f'<a class="btn btn-primary btn-sm" href="{esc(target_url)}">{esc(label)}</a>'
    if pending_tasks > 0:
        main_label = "Concluir tarefa pendente"
        main_reason = "Existe uma tarefa ativa nesta oportunidade."
        if should_hide_regressive_quotation_hint(opportunity, snapshot, next_action):
            main_extra = "Depois: continuar a partir do documento/fase atual."
        else:
            main_extra = f"Depois: {workflow_label}"
        action_html = '<a class="btn btn-primary btn-sm" href="/tasks">Ver tarefas</a>'
    else:
        main_label = workflow_label
        main_reason = physical_reason or workflow_reason
        main_extra = physical_next if physical_next and physical_next != main_reason else ""
        if central_action_code:
            # v1.5.107: the opportunity detail top card is driven by the
            # central next-action engine.  Mirror it here so the operational
            # cockpit does not regress to a stale legacy plan such as
            # "Enviar fatura" after the central engine already decided
            # CLOSE_OPPORTUNITY.
            main_label = central_next_action.get("label") or main_label
            main_reason = central_next_action.get("description") or central_next_action.get("reason") or main_reason
            main_extra = ""
            action_html = _central_action_button_html(
                central_action_code,
                str(main_label or "Continuar"),
                central_next_action.get("target_url"),
            )
        else:
            if should_hide_regressive_quotation_hint(opportunity, snapshot, next_action):
                main_label = "Rever fluxo atual"
                main_reason = "A oportunidade já tem documento/fase posterior; não criar novo orçamento neste processo."
                main_extra = "Continua pela fatura, pagamento, envio ou histórico conforme o caso."
                next_kind = "review"
            if next_kind == "operation" and next_action.get("action_key"):
                action_key = str(next_action.get("action_key") or "")
                action_html = (
                    f'<form method="post" action="/opportunities/{esc(opportunity_id)}/operations/{esc(action_key)}">'
                    f'<div class="input-group input-group-sm">'
                    f'<input type="text" class="form-control" name="external_name" placeholder="Referência opcional">'
                    f'<button class="btn btn-primary" type="submit">{esc(main_label)}</button>'
                    f'</div>'
                    f'</form>'
                )
            elif next_kind == "sync_odoo":
                action_html = (
                    f'<form method="post" action="/opportunities/{esc(opportunity_id)}/odoo/sync-status">'
                    f'<button class="btn btn-primary btn-sm" type="submit">Sincronizar Odoo</button>'
                    f'</form>'
                )
            elif next_kind == "wait":
                action_html = '<span class="badge bg-warning text-dark">Aguardar</span>'
            elif next_kind == "manual" and str(next_action.get("action_code") or "").upper() == "SEND_INVOICE":
                action_html = f'<a class="btn btn-primary btn-sm" href="/opportunities/{esc(opportunity_id)}#documentos">Enviar fatura</a>'
            else:
                action_html = '<span class="badge bg-secondary">Sem ação</span>'
    def step_visual(status):
        s = str(status or "").lower()
        if s in {"confirmed", "issued", "created", "validated", "sent", "delivered", "done", "ready_to_ship", "shipped"}:
            return "bg-success text-white", "✓"
        if s in {"in_progress", "pending", "running", "open", "in_production"}:
            return "bg-warning text-dark", "…"
        if s in {"failed", "blocked", "cancelled", "not_found"}:
            return "bg-danger text-white", "!"
        return "bg-light text-secondary border", "○"
    short_labels = {
        "payment": "Pagamento",
        "proforma": "Orçamento legado",
        "odoo_sale_order": "Venda",
        "odoo_production": "Produção",
        "odoo_physical_status": "Estado físico Odoo",
        "physical_status": "Odoo",
        "physical_validation": "Validado",
        "jasmin_quotation": "Orçamento",
        "jasmin_invoice": "Fatura",
        "packlink_shipment": "Envio",
        "tracking": "Tracking",
        "delivery": "Entregue",
    }
    steps_html = ""
    for card in cards:
        key = str(card.get("key") or "")
        if key == "odoo_production":
            continue
        badge_class, mark = step_visual(card.get("status"))
        label = short_labels.get(key, card.get("label") or "")
        url = str(card.get("external_url") or "").strip()
        title = card.get("external_name") or card.get("status_label") or label
        link_open = ""
        if url:
            link_open = (
                '<a class="stretched-link" '
                f'href="{esc(url)}" target="_blank" rel="noopener" title="{esc(title)}"></a>'
            )
        steps_html += (
            '<div class="col">'
            '<div class="card border-0 bg-light h-100 position-relative">'
            '<div class="card-body p-2 text-center">'
            f'<span class="badge rounded-pill {badge_class} mb-1">{mark}</span>'
            f'<div class="small fw-semibold text-truncate" title="{esc(label)}">{esc(label)}</div>'
            f'{link_open}'
            '</div>'
            '</div>'
            '</div>'
        )
    if not steps_html:
        steps_html = '<div class="text-secondary small">Sem integrações registadas.</div>'
    if has_invoice_card and str(main_label).strip().casefold() == "criar orçamento jasmin":
        main_label = "Acompanhar fatura"
        main_reason = "Já existe fatura Jasmin associada; não criar novo orçamento neste processo."
        main_extra = "Confirma pagamento, envio ou marca como histórico/concluído."
        action_html = '<span class="badge bg-warning text-dark">Rever processo</span>'
    reason_html = ""
    if main_reason:
        reason_html += f'<div class="small text-secondary">{esc(main_reason)}</div>'
    if main_extra:
        reason_html += f'<div class="small text-secondary">{esc(main_extra)}</div>'
    return (
        '<section class="card cf-card mb-4">'
        '<div class="card-body p-3">'
        '<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">'
        '<div>'
        '<div class="cf-eyebrow">Fluxo operacional</div>'
        f'<h2 class="h5 mb-1">{esc(main_label)}</h2>'
        f'{reason_html}'
        '</div>'
        f'<div>{action_html}</div>'
        '</div>'
        '<div class="row row-cols-3 row-cols-md-5 row-cols-xl-8 g-2">'
        f'{steps_html}'
        '</div>'
        '</div>'
        '</section>'
    )
def task_priority_chip(task: dict) -> str:
    priority = str(task.get("priority") or "").strip().lower()
    if is_task_overdue(task) or priority == "alta":
        return '<span class="cf-chip cf-chip-red">Alta</span>'
    if priority == "normal":
        return '<span class="cf-chip cf-chip-orange">Normal</span>'
    if priority == "baixa":
        return '<span class="cf-chip cf-chip-gray">Baixa</span>'
    route = str(task.get("route") or "")
    if route in {"financeiro", "operacoes"}:
        return '<span class="cf-chip cf-chip-orange">Normal</span>'
    return '<span class="cf-chip cf-chip-gray">Baixa</span>'
def pretty_json(value) -> str:
    return json.dumps(value or {}, ensure_ascii=False, indent=2, default=str)
def status_badge(status: str) -> str:
    value = str(status or "").strip() or "unknown"
    label = {
        "pending": "Pendente",
        "done": "Concluída",
        "skipped": "Ignorada",
        "failed": "Falha",
        "sent": "Enviada",
        "ignored": "Ignorada",
    }.get(value, value)
    cls = {
        "pending": "status-pending",
        "done": "status-done",
        "skipped": "status-skipped",
        "failed": "status-failed",
        "sent": "status-done",
        "ignored": "status-skipped",
    }.get(value, "status-skipped")
    return f'<span class="status-badge {esc(cls)}">{esc(label)}</span>'
def route_badge(route: str) -> str:
    value = str(route or "").strip() or "rever"
    label = {
        "vendas": "COMERCIAL",
        "financeiro": "FINANCEIRO",
        "suporte": "SUPORTE",
        "operacoes": "LOGÍSTICA",
        "spam": "SPAM",
        "rever": "REVER",
    }.get(value, value.upper())
    cls = {
        "vendas": "route-vendas",
        "financeiro": "route-financeiro",
        "suporte": "route-suporte",
        "operacoes": "route-operacoes",
        "spam": "route-rever",
        "rever": "route-rever",
    }.get(value, "route-rever")
    return f'<span class="route-badge {esc(cls)}">{esc(label)}</span>'
def task_sla_minutes(route: str) -> int:
    return {
        "suporte": 120,
        "vendas": 240,
        "financeiro": 480,
        "operacoes": 1440,
        "rever": 1440,
    }.get(route or "", 1440)
def _coerce_datetime_utc(value):
    if not value:
        return None
    if isinstance(value, str):
        try:
            value = datetime.fromisoformat(value.replace("Z", "+00:00"))
        except Exception:
            return None
    if getattr(value, "tzinfo", None) is None:
        value = value.replace(tzinfo=timezone.utc)
    return value
def task_age_minutes(task: dict) -> int:
    created_at = _coerce_datetime_utc(task.get("created_at"))
    if not created_at:
        return 0
    return max(0, int((datetime.now(timezone.utc) - created_at).total_seconds() // 60))
def task_due_minutes(task: dict) -> Optional[int]:
    due_at = _coerce_datetime_utc(task.get("due_at"))
    if not due_at:
        return None
    return int((due_at - datetime.now(timezone.utc)).total_seconds() // 60)
def is_task_overdue(task: dict) -> bool:
    if task.get("status") != "pending":
        return False
    due_minutes = task_due_minutes(task)
    if due_minutes is not None:
        return due_minutes < 0
    return task_age_minutes(task) > task_sla_minutes(task.get("route"))
def is_task_today(task: dict) -> bool:
    due_at = _coerce_datetime_utc(task.get("due_at"))
    created_at = _coerce_datetime_utc(task.get("created_at"))
    dt = due_at or created_at
    if not dt:
        return False
    now = datetime.now(timezone.utc)
    return dt.date() == now.date()
def sla_badge_html(task: dict) -> str:
    if task.get("status") != "pending":
        return ""
    due_minutes = task_due_minutes(task)
    if due_minutes is not None:
        if due_minutes < 0:
            overdue = abs(due_minutes)
            label = f"Follow-up atrasado {overdue // 60}h" if overdue >= 60 else f"Follow-up atrasado {overdue}m"
            return f'<span class="sla-badge sla-overdue">{esc(label)}</span>'
        if due_minutes <= 24 * 60:
            label = f"Vence hoje" if due_minutes >= 0 else "Vencido"
            return f'<span class="sla-badge sla-ok">{esc(label)}</span>'
        days = max(1, due_minutes // (24 * 60))
        return f'<span class="sla-badge sla-ok">{esc(f"Follow-up D+{days}")}</span>'
    age = task_age_minutes(task)
    sla = task_sla_minutes(task.get("route"))
    if age > sla:
        overdue = age - sla
        label = f"Atrasada {overdue // 60}h" if overdue >= 60 else f"Atrasada {overdue}m"
        return f'<span class="sla-badge sla-overdue">{esc(label)}</span>'
    remaining = sla - age
    label = f"SLA {remaining // 60}h" if remaining >= 60 else f"SLA {remaining}m"
    return f'<span class="sla-badge sla-ok">{esc(label)}</span>'
# ADMIN_UI_V451_CSS moved to app.admin_ui.styles in v4.7.
# Layout, navigation and KPI card helpers moved to app.admin_ui in v4.7.
def is_htmx(request: Request) -> bool:
    return str(request.headers.get("HX-Request") or "").lower() == "true"
@router.get("/ui.css")
async def admin_ui_css():
    return Response(ADMIN_UI_V451_CSS, media_type="text/css")
def opportunity_stage_badge(stage: str) -> str:
    classes = {
        "NEW_LEAD": "cf-chip-blue",
        "INFO_REQUESTED": "cf-chip-blue",
        "INFO_SENT": "cf-chip-green",
        "QUOTE_REQUESTED": "cf-chip-orange",
        "QUOTE_SENT": "cf-chip-green",
        "PROFORMA_REQUESTED": "cf-chip-orange",
        "PROFORMA_SENT": "cf-chip-green",
        "INVOICE_REQUESTED": "cf-chip-orange",
        "INVOICE_SENT": "cf-chip-green",
        "WAITING_PAYMENT": "cf-chip-orange",
        "PAYMENT_CONFIRMED": "cf-chip-green",
        "ORDER_PREPARATION": "cf-chip-purple",
        "SHIPPED": "cf-chip-purple",
        "WON": "cf-chip-green",
        "LOST": "cf-chip-red",
        "NO_INTEREST": "cf-chip-gray",
        "REVIEW": "cf-chip-gray",
        "ARCHIVED": "cf-chip-gray",
    }
    return f'<span class="cf-chip {esc(classes.get(str(stage or ""), "cf-chip-gray"))}">{esc(stage_label(stage))}</span>'
def _opportunity_board_column_for_stage(stage: str) -> str:
    stage = str(stage or "")
    for key, _label, stages in OPPORTUNITY_BOARD_COLUMNS:
        if stage in stages:
            return key
    return "requests"
def money_html(value, currency: str = "€") -> str:
    try:
        number = float(value or 0)
    except Exception:
        number = 0.0
    formatted = f"{number:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
    return f"{formatted} {esc(currency)}"
def product_status_badge(active) -> str:
    if active:
        return '<span class="cf-chip cf-chip-green">Ativo</span>'
    return '<span class="cf-chip cf-chip-gray">Inativo</span>'
def item_status_label(status: str) -> str:
    labels = {
        "INTERESTED": "Em análise",
        "QUOTED": "Orçamentado",
        "ACCEPTED": "Aceite",
        "REJECTED": "Rejeitado",
        "CANCELLED": "Cancelado",
        "UNAVAILABLE": "Indisponível",
    }
    return labels.get(str(status or "").upper(), str(status or "—"))
def item_status_badge(status: str) -> str:
    status = str(status or "").upper()
    cls = {
        "INTERESTED": "cf-chip-orange",
        "QUOTED": "cf-chip-blue",
        "ACCEPTED": "cf-chip-green",
        "REJECTED": "cf-chip-red",
        "CANCELLED": "cf-chip-gray",
        "UNAVAILABLE": "cf-chip-gray",
    }.get(status, "cf-chip-gray")
    return f'<span class="cf-chip {cls}">{esc(item_status_label(status))}</span>'
def product_form_html(product: Optional[dict] = None, *, action: str = "/products", submit_label: str = "Guardar produto") -> str:
    product = product or {}
    checked = "checked" if product.get("active", True) else ""
    return f'''
    <form method="post" action="{esc(action)}" class="row g-3">
      <div class="col-md-4">
        <label class="form-label small fw-bold text-secondary">SKU interno / Odoo</label>
        <input class="form-control" name="sku" value="{esc(product.get('sku') or '')}" placeholder="ODOO-1" required>
      </div>
      <div class="col-md-4">
        <label class="form-label small fw-bold text-secondary">Artigo Jasmin</label>
        <input class="form-control" name="jasmin_sales_item" value="{esc(product.get('jasmin_sales_item') or '')}" placeholder="CARREGADOR_MONO_7KW">
        <div class="form-text">Usado para criar orçamentos/faturas Jasmin. Pode ser diferente do SKU Odoo.</div>
      </div>
      <div class="col-md-4">
        <label class="form-label small fw-bold text-secondary">Nome</label>
        <input class="form-control" name="name" value="{esc(product.get('name') or '')}" placeholder="Carregador EV 7.4kW monofásico" required>
      </div>
      <div class="col-md-4">
        <label class="form-label small fw-bold text-secondary">Categoria</label>
        <input class="form-control" name="category" value="{esc(product.get('category') or 'Carregadores')}" placeholder="Carregadores">
      </div>
      <div class="col-md-4">
        <label class="form-label small fw-bold text-secondary">Preço base sem IVA</label>
        <input class="form-control" name="default_unit_price" value="{esc(product.get('default_unit_price') if product.get('default_unit_price') is not None else '')}" placeholder="590.00" inputmode="decimal">
      </div>
      <div class="col-md-4">
        <label class="form-label small fw-bold text-secondary">IVA %</label>
        <input class="form-control" name="vat_rate" value="{esc(product.get('vat_rate') if product.get('vat_rate') is not None else '23.00')}" placeholder="23.00" inputmode="decimal">
      </div>
      <div class="col-12">
        <label class="form-label small fw-bold text-secondary">Descrição / texto comercial</label>
        <textarea class="form-control" name="description" rows="4" placeholder="Descrição curta para propostas e respostas sugeridas">{esc(product.get('description') or '')}</textarea>
      </div>
      <div class="col-12">
        <label class="form-check">
          <input class="form-check-input" type="checkbox" name="active" value="true" {checked}>
          <span class="form-check-label">Produto ativo</span>
        </label>
      </div>
      <div class="col-12 d-flex flex-wrap gap-2">
        <button class="btn btn-primary" type="submit">{esc(submit_label)}</button>
        <a class="btn btn-outline-secondary" href="/products">Cancelar</a>
      </div>
    </form>
    '''
def _outbox_items_for_opportunity(opportunity_id: str, *, target_system: str | None = "jasmin", limit: int = 30) -> list[dict]:
    try:
        items = list_outbox(target_system=target_system, limit=300)
    except Exception:
        return []
    filtered = []
    for item in items:
        payload = item.get("payload") or {}
        if isinstance(payload, str):
            try:
                payload = json.loads(payload)
            except Exception:
                payload = {}
        if str(payload.get("opportunity_id") or "") == str(opportunity_id):
            item = dict(item)
            item["payload"] = payload
            filtered.append(item)
        if len(filtered) >= limit:
            break
    return filtered
def opportunity_integrations_panel_html(opportunity_id: str) -> str:
    items = _outbox_items_for_opportunity(opportunity_id, target_system=None, limit=16)
    counts = {"pending": 0, "failed": 0, "blocked": 0, "dry_run": 0}
    for item in items:
        status = str(item.get("status") or "pending")
        if status in counts:
            counts[status] += 1
    rows = ""
    for item in items[:8]:
        status = str(item.get("status") or "pending")
        row_cls = f"cf-outbox-row-{status}"
        err = compact_text(item.get("last_error") or "", 160)
        actions = ""
        if status in {"failed", "blocked", "dry_run", "ignored", "cancelled"}:
            actions = f'''
            <form method="post" action="/outbox/{esc(item.get('id'))}/retry" class="d-inline">
              <input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}">
              <button class="btn btn-sm btn-outline-primary" type="submit">Reprocessar</button>
            </form>
            '''
        elif status == "pending":
            actions = '<span class="small text-secondary">A aguardar timer</span>'
        rows += f'''
        <tr class="{esc(row_cls)}">
          <td><strong>{esc(item.get('target_system'))}</strong><div class="small text-secondary"><code>{esc(item.get('action_type'))}</code></div></td>
          <td>{operation_status_badge(status)}</td>
          <td class="small text-secondary">{esc(fmt_dt(item.get('updated_at') or item.get('created_at')))}</td>
          <td class="small text-danger text-break">{esc(err)}</td>
          <td class="text-end">{actions}</td>
        </tr>
        '''
    if not rows:
        rows = '<tr><td colspan="5" class="text-secondary py-4">Sem ações de integração para esta oportunidade.</td></tr>'
    return f'''
    <section class="card cf-card">
      <div class="card-body p-0">
        <div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
          <div>
            <h2 class="cf-section-title">Integrações da oportunidade</h2>
            <div class="small text-secondary">Estado operacional das ações Jasmin, Packlink, Chatwoot e Mautic ligadas a esta oportunidade.</div>
          </div>
          <a class="btn btn-sm btn-outline-secondary" href="/outbox">Ver outbox</a>
        </div>
        <div class="p-3 border-bottom">
          <div class="cf-op-alert-grid">
            <div class="cf-op-alert"><span>Pendentes</span><strong>{counts['pending']}</strong></div>
            <div class="cf-op-alert"><span>Falhadas</span><strong>{counts['failed']}</strong></div>
            <div class="cf-op-alert"><span>Bloqueadas</span><strong>{counts['blocked']}</strong></div>
            <div class="cf-op-alert"><span>Dry-run</span><strong>{counts['dry_run']}</strong></div>
          </div>
        </div>
        <div class="cf-table-wrap border-0 rounded-0">
          <table class="table cf-table">
            <thead><tr><th>Sistema / Ação</th><th>Estado</th><th>Atualizado</th><th>Último erro</th><th class="text-end">Ação</th></tr></thead>
            <tbody>{rows}</tbody>
          </table>
        </div>
      </div>
    </section>
    '''
def opportunity_outbox_panel_html(opportunity_id: str, *, target_system: str = "jasmin") -> str:
    items = _outbox_items_for_opportunity(opportunity_id, target_system=target_system, limit=12)
    if not items:
        return ""
    rows = ""
    for item in items:
        retry = ""
        if item.get("status") in {"failed", "pending"}:
            retry = f'''
            <form method="post" action="/outbox/{esc(item.get('id'))}/retry" hx-post="/outbox/{esc(item.get('id'))}/retry" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" class="d-inline">
              <input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}">
              <button class="btn btn-sm btn-outline-primary" type="submit">Reprocessar</button>
            </form>
            '''
        err = esc(item.get("last_error") or "")
        if err and len(err) > 180:
            err = err[:180] + "…"
        rows += f'''
        <tr>
          <td><code>{esc(item.get('action_type'))}</code><div class="small text-secondary">{esc(fmt_dt(item.get('created_at')))}</div></td>
          <td>{operation_status_badge(str(item.get('status') or 'pending'))}</td>
          <td class="small text-danger text-break">{err}</td>
          <td class="text-end">{retry}</td>
        </tr>
        '''
    return f'''
    <div class="p-3 border-top cf-outbox-mini">
      <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
        <div><strong>Ações Jasmin / outbox</strong><div class="small text-secondary">Processamento automático ou manual das ações pedidas pelos botões.</div></div>
        <a class="btn btn-sm btn-outline-secondary" href="/outbox?target_system=jasmin">Ver outbox</a>
      </div>
      <div class="cf-table-wrap border-0 rounded-0">
        <table class="table cf-table"><thead><tr><th>Ação</th><th>Estado</th><th>Erro</th><th></th></tr></thead><tbody>{rows}</tbody></table>
      </div>
    </div>
    '''
def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice: str = "") -> str:
    try:
        from app.commercial_service import list_commercial_documents
        docs = list_commercial_documents(opportunity_id=opportunity_id, limit=20)
    except Exception as exc:
        docs = []
        error = str(exc)
    else:
        error = ""
    try:
        from app.jasmin_backfill_service import find_jasmin_document_candidates_for_opportunity
        jasmin_candidates = find_jasmin_document_candidates_for_opportunity(opportunity_id, limit=8)
    except Exception as exc:
        jasmin_candidates = []
        if not error:
            error = f"Erro ao procurar documentos Jasmin existentes: {exc}"
    linked_tax_id = ""
    try:
        from app.commercial_service import get_customer_for_opportunity, normalize_tax_id
        linked_customer = get_customer_for_opportunity(opportunity_id)
        linked_tax_id = normalize_tax_id((linked_customer or {}).get("tax_id"))
    except Exception:
        linked_tax_id = ""
    rows = ""
    kind_labels = {"quotation": "Orçamento", "proforma": "Orçamento legado", "invoice": "Fatura"}
    for doc in docs:
        number = commercial_document_display_number(doc, fallback="número por atualizar")
        amount = doc.get("total_amount") if doc.get("total_amount") is not None else doc.get("amount")
        kind = kind_labels.get(str(doc.get("document_kind") or ""), doc.get("document_kind") or "Documento")
        doc_id = str(doc.get("id") or "")
        role = str(doc.get("role") or "current")
        is_primary = bool(doc.get("is_primary"))
        make_primary_action = "" if (role in {"current", "accepted"} and is_primary) else f'<form method="post" action="/commercial-documents/{esc(doc_id)}/role" hx-post="/commercial-documents/{esc(doc_id)}/role" hx-target="#jasmin-documents-panel" hx-swap="outerHTML"><input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}"><input type="hidden" name="role" value="current"><input type="hidden" name="make_primary" value="1"><button class="btn btn-sm btn-outline-success" type="submit">Definir principal</button></form>'
        historical_action = "" if role == "historical" else f'<form method="post" action="/commercial-documents/{esc(doc_id)}/role" hx-post="/commercial-documents/{esc(doc_id)}/role" hx-target="#jasmin-documents-panel" hx-swap="outerHTML"><input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}"><input type="hidden" name="role" value="historical"><input type="hidden" name="make_primary" value="0"><button class="btn btn-sm btn-outline-secondary" type="submit">Histórico</button></form>'
        unlink_action = f'<form method="post" action="/commercial-documents/{esc(doc_id)}/unlink-from-opportunity" hx-post="/commercial-documents/{esc(doc_id)}/unlink-from-opportunity" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Desassociar apenas este documento desta oportunidade? Não apaga nada no Jasmin; remove só a ligação local e as linhas importadas deste documento."><input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}"><input type="hidden" name="remove_imported_lines" value="1"><input type="hidden" name="note" value="Documento pertence a outra compra/processo; desassociado manualmente."><button class="btn btn-sm btn-outline-danger" type="submit">Desassociar este</button></form>'
        refresh_action = f'<form method="post" action="/commercial-documents/{esc(doc_id)}/refresh" hx-post="/commercial-documents/{esc(doc_id)}/refresh" hx-target="#jasmin-documents-panel" hx-swap="outerHTML"><input type="hidden" name="opportunity_id" value="{esc(opportunity_id)}"><button class="btn btn-sm btn-outline-secondary" type="submit">Atualizar nº</button></form>'
        pdf_action = f'<a class="btn btn-sm btn-outline-primary" href="/commercial-documents/{esc(doc_id)}/pdf" target="_blank" rel="noopener">PDF</a>'
        actions = f'<div class="d-flex flex-wrap gap-1 justify-content-end">{make_primary_action}{historical_action}{unlink_action}{refresh_action}{pdf_action}</div>'
        role_label = {
            "current": "Atual",
            "accepted": "Aceite",
            "historical": "Histórico",
            "cancelled": "Cancelado",
            "related": "Relacionado",
        }.get(str(doc.get("role") or "current"), str(doc.get("role") or "current"))
        role_class = "text-bg-primary" if str(doc.get("role") or "current") in {"current", "accepted"} and doc.get("is_primary") else "text-bg-light"
        external_id_html = ""
        if not doc.get("document_number") and doc.get("external_id") and not is_uuid_text(doc.get("external_id")):
            external_id_html = f"<div class='small text-secondary text-break'>ref. externa {esc(doc.get('external_id') or '')}</div>"
        elif not doc.get("document_number") and doc.get("external_id"):
            external_id_html = "<div class='small text-secondary'>ID técnico oculto; usar Atualizar nº.</div>"
        rows += (
            "<tr>"
            f"<td><strong>{esc(kind)}</strong><div class='small text-secondary'>v{esc(doc.get('version_number') or '—')}</div><span class='badge {role_class}'>{esc(role_label)}</span></td>"
            f"<td><code>{esc(number)}</code>{external_id_html}</td>"
            f"<td>{operation_status_badge(str(doc.get('status') or 'created'))}</td>"
            f"<td>{money_html(amount or 0)}</td>"
            f"<td class='small text-secondary'>{esc(fmt_dt(doc.get('created_at')))}</td>"
            f"<td class='text-end'>{actions}</td>"
            "</tr>"
        )
    if not rows:
        rows = '<tr><td colspan="6" class="text-secondary py-4">Ainda sem documentos Jasmin nesta oportunidade.</td></tr>'
    current_jasmin_docs_exist = bool(docs)
    unlink_jasmin_button_html = ""
    if current_jasmin_docs_exist:
        unlink_jasmin_button_html = f"""
            <form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/unlink" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/unlink" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Desassociar documentos Jasmin desta oportunidade? Não apaga nada no Jasmin; remove apenas documentos/linhas locais ClientFlow e envia candidatos para revisão.">
              <button class="btn btn-outline-danger btn-sm" type="submit">Desassociar Jasmin</button>
            </form>
        """
    invoice_source_exists = any(
        str(doc.get("document_kind") or "") in {"quotation", "proforma"}
        and str(doc.get("status") or "").lower() not in {"cancelled", "failed"}
        and str(doc.get("role") or "current") in {"current", "accepted", "related"}
        for doc in docs
    )
    candidate_rows = ""
    ignored_rows = ""
    valid_candidate_count = 0
    ignored_candidate_count = 0
    hidden_other_customer_count = 0
    for item in jasmin_candidates:
        totals = item.get("totals") if isinstance(item.get("totals"), dict) else {}
        total_amount = totals.get("total_amount") or item.get("amount") or 0
        doc_number = item.get("document_number") or (item.get("external_id") if not is_uuid_text(item.get("external_id")) else None) or "número por atualizar"
        match_reason = item.get("match_reason") or "match"
        match_score = item.get("match_score") or ""
        item_id = str(item.get("id") or "")
        is_valid = bool(item.get("is_valid_candidate"))
        candidate_tax = ""
        try:
            from app.commercial_service import normalize_tax_id
            candidate_tax = normalize_tax_id(item.get("customer_tax_id"))
        except Exception:
            candidate_tax = str(item.get("customer_tax_id") or "").strip()
        other_customer = bool(linked_tax_id and candidate_tax and candidate_tax != linked_tax_id)
        tax_conflict = bool(other_customer)
        invalid_reason = item.get("invalid_reason") or ""
        if tax_conflict:
            # Segurança operacional: um documento Jasmin de NIF diferente nunca deve
            # aparecer como candidato acionável. Fica apenas em auditoria/revisão.
            is_valid = False
            invalid_reason = invalid_reason or "NIF divergente do cliente fiscal validado; rever manualmente."
        if is_valid:
            valid_candidate_count += 1
        else:
            ignored_candidate_count += 1
        status_label = item.get("jasmin_status_label") or "—"
        status_badge_html = (
            '<span class="badge text-bg-danger">NIF divergente</span>'
            if tax_conflict
            else (
                '<span class="badge text-bg-success">Aberto/válido</span>'
                if is_valid
                else '<span class="badge text-bg-secondary">Ignorado</span>'
            )
        )
        if is_valid:
            is_invoice_candidate = str(item.get('external_type') or '') == 'jasmin_invoice'
            if current_jasmin_docs_exist and is_invoice_candidate:
                action_html = f'''
                <div class="d-flex flex-column gap-1 align-items-end">
                  <form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Associar esta fatura à mesma oportunidade? Usa apenas se é a fatura emitida a partir deste orçamento/processo.">
                    <button class="btn btn-sm btn-primary" type="submit">Associar fatura</button>
                  </form>
                  <form method="post" action="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-post="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Ignorar esta fatura candidata nesta oportunidade? Não altera o documento no Jasmin.">
                    <button class="btn btn-sm btn-outline-danger" type="submit">Ignorar</button>
                  </form>
                  <span class="small text-secondary text-end">Fatura do mesmo cliente/processo. Deve ser associada como documento seguinte, não substituir o orçamento.</span>
                </div>
                '''
            elif current_jasmin_docs_exist:
                action_html = f'''
                <div class="d-flex flex-column gap-1 align-items-end">
                  <form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/replace-candidate/{esc(item_id)}" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/replace-candidate/{esc(item_id)}" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Substituir o documento Jasmin atual por este candidato aberto? Mantém histórico no ClientFlow; não apaga nada no Jasmin.">
                    <button class="btn btn-sm btn-warning" type="submit">Substituir atual</button>
                  </form>
                  <form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Associar este documento adicional a esta oportunidade? Usa apenas se pertence à mesma compra/processo.">
                    <button class="btn btn-sm btn-outline-primary" type="submit">Associar adicional</button>
                  </form>
                  <form method="post" action="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-post="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Ignorar este candidato Jasmin nesta oportunidade? Não altera o documento no Jasmin.">
                    <button class="btn btn-sm btn-outline-danger" type="submit">Ignorar</button>
                  </form>
                  <span class="small text-secondary text-end">Já existe documento atual. Usa Substituir para trocar o principal ou Associar adicional quando pertence à mesma compra.</span>
                </div>
                '''
            else:
                action_html = f'''
                <div class="d-flex flex-column gap-1 align-items-end">
                  <form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/link-candidate/{esc(item_id)}" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Associar este documento Jasmin à oportunidade e importar linhas/valor?">
                    <button class="btn btn-sm btn-primary" type="submit">Associar e importar</button>
                  </form>
                  <form method="post" action="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-post="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Ignorar este candidato Jasmin nesta oportunidade? Não altera o documento no Jasmin.">
                    <button class="btn btn-sm btn-outline-danger" type="submit">Ignorar</button>
                  </form>
                </div>
                '''
        else:
            action_html = (
                '<button class="btn btn-sm btn-outline-danger" type="button" disabled>NIF divergente — rever manualmente</button>'
                if tax_conflict
                else f'<form method="post" action="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-post="/opportunities/{esc(opportunity_id)}/external-candidate/{esc(item_id)}/ignore" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Ignorar este candidato Jasmin nesta oportunidade?"><button class="btn btn-sm btn-outline-secondary" type="submit">Ignorar</button></form>'
            )
        candidate_customer_meta = (
            f'<div class="small text-secondary">NIF {esc(item.get("customer_tax_id") or "—")}</div>'
            if is_valid
            else (
                '<div class="small text-secondary">NIF divergente oculto em auditoria</div>'
                if tax_conflict
                else '<div class="small text-secondary">NIF oculto em auditoria</div>'
            )
        )
        row_html = f'''
        <tr class="{'table-warning' if not is_valid else ''}">
          <td><strong>{esc(doc_number)}</strong><div class="small text-secondary">{esc(item.get('external_type') or 'jasmin')}</div><div class="small text-secondary">{esc(fmt_dt(item.get('document_date') or item.get('updated_at')))}</div></td>
          <td>{money_html(total_amount or 0)}<div class="small text-secondary">{esc(item.get('currency') or 'EUR')}</div></td>
          <td>{esc(item.get('customer_name') or '—')}{candidate_customer_meta}</td>
          <td>{status_badge_html}<div class="small text-secondary">Jasmin: {esc(status_label)} {esc(item.get('jasmin_status_code') or '')}</div><div class="small text-secondary">{esc(invalid_reason)}</div></td>
          <td><span class="badge text-bg-light">{esc(match_reason)} {esc(match_score)}</span><div class="small text-secondary">{esc(item.get('line_count') or 0)} linha(s)</div></td>
          <td class="text-end">{action_html}</td>
        </tr>
        '''
        if is_valid:
            candidate_rows += row_html
        else:
            ignored_rows += row_html
    candidates_html = ""
    if candidate_rows or ignored_rows or hidden_other_customer_count:
        if candidate_rows:
            candidates_html += f'''
            <div class="alert alert-warning py-2 small mx-3 mt-3 mb-0">
              <strong>Candidatos Jasmin acionáveis encontrados.</strong><br>
              Valida antes de substituir ou associar, especialmente em processos antigos/reconstruídos.
            </div>
            <div class="cf-table-wrap border-0 rounded-0">
              <table class="table cf-table">
                <thead><tr><th>Documento encontrado</th><th>Valor</th><th>Cliente Jasmin</th><th>Validação</th><th>Match</th><th class="text-end">Ação</th></tr></thead>
                <tbody>{candidate_rows}</tbody>
              </table>
            </div>
            '''
        else:
            candidates_html += '''
            <div class="alert alert-info py-2 small mx-3 mt-3 mb-0">
              <strong>Candidatos adicionais</strong><br>
              Nenhum documento Jasmin candidato seguro encontrado. Documentos antigos, cancelados ou de outro cliente ficam apenas na auditoria.
            </div>
            '''
        if ignored_rows:
            candidates_html += f'''
            <details class="mx-3 mt-2 mb-0">
              <summary class="small fw-bold text-secondary" style="cursor:pointer">Documentos ignorados / auditoria</summary>
              <div class="cf-table-wrap border-0 rounded-0 mt-2">
                <table class="table cf-table">
                  <thead><tr><th>Documento</th><th>Valor</th><th>Cliente Jasmin</th><th>Validação</th><th>Match</th><th class="text-end">Ação</th></tr></thead>
                  <tbody>{ignored_rows}</tbody>
                </table>
              </div>
            </details>
            '''
        if hidden_other_customer_count:
            candidates_html += f'<div class="small text-secondary mx-3 mt-2">{hidden_other_customer_count} documento(s) ignorado(s) de outro NIF ocultados da lista principal.</div>'
    notice_html = f'<div class="alert alert-info py-2 small mb-2">{esc(notice)}</div>' if notice else ''
    error_notice_html = f'<div class="alert alert-danger py-2 small mb-2"><strong>Não foi possível pedir a ação.</strong><br>{esc(error_notice).replace(chr(10), "<br>")}</div>' if error_notice else ''
    error_html = f'<div class="alert alert-warning py-2 small mb-2">{esc(error)}</div>' if error else ''
    outbox_html = opportunity_outbox_panel_html(opportunity_id, target_system="jasmin")
    refreshed_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
    if invoice_source_exists:
        convert_invoice_button_html = (
            f'<form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/convert-invoice" '
            f'hx-post="/opportunities/{esc(opportunity_id)}/jasmin/convert-invoice" '
            'hx-target="#jasmin-documents-panel" hx-swap="outerHTML">'
            '<button class="btn btn-outline-primary btn-sm" type="submit">Converter em fatura <span class="htmx-indicator">…</span></button>'
            '</form>'
        )
    else:
        # Legacy static-test anchor: É necessário um orçamento ou pró-forma atual para converter
        convert_invoice_button_html = '<button class="btn btn-outline-secondary btn-sm" type="button" disabled title="É necessário um orçamento atual para converter.">Converter em fatura</button>'
    if current_jasmin_docs_exist:
        create_quotation_button_html = f'''
            <form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/create-quotation" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/create-quotation" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Esta oportunidade já tem documento Jasmin associado. Criar novo orçamento adicional mesmo assim?">
              <button class="btn btn-outline-secondary btn-sm" type="submit">Novo orçamento adicional <span class="htmx-indicator">…</span></button>
            </form>
        '''
    else:
        create_quotation_button_html = f'''
            <form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/create-quotation" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/create-quotation" hx-target="#jasmin-documents-panel" hx-swap="outerHTML">
              <button class="btn btn-primary btn-sm" type="submit">Criar orçamento <span class="htmx-indicator">…</span></button>
            </form>
        '''
    return f'''
    <section id="jasmin-documents-panel" class="card cf-card cf-live-panel">
      <div class="card-body p-0">
        <div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
          <div>
            <h2 class="cf-section-title">Documentos Jasmin</h2>
            <div class="small text-secondary">Escolhe quais documentos Jasmin pertencem a esta oportunidade. Em clientes com várias compras próximas, define o documento principal ou desassocia apenas o documento errado.</div>
            <div class="small text-secondary">Última atualização: {esc(refreshed_at)}. Atualização manual para evitar reconstrução automática da janela.</div>
          </div>
          <div class="d-flex flex-wrap gap-2">
            {unlink_jasmin_button_html}
            {create_quotation_button_html}
            {convert_invoice_button_html}
            <form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/reimport-details" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/reimport-details" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Reimportar detalhes Jasmin para esta oportunidade? Atualiza documentos, linhas, produtos e valor.">
              <button class="btn btn-outline-secondary btn-sm" type="submit">Reimportar detalhes <span class="htmx-indicator">…</span></button>
            </form>
            <form method="post" action="/opportunities/{esc(opportunity_id)}/jasmin/sync-candidates" hx-post="/opportunities/{esc(opportunity_id)}/jasmin/sync-candidates" hx-target="#jasmin-documents-panel" hx-swap="outerHTML" hx-confirm="Sincronizar documentos Jasmin recentes antes de escolher candidato?">
              <button class="btn btn-outline-secondary btn-sm" type="submit">Sincronizar Jasmin</button>
            </form>
            <button class="btn btn-outline-secondary btn-sm" type="button" hx-get="/opportunities/{esc(opportunity_id)}/partials/jasmin-documents" hx-target="#jasmin-documents-panel" hx-swap="outerHTML">Atualizar estado</button>
          </div>
        </div>
        <div class="p-3 pb-0">{notice_html}{error_notice_html}{error_html}</div>
        <div class="px-3 pt-3 pb-2 border-top">
          <h3 class="h6 fw-bold mb-1">Documentos associados</h3>
          <div class="small text-secondary">Documentos já ligados a esta oportunidade. Usa candidatos adicionais apenas quando forem da mesma compra/processo.</div>
        </div>
        <div class="cf-table-wrap border-0 rounded-0">
          <table class="table cf-table">
            <thead><tr><th>Tipo</th><th>Número/ID</th><th>Estado</th><th>Valor</th><th>Criado</th><th class="text-end">Ações</th></tr></thead>
            <tbody>{rows}</tbody>
          </table>
        </div>
        <div class="px-3 pt-3 pb-2 border-top">
          <h3 class="h6 fw-bold mb-1">Candidatos adicionais</h3>
          <div class="small text-secondary">Documentos Jasmin encontrados por reconciliação que ainda não estão associados como documento principal.</div>
        </div>
        {candidates_html}
        {outbox_html}
      </div>
    </section>
    '''
def _opportunity_item_metadata(item: dict) -> dict:
    raw = item.get("metadata") if isinstance(item, dict) else {}
    if isinstance(raw, dict):
        return raw
    if isinstance(raw, str) and raw.strip():
        try:
            data = json.loads(raw)
            return data if isinstance(data, dict) else {}
        except Exception:
            return {}
    return {}
DEFAULT_NON_BILLABLE_ODOO_LINE_PATTERNS = (
    "delivery_007",
    "standard delivery",
    "shipping",
    "transportadora",
)
def _is_non_billable_odoo_line(item: dict) -> bool:
    """True for Odoo logistics helper lines that should not block Jasmin docs."""
    meta = _opportunity_item_metadata(item)
    status = str(item.get("status") or "").upper()
    source_system = str(meta.get("source_system") or "").lower()
    if status != "ODOO_IMPORTED" and source_system != "odoo":
        return False
    haystack = " ".join(
        str(value or "")
        for value in (
            item.get("product_name"),
            item.get("sku"),
            item.get("description"),
            meta.get("product_code"),
            meta.get("product_name"),
            meta.get("source_external_id"),
        )
    ).casefold()
    return any(pattern in haystack for pattern in DEFAULT_NON_BILLABLE_ODOO_LINE_PATTERNS)
def _opportunity_item_origin_label(item: dict) -> str:
    meta = _opportunity_item_metadata(item)
    status = str(item.get("status") or "").upper()
    source_system = str(meta.get("source_system") or "").lower()
    source_document = str(meta.get("source_document") or "").strip()
    source_external_type = str(meta.get("source_external_type") or "").lower()
    if source_system == "odoo" or status == "ODOO_IMPORTED":
        sale_name = str(meta.get("source_document") or meta.get("sale_name") or meta.get("source_external_id") or "").strip()
        return f"Linhas Odoo {sale_name}" if sale_name else "Linhas Odoo"
    if source_document:
        if "invoice" in source_external_type or source_document.upper().startswith(("FA", "FT")):
            return f"Linhas da fatura {source_document}"
        if "quotation" in source_external_type or source_document.upper().startswith("ORC"):
            return f"Linhas do orçamento {source_document}"
        return f"Linhas Jasmin {source_document}"
    if source_system == "jasmin" or status == "JASMIN_IMPORTED":
        return "Linhas Jasmin importadas"
    return "Linhas importadas"
def opportunity_items_table_html(opportunity_id: str, items: list[dict]) -> str:
    rows = ""
    imported_groups: dict[str, str] = {}
    historical_rows = ""
    for item in items:
        status_upper = str(item.get('status') or '').upper()
        row_html = f"""
        <tr>
          <td><strong>{esc(item.get('product_name') or 'Produto')}</strong><div class="small text-secondary">SKU/Odoo <code>{esc(item.get('sku') or '—')}</code></div><div class="small text-secondary">Jasmin <code>{esc(item.get('jasmin_sales_item') or '—')}</code></div></td>
          <td class="text-end">{esc(item.get('quantity') or '1')}</td>
          <td class="text-end">{money_html(item.get('unit_price'))}</td>
          <td class="text-end">{money_html(item.get('discount_amount'))}</td>
          <td class="text-end fw-bold">{money_html(item.get('total_price'))}</td>
          <td>{item_status_badge(item.get('status'))}</td>
          <td class="text-end">
            <form method="post" action="/opportunities/{esc(opportunity_id)}/items/{esc(item.get('id'))}/delete" hx-post="/opportunities/{esc(opportunity_id)}/items/{esc(item.get('id'))}/delete" hx-target="#opportunity-products-panel" hx-swap="outerHTML" onsubmit="return confirm('Remover esta linha?')">
              <button class="btn btn-sm btn-outline-danger" type="submit">Remover</button>
            </form>
          </td>
        </tr>
        """
        if status_upper in {"DELIVERED", "HISTORICAL"}:
            historical_rows += row_html
        elif status_upper.endswith("_IMPORTED") or status_upper in {"JASMIN_IMPORTED", "ODOO_IMPORTED"}:
            label = _opportunity_item_origin_label(item)
            imported_groups[label] = imported_groups.get(label, "") + row_html
        else:
            rows += row_html
    if not rows:
        rows = '<tr><td colspan="7" class="text-center text-secondary py-4">Sem produtos atuais manuais nesta oportunidade.</td></tr>'
    imported_html = ""
    if imported_groups:
        groups_html = ""
        for label, group_rows in imported_groups.items():
            groups_html += f"""
            <details class="border rounded p-2 mb-2" open>
              <summary class="small fw-bold text-secondary" style="cursor:pointer">{esc(label)}</summary>
              <div class="cf-table-wrap border-0 rounded-0 mt-2">
                <table class="table cf-table">
                  <thead><tr><th>Produto</th><th class="text-end">Qtd.</th><th class="text-end">Preço</th><th class="text-end">Desc.</th><th class="text-end">Total</th><th>Estado</th><th></th></tr></thead>
                  <tbody>{group_rows}</tbody>
                </table>
              </div>
            </details>
            """
        imported_html = f"""
        <details class="p-3 border-top" open>
          <summary class="small fw-bold text-secondary" style="cursor:pointer">Linhas importadas agrupadas por origem</summary>
          <div class="small text-secondary mt-1 mb-2">Estas linhas são contexto documental/histórico e não entram no total manual atual. O agrupamento evita parecerem duplicados quando vêm do orçamento, fatura e Odoo.</div>
          {groups_html}
        </details>
        """
    historical_html = ""
    if historical_rows:
        historical_html = f"""
        <details class="p-3 border-top">
          <summary class="small fw-bold text-secondary" style="cursor:pointer">Ver linhas históricas / entregues</summary>
          <div class="cf-table-wrap border-0 rounded-0 mt-2">
            <table class="table cf-table">
              <thead><tr><th>Produto</th><th class="text-end">Qtd.</th><th class="text-end">Preço</th><th class="text-end">Desc.</th><th class="text-end">Total</th><th>Estado</th><th></th></tr></thead>
              <tbody>{historical_rows}</tbody>
            </table>
          </div>
        </details>
        """
    return f"""
    <div class="cf-table-wrap border-0 rounded-0">
      <table class="table cf-table">
        <thead><tr><th>Produto</th><th class="text-end">Qtd.</th><th class="text-end">Preço</th><th class="text-end">Desc.</th><th class="text-end">Total</th><th>Estado</th><th></th></tr></thead>
        <tbody>{rows}</tbody>
      </table>
    </div>
    {imported_html}
    {historical_html}
    """
def opportunity_add_item_form_html(opportunity_id: str, products: list[dict]) -> str:
    options = '<option value="">Selecionar produto...</option>'
    for product in products:
        product_id = esc(product.get("id"))
        sku = esc(product.get("sku") or "")
        jasmin = esc(product.get("jasmin_sales_item") or "")
        name = esc(product.get("name") or "")
        options += f'<option value="{product_id}">{sku} → {jasmin or "sem Jasmin"} · {name}</option>'
    return f'''
    <form method="post" action="/opportunities/{esc(opportunity_id)}/items/add" hx-post="/opportunities/{esc(opportunity_id)}/items/add" hx-target="#opportunity-products-panel" hx-swap="outerHTML" class="row g-2 align-items-end">
      <div class="col-lg-6">
        <label class="form-label small fw-bold text-secondary">Produto</label>
        <select class="form-select" name="product_id" required>{options}</select>
      </div>
      <div class="col-6 col-lg-2">
        <label class="form-label small fw-bold text-secondary">Qtd.</label>
        <input class="form-control" name="quantity" value="1" inputmode="decimal">
      </div>
      <div class="col-6 col-lg-2">
        <label class="form-label small fw-bold text-secondary">Preço opcional</label>
        <input class="form-control" name="unit_price" placeholder="auto" inputmode="decimal">
      </div>
      <div class="col-lg-2 d-grid">
        <button class="btn btn-primary" type="submit">Adicionar</button>
      </div>
    </form>
    '''
def opportunity_products_panel_html(opportunity_id: str, *, notice: str = "", error_notice: str = "") -> str:
    try:
        items = list_opportunity_items(opportunity_id)
        active_products = list_products(active="true", limit=300)
    except Exception as exc:
        return f'<section id="opportunity-products-panel" class="card cf-card"><div class="card-body"><div class="alert alert-danger">Erro ao carregar produtos: {esc(exc)}</div></div></section>'
    total = sum(
        float(item.get("total_price") or 0)
        for item in items
        if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED", "DELIVERED", "HISTORICAL", "JASMIN_IMPORTED", "ODOO_IMPORTED"}
        and not str(item.get("status") or "").upper().endswith("_IMPORTED")
    )
    notice_html = f'<div class="alert alert-info py-2 small mb-2">{esc(notice)}</div>' if notice else ''
    error_html = f'<div class="alert alert-danger py-2 small mb-2">{esc(error_notice)}</div>' if error_notice else ''
    missing = [
        item
        for item in items
        if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED"}
        and not item.get("jasmin_sales_item")
        and not _is_non_billable_odoo_line(item)
    ]
    non_billable_missing = [
        item
        for item in items
        if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED"}
        and not item.get("jasmin_sales_item")
        and _is_non_billable_odoo_line(item)
    ]
    validation_html = ""
    if missing:
        lis = "".join(f"<li>{esc(i.get('product_name') or i.get('sku') or 'Produto')} sem Artigo Jasmin.</li>" for i in missing)
        validation_html = f'<div class="alert alert-warning py-2 small mb-0"><strong>Atenção:</strong> estes produtos bloqueiam o orçamento Jasmin:<ul class="cf-validation-list">{lis}</ul></div>'
    elif non_billable_missing:
        lis = "".join(f"<li>{esc(i.get('product_name') or i.get('sku') or 'Linha logística')} configurada como logística/não faturável.</li>" for i in non_billable_missing)
        validation_html = f'<div class="alert alert-info py-2 small mb-0"><strong>Linhas logísticas:</strong> não bloqueiam o orçamento/fatura Jasmin.<ul class="cf-validation-list">{lis}</ul></div>'
    return f'''
    <section id="opportunity-products-panel" class="card cf-card cf-live-panel">
      <div class="card-body p-0">
        <div class="p-3 border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
          <div><h2 class="cf-section-title">Produtos</h2><div class="small text-secondary">Linhas comerciais da oportunidade.</div></div>
          <span class="cf-chip cf-chip-green">Total linhas atuais {money_html(total)}</span>
        </div>
        <div class="p-3 border-bottom bg-light"><h3 class="h6 fw-bold mb-2">Adicionar produto</h3>{notice_html}{error_html}{opportunity_add_item_form_html(opportunity_id, active_products)}</div>
        {opportunity_items_table_html(opportunity_id, items)}
        <div class="p-3 border-top">{validation_html}</div>
      </div>
    </section>
    '''
