"""
txn_messages.py — Auto Transaction Message Trigger Layer (v12.37).

WHY: existing modules (messaging.py, marketing.py, reminders.py, notifications.py,
whatsapp_auth.py) cover MANUAL send + scheduled cron + bulk campaigns + in-app
notifications. What was missing: an EVENT-DRIVEN auto-send pipeline that fires
WhatsApp / SMS the moment a transaction is saved.

This module is the THIN trigger layer (NOT a new module — the user explicitly
asked us to reuse existing dispatchers). It owns:
  1. The 4-tier gate (platform flag → license active → customer setting →
     user permission).
  2. Per-event rules (template + channel + cooldown + recipient field).
  3. The actual hook `fire_event(...)` that routes.py / payments.py call on
     every successful save.
  4. CRUD over rules / settings / logs.

It REUSES:
  • messaging._send_twilio  — the actual SMS/WhatsApp dispatcher
  • marketing._interp       — the {{var}} interpolator
  • marketing.TEMPLATES     — the seven preset templates
  • feature_flags.is_flag_enabled — the platform-level gate
  • licensing._get_license_doc + _days_left — the license gate

It NEVER blocks a transaction. Every call into `fire_event` is wrapped in a
broad try/except that logs the error to `txn_message_logs` and returns. A
failed message MUST NOT break invoice / payment save flows.

Collections (NEW, per-company-scoped where applicable):
  txn_message_settings  — singleton per company
    { company_id, enabled, channel_default, auto_send_enabled,
      enabled_users: [user_id], created_at, updated_at, updated_by }
  txn_message_rules     — one per (company × event)
    { company_id, event_key, enabled, channel, template_id, custom_body,
      recipient_field, cooldown_min, vars, created_by, created_at, updated_at }
  txn_message_logs      — outbound dispatch log
    { company_id, event_key, txn_id, txn_type, party_id, party_name, phone,
      channel, body, status: queued|sent|failed|skipped, provider_sid,
      error, reason, fired_by, fired_at }

Event keys (the public taxonomy):
  sale.created          purchase.created          payment_in.created
  sale_order.created    purchase_order.created    payment_out.created
  quotation.created     delivery_challan.created  expense.created
  credit_note.created   debit_note.created
  sales_return.created  purchase_return.created
  (plus *.updated variants are accepted but no template ships for them
   by default — keeps the auto-send surface tight.)
"""
from __future__ import annotations

import logging
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional

from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field

from auth import get_current_user, require_admin

log = logging.getLogger("rge.txn_messages")

router = APIRouter(prefix="/api/txn-messages", tags=["txn-messages"])

# Master flag name — also seeded into BUILT_IN_FLAGS so super-admin can flip
# it from the existing /admin/feature-flags screen.
PLATFORM_FLAG = "auto-transaction-messages"

# Canonical event taxonomy. Frontend renders toggles in this exact order.
SUPPORTED_EVENTS: List[Dict[str, str]] = [
    {"key": "sale.created",             "label": "Sale Invoice",        "group": "sales"},
    {"key": "purchase.created",         "label": "Purchase Bill",       "group": "purchases"},
    {"key": "sale_order.created",       "label": "Sale Order",          "group": "sales"},
    {"key": "purchase_order.created",   "label": "Purchase Order",      "group": "purchases"},
    {"key": "quotation.created",        "label": "Estimate / Quotation","group": "sales"},
    {"key": "delivery_challan.created", "label": "Delivery Challan",    "group": "sales"},
    {"key": "credit_note.created",      "label": "Credit Note",         "group": "sales"},
    {"key": "debit_note.created",       "label": "Debit Note",          "group": "purchases"},
    {"key": "sales_return.created",     "label": "Sales Return",        "group": "sales"},
    {"key": "purchase_return.created",  "label": "Purchase Return",     "group": "purchases"},
    {"key": "payment_in.created",       "label": "Payment In Received", "group": "payments"},
    {"key": "payment_out.created",      "label": "Payment Out Sent",    "group": "payments"},
    {"key": "expense.created",          "label": "Expense Recorded",    "group": "payments"},
]
EVENT_KEYS = {e["key"] for e in SUPPORTED_EVENTS}

