# @File: backend/services/whatsapp/insights.py
# @Desc: AI-powered insights for WhatsApp campaign analytics.
#
#        This module takes the same aggregated data that powers the
#        analytics dashboard (KPIs, trend, per-campaign compare) and
#        asks an LLM to interpret it into:
#
#          * a short plain-English summary,
#          * a list of noteworthy trends,
#          * concrete optimization recommendations,
#          * a list of "warnings" for campaigns that underperform.
#
#        The LLM is ONLY used to interpret the already-aggregated
#        numbers — it never sees recipient-level data. This keeps the
#        prompt tiny, deterministic-ish, and privacy-friendly.
#
#        The service is intentionally defensive: if the model call
#        fails, we fall back to a deterministic rule-based summary so
#        the UI always renders something useful.
from __future__ import annotations

import json
import logging
import re
from typing import Any, Optional

from schemas.aihub import ChatMessage, GenTxtRequest
from services.aihub import AIHubService
from services.whatsapp import campaign_analytics
from sqlalchemy.ext.asyncio import AsyncSession

logger = logging.getLogger(__name__)

# We intentionally stick to the cost-effective general-purpose model
# here — insights are short and don't benefit from the premium tier.
INSIGHTS_MODEL = "gpt-5.4"

# Hard caps to avoid accidentally shipping huge prompts if someone
# happens to have thousands of campaigns.
MAX_COMPARE_CAMPAIGNS = 20


# ---------------------------------------------------------------------------
# Deterministic fallback
# ---------------------------------------------------------------------------


def _fmt_pct(value: float) -> str:
    return f"{value:.1f}%"


def _rule_based_summary(overview: dict, compare: dict) -> dict:
    """Build a deterministic insights payload without calling the LLM."""
    kpis = overview.get("kpis") or {}
    trend = overview.get("trend") or []
    campaigns = (compare.get("campaigns") or [])[:MAX_COMPARE_CAMPAIGNS]

    sent = int(kpis.get("sent") or 0)
    delivered = int(kpis.get("delivered") or 0)
    replied = int(kpis.get("replied") or 0)
    failed = int(kpis.get("failed") or 0)
    delivery_rate = float(kpis.get("delivery_rate") or 0.0)
    reply_rate = float(kpis.get("reply_rate") or 0.0)
    failure_rate = float(kpis.get("failure_rate") or 0.0)

    trends: list[str] = []
    if len(trend) >= 2:
        first, last = trend[0], trend[-1]
        first_dr = float(first.get("delivery_rate") or 0.0)
        last_dr = float(last.get("delivery_rate") or 0.0)
        if last_dr - first_dr >= 2.0:
            trends.append(
                f"Delivery rate is improving across the selected window "
                f"({_fmt_pct(first_dr)} → {_fmt_pct(last_dr)})."
            )
        elif first_dr - last_dr >= 2.0:
            trends.append(
                f"Delivery rate dropped from {_fmt_pct(first_dr)} to "
                f"{_fmt_pct(last_dr)} across the window — worth checking "
                f"device health or recipient quality."
            )
        first_rr = float(first.get("reply_rate") or 0.0)
        last_rr = float(last.get("reply_rate") or 0.0)
        if last_rr - first_rr >= 2.0:
            trends.append(
                f"Reply rate is trending up ({_fmt_pct(first_rr)} → "
                f"{_fmt_pct(last_rr)})."
            )
    if not trends:
        trends.append(
            "Not enough trend movement to call out a direction yet — "
            "keep sending to build a baseline."
        )

    recommendations: list[str] = []
    if failure_rate >= 10.0:
        recommendations.append(
            "Failure rate is above 10% — review the device pairing, "
            "number format of your recipient lists, and throttling."
        )
    elif failure_rate >= 3.0:
        recommendations.append(
            "Failure rate is elevated. Clean your recipient list and "
            "remove numbers that failed more than once."
        )
    if delivery_rate < 85.0 and sent > 0:
        recommendations.append(
            "Delivery rate is below 85%. Consider slowing the send "
            "rate or splitting into smaller batches."
        )
    if reply_rate < 5.0 and delivered > 0:
        recommendations.append(
            "Reply rate is low. Try a shorter opening line, a clear "
            "question, or a single call-to-action to lift engagement."
        )
    if not recommendations:
        recommendations.append(
            "Metrics look healthy. Keep the current cadence and run an "
            "A/B test on message copy to push reply rate higher."
        )

    warnings: list[dict[str, Any]] = []
    for c in campaigns:
        dr = float(c.get("delivery_rate") or 0.0)
        rr = float(c.get("reply_rate") or 0.0)
        fr = float(c.get("failure_rate") or 0.0)
        sent_c = int(c.get("sent") or 0)
        if sent_c < 10:
            continue  # too small to judge
        if fr >= 15.0:
            warnings.append(
                {
                    "campaign_id": c.get("id"),
                    "campaign_name": c.get("name"),
                    "severity": "high",
                    "reason": f"Failure rate {_fmt_pct(fr)} — unusually high.",
                }
            )
        elif dr < 70.0:
            warnings.append(
                {
                    "campaign_id": c.get("id"),
                    "campaign_name": c.get("name"),
                    "severity": "medium",
                    "reason": f"Delivery rate {_fmt_pct(dr)} — below the 70% guardrail.",
                }
            )
        elif rr < 1.0 and (c.get("replied") or 0) == 0 and (c.get("delivered") or 0) >= 50:
            warnings.append(
                {
                    "campaign_id": c.get("id"),
                    "campaign_name": c.get("name"),
                    "severity": "low",
                    "reason": "No replies on a large delivered audience — copy may not be engaging.",
                }
            )

    summary = (
        f"Across {kpis.get('total_campaigns', 0)} campaign(s), you sent "
        f"{sent:,} messages, delivered {delivered:,} "
        f"({_fmt_pct(delivery_rate)}), received {replied:,} replies "
        f"({_fmt_pct(reply_rate)}), and had {failed:,} failures "
        f"({_fmt_pct(failure_rate)})."
    )

    return {
        "summary": summary,
        "trends": trends,
        "recommendations": recommendations,
        "warnings": warnings,
        "model": "rule-based",
        "fallback": True,
    }


