"""Semi-automatic, cascading follow-up workflow for ClientFlow opportunities.

Follow-ups are normal human tasks with ``due_at``:
- ClientFlow schedules only the next required follow-up;
- the operator sends/handles it manually or semi-automatically;
- completing a follow-up creates the next step in the cascade only if the
  opportunity has not advanced;
- no email/Chatwoot/WhatsApp message is sent automatically here.
"""

from __future__ import annotations

import json
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional

from sqlalchemy import text

from app.config import settings
from app.db import engine


FOLLOW_UP_ACTION_CODES = {
    "FOLLOW_UP_QUOTE",
    "FOLLOW_UP_PROFORMA",
    "FOLLOW_UP_PAYMENT",
    "FOLLOW_UP_CUSTOMER_REVIEW",
    "FOLLOW_UP_GENERIC",
    "CONFIRM_DELIVERY",
    "RECOVER_OPPORTUNITY",
    "REVIEW_NURTURE",
}

# Short, stage-aware contact cadences. v4928.1.5.127 starts directly with
# commercial follow-up; delivery verification is an explicit manual exception.
# Delays are business days counted from the operator action that completed the
# prior step.
FOLLOW_UP_CADENCES: Dict[str, list[Dict[str, Any]]] = {
    # v4928.1.5.127: delivery verification is no longer an automatic first
    # step. The normal sequence starts with the commercial follow-up. The
    # operator may still create CONFIRM_DELIVERY manually when the history
    # suggests a real delivery problem.
    "info": [
        {
            "action_code": "FOLLOW_UP_CUSTOMER_REVIEW",
            "route": "vendas",
            "delay_business_days": 2,
            "action": "Confirmar interesse após informação",
            "note": "Perguntar se a informação corresponde ao que procura e se pretende orçamento. Não enviar automaticamente.",
            "purpose": "interest_check",
        },
        {
            "action_code": "FOLLOW_UP_CUSTOMER_REVIEW",
            "route": "vendas",
            "delay_business_days": 4,
            "action": "Segundo follow-up da informação",
            "note": "Fazer uma pergunta concreta sobre necessidade, potência, instalação ou prazo. Não enviar automaticamente.",
            "purpose": "qualification_followup",
        },
        {
            "action_code": "FOLLOW_UP_CUSTOMER_REVIEW",
            "route": "vendas",
            "delay_business_days": 5,
            "action": "Última tentativa sobre a informação",
            "note": "Última tentativa antes de enviar a oportunidade para recuperação ou acompanhamento futuro. Não enviar automaticamente.",
            "purpose": "final_attempt",
        },
    ],
    "quote": [
        {
            "action_code": "FOLLOW_UP_QUOTE",
            "route": "vendas",
            "delay_business_days": 2,
            "action": "Esclarecer dúvidas do orçamento",
            "note": "Perguntar se existem dúvidas técnicas, comerciais ou de instalação. Não enviar automaticamente.",
            "purpose": "objection_check",
        },
        {
            "action_code": "FOLLOW_UP_QUOTE",
            "route": "vendas",
            "delay_business_days": 4,
            "action": "Follow-up de decisão do orçamento",
            "note": "Confirmar decisão, prazo e eventual obstáculo para avançar. Não enviar automaticamente.",
            "purpose": "decision_followup",
        },
        {
            "action_code": "FOLLOW_UP_QUOTE",
            "route": "vendas",
            "delay_business_days": 5,
            "action": "Última tentativa do orçamento",
            "note": "Última tentativa comercial antes de recuperação ou acompanhamento futuro. Não enviar automaticamente.",
            "purpose": "final_attempt",
        },
    ],
    "payment": [
        {
            "action_code": "FOLLOW_UP_PAYMENT",
            "route": "financeiro",
            "delay_business_days": 2,
            "action": "Pedir data prevista de pagamento",
            "note": "Perguntar quando prevê efetuar o pagamento e registar a data prometida. Não enviar automaticamente.",
            "purpose": "payment_date",
        },
        {
            "action_code": "FOLLOW_UP_PAYMENT",
            "route": "financeiro",
            "delay_business_days": 3,
            "action": "Lembrete de pagamento",
            "note": "Confirmar se existe alguma pendência administrativa ou financeira. Não enviar automaticamente.",
            "purpose": "payment_reminder",
        },
        {
            "action_code": "FOLLOW_UP_PAYMENT",
            "route": "financeiro",
            "delay_business_days": 3,
            "action": "Contacto direto sobre pagamento",
            "note": "Contactar por canal alternativo quando adequado e rever intenção de compra. Não enviar automaticamente.",
            "purpose": "direct_contact",
        },
        {
            "action_code": "FOLLOW_UP_PAYMENT",
            "route": "financeiro",
            "delay_business_days": 5,
            "action": "Rever intenção e suspender reserva",
            "note": "Última tentativa: confirmar se ainda pretende avançar antes de suspender ou mover para recuperação. Não enviar automaticamente.",
            "purpose": "final_attempt",
        },
    ],
    "generic": [
        {
            "action_code": "FOLLOW_UP_GENERIC",
            "route": "vendas",
            "delay_business_days": 2,
            "action": "Fazer follow-up manual",
            "note": "Novo contacto de seguimento conforme contexto da oportunidade. Não enviar automaticamente.",
            "purpose": "generic_followup",
        },
        {
            "action_code": "FOLLOW_UP_GENERIC",
            "route": "vendas",
            "delay_business_days": 4,
            "action": "Segundo follow-up manual",
            "note": "Segundo contacto, preferencialmente com pergunta concreta ou canal alternativo. Não enviar automaticamente.",
            "purpose": "generic_followup",
        },
        {
            "action_code": "FOLLOW_UP_GENERIC",
            "route": "vendas",
            "delay_business_days": 5,
            "action": "Última tentativa manual",
            "note": "Última tentativa antes de revisão de recuperação. Não enviar automaticamente.",
            "purpose": "final_attempt",
        },
    ],
}