# Fallback message bodies used when a rule has no `custom_body` AND no template
# id is set. Keeps the system usable on day one for an admin who just flips
# auto-send ON without configuring each event.
DEFAULT_BODIES: Dict[str, str] = {
    "sale.created":
        "Namaste {{party_name}},\nAapka bill {{invoice_no}} (₹{{total}}) "
        "{{shop_name}} se generate ho gaya hai.\nDhanyavaad!",
    "purchase.created":
        "Namaste {{party_name}},\nPurchase bill {{invoice_no}} (₹{{total}}) "
        "recorded against {{shop_name}}.",
    "sale_order.created":
        "Namaste {{party_name}},\nAapka order {{invoice_no}} confirm ho gaya. "
        "Total: ₹{{total}}. — {{shop_name}}",
    "purchase_order.created":
        "Hello {{party_name}}, purchase order {{invoice_no}} placed for ₹{{total}}. — {{shop_name}}",
    "quotation.created":
        "Namaste {{party_name}},\nEstimate {{invoice_no}} taiyar hai — total ₹{{total}}. "
        "Confirm karne ke liye reply karein. — {{shop_name}}",
    "delivery_challan.created":
        "Namaste {{party_name}},\nDelivery challan {{invoice_no}} dispatched. — {{shop_name}}",
    "credit_note.created":
        "Namaste {{party_name}},\nCredit note {{invoice_no}} (₹{{total}}) issue ho gaya hai. — {{shop_name}}",
    "debit_note.created":
        "Namaste {{party_name}},\nDebit note {{invoice_no}} (₹{{total}}) raise ho gaya. — {{shop_name}}",
    "sales_return.created":
        "Namaste {{party_name}},\nReturn {{invoice_no}} (₹{{total}}) record ho gaya. Refund process mein hai. — {{shop_name}}",
    "purchase_return.created":
        "Hello {{party_name}}, return note {{invoice_no}} (₹{{total}}) raised. — {{shop_name}}",
    "payment_in.created":
        "Namaste {{party_name}},\n₹{{amount}} ka payment receive ho gaya. Dhanyavaad! — {{shop_name}}",
    "payment_out.created":
        "Hello {{party_name}}, ₹{{amount}} ka payment {{shop_name}} se send kar diya gaya hai.",
    "expense.created":
        "Expense recorded: {{category}} · ₹{{amount}} — {{shop_name}}",
}

# Singleton cache to avoid re-importing on every fire.
_SHOP_NAME_CACHE: Dict[str, str] = {}


# ============================================================================
# Helpers
# ============================================================================
def _now() -> datetime:
    return datetime.now(timezone.utc)


def _now_iso() -> str:
    return _now().isoformat()


def _oid(s: str) -> ObjectId:
    try:
        return ObjectId(s)
    except Exception:
        raise HTTPException(400, f"Invalid id: {s}")


def _ser(d: Optional[dict]) -> Optional[dict]:
    if not d:
        return d
    out = {k: v for k, v in d.items() if k != "_id"}
    if "_id" in d:
        out["id"] = str(d["_id"])
    return out


async def _shop_name(db, company_id: str) -> str:
    if not company_id:
        return "RGE REGALGOA"
    if company_id in _SHOP_NAME_CACHE:
        return _SHOP_NAME_CACHE[company_id]
    try:
        c = await db.companies.find_one({"_id": _oid(company_id)})
        name = (c or {}).get("name") or "RGE REGALGOA"
    except Exception:
        name = "RGE REGALGOA"
    _SHOP_NAME_CACHE[company_id] = name
    return name


async def _normalise_phone(phone: str) -> str:
    """Reuse the same normaliser as messaging.py."""
    n = (phone or "").strip().replace(" ", "").replace("-", "")
    if not n:
        return ""
    if not n.startswith("+"):
        if len(n) == 10 and n.isdigit():
            n = "+91" + n
        else:
            n = "+" + n
    return n


