# @File: backend/services/uptimerobot/sync.py
# @Desc: Scheduled synchronisation between the upstream UptimeRobot Team
#        account and our internal mirror tables. Updates monitor status,
#        uptime ratios, and records a Team usage snapshot. Safe to call on
#        demand from the admin UI via the /sync-now endpoint.
#
#        When sync detects that a monitor has transitioned between the
#        "up" and "down/seems_down" states, and the owning subscription has
#        `email_notifications_enabled=True`, an email alert is dispatched
#        to the subscriber via services.email_sender.
from __future__ import annotations

import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Tuple

from models.uptimerobot import UptimeClientMonitor, UptimeClientSubscription
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from services.uptimerobot import UptimeRobotError, get_client
from services.uptimerobot.usage import compute_team_usage, persist_usage_snapshot

logger = logging.getLogger(__name__)


# States considered "bad" — transitioning into any of these from "up" triggers
# a DOWN alert; transitioning OUT of any of these into "up" triggers a RECOVERY.
_DOWN_STATES = {"down", "seems_down"}
_UP_STATES = {"up"}


def _is_meaningful_transition(old: str, new: str) -> str:
    """Return ``"down"``, ``"up"``, or ``""`` for no-op transitions.

    * up -> down/seems_down  -> "down" (send outage alert)
    * down/seems_down -> up  -> "up"   (send recovery alert)
    * anything else (unknown/paused start-states, same-state)  -> ""
    """
    if old == new:
        return ""
    if old in _UP_STATES and new in _DOWN_STATES:
        return "down"
    if old in _DOWN_STATES and new in _UP_STATES:
        return "up"
    return ""


async def _dispatch_status_email(
    monitor: UptimeClientMonitor,
    subscription: UptimeClientSubscription,
    transition: str,
) -> None:
    """Send a best-effort status-change email to the subscription owner.

    Failures are logged but never propagated — a broken SMTP setup must not
    abort the sync loop for all other monitors.
    """
    recipient = (subscription.user_email or "").strip()
    if not recipient:
        logger.debug(
            "Skip status email for monitor %s: subscription %s has no user_email",
            monitor.ur_monitor_id,
            subscription.id,
        )
        return

    # Local import to avoid a circular chain (services -> email_sender ->
    # models) during module load.
    from services.email_sender import SmtpConfigError, send_email

    name = monitor.friendly_name or f"Monitor #{monitor.id}"
    target = monitor.url or monitor.friendly_name or "—"
    when = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")

    if transition == "down":
        subject = f"[DoItEasy Uptime] DOWN — {name}"
        headline = f"{name} is currently DOWN."
    else:
        subject = f"[DoItEasy Uptime] RECOVERED — {name}"
        headline = f"{name} is back UP."

    text_body = (
        f"{headline}\n\n"
        f"Target: {target}\n"
        f"New status: {monitor.status}\n"
        f"Detected at: {when}\n\n"
        "Manage this monitor at /client/uptime.\n"
        "You're receiving this email because monitor alerts are enabled "
        "on your uptime subscription. You can disable them anytime from "
        "the Uptime Monitoring page."
    )
    html_body = (
        "<!doctype html><html><body style='font-family:system-ui,sans-serif;"
        "background:#f6f8fa;padding:24px;'>"
        "<div style='max-width:560px;margin:0 auto;background:#fff;border-radius:12px;"
        "padding:24px;box-shadow:0 1px 4px rgba(0,0,0,.06);'>"
        f"<h2 style='margin:0 0 12px 0;color:"
        f"{'#c0392b' if transition == 'down' else '#1e8a4c'};'>{headline}</h2>"
        "<table style='width:100%;border-collapse:collapse;font-size:14px;'>"
        f"<tr><td style='padding:6px 0;color:#555;'>Target</td><td>{target}</td></tr>"
        f"<tr><td style='padding:6px 0;color:#555;'>New status</td><td>{monitor.status}</td></tr>"
        f"<tr><td style='padding:6px 0;color:#555;'>Detected at</td><td>{when}</td></tr>"
        "</table>"
        "<p style='margin-top:20px;font-size:13px;color:#555;'>"
        "You can disable these alerts from the "
        "<a href='/client/uptime'>Uptime Monitoring</a> page.</p>"
        "</div></body></html>"
    )

    try:
        await send_email(
            to_addrs=[recipient],
            subject=subject,
            text_body=text_body,
            html_body=html_body,
        )
    except SmtpConfigError as exc:
        logger.warning("Status email skipped (SMTP not configured): %s", exc)
    except Exception:  # noqa: BLE001
        logger.exception(
            "Failed to send status-change email for monitor %s", monitor.ur_monitor_id
        )


def _extract_list(resp: Any, *keys: str) -> List[Dict[str, Any]]:
    if isinstance(resp, list):
        return [x for x in resp if isinstance(x, dict)]
    if isinstance(resp, dict):
        for key in keys + ("data", "items", "results"):
            val = resp.get(key)
            if isinstance(val, list):
                return [x for x in val if isinstance(x, dict)]
            if isinstance(val, dict):
                inner = val.get("items") or val.get("data")
                if isinstance(inner, list):
                    return [x for x in inner if isinstance(x, dict)]
    return []