FOLLOW_UP_POLICIES: Dict[str, Dict[str, Any]] = {
    "SEND_INFO": {"family": "info", "reason": "INFO_SENT", "action_code": "FOLLOW_UP_CUSTOMER_REVIEW"},
    "SEND_QUOTE": {"family": "quote", "reason": "QUOTE_SENT", "action_code": "FOLLOW_UP_QUOTE"},
    "SEND_PROFORMA": {"family": "payment", "reason": "PROFORMA_SENT", "action_code": "FOLLOW_UP_PAYMENT"},
    "SEND_INVOICE": {"family": "payment", "reason": "INVOICE_SENT_AFTER_DELIVERY", "action_code": "FOLLOW_UP_PAYMENT"},
}

# Kept for compatibility with older callers/tests.  The actual next step is
# resolved from the completed task metadata and FOLLOW_UP_CADENCES.
FOLLOW_UP_CASCADE_BY_ACTION: Dict[str, Dict[str, Any]] = {
    "CONFIRM_DELIVERY": {"family": "generic"},
    "FOLLOW_UP_CUSTOMER_REVIEW": {"family": "info"},
    "FOLLOW_UP_QUOTE": {"family": "quote"},
    "FOLLOW_UP_PROFORMA": {"family": "payment"},
    "FOLLOW_UP_PAYMENT": {"family": "payment"},
    "FOLLOW_UP_GENERIC": {"family": "generic"},
}

MANUAL_FOLLOW_UP_DEFAULTS: Dict[str, Dict[str, Any]] = {
    "quote": {"family": "quote", "reason": "QUOTE_SENT"},
    "proforma": {"family": "payment", "reason": "PROFORMA_SENT"},
    "payment": {"family": "payment", "reason": "PAYMENT_FOLLOWUP"},
    "review": {"family": "info", "reason": "INFO_SENT"},
    "generic": {"family": "generic", "reason": "GENERIC_FOLLOWUP"},
    "delivery": {"family": "generic", "reason": "MANUAL_DELIVERY_CHECK"},
}

TERMINAL_STAGE_VALUES = {"WON", "LOST", "NO_INTEREST", "DELIVERED", "ARCHIVED"}
TERMINAL_STATUS_VALUES = {"closed", "won", "lost", "no_interest", "archived", "spam"}


def _json(value: Any) -> str:
    return json.dumps(value or {}, ensure_ascii=False, default=str)


def _metadata_dict(value: Any) -> Dict[str, Any]:
    if isinstance(value, dict):
        return value
    if not value:
        return {}
    try:
        return json.loads(value) if isinstance(value, str) else dict(value)
    except Exception:
        return {}


def _cadence_for_family(family: str) -> list[Dict[str, Any]]:
    key = str(family or "generic").strip().lower() or "generic"
    return list(FOLLOW_UP_CADENCES.get(key) or FOLLOW_UP_CADENCES["generic"])


def _cadence_step(family: str, stage: int) -> Optional[Dict[str, Any]]:
    cadence = _cadence_for_family(family)
    index = max(1, int(stage or 1)) - 1
    if index < 0 or index >= len(cadence):
        return None
    return dict(cadence[index])


def _family_reason(family: str, fallback: str = "FOLLOW_UP") -> str:
    return {
        "info": "INFO_SENT",
        "quote": "QUOTE_SENT",
        "payment": "PAYMENT_FOLLOWUP",
        "generic": "GENERIC_FOLLOWUP",
    }.get(str(family or "").strip().lower(), fallback)


def _business_days_from_now(days: int) -> datetime:
    """Return an aware datetime after N business days, keeping current time."""
    days = max(1, min(int(days or 1), 60))
    current = datetime.now(timezone.utc)
    due = current
    remaining = days
    while remaining > 0:
        due += timedelta(days=1)
        if due.weekday() < 5:
            remaining -= 1
    return due


def is_follow_up_action(action_code: str) -> bool:
    return str(action_code or "").strip().upper() in FOLLOW_UP_ACTION_CODES


def follow_up_suggested_message(
    *,
    action_code: str,
    customer_name: str = "",
    follow_up_stage: int = 1,
    follow_up_family: str = "generic",
) -> str:
    """Return a safe draft text matched to the contact sequence step."""
    code = str(action_code or "").strip().upper()
    stage = max(1, int(follow_up_stage or 1))
    family = str(follow_up_family or "generic").strip().lower() or "generic"
    first_name = str(customer_name or "").strip().split(" ")[0]
    greeting = f"Olá {first_name}," if first_name and first_name.casefold() not in {"cliente", "desconhecido"} else "Olá,"
    closing = "Obrigado,\nEquipa BLIF"

    if code == "CONFIRM_DELIVERY":
        object_label = {
            "quote": "o orçamento",
            "payment": "os dados/documento para pagamento",
            "info": "a informação",
        }.get(family, "a nossa comunicação")
        return f"""{greeting}

Queria apenas confirmar se recebeu {object_label} que enviámos.

Caso não tenha chegado corretamente, reenvio de imediato.

{closing}"""

    if code == "FOLLOW_UP_QUOTE":
        if stage >= len(_cadence_for_family("quote")):
            body = "Ainda pretende avançar com esta proposta ou prefere que encerremos o processo por agora?"
        elif stage >= 3:
            body = "Conseguiu avaliar a proposta? Há algum ponto de preço, prazo, instalação ou compatibilidade que esteja a bloquear a decisão?"
        else:
            body = "Ficou com alguma dúvida técnica ou comercial sobre o orçamento que possamos esclarecer?"
        return f"""{greeting}

{body}

{closing}"""

    if code in {"FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT"}:
        if stage >= len(_cadence_for_family("payment")):
            body = "Ainda pretende avançar com a encomenda? Precisamos de confirmar a intenção antes de manter a reserva/processo ativo."
        elif stage >= 3:
            body = "Existe alguma pendência administrativa ou financeira que esteja a impedir o pagamento?"
        else:
            body = "Consegue indicar-nos a data prevista para efetuar o pagamento?"
        return f"""{greeting}

{body}

{closing}"""

    if code == "FOLLOW_UP_CUSTOMER_REVIEW":
        if stage >= len(_cadence_for_family("info")):
            body = "Ainda pretende avançar com este pedido ou prefere que retomemos o contacto noutra altura?"
        elif stage >= 3:
            body = "Para conseguirmos orientar a solução, pode indicar potência pretendida, local de instalação e prazo estimado?"
        else:
            body = "A informação enviada corresponde ao que procura? Pretende que preparemos um orçamento?"
        return f"""{greeting}

{body}

{closing}"""

    return f"""{greeting}

Ficámos sem resposta relativamente ao processo em aberto.

Ainda pretende avançar ou prefere que deixemos este contacto para mais tarde?

{closing}"""