# ============================================================================
# 4-tier access gate — the core of the license-based rollout plan.
# Order: Platform → License → Customer → User → Allow
# Returns (allowed: bool, reason: str, ctx: dict).
# ============================================================================
async def check_access(db, user: Optional[dict], company_id: Optional[str] = None) -> dict:
    ctx = {
        "platform_enabled": False,
        "license_active": False,
        "customer_enabled": False,
        "user_allowed": False,
        "allowed": False,
        "reason": "",
    }

    # 1. PLATFORM — super-admin global flag (lives in feature_flags engine).
    try:
        from feature_flags import is_flag_enabled
        ctx["platform_enabled"] = bool(await is_flag_enabled(db, PLATFORM_FLAG, user or {}))
    except Exception:
        ctx["platform_enabled"] = False
    if not ctx["platform_enabled"]:
        ctx["reason"] = "Platform feature disabled (super-admin only)"
        return ctx

    # 2. LICENSE — singleton license doc, must be active.
    try:
        from licensing import _get_license_doc, _days_left
        lic = await _get_license_doc(db)
        ctx["license_active"] = _days_left(lic.get("expires")) > 0
    except Exception:
        ctx["license_active"] = False
    if not ctx["license_active"]:
        ctx["reason"] = "License inactive or expired"
        return ctx

    # 3. CUSTOMER — per-company settings (the license owner's admin controls).
    settings = await _load_settings(db, company_id) if company_id else None
    ctx["customer_enabled"] = bool(settings and settings.get("enabled"))
    if not ctx["customer_enabled"]:
        ctx["reason"] = "Customer admin has not enabled Auto Transaction Messages"
        return ctx

    # 4. USER — must be either an admin OR in the explicit enabled_users list.
    role = (user or {}).get("role") or ""
    uid = (user or {}).get("id") or ""
    allowed_users = settings.get("enabled_users") or []
    if role == "admin" or uid in allowed_users:
        ctx["user_allowed"] = True
    else:
        ctx["reason"] = "User does not have permission for this feature"
        return ctx

    ctx["allowed"] = True
    ctx["reason"] = "OK"
    return ctx


# ============================================================================
# Settings (per company) — defaults applied on read
# ============================================================================
DEFAULT_SETTINGS = {
    "enabled": False,            # customer-admin master toggle
    "auto_send_enabled": False,  # if False → only show "Send WhatsApp" shortcut, do NOT auto-fire
    "channel_default": "whatsapp",
    "enabled_users": [],
    "whatsapp_shortcut_enabled": False,  # the "WhatsApp Floating" UX (default OFF)
    # v12.38 — Section 1 of the Auto Transaction Message Center spec
    "send_to_party": True,         # send to the customer/vendor linked with the txn
    "send_on_update": False,       # also fire on .updated paths (off by default to avoid spam)
    "send_copy_to_self": False,    # blind-copy the admin's own phone
    "self_copy_phone": "",         # admin's own WhatsApp number (E.164-ish)
    "last_synced_at": None,        # surface a "last synced" timestamp on the UI
}


async def _load_settings(db, company_id: str) -> dict:
    if not company_id:
        return {**DEFAULT_SETTINGS, "company_id": ""}
    doc = await db.txn_message_settings.find_one({"company_id": company_id})
    if not doc:
        return {**DEFAULT_SETTINGS, "company_id": company_id}
    out = {**DEFAULT_SETTINGS, **{k: v for k, v in doc.items() if k != "_id"}}
    out["company_id"] = company_id
    return out


# ============================================================================
# Rules (per company × event)
# ============================================================================
async def _load_rule(db, company_id: str, event_key: str) -> Optional[dict]:
    if not company_id or event_key not in EVENT_KEYS:
        return None
    return await db.txn_message_rules.find_one(
        {"company_id": company_id, "event_key": event_key},
    )