# ---------------------------------------------------------------------------
# LLM prompt + response parsing
# ---------------------------------------------------------------------------


_SYSTEM_PROMPT = (
    "You are an expert WhatsApp marketing analyst. You are given "
    "already-aggregated KPI numbers from a client's WhatsApp campaigns "
    "(no personal data). Your job is to interpret the numbers into "
    "clear, actionable insights for a non-technical business owner. "
    "Be concrete: reference the numbers you see. Never invent data "
    "that is not present. Avoid jargon. Respond in English.\n\n"
    "Return ONLY valid JSON matching this shape:\n"
    "{\n"
    '  "summary": "<2-3 sentence plain-English overview>",\n'
    '  "trends": ["<short bullet>", "<short bullet>"],\n'
    '  "recommendations": ["<actionable bullet>", "<actionable bullet>"],\n'
    '  "warnings": [\n'
    '    {"campaign_id": <int or null>, "campaign_name": "<string>", '
    '"severity": "low|medium|high", "reason": "<short reason>"}\n'
    "  ]\n"
    "}\n\n"
    "Keep each bullet under 160 characters. Keep arrays under 5 items. "
    "If the data is too sparse to draw a conclusion, say so honestly "
    "in the summary and return empty arrays for the others."
)


def _build_user_payload(overview: dict, compare: dict) -> str:
    campaigns = (compare.get("campaigns") or [])[:MAX_COMPARE_CAMPAIGNS]
    payload = {
        "range": overview.get("range") or {},
        "kpis": overview.get("kpis") or {},
        "trend_points": overview.get("trend") or [],
        "per_campaign": [
            {
                "id": c.get("id"),
                "name": c.get("name"),
                "status": c.get("status"),
                "recipients": c.get("total_recipients"),
                "sent": c.get("sent"),
                "delivered": c.get("delivered"),
                "replied": c.get("replied"),
                "failed": c.get("failed"),
                "delivery_rate": c.get("delivery_rate"),
                "reply_rate": c.get("reply_rate"),
                "failure_rate": c.get("failure_rate"),
            }
            for c in campaigns
        ],
    }
    return json.dumps(payload, ensure_ascii=False, default=str)


_JSON_BLOCK_RE = re.compile(r"\{.*\}", re.DOTALL)


def _parse_llm_output(raw: str) -> Optional[dict]:
    """Parse the LLM response into our insights dict, or None if unusable."""
    if not raw:
        return None
    text = raw.strip()
    # Strip optional markdown code fences
    if text.startswith("```"):
        text = re.sub(r"^```[a-zA-Z]*\n?", "", text)
        text = re.sub(r"\n?```$", "", text)

    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        match = _JSON_BLOCK_RE.search(text)
        if not match:
            return None
        try:
            data = json.loads(match.group(0))
        except json.JSONDecodeError:
            return None

    if not isinstance(data, dict):
        return None

    summary = str(data.get("summary") or "").strip()
    trends_raw = data.get("trends") or []
    recs_raw = data.get("recommendations") or []
    warnings_raw = data.get("warnings") or []

    def _as_str_list(items: Any) -> list[str]:
        out: list[str] = []
        if isinstance(items, list):
            for item in items:
                if isinstance(item, str):
                    s = item.strip()
                    if s:
                        out.append(s[:300])
        return out[:5]

    warnings: list[dict[str, Any]] = []
    if isinstance(warnings_raw, list):
        for item in warnings_raw[:5]:
            if not isinstance(item, dict):
                continue
            severity = str(item.get("severity") or "medium").lower()
            if severity not in ("low", "medium", "high"):
                severity = "medium"
            reason = str(item.get("reason") or "").strip()
            if not reason:
                continue
            cid = item.get("campaign_id")
            try:
                cid_int = int(cid) if cid is not None else None
            except (TypeError, ValueError):
                cid_int = None
            warnings.append(
                {
                    "campaign_id": cid_int,
                    "campaign_name": str(item.get("campaign_name") or "").strip()
                    or "Unknown campaign",
                    "severity": severity,
                    "reason": reason[:300],
                }
            )

    if not summary and not trends_raw and not recs_raw and not warnings:
        return None

    return {
        "summary": summary,
        "trends": _as_str_list(trends_raw),
        "recommendations": _as_str_list(recs_raw),
        "warnings": warnings,
    }