def _load_opportunity_context(opportunity_id: str) -> Optional[Dict[str, Any]]:
    with engine.begin() as conn:
        row = conn.execute(text("""
            SELECT
                id::text,
                status,
                stage,
                customer_id,
                contact_id,
                conversation_id,
                customer_name,
                customer_email,
                customer_phone,
                title,
                lifecycle_state,
                last_customer_activity_at,
                last_operator_activity_at,
                last_outbound_sent_at,
                last_delivery_checked_at,
                last_delivery_status,
                last_commercial_activity_at,
                next_follow_up_at,
                follow_up_attempts,
                nurture_until,
                metadata
            FROM opportunities
            WHERE id = CAST(:opportunity_id AS UUID)
            LIMIT 1
        """), {"opportunity_id": opportunity_id}).mappings().first()
    return dict(row) if row else None


def _load_task_context(task_id: str) -> Dict[str, Any]:
    if not task_id:
        return {}
    with engine.begin() as conn:
        row = conn.execute(text("""
            SELECT
                id::text,
                opportunity_id::text,
                action_code,
                action,
                note,
                route,
                status,
                customer_id,
                contact_id,
                conversation_id,
                source_system,
                source_event_id,
                created_at,
                done_at,
                metadata
            FROM tasks
            WHERE id = CAST(:task_id AS UUID)
            LIMIT 1
        """), {"task_id": task_id}).mappings().first()
    return dict(row) if row else {}


def _doc_counts(conn, opportunity_id: str) -> Dict[str, int]:
    try:
        row = conn.execute(text("""
            SELECT
              COUNT(*) FILTER (WHERE document_kind IN ('quotation','proforma'))::int AS quotes,
              COUNT(*) FILTER (WHERE document_kind = 'invoice')::int AS invoices
            FROM commercial_documents
            WHERE opportunity_id = CAST(:opportunity_id AS UUID)
        """), {"opportunity_id": opportunity_id}).mappings().first()
        return {"quotes": int((row or {}).get("quotes") or 0), "invoices": int((row or {}).get("invoices") or 0)}
    except Exception:
        return {"quotes": 0, "invoices": 0}


def _payment_confirmed(conn, opportunity_id: str) -> bool:
    try:
        return bool(conn.execute(text("""
            SELECT 1
            FROM operation_links
            WHERE opportunity_id = CAST(:opportunity_id AS UUID)
              AND system = 'clientflow'
              AND external_type = 'payment'
              AND status = 'confirmed'
            LIMIT 1
        """), {"opportunity_id": opportunity_id}).scalar())
    except Exception:
        return False


def _current_next_action_code(opportunity_id: str) -> str:
    try:
        from app.opportunity_next_action_service import get_opportunity_next_action

        decision = get_opportunity_next_action(opportunity_id) or {}
        return str(decision.get("action_code") or "").strip().upper()
    except Exception:
        return ""


def _should_skip_because_advanced(*, opportunity_id: str, family: str, source_action_code: str = "") -> str:
    """Return a non-empty reason if the opportunity has advanced past this follow-up."""
    opportunity = _load_opportunity_context(opportunity_id)
    if not opportunity:
        return "opportunity_not_found"
    if str(opportunity.get("status") or "").strip().lower() in TERMINAL_STATUS_VALUES:
        return "opportunity_closed"
    if str(opportunity.get("stage") or "").strip().upper() in TERMINAL_STAGE_VALUES:
        return "opportunity_terminal_stage"

    family = str(family or "generic").strip().lower()
    source_code = str(source_action_code or "").strip().upper()
    next_code = _current_next_action_code(opportunity_id)
    stage = str(opportunity.get("stage") or "").strip().upper()

    with engine.begin() as conn:
        docs = _doc_counts(conn, opportunity_id)
        paid = _payment_confirmed(conn, opportunity_id)
        pending_codes = {
            str(value or "").strip().upper()
            for value in conn.execute(text("""
                SELECT action_code
                FROM tasks
                WHERE opportunity_id = CAST(:opportunity_id AS UUID)
                  AND status = 'pending'
            """), {"opportunity_id": opportunity_id}).scalars().all()
        }

    if family == "info" and (docs["quotes"] > 0 or docs["invoices"] > 0):
        return "document_already_created"
    if family == "quote" and (docs["invoices"] > 0 or paid):
        return "quote_already_advanced"
    if family == "quote" and stage in {
        "INVOICE_REQUESTED", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED",
        "ODOO_ORDER_CREATED", "IN_PRODUCTION", "ORDER_PREPARATION", "READY_TO_SHIP",
        "INVOICED", "SHIPMENT_CREATED", "SHIPPED", "TRACKING_SENT", "DELIVERED", "WON",
    }:
        return f"quote_followup_obsolete_for_stage:{stage}"
    if family == "quote" and pending_codes & {"SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT"}:
        return "quote_followup_superseded_by_pending_action"
    if family == "payment" and paid:
        return "payment_already_confirmed"

    # If central decision has moved to a more concrete operational action, do
    # not create a generic follow-up that competes with it.
    concrete_next_actions = {
        "SEND_INVOICE",
        "CREATE_INVOICE",
        "SHIP_ORDER",
        "CLOSE_OPPORTUNITY",
        "WAIT_PRODUCTION",
        "WAIT_ODOO",
        "CREATE_ODOO_ORDER",
        "LINK_ODOO_ORDER",
        "RECONCILE_DOCUMENTS",
        "VALIDATE_FISCAL_CUSTOMER",
        "REVIEW",
    }
    if next_code in concrete_next_actions:
        return f"central_next_action:{next_code}"

    # For SEND_INVOICE, schedule payment follow-up only when the central model
    # still says payment follow-up/payment confirmation is needed.  This avoids
    # creating payment follow-ups in the normal before-shipping flow after a
    # pre-paid invoice has just been sent.
    if source_code == "SEND_INVOICE" and next_code not in {"FOLLOW_UP_PAYMENT", "CONFIRM_PAYMENT", ""}:
        return f"invoice_not_payment_followup:{next_code}"

    return ""