# ============================================================================
# Template interpolation — reuse marketing._interp, with a richer context
# ============================================================================
def _build_vars(txn_doc: dict, shop_name: str) -> dict:
    return {
        "shop_name": shop_name,
        "company_name": shop_name,
        "party_name": txn_doc.get("party_name") or "Customer",
        "name": txn_doc.get("party_name") or "Customer",
        "customer_name": txn_doc.get("party_name") or "Customer",
        "invoice_no": txn_doc.get("invoice_no") or txn_doc.get("voucher_no") or "",
        "voucher_no": txn_doc.get("voucher_no") or txn_doc.get("invoice_no") or "",
        "total": f"{float(txn_doc.get('total') or 0):,.2f}",
        "amount": f"{float(txn_doc.get('amount') or txn_doc.get('total') or 0):,.2f}",
        "due_date": txn_doc.get("due_date") or "",
        "category": txn_doc.get("category") or "",
        "date": txn_doc.get("invoice_date") or txn_doc.get("date") or "",
    }


def _render_body(rule: Optional[dict], event_key: str, vars_: dict) -> str:
    # Priority: rule custom_body > rule.template_id (marketing TEMPLATES) > DEFAULT_BODIES.
    body_raw: Optional[str] = None
    if rule and (rule.get("custom_body") or "").strip():
        body_raw = rule["custom_body"]
    elif rule and rule.get("template_id"):
        try:
            from marketing import TEMPLATES
            t = next((t for t in TEMPLATES if t["key"] == rule["template_id"]), None)
            if t:
                body_raw = t["body"]
        except Exception:
            body_raw = None
    if not body_raw:
        body_raw = DEFAULT_BODIES.get(event_key, "")
    try:
        from marketing import _interp
        return _interp(body_raw, {}, vars_)
    except Exception:
        # Fallback to brace-style replace
        out = body_raw or ""
        for k, v in vars_.items():
            out = out.replace("{{" + k + "}}", str(v))
        return out