# ---------------------------------------------------------------------------
# Public entrypoint
# ---------------------------------------------------------------------------


async def generate_insights(
    db: AsyncSession,
    user_id: str,
    *,
    start: Optional[str] = None,
    end: Optional[str] = None,
    granularity: str = "day",
) -> dict:
    """Return an insights payload for the caller's WhatsApp campaigns.

    Data flow:
      1. Pull overview + compare straight from ``campaign_analytics``.
      2. Ask the LLM to interpret them into a structured JSON insights
         payload.
      3. If the LLM is unavailable or returns unusable output, fall
         back to a deterministic rule-based summary so the dashboard
         never shows an empty panel.
    """
    overview = await campaign_analytics.overview(
        db, user_id, start=start, end=end, granularity=granularity
    )

    # ``compare`` requires explicit campaign ids, so derive them from
    # the summary CSV path — easier: hit the compare service with
    # whatever ids exist in the per-campaign CSV rollup. We re-use the
    # summary_csv path indirectly by pulling from compare() with no
    # ids filter: since compare() needs ids, we walk the DB via the
    # summary CSV helper which already respects ownership.
    summary_csv = await campaign_analytics.summary_csv(db, user_id)
    campaign_ids = _extract_campaign_ids_from_summary_csv(summary_csv)
    if campaign_ids:
        compare = await campaign_analytics.compare(db, user_id, campaign_ids)
    else:
        compare = {"campaigns": []}

    fallback = _rule_based_summary(overview, compare)

    # No point calling the LLM if there's literally no data.
    kpis = overview.get("kpis") or {}
    if int(kpis.get("sent") or 0) == 0 and not compare.get("campaigns"):
        fallback["summary"] = (
            "No WhatsApp campaign activity in the selected window yet — "
            "launch a campaign to start generating insights."
        )
        return {
            **fallback,
            "range": overview.get("range") or {},
            "kpis": kpis,
        }

    messages = [
        ChatMessage(role="system", content=_SYSTEM_PROMPT),
        ChatMessage(
            role="user",
            content=(
                "Here is the aggregated analytics data for my WhatsApp "
                "campaigns:\n\n" + _build_user_payload(overview, compare)
            ),
        ),
    ]

    service = AIHubService()
    try:
        resp = await service.gentxt(
            GenTxtRequest(
                model=INSIGHTS_MODEL,
                messages=messages,
                temperature=0.3,
                max_tokens=700,
            )
        )
    except Exception as exc:  # pragma: no cover — upstream failure path
        logger.warning("WhatsApp insights: LLM call failed, using fallback: %s", exc)
        return {
            **fallback,
            "range": overview.get("range") or {},
            "kpis": kpis,
        }

    parsed = _parse_llm_output(resp.content or "")
    if not parsed:
        logger.info(
            "WhatsApp insights: LLM output was not parsable JSON, using fallback."
        )
        return {
            **fallback,
            "range": overview.get("range") or {},
            "kpis": kpis,
        }

    return {
        "summary": parsed["summary"] or fallback["summary"],
        "trends": parsed["trends"] or fallback["trends"],
        "recommendations": parsed["recommendations"] or fallback["recommendations"],
        "warnings": parsed["warnings"] or fallback["warnings"],
        "model": resp.model or INSIGHTS_MODEL,
        "fallback": False,
        "range": overview.get("range") or {},
        "kpis": kpis,
    }


def _extract_campaign_ids_from_summary_csv(csv_text: str) -> list[int]:
    """Pull campaign ids out of the summary CSV produced by
    :func:`campaign_analytics.summary_csv`.

    The CSV's first column is the campaign id. We parse defensively so
    a format change upstream doesn't crash insights.
    """
    ids: list[int] = []
    if not csv_text:
        return ids
    lines = csv_text.splitlines()
    for line in lines[1:]:  # skip header
        first = line.split(",", 1)[0].strip().strip('"')
        if not first:
            continue
        try:
            ids.append(int(first))
        except ValueError:
            continue
    return ids[:MAX_COMPARE_CAMPAIGNS]