def _has_pending_equivalent(conn, *, opportunity_id: str, family: str, action_code: str, reason: str) -> Optional[Dict[str, Any]]:
    family = str(family or "").strip().lower()
    return conn.execute(text("""
        SELECT id::text, due_at, action_code
        FROM tasks
        WHERE opportunity_id = CAST(:opportunity_id AS UUID)
          AND status = 'pending'
          AND (
            action_code = CAST(:action_code AS TEXT)
            OR COALESCE(metadata->>'follow_up_family', '') = CAST(:family AS TEXT)
            OR COALESCE(metadata->>'follow_up_reason', '') = CAST(:reason AS TEXT)
          )
        ORDER BY due_at NULLS LAST, created_at DESC
        LIMIT 1
    """), {
        "opportunity_id": opportunity_id,
        "action_code": action_code,
        "family": family,
        "reason": reason,
    }).mappings().first()


def _task_stage_from_metadata(task: Dict[str, Any]) -> int:
    metadata = _metadata_dict(task.get("metadata"))
    try:
        return int(metadata.get("follow_up_stage") or 0)
    except Exception:
        return 0


def _root_task_id_for(task: Dict[str, Any], fallback: str = "") -> str:
    metadata = _metadata_dict(task.get("metadata"))
    return str(metadata.get("follow_up_root_task_id") or metadata.get("follow_up_trigger_task_id") or fallback or task.get("id") or "")


def _record_completed_outbound_activity(
    *,
    opportunity_id: str,
    completed_action_code: str,
    completed_task: Optional[Dict[str, Any]] = None,
    next_stage: int = 1,
) -> None:
    """Record real operator contact separately from technical updated_at changes."""
    task = completed_task or {}
    metadata = _metadata_dict(task.get("metadata"))
    completed_at = task.get("done_at") or datetime.now(timezone.utc)
    current_stage = _task_stage_from_metadata(task)
    is_contact_attempt = str(completed_action_code or "").strip().upper() in FOLLOW_UP_ACTION_CODES
    attempts = current_stage if is_contact_attempt and current_stage < 90 else max(0, int(next_stage or 1) - 1)
    with engine.begin() as conn:
        conn.execute(text("""
            UPDATE opportunities
            SET last_operator_activity_at = COALESCE(CAST(:completed_at AS TIMESTAMPTZ), now()),
                last_outbound_sent_at = CASE
                    WHEN :action_code IN ('SEND_INFO','SEND_QUOTE','SEND_PROFORMA','SEND_INVOICE','FOLLOW_UP_QUOTE','FOLLOW_UP_PROFORMA','FOLLOW_UP_PAYMENT','FOLLOW_UP_CUSTOMER_REVIEW','FOLLOW_UP_GENERIC')
                    THEN COALESCE(CAST(:completed_at AS TIMESTAMPTZ), now())
                    ELSE last_outbound_sent_at
                END,
                last_delivery_checked_at = CASE
                    WHEN :action_code = 'CONFIRM_DELIVERY' THEN COALESCE(CAST(:completed_at AS TIMESTAMPTZ), now())
                    ELSE last_delivery_checked_at
                END,
                last_delivery_status = CASE
                    WHEN :action_code = 'CONFIRM_DELIVERY' THEN 'checked'
                    WHEN :action_code IN ('SEND_INFO','SEND_QUOTE','SEND_PROFORMA','SEND_INVOICE') THEN 'sent_unverified'
                    ELSE last_delivery_status
                END,
                last_commercial_activity_at = COALESCE(CAST(:completed_at AS TIMESTAMPTZ), now()),
                follow_up_attempts = GREATEST(COALESCE(follow_up_attempts, 0), :attempts),
                lifecycle_state = 'awaiting_customer',
                updated_at = now(),
                metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
                    'last_operator_contact_action', CAST(:action_code AS TEXT),
                    'last_operator_contact_at', COALESCE(CAST(:completed_at AS TIMESTAMPTZ), now())::text,
                    'last_completed_follow_up_purpose', CAST(:purpose AS TEXT)
                )
            WHERE id = CAST(:opportunity_id AS UUID)
        """), {
            "opportunity_id": opportunity_id,
            "completed_at": completed_at,
            "attempts": int(attempts or 0),
            "action_code": str(completed_action_code or ""),
            "purpose": str(metadata.get("follow_up_purpose") or ""),
        })