# ============================================================================
# THE HOOK — called from routes.py / payments.py at the END of every save.
# Always returns a dict; NEVER raises.
# ============================================================================
async def fire_event(db, user: Optional[dict], event_key: str,
                    txn_doc: Optional[dict], request=None,
                    is_update: bool = False) -> Dict[str, Any]:
    """Non-blocking. Logs everything to txn_message_logs.

    v12.38 adds three settings that change WHO gets the message:
      • send_to_party       — send to the customer/vendor (default ON)
      • send_copy_to_self   — also send to admin's own phone (default OFF)
      • send_on_update      — also fire on .updated paths (default OFF)
    The recipient list is built once, the body is rendered once, then we
    dispatch + log per-recipient so the admin sees one row per send.
    """
    out: Dict[str, Any] = {"event": event_key, "skipped": True, "reason": "", "sent_count": 0}
    try:
        if event_key not in EVENT_KEYS:
            out["reason"] = "unsupported event"
            return out
        if not txn_doc:
            out["reason"] = "no txn doc"
            return out

        company_id = txn_doc.get("company_id") or ""
        access = await check_access(db, user or {}, company_id=company_id)
        if not access["allowed"]:
            out["reason"] = access["reason"]
            await _log_skipped(db, company_id, event_key, txn_doc, user, access["reason"])
            return out

        settings = await _load_settings(db, company_id)
        if not settings.get("auto_send_enabled"):
            out["reason"] = "auto_send disabled (manual mode)"
            await _log_skipped(db, company_id, event_key, txn_doc, user, out["reason"])
            return out

        # v12.38 — update gate. Update paths are off by default to avoid spam.
        if is_update and not settings.get("send_on_update"):
            out["reason"] = "Update events disabled (send_on_update OFF)"
            await _log_skipped(db, company_id, event_key, txn_doc, user, out["reason"])
            return out

        rule = await _load_rule(db, company_id, event_key)
        if rule and rule.get("enabled") is False:
            out["reason"] = "event rule disabled"
            await _log_skipped(db, company_id, event_key, txn_doc, user, out["reason"])
            return out

        channel = (rule or {}).get("channel") or settings.get("channel_default") or "whatsapp"

        # v12.38 — Build recipient list.
        recipients: List[Dict[str, str]] = []
        party_name = txn_doc.get("party_name") or ""
        if settings.get("send_to_party", True):
            party_phone = await _resolve_recipient(db, txn_doc)
            if party_phone:
                recipients.append({"phone": party_phone, "kind": "party", "name": party_name or "Customer"})
        if settings.get("send_copy_to_self") and settings.get("self_copy_phone"):
            self_phone = await _normalise_phone(settings["self_copy_phone"])
            if self_phone:
                recipients.append({"phone": self_phone, "kind": "self_copy", "name": "Self copy"})

        if not recipients:
            out["reason"] = "no recipient (party phone missing AND no self-copy configured)"
            await _log_skipped(db, company_id, event_key, txn_doc, user, out["reason"])
            return out

        # Cooldown applies per-(txn, event) regardless of recipient.
        cooldown_min = int((rule or {}).get("cooldown_min") or 0)
        if cooldown_min > 0:
            recent = await db.txn_message_logs.find_one({
                "txn_id": str(txn_doc.get("_id") or txn_doc.get("id") or ""),
                "event_key": event_key,
                "status": "sent",
                "fired_at": {"$gt": (_now() - timedelta(minutes=cooldown_min)).isoformat()},
            })
            if recent:
                out["reason"] = "cooldown window"
                await _log_skipped(db, company_id, event_key, txn_doc, user, out["reason"])
                return out

        shop = await _shop_name(db, company_id)
        body = _render_body(rule, event_key, _build_vars(txn_doc, shop))
        # v12.38 — when fired from an update path, append a tiny marker so the
        # recipient understands the transaction was edited (no separate template
        # needed — keeps the surface tight).
        if is_update:
            body = body.rstrip() + "\n(Updated)"

        from messaging import _send_twilio, SendIn

        for rec in recipients:
            log_doc = {
                "company_id": company_id,
                "event_key": event_key,
                "txn_id": str(txn_doc.get("_id") or txn_doc.get("id") or ""),
                "txn_type": txn_doc.get("type") or event_key.split(".")[0],
                "party_id": txn_doc.get("party_id") or "",
                "party_name": rec["name"],
                "recipient_kind": rec["kind"],         # "party" | "self_copy"
                "phone": rec["phone"],
                "channel": channel,
                "body": body,
                "status": "queued",
                "is_update": is_update,
                "fired_by": (user or {}).get("email") or "system",
                "fired_at": _now_iso(),
            }
            try:
                payload = SendIn(to=rec["phone"], body=body, invoice_id=log_doc["txn_id"] or None)
                res = await _send_twilio(channel, payload,
                                         user or {"id": "system", "email": "system@rge", "role": "admin"},
                                         db)
                log_doc["status"] = "sent"
                log_doc["provider_sid"] = res.get("sid")
                out["sent_count"] += 1
            except HTTPException as he:
                log_doc["status"] = "failed"
                log_doc["error"] = str(he.detail)
            except Exception as e:
                log_doc["status"] = "failed"
                log_doc["error"] = str(e)
            await db.txn_message_logs.insert_one(log_doc)

        # Stamp "last synced" so the UI shows a fresh timestamp after every fire.
        try:
            await db.txn_message_settings.update_one(
                {"company_id": company_id},
                {"$set": {"last_synced_at": _now_iso()}},
                upsert=False,
            )
        except Exception:
            pass

        out["skipped"] = out["sent_count"] == 0
        out["channel"] = channel
        out["recipients"] = recipients
        return out
    except Exception as e:
        # NEVER raise — transaction save must succeed regardless.
        log.exception("fire_event failed: %s", e)
        out["reason"] = f"hook crash: {e}"
        return out


async def _resolve_recipient(db, txn_doc: dict) -> str:
    phone = (txn_doc.get("party_phone") or "").strip()
    if not phone and txn_doc.get("party_id"):
        try:
            p = await db.parties.find_one({"_id": _oid(txn_doc["party_id"])})
            if p:
                phone = (p.get("phone") or "").strip()
        except Exception:
            pass
    return await _normalise_phone(phone) if phone else ""