def _ur_status_to_internal(raw_status: Any) -> str:
    if isinstance(raw_status, str):
        s = raw_status.lower()
        if s in ("up", "down", "paused", "unknown", "seems_down"):
            return s
        # UR v3 numeric codes: 2=up, 9=down, 0=paused, 8=seems_down, 1=not_checked
        mapping_num = {"2": "up", "9": "down", "0": "paused", "8": "seems_down", "1": "unknown"}
        return mapping_num.get(s, "unknown")
    if isinstance(raw_status, int):
        return {2: "up", 9: "down", 0: "paused", 8: "seems_down", 1: "unknown"}.get(
            raw_status, "unknown"
        )
    return "unknown"


async def sync_monitors(session: AsyncSession) -> int:
    """Pull all upstream monitors, update mirror rows, and notify on status changes."""
    client = await get_client()
    touched = 0
    offset = 0
    limit = 50

    # Accumulated over the whole sync; dispatched AFTER the commit so the UI
    # and any follow-up requests read a consistent state even if email
    # delivery is slow.
    pending_alerts: List[Tuple[int, int, str]] = []  # (monitor.id, subscription_id, transition)

    while True:
        try:
            resp = await client.list_monitors(limit=limit, offset=offset)
        except UptimeRobotError as exc:
            logger.warning("sync_monitors: upstream list failed: %s", exc.message)
            return touched
        items = _extract_list(resp, "monitors")
        if not items:
            break

        ids = [str(m.get("id") or m.get("monitor_id") or "") for m in items if m.get("id") or m.get("monitor_id")]
        if not ids:
            break
        result = await session.execute(
            select(UptimeClientMonitor).where(UptimeClientMonitor.ur_monitor_id.in_(ids))
        )
        local_map = {m.ur_monitor_id: m for m in result.scalars().all()}

        for item in items:
            ur_id = str(item.get("id") or item.get("monitor_id") or "")
            if not ur_id:
                continue
            local = local_map.get(ur_id)
            if local is None:
                continue
            previous_status = local.status or "unknown"
            new_status = _ur_status_to_internal(item.get("status"))
            local.status = new_status
            local.uptime_ratio_24h = str(
                item.get("uptime_ratio_1d")
                or item.get("custom_uptime_ratio_1d")
                or local.uptime_ratio_24h
                or ""
            ) or None
            local.uptime_ratio_7d = str(
                item.get("uptime_ratio_7d")
                or item.get("custom_uptime_ratio_7d")
                or local.uptime_ratio_7d
                or ""
            ) or None
            local.uptime_ratio_30d = str(
                item.get("uptime_ratio_30d")
                or item.get("custom_uptime_ratio_30d")
                or local.uptime_ratio_30d
                or ""
            ) or None
            rt = item.get("average_response_time") or item.get("response_time")
            try:
                local.response_time_ms = int(rt) if rt is not None else local.response_time_ms
            except (TypeError, ValueError):
                pass
            local.last_synced_at = datetime.now(tz=timezone.utc)
            if new_status == "paused":
                local.paused = True
            touched += 1

            transition = _is_meaningful_transition(previous_status, new_status)
            if transition:
                pending_alerts.append((local.id, local.subscription_id, transition))

        if len(items) < limit:
            break
        offset += limit

    await session.commit()

    # Dispatch notifications. Each failure is swallowed inside the helper so
    # one bad recipient can't abort the rest of the batch.
    if pending_alerts:
        # Load subscriptions in bulk to see which have notifications enabled.
        sub_ids = list({sid for _, sid, _ in pending_alerts})
        sub_result = await session.execute(
            select(UptimeClientSubscription).where(UptimeClientSubscription.id.in_(sub_ids))
        )
        sub_map = {s.id: s for s in sub_result.scalars().all()}

        # Re-fetch the monitors (now committed) so we have up-to-date fields.
        monitor_ids = [mid for mid, _, _ in pending_alerts]
        mon_result = await session.execute(
            select(UptimeClientMonitor).where(UptimeClientMonitor.id.in_(monitor_ids))
        )
        mon_map = {m.id: m for m in mon_result.scalars().all()}

        for monitor_id, subscription_id, transition in pending_alerts:
            sub = sub_map.get(subscription_id)
            monitor = mon_map.get(monitor_id)
            if sub is None or monitor is None:
                continue
            if not getattr(sub, "email_notifications_enabled", False):
                continue
            if sub.status != "active":
                continue
            await _dispatch_status_email(monitor, sub, transition)

    return touched


async def run_sync(session: AsyncSession) -> Dict[str, Any]:
    """Orchestrate a full sync + usage snapshot. Best-effort per step."""
    monitors_synced = 0
    error: str = ""
    try:
        monitors_synced = await sync_monitors(session)
    except Exception as exc:  # noqa: BLE001
        logger.exception("run_sync: sync_monitors crashed")
        error = str(exc)

    usage, upstream_err = await compute_team_usage(session)
    if upstream_err and not error:
        error = upstream_err

    snapshot_id = None
    try:
        snap = await persist_usage_snapshot(session, usage)
        snapshot_id = snap.id
    except Exception as exc:  # noqa: BLE001
        logger.exception("run_sync: failed to persist usage snapshot")
        if not error:
            error = str(exc)

    return {
        "ok": not bool(error),
        "monitors_synced": monitors_synced,
        "contacts_synced": 0,
        "windows_synced": 0,
        "pages_synced": 0,
        "snapshot_id": snapshot_id,
        "error": error or None,
    }