def create_follow_up_task(
    *,
    opportunity_id: str,
    action_code: str,
    route: str,
    action: str,
    note: str,
    reason: str,
    delay_days: int = 3,
    trigger_task_id: str = "",
    created_by: str = "system",
    idempotency_suffix: str = "",
    follow_up_family: str = "generic",
    follow_up_stage: int = 1,
    follow_up_max_stage: int = 3,
    cascade: bool = True,
    contact_purpose: str = "",
    due_at_override: Optional[datetime] = None,
    allow_confirm_delivery: bool = False,
) -> Dict[str, Any]:
    """Create one pending follow-up task with due_at.

    Only the next step is created.  Idempotency is per opportunity + family +
    stage + root trigger, and we also check for an already pending equivalent.
    """
    opportunity_id = str(opportunity_id or "").strip()
    if not opportunity_id:
        return {"ok": False, "status": "missing_opportunity_id"}

    opportunity = _load_opportunity_context(opportunity_id)
    if not opportunity:
        return {"ok": False, "status": "opportunity_not_found"}
    if str(opportunity.get("status") or "").strip().lower() in TERMINAL_STATUS_VALUES:
        return {"ok": True, "status": "skipped_closed_opportunity", "opportunity_id": opportunity_id}

    action_code = str(action_code or "FOLLOW_UP_GENERIC").strip().upper()
    if (
        action_code == "CONFIRM_DELIVERY"
        and not bool(getattr(settings, "confirm_delivery_automation_enabled", False))
        and not bool(allow_confirm_delivery)
    ):
        return {
            "ok": True,
            "status": "skipped_confirm_delivery_automation_disabled",
            "opportunity_id": opportunity_id,
        }
    route = str(route or "vendas").strip().lower() or "vendas"
    reason = str(reason or "MANUAL").strip().upper() or "MANUAL"
    family = str(follow_up_family or "generic").strip().lower() or "generic"
    opportunity_stage = str(opportunity.get("stage") or "").strip().upper()
    if opportunity_stage == "WAITING_PAYMENT" and action_code in {"FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA"}:
        action_code = "FOLLOW_UP_PAYMENT"
        family = "payment"
        route = "financeiro"
        action = "Fazer follow-up de pagamento"
        note = "Confirmar se existe alguma pendência relativamente ao pagamento. Não enviar automaticamente."
        reason = "PAYMENT_FOLLOWUP"
    stage = max(1, int(follow_up_stage or 1))
    max_stage = max(stage, int(follow_up_max_stage or stage))
    trigger = _load_task_context(trigger_task_id) if trigger_task_id else {}
    root_task_id = _root_task_id_for(trigger, fallback=trigger_task_id) if trigger else str(trigger_task_id or idempotency_suffix or "manual")

    customer_id = str(opportunity.get("customer_id") or trigger.get("customer_id") or "")
    contact_id = str(opportunity.get("contact_id") or trigger.get("contact_id") or "")
    conversation_id = str(opportunity.get("conversation_id") or trigger.get("conversation_id") or "")
    suggested_message = follow_up_suggested_message(
        action_code=action_code,
        customer_name=str(opportunity.get("customer_name") or ""),
        follow_up_stage=stage,
        follow_up_family=family,
    )
    due_at = due_at_override or _business_days_from_now(delay_days)

    idempotency_source = str(root_task_id or idempotency_suffix or datetime.now(timezone.utc).isoformat())
    idempotency_key = f"followup:{opportunity_id}:{family}:{stage}:{idempotency_source}"
    metadata = {
        "task_type": "follow_up",
        "follow_up": True,
        "follow_up_mode": "semi_automatic_cascade",
        "follow_up_family": family,
        "follow_up_stage": stage,
        "follow_up_max_stage": max_stage,
        "follow_up_reason": reason,
        "follow_up_trigger_task_id": str(trigger_task_id or ""),
        "follow_up_root_task_id": str(root_task_id or ""),
        "opportunity_id": opportunity_id,
        "suggested_message": suggested_message,
        "automation_policy": "system_schedules_next_operator_followup_only",
        "created_by": created_by,
        "cascade": bool(cascade),
        "business_days_delay": int(delay_days or 1),
        "follow_up_purpose": str(contact_purpose or ""),
        "delivery_confirmation": str(contact_purpose or "") == "delivery_confirmation",
    }

    with engine.begin() as conn:
        existing = _has_pending_equivalent(
            conn,
            opportunity_id=opportunity_id,
            family=family,
            action_code=action_code,
            reason=reason,
        )
        if existing:
            return {
                "ok": True,
                "status": "already_pending",
                "task_id": str(existing.get("id")),
                "opportunity_id": opportunity_id,
            }

        row = conn.execute(text("""
            INSERT INTO tasks (
                opportunity_id,
                customer_id,
                contact_id,
                conversation_id,
                action_code,
                route,
                action,
                note,
                action_required,
                safe_to_post,
                status,
                source_system,
                source_event_id,
                idempotency_key,
                due_at,
                metadata,
                priority
            ) VALUES (
                CAST(:opportunity_id AS UUID),
                NULLIF(:customer_id, ''),
                NULLIF(:contact_id, ''),
                NULLIF(:conversation_id, ''),
                :action_code,
                :route,
                :action,
                :note,
                TRUE,
                FALSE,
                'pending',
                'clientflow_followup',
                :source_event_id,
                :idempotency_key,
                CAST(:due_at AS TIMESTAMPTZ),
                CAST(:metadata AS JSONB),
                :priority
            )
            ON CONFLICT (idempotency_key) DO NOTHING
            RETURNING id::text, due_at
        """), {
            "opportunity_id": opportunity_id,
            "customer_id": customer_id,
            "contact_id": contact_id,
            "conversation_id": conversation_id,
            "action_code": action_code,
            "route": route,
            "action": action,
            "note": note,
            "source_event_id": f"followup:{opportunity_id}:{family}:{stage}",
            "idempotency_key": idempotency_key,
            "due_at": due_at.isoformat(),
            "metadata": _json(metadata),
            "priority": "high" if action_code in {"RECOVER_OPPORTUNITY", "REVIEW_NURTURE"} or (bool(cascade) and stage >= max_stage) else "normal",
        }).mappings().first()

        if not row:
            return {"ok": True, "status": "idempotent_noop", "opportunity_id": opportunity_id}

        task_id = str(row.get("id"))
        event_payload = {
            "opportunity_id": opportunity_id,
            "reason": reason,
            "family": family,
            "stage": stage,
            "max_stage": max_stage,
            "delay_business_days": int(delay_days or 1),
            "due_at": due_at.isoformat(),
        }
        conn.execute(text("""
            INSERT INTO task_events (task_id, event_type, payload, created_by)
            VALUES (CAST(:task_id AS UUID), 'follow_up_scheduled', CAST(:payload AS JSONB), :created_by)
        """), {
            "task_id": task_id,
            "payload": _json(event_payload),
            "created_by": created_by,
        })
        conn.execute(text("""
            INSERT INTO opportunity_events (
                id, opportunity_id, event_type, task_id, action_code, note, payload, created_by
            ) VALUES (
                gen_random_uuid(), CAST(:opportunity_id AS UUID), 'follow_up_scheduled',
                CAST(:task_id AS UUID), :action_code, :note, CAST(:payload AS JSONB), :created_by
            )
        """), {
            "opportunity_id": opportunity_id,
            "task_id": task_id,
            "action_code": action_code,
            "note": note,
            "payload": _json(event_payload),
            "created_by": created_by,
        })
        lifecycle_state = (
            "recovery" if action_code == "RECOVER_OPPORTUNITY"
            else "nurture" if action_code == "REVIEW_NURTURE"
            else "awaiting_customer"
        )
        attempts = max_stage if stage >= 90 else max(0, stage - 1)
        conn.execute(text("""
            UPDATE opportunities
            SET updated_at = now(),
                lifecycle_state = :lifecycle_state,
                next_follow_up_at = CAST(:due_at AS TIMESTAMPTZ),
                follow_up_attempts = GREATEST(COALESCE(follow_up_attempts, 0), :attempts),
                metadata = COALESCE(metadata, '{}'::jsonb)
                    || jsonb_build_object(
                        'last_follow_up_task_id', CAST(:task_id AS TEXT),
                        'last_follow_up_scheduled_at', now()::text,
                        'last_follow_up_family', CAST(:family AS TEXT),
                        'last_follow_up_stage', CAST(:stage AS INTEGER),
                        'last_follow_up_purpose', CAST(:purpose AS TEXT)
                    )
            WHERE id = CAST(:opportunity_id AS UUID)
        """), {
            "opportunity_id": opportunity_id,
            "task_id": task_id,
            "family": family,
            "stage": stage,
            "purpose": str(contact_purpose or ""),
            "lifecycle_state": lifecycle_state,
            "due_at": due_at.isoformat(),
            "attempts": attempts,
        })

    return {"ok": True, "status": "created", "task_id": task_id, "opportunity_id": opportunity_id, "stage": stage, "family": family}