async def _log_skipped(db, company_id: str, event_key: str, txn_doc: dict, user: Optional[dict], reason: str):
    try:
        await db.txn_message_logs.insert_one({
            "company_id": company_id,
            "event_key": event_key,
            "txn_id": str(txn_doc.get("_id") or txn_doc.get("id") or ""),
            "txn_type": txn_doc.get("type") or event_key.split(".")[0],
            "party_name": txn_doc.get("party_name") or "",
            "channel": "",
            "body": "",
            "status": "skipped",
            "reason": reason,
            "fired_by": (user or {}).get("email") or "system",
            "fired_at": _now_iso(),
        })
    except Exception:
        # Logging best-effort — never propagate.
        pass


# ============================================================================
# Models
# ============================================================================
class SettingsIn(BaseModel):
    company_id: str
    enabled: Optional[bool] = None
    auto_send_enabled: Optional[bool] = None
    channel_default: Optional[str] = None
    enabled_users: Optional[List[str]] = None
    whatsapp_shortcut_enabled: Optional[bool] = None
    # v12.38 new fields
    send_to_party: Optional[bool] = None
    send_on_update: Optional[bool] = None
    send_copy_to_self: Optional[bool] = None
    self_copy_phone: Optional[str] = None


class RuleIn(BaseModel):
    company_id: str
    event_key: str
    enabled: bool = True
    channel: str = "whatsapp"
    template_id: Optional[str] = ""
    custom_body: Optional[str] = ""
    cooldown_min: int = 0


class TestFireIn(BaseModel):
    company_id: str
    event_key: str
    phone: str
    sample_doc: Optional[Dict[str, Any]] = None


# ============================================================================
# Endpoints
# ============================================================================
@router.get("/events")
async def list_events(user=Depends(get_current_user)):
    """Public taxonomy. The frontend uses this to render the toggle list."""
    return {"events": SUPPORTED_EVENTS, "default_bodies": DEFAULT_BODIES}


@router.get("/access")
async def get_access(request: Request,
                     company_id: Optional[str] = Query(None),
                     user=Depends(get_current_user)):
    """Returns the full 4-tier state so the frontend can decide whether to
    show the Settings section AT ALL (hide entirely when platform OFF)."""
    db = request.app.state.db
    state = await check_access(db, user, company_id=company_id)
    state["is_admin"] = user.get("role") == "admin"
    return state


@router.get("/settings")
async def get_settings(request: Request,
                       company_id: str = Query(...),
                       user=Depends(get_current_user)):
    db = request.app.state.db
    return await _load_settings(db, company_id)