def _create_recovery_task(*, opportunity_id: str, family: str, trigger_task_id: str, created_by: str) -> Dict[str, Any]:
    """Move an exhausted contact sequence to a dedicated recovery queue."""
    family = str(family or "generic").strip().lower() or "generic"
    action = "Recuperar oportunidade sem resposta"
    note = (
        "A sequência normal de contactos terminou sem resposta. Fazer uma última revisão: "
        "tentar canal alternativo, alterar abordagem, colocar em acompanhamento futuro ou marcar como perdida com motivo."
    )
    return create_follow_up_task(
        opportunity_id=opportunity_id,
        action_code="RECOVER_OPPORTUNITY",
        route="financeiro" if family == "payment" else "vendas",
        action=action,
        note=note,
        reason=f"{family.upper()}_CASCADE_EXHAUSTED",
        delay_days=1,
        trigger_task_id=trigger_task_id,
        created_by=created_by,
        follow_up_family=family,
        follow_up_stage=99,
        follow_up_max_stage=99,
        cascade=False,
        contact_purpose="recovery_review",
    )


def schedule_follow_up_after_task_done(
    *,
    task_id: str,
    action_code: str,
    opportunity_id: str,
    created_by: str = "system",
) -> Dict[str, Any]:
    """Schedule the next short-cadence step after a real operator action."""
    code = str(action_code or "").strip().upper()
    task = _load_task_context(task_id)
    task_metadata = _metadata_dict(task.get("metadata"))

    if code in FOLLOW_UP_POLICIES:
        trigger_policy = dict(FOLLOW_UP_POLICIES[code])
        family = str(trigger_policy.get("family") or "generic").strip().lower()
        reason = str(trigger_policy.get("reason") or _family_reason(family))
        next_stage = 1
    elif code in FOLLOW_UP_CASCADE_BY_ACTION or code in FOLLOW_UP_ACTION_CODES:
        family = str(
            task_metadata.get("follow_up_family")
            or FOLLOW_UP_CASCADE_BY_ACTION.get(code, {}).get("family")
            or "generic"
        ).strip().lower()
        reason = str(task_metadata.get("follow_up_reason") or _family_reason(family))
        current_stage = _task_stage_from_metadata(task)
        if current_stage >= 90 and task_metadata.get("cascade") is not False:
            return {"ok": True, "status": "recovery_task_completed", "opportunity_id": opportunity_id}
        next_stage = max(2, current_stage + 1)
    else:
        return {"ok": True, "status": "no_policy"}

    cadence = _cadence_for_family(family)
    max_stage = len(cadence)

    if code in FOLLOW_UP_ACTION_CODES and task_metadata.get("cascade") is False:
        _record_completed_outbound_activity(
            opportunity_id=opportunity_id,
            completed_action_code=code,
            completed_task=task,
            next_stage=max(1, _task_stage_from_metadata(task)),
        )
        return {
            "ok": True,
            "status": "manual_follow_up_completed_without_cascade",
            "opportunity_id": opportunity_id,
        }

    skip_reason = _should_skip_because_advanced(
        opportunity_id=opportunity_id,
        family=family,
        source_action_code=code,
    )
    if skip_reason:
        return {"ok": True, "status": "skipped_advanced", "reason": skip_reason, "opportunity_id": opportunity_id}

    _record_completed_outbound_activity(
        opportunity_id=opportunity_id,
        completed_action_code=code,
        completed_task=task,
        next_stage=next_stage,
    )

    if next_stage > max_stage:
        return _create_recovery_task(
            opportunity_id=opportunity_id,
            family=family,
            trigger_task_id=str(task_id or ""),
            created_by=created_by,
        )

    step = _cadence_step(family, next_stage) or {}
    return create_follow_up_task(
        opportunity_id=opportunity_id,
        action_code=str(step.get("action_code") or "FOLLOW_UP_GENERIC"),
        route=str(step.get("route") or ("financeiro" if family == "payment" else "vendas")),
        action=str(step.get("action") or "Fazer follow-up"),
        note=str(step.get("note") or "Novo contacto de seguimento. Não enviar automaticamente."),
        reason=reason,
        delay_days=int(step.get("delay_business_days") or 1),
        trigger_task_id=str(task_id or ""),
        created_by=created_by,
        follow_up_family=family,
        follow_up_stage=next_stage,
        follow_up_max_stage=max_stage,
        cascade=True,
        contact_purpose=str(step.get("purpose") or ""),
    )


def materialize_initial_follow_up_for_opportunity(
    *,
    opportunity_id: str,
    family: str,
    due_at: Optional[datetime] = None,
    reason: str = "BACKFILL",
    created_by: str = "system",
) -> Dict[str, Any]:
    """Create the first normal commercial follow-up for an open process.

    v4928.1.5.127 deliberately skips automatic delivery confirmation. Delivery
    verification remains available only as an explicit operator action.
    """
    family = str(family or "generic").strip().lower() or "generic"
    step = _cadence_step(family, 1) or _cadence_step("generic", 1) or {}
    return create_follow_up_task(
        opportunity_id=opportunity_id,
        action_code=str(step.get("action_code") or "FOLLOW_UP_GENERIC"),
        route=str(step.get("route") or ("financeiro" if family == "payment" else "vendas")),
        action=str(step.get("action") or "Fazer follow-up"),
        note=str(step.get("note") or "Fazer novo contacto comercial sem envio automático."),
        reason=str(reason or _family_reason(family)),
        delay_days=int(step.get("delay_business_days") or 1),
        created_by=created_by,
        idempotency_suffix=f"initial:{family}:{reason}",
        follow_up_family=family,
        follow_up_stage=1,
        follow_up_max_stage=len(_cadence_for_family(family)),
        cascade=True,
        contact_purpose=str(step.get("purpose") or "commercial_followup"),
        due_at_override=due_at,
    )


def create_manual_follow_up_for_opportunity(
    *,
    opportunity_id: str,
    follow_up_type: str = "generic",
    delay_days: int = 3,
    note: str = "",
    created_by: str = "operator",
) -> Dict[str, Any]:
    key = str(follow_up_type or "generic").strip().lower() or "generic"

    if key == "delivery":
        opportunity = _load_opportunity_context(opportunity_id) or {}
        stage = str(opportunity.get("stage") or "").strip().upper()
        if stage in {"PROFORMA_SENT", "INVOICE_SENT", "WAITING_PAYMENT"}:
            family = "payment"
            route = "financeiro"
            action = "Verificar entrega dos dados de pagamento"
            default_note = (
                "Usar apenas quando o histórico sugere um problema real de envio. "
                "Validar destinatário, documento, bounce/outbox e decidir se é necessário reenviar."
            )
        elif stage == "QUOTE_SENT":
            family = "quote"
            route = "vendas"
            action = "Verificar entrega do orçamento"
            default_note = (
                "Usar apenas quando vários contactos sem resposta sugerem um problema de envio. "
                "Validar destinatário, anexo, bounce/outbox e decidir se é necessário reenviar."
            )
        else:
            family = "info" if stage == "INFO_SENT" else "generic"
            route = "vendas"
            action = "Verificar entrega da comunicação"
            default_note = (
                "Usar apenas quando o histórico sugere um problema real de envio. "
                "Validar destinatário, conteúdo, bounce/outbox e decidir se é necessário reenviar."
            )
        suffix = f"manual-delivery:{created_by}:{datetime.now(timezone.utc).isoformat()}"
        return create_follow_up_task(
            opportunity_id=opportunity_id,
            action_code="CONFIRM_DELIVERY",
            route=route,
            action=action,
            note=(str(note or "").strip() or default_note),
            reason="MANUAL_DELIVERY_CHECK",
            delay_days=delay_days,
            trigger_task_id="",
            created_by=created_by,
            idempotency_suffix=suffix,
            follow_up_family=family,
            follow_up_stage=1,
            follow_up_max_stage=1,
            cascade=False,
            contact_purpose="manual_delivery_check",
            allow_confirm_delivery=True,
        )

    policy = MANUAL_FOLLOW_UP_DEFAULTS.get(key, MANUAL_FOLLOW_UP_DEFAULTS["generic"])
    family = str(policy.get("family") or key).strip().lower() or "generic"
    step = _cadence_step(family, 1) or _cadence_step("generic", 1) or {}
    suffix = f"manual:{created_by}:{datetime.now(timezone.utc).isoformat()}"
    return create_follow_up_task(
        opportunity_id=opportunity_id,
        action_code=str(step.get("action_code") or "FOLLOW_UP_GENERIC"),
        route=str(step.get("route") or "vendas"),
        action=str(step.get("action") or "Fazer follow-up manual"),
        note=(str(note or "").strip() or str(step.get("note") or "Novo contacto de seguimento.")),
        reason=str(policy.get("reason") or _family_reason(family)),
        delay_days=delay_days,
        trigger_task_id="",
        created_by=created_by,
        idempotency_suffix=suffix,
        follow_up_family=family,
        follow_up_stage=1,
        follow_up_max_stage=len(_cadence_for_family(family)),
        cascade=True,
        contact_purpose=str(step.get("purpose") or "manual_followup"),
    )