@router.put("/settings")
async def put_settings(payload: SettingsIn, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    # Platform + license MUST be OK to mutate the toggle.
    access = await check_access(db, user, company_id=payload.company_id)
    if not access["platform_enabled"]:
        raise HTTPException(403, "Platform feature disabled by super-admin")
    if not access["license_active"]:
        raise HTTPException(403, "License inactive — please activate or renew")
    patch = {k: v for k, v in payload.model_dump(exclude_none=True).items() if k != "company_id"}
    patch["updated_at"] = _now_iso()
    patch["updated_by"] = user.get("email")
    await db.txn_message_settings.update_one(
        {"company_id": payload.company_id},
        {"$set": patch, "$setOnInsert": {"company_id": payload.company_id, "created_at": _now_iso()}},
        upsert=True,
    )
    return await _load_settings(db, payload.company_id)


@router.get("/rules")
async def list_rules(request: Request,
                     company_id: str = Query(...),
                     user=Depends(get_current_user)):
    db = request.app.state.db
    docs = await db.txn_message_rules.find({"company_id": company_id}).to_list(200)
    return [_ser(d) for d in docs]


@router.put("/rules")
async def upsert_rule(payload: RuleIn, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    if payload.event_key not in EVENT_KEYS:
        raise HTTPException(400, f"Unknown event: {payload.event_key}")
    if payload.channel not in ("whatsapp", "sms"):
        raise HTTPException(400, "channel must be 'whatsapp' or 'sms'")
    patch = {
        "company_id": payload.company_id,
        "event_key": payload.event_key,
        "enabled": payload.enabled,
        "channel": payload.channel,
        "template_id": payload.template_id or "",
        "custom_body": payload.custom_body or "",
        "cooldown_min": max(0, int(payload.cooldown_min or 0)),
        "updated_at": _now_iso(),
        "updated_by": user.get("email"),
    }
    await db.txn_message_rules.update_one(
        {"company_id": payload.company_id, "event_key": payload.event_key},
        {"$set": patch, "$setOnInsert": {"created_at": _now_iso(), "created_by": user.get("email")}},
        upsert=True,
    )
    doc = await db.txn_message_rules.find_one(
        {"company_id": payload.company_id, "event_key": payload.event_key},
    )
    return _ser(doc)


@router.delete("/rules/{rule_id}")
async def delete_rule(rule_id: str, request: Request, user=Depends(require_admin)):
    db = request.app.state.db
    res = await db.txn_message_rules.delete_one({"_id": _oid(rule_id)})
    if res.deleted_count == 0:
        raise HTTPException(404, "Rule not found")
    return {"ok": True}


@router.get("/logs")
async def list_logs(request: Request,
                    company_id: str = Query(...),
                    limit: int = Query(50, ge=1, le=200),
                    status: Optional[str] = Query(None),
                    user=Depends(get_current_user)):
    db = request.app.state.db
    q: Dict[str, Any] = {"company_id": company_id}
    if status:
        q["status"] = status
    docs = await db.txn_message_logs.find(q).sort("fired_at", -1).limit(limit).to_list(limit)
    return [_ser(d) for d in docs]


@router.delete("/logs")
async def clear_logs(request: Request,
                     company_id: str = Query(...),
                     user=Depends(require_admin)):
    db = request.app.state.db
    res = await db.txn_message_logs.delete_many({"company_id": company_id})
    return {"ok": True, "deleted": res.deleted_count}


@router.post("/test-fire")
async def test_fire(payload: TestFireIn, request: Request, user=Depends(require_admin)):
    """Dry-run: bypasses the 4-tier gate and sends directly to the given phone
    using the configured rule body / default. Admin only — used from the
    settings UI to verify the message wording looks correct end-to-end."""
    db = request.app.state.db
    if payload.event_key not in EVENT_KEYS:
        raise HTTPException(400, f"Unknown event: {payload.event_key}")
    phone = await _normalise_phone(payload.phone)
    if not phone:
        raise HTTPException(400, "Valid phone number required")
    rule = await _load_rule(db, payload.company_id, payload.event_key)
    shop = await _shop_name(db, payload.company_id)
    sample = payload.sample_doc or {
        "party_name": "Test Customer",
        "invoice_no": "TEST/001",
        "total": 1000,
        "amount": 1000,
        "category": "Demo",
    }
    body = _render_body(rule, payload.event_key, _build_vars(sample, shop))
    settings = await _load_settings(db, payload.company_id)
    channel = (rule or {}).get("channel") or settings.get("channel_default") or "whatsapp"
    log_doc = {
        "company_id": payload.company_id,
        "event_key": payload.event_key,
        "txn_id": "test-fire",
        "txn_type": "test",
        "party_name": sample.get("party_name") or "Test",
        "phone": phone,
        "channel": channel,
        "body": body,
        "status": "queued",
        "fired_by": user.get("email"),
        "fired_at": _now_iso(),
    }
    try:
        from messaging import _send_twilio, SendIn
        res = await _send_twilio(channel, SendIn(to=phone, body=body), user, db)
        log_doc["status"] = "sent"
        log_doc["provider_sid"] = res.get("sid")
    except HTTPException as he:
        log_doc["status"] = "failed"
        log_doc["error"] = str(he.detail)
    except Exception as e:
        log_doc["status"] = "failed"
        log_doc["error"] = str(e)
    await db.txn_message_logs.insert_one(log_doc)
    return {"ok": log_doc["status"] == "sent",
            "status": log_doc["status"],
            "body": body,
            "phone": phone,
            "channel": channel,
            "error": log_doc.get("error")}


@router.post("/test-self")
async def test_self(request: Request,
                    company_id: str = Query(...),
                    user=Depends(require_admin)):
    """v12.38 — Section 1 'Test Message' button. Sends a smoke-test WhatsApp
    to the admin's configured self_copy_phone using the same dispatcher used
    by fire_event. Bypasses the 4-tier gate (this is a config-time sanity
    check, not a real transaction)."""
    db = request.app.state.db
    settings = await _load_settings(db, company_id)
    phone = await _normalise_phone(settings.get("self_copy_phone") or "")
    if not phone:
        raise HTTPException(400, "Configure 'self copy phone' first")
    channel = settings.get("channel_default") or "whatsapp"
    shop = await _shop_name(db, company_id)
    body = (f"Test message from {shop} — Auto Transaction Message setup OK. "
            f"Time: {_now_iso()}")
    log_doc = {
        "company_id": company_id,
        "event_key": "test.self",
        "txn_id": "test-self",
        "txn_type": "test",
        "party_name": "Self",
        "recipient_kind": "self_copy",
        "phone": phone,
        "channel": channel,
        "body": body,
        "status": "queued",
        "fired_by": user.get("email"),
        "fired_at": _now_iso(),
    }
    try:
        from messaging import _send_twilio, SendIn
        res = await _send_twilio(channel, SendIn(to=phone, body=body), user, db)
        log_doc["status"] = "sent"
        log_doc["provider_sid"] = res.get("sid")
    except HTTPException as he:
        log_doc["status"] = "failed"
        log_doc["error"] = str(he.detail)
    except Exception as e:
        log_doc["status"] = "failed"
        log_doc["error"] = str(e)
    await db.txn_message_logs.insert_one(log_doc)
    return {"ok": log_doc["status"] == "sent",
            "status": log_doc["status"],
            "phone": phone,
            "channel": channel,
            "body": body,
            "error": log_doc.get("error")}


@router.post("/sync-now")
async def sync_now(request: Request,
                   company_id: str = Query(...),
                   user=Depends(require_admin)):
    """v12.38 — Section 1 'Sync Now' button. Refreshes the last_synced_at
    timestamp on the customer settings doc so the UI shows a fresh value.
    Cheap & idempotent — no message dispatch happens here, that's intentional."""
    db = request.app.state.db
    await db.txn_message_settings.update_one(
        {"company_id": company_id},
        {"$set": {"last_synced_at": _now_iso()}, "$setOnInsert": {"company_id": company_id, "created_at": _now_iso()}},
        upsert=True,
    )
    settings = await _load_settings(db, company_id)
    return {"ok": True, "last_synced_at": settings.get("last_synced_at")}


@router.get("/wa-shortcut")
async def wa_shortcut(request: Request,
                      company_id: str = Query(...),
                      event_key: str = Query(...),
                      txn_id: str = Query(...),
                      user=Depends(get_current_user)):
    """Build a pre-rendered wa.me URL for the given saved transaction.
    Used by the post-save "Send WhatsApp now?" shortcut (gated by the customer
    setting `whatsapp_shortcut_enabled`). Does NOT actually send anything —
    the frontend opens the URL in a new tab and the user hits Send manually."""
    db = request.app.state.db
    settings = await _load_settings(db, company_id)
    if not settings.get("whatsapp_shortcut_enabled"):
        raise HTTPException(403, "WhatsApp shortcut disabled for this company")
    if event_key not in EVENT_KEYS:
        raise HTTPException(400, f"Unknown event: {event_key}")
    # Look up txn doc (search across invoices, payments, expenses)
    txn = None
    for coll in ("invoices", "payments", "expenses"):
        try:
            txn = await getattr(db, coll).find_one({"_id": _oid(txn_id)})
            if txn:
                break
        except Exception:
            continue
    if not txn:
        raise HTTPException(404, "Transaction not found")
    phone = await _resolve_recipient(db, txn)
    if not phone:
        raise HTTPException(400, "Recipient phone unavailable")
    rule = await _load_rule(db, company_id, event_key)
    shop = await _shop_name(db, company_id)
    body = _render_body(rule, event_key, _build_vars(txn, shop))
    import urllib.parse as _up
    digits = "".join(ch for ch in phone if ch.isdigit())
    return {
        "wa_url": f"https://wa.me/{digits}?text={_up.quote(body)}",
        "phone": phone,
        "body": body,
    }