def align_pending_followups_for_opportunity(
    *,
    opportunity_id: str,
    created_by: str = "system",
) -> Dict[str, Any]:
    """Repair pending follow-ups so they match the current commercial stage.

    This is idempotent and safe to run after every stage transition or as a
    maintenance backfill. It never sends messages.
    """
    opportunity = _load_opportunity_context(opportunity_id)
    if not opportunity:
        return {"ok": False, "status": "opportunity_not_found", "updated": 0, "closed": 0}
    stage = str(opportunity.get("stage") or "").strip().upper()
    status = str(opportunity.get("status") or "").strip().lower()
    if status in TERMINAL_STATUS_VALUES or stage in TERMINAL_STAGE_VALUES:
        closed = cancel_pending_followups_for_opportunity(
            opportunity_id=opportunity_id, reason=f"terminal:{stage or status}", created_by=created_by
        )
        return {"ok": True, "status": "terminal", "updated": 0, "closed": closed}

    with engine.begin() as conn:
        pending_codes = {
            str(v or "").strip().upper()
            for v in conn.execute(text("""
                SELECT action_code FROM tasks
                WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'pending'
            """), {"opportunity_id": opportunity_id}).scalars().all()
        }
        superseding = pending_codes & {"SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT"}
        closed_rows = []
        if superseding:
            closed_rows = conn.execute(text("""
                UPDATE tasks
                SET status = 'done', done_at = COALESCE(done_at, now()), done_by = :created_by, updated_at = now(),
                    metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
                        'follow_up_closed_reason', 'superseded_by_pending_action',
                        'superseding_action_codes', CAST(:codes AS JSONB),
                        'follow_up_closed_at', now()
                    )
                WHERE opportunity_id = CAST(:opportunity_id AS UUID)
                  AND status = 'pending'
                  AND action_code IN ('FOLLOW_UP_QUOTE','FOLLOW_UP_PROFORMA')
                RETURNING id::text
            """), {
                "opportunity_id": opportunity_id,
                "created_by": created_by,
                "codes": _json(sorted(superseding)),
            }).mappings().all()

        updated_rows = []
        if stage == "WAITING_PAYMENT" and not superseding:
            updated_rows = conn.execute(text("""
                UPDATE tasks
                SET action_code = 'FOLLOW_UP_PAYMENT',
                    route = 'financeiro',
                    action = 'Fazer follow-up de pagamento',
                    note = 'Confirmar se existe alguma pendência relativamente ao pagamento. Não enviar automaticamente.',
                    updated_at = now(),
                    metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
                        'follow_up_family', 'payment',
                        'follow_up_reason', 'PAYMENT_FOLLOWUP',
                        'aligned_from_action_code', action_code,
                        'aligned_at', now()
                    )
                WHERE opportunity_id = CAST(:opportunity_id AS UUID)
                  AND status = 'pending'
                  AND action_code IN ('FOLLOW_UP_QUOTE','FOLLOW_UP_PROFORMA')
                RETURNING id::text
            """), {"opportunity_id": opportunity_id}).mappings().all()

    return {
        "ok": True,
        "status": "aligned",
        "updated": len(updated_rows),
        "closed": len(closed_rows),
        "stage": stage,
    }


def close_pending_followups_for_opportunity(
    *,
    opportunity_id: str,
    reason: str,
    created_by: str = "system",
    except_task_id: str = "",
) -> int:
    """Mark open follow-ups as done when the client replies or the process advances."""
    opportunity_id = str(opportunity_id or "").strip()
    if not opportunity_id:
        return 0
    params = {
        "opportunity_id": opportunity_id,
        "reason": str(reason or "").strip() or "follow_up_closed",
        "created_by": created_by,
        "except_task_id": str(except_task_id or ""),
    }
    with engine.begin() as conn:
        rows = conn.execute(text("""
            UPDATE tasks
            SET status = 'done',
                done_at = now(),
                done_by = :created_by,
                updated_at = now(),
                metadata = COALESCE(metadata, '{}'::jsonb)
                    || jsonb_build_object('follow_up_closed_reason', :reason, 'follow_up_closed_at', now())
            WHERE opportunity_id = CAST(:opportunity_id AS UUID)
              AND status = 'pending'
              AND (
                action_code LIKE 'FOLLOW_UP_%'
                OR COALESCE(metadata->>'task_type', '') = 'follow_up'
              )
              AND (:except_task_id = '' OR id <> CAST(:except_task_id AS UUID))
            RETURNING id::text, action_code
        """), params).mappings().all()
        for row in rows:
            conn.execute(text("""
                INSERT INTO task_events (task_id, event_type, payload, created_by)
                VALUES (CAST(:task_id AS UUID), 'follow_up_auto_closed', CAST(:payload AS JSONB), :created_by)
            """), {
                "task_id": row["id"],
                "payload": _json({"opportunity_id": opportunity_id, "reason": reason}),
                "created_by": created_by,
            })
    return len(rows)


def cancel_pending_followups_for_opportunity(
    *,
    opportunity_id: str,
    reason: str,
    created_by: str = "system",
) -> int:
    opportunity_id = str(opportunity_id or "").strip()
    if not opportunity_id:
        return 0
    with engine.begin() as conn:
        rows = conn.execute(text("""
            UPDATE tasks
            SET status = 'skipped',
                updated_at = now(),
                metadata = COALESCE(metadata, '{}'::jsonb)
                    || jsonb_build_object('follow_up_cancelled_reason', :reason, 'follow_up_cancelled_at', now())
            WHERE opportunity_id = CAST(:opportunity_id AS UUID)
              AND status = 'pending'
              AND (
                action_code LIKE 'FOLLOW_UP_%'
                OR COALESCE(metadata->>'task_type', '') = 'follow_up'
              )
            RETURNING id::text
        """), {
            "opportunity_id": opportunity_id,
            "reason": str(reason or "opportunity_closed"),
        }).mappings().all()
        for row in rows:
            conn.execute(text("""
                INSERT INTO task_events (task_id, event_type, payload, created_by)
                VALUES (CAST(:task_id AS UUID), 'follow_up_cancelled', CAST(:payload AS JSONB), :created_by)
            """), {
                "task_id": row["id"],
                "payload": _json({"opportunity_id": opportunity_id, "reason": reason}),
                "created_by": created_by,
            })
    return len(rows)
