"""Meeting follow-up SLA breach scout (Salesforce-first)."""

from __future__ import annotations

import time
from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, List, Tuple

from .base import BaseScoutCheck, PreflightResult
from ..crm_provider import Filter, FilterExpr, FilterOp, OrGroup
from ..models import EvidenceRow, RecommendedAction, ScoutFinding
from ..utils import chunked


class MeetingFollowupBreachCheck(BaseScoutCheck):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self._recommended_actions: list[RecommendedAction] = []
        self._action_keys: set[str] = set()

    def preflight(self) -> PreflightResult:
        base = super().preflight()
        if base.unsupported:
            return base

        if not self.provider.supports_object("Event"):
            return PreflightResult(ok=False, unsupported=True, reason="Unsupported object: Event")

        available = self._available_fields("Event")
        if "id" not in available:
            return PreflightResult(ok=False, unsupported=True, reason="Event.Id field unavailable")
        if "enddatetime" not in available and "activitydate" not in available:
            return PreflightResult(
                ok=False,
                unsupported=True,
                reason="Event.EndDateTime or Event.ActivityDate required for follow-up SLA checks",
            )
        return PreflightResult(ok=True)

    def build_count_filters(self) -> Tuple[str, List[FilterExpr]]:
        return "Event", self._event_filters(self._available_fields("Event"))

    def build_sample_request(self) -> Tuple[str, List[str], List[FilterExpr], str | None, str, bool]:
        available = self._available_fields("Event")
        fields, order_by = self._event_fields(available)
        return "Event", fields, self._event_filters(available), order_by, "DESC", False

    def evaluate(self, rows: List[Dict[str, Any]]) -> List[EvidenceRow]:
        del rows
        return []

    def execute(self) -> ScoutFinding:  # type: ignore[override]
        start = time.perf_counter()
        self._recommended_actions = []
        self._action_keys = set()

        try:
            preflight = self.preflight()
            if preflight.unsupported:
                return self._unsupported_finding(preflight.reason or "Unsupported")

            available = self._available_fields("Event")
            filters = self._event_filters(available)
            total_scanned = self.provider.count("Event", filters)
            self._collect_provider_meta()

            evidence_all: list[EvidenceRow] = []
            if total_scanned > 0:
                query_limit = self._query_limit()
                if total_scanned > query_limit:
                    self._warnings.append(
                        f"Event scan capped at {query_limit} rows out of {total_scanned}; findings may be partial."
                    )

                fields, order_by = self._event_fields(available)
                rows = self.provider.query(
                    "Event",
                    fields,
                    filters,
                    order_by=order_by,
                    order_dir="DESC",
                    nulls_first=False,
                    limit=query_limit,
                )
                self._collect_provider_meta()

                followup_index = self._load_followup_touch_index(rows)
                now = datetime.now(timezone.utc)
                for row in rows:
                    candidate = self._evaluate_event_row(row, followup_index, now)
                    if candidate is not None:
                        evidence_all.append(candidate)

            findings_count = len(evidence_all)
            evidence = evidence_all[: self.top_n]
            status = "findings" if findings_count > 0 else "ok"
            duration_ms = int((time.perf_counter() - start) * 1000)
            return ScoutFinding(
                scout_name=self.definition.name,
                display_name=self.definition.display_name,
                description=self.definition.description,
                category=self.definition.category,
                severity=self.definition.severity_default,
                total_records_scanned=total_scanned,
                findings_count=findings_count,
                evidence=evidence,
                recommended_actions=self.get_recommended_actions(),
                soql_queries=self._soql_queries,
                duration_ms=duration_ms,
                parameters_used=self.params,
                status=status,
                warnings=self._warnings,
            )
        except Exception as exc:
            duration_ms = int((time.perf_counter() - start) * 1000)
            return self._error_finding(str(exc), duration_ms=duration_ms)

    def get_recommended_actions(self) -> List[RecommendedAction]:
        return list(self._recommended_actions)

    def _evaluate_event_row(
        self,
        row: dict[str, Any],
        followup_index: dict[str, list[datetime]],
        now: datetime,
    ) -> EvidenceRow | None:
        event_id = str(row.get("Id") or "").strip()
        if not event_id:
            return None

        event_end = self._event_end_time(row)
        if event_end is None:
            return None

        sla_hours = self._sla_hours_for_event(row)
        if sla_hours <= 0:
            return None
        followup_deadline = event_end + timedelta(hours=sla_hours)
        if now <= followup_deadline:
            return None

        related_ids = self._event_related_ids(row)
        touches = self._related_touches(followup_index, related_ids)
        if any(event_end <= touch <= followup_deadline for touch in touches):
            return None

        owner_id = str(row.get("OwnerId") or "").strip()
        reason = (
            f"Meeting ended at {event_end.isoformat()} with no logged follow-up activity within "
            f"{sla_hours} hours."
        )
        self._queue_action(event_id=event_id, owner_id=owner_id, sla_hours=sla_hours, followup_deadline=followup_deadline)

        return EvidenceRow(
            record_id=event_id,
            sobject="Event",
            fields={
                "Subject": row.get("Subject"),
                "OwnerId": owner_id,
                "WhoId": row.get("WhoId"),
                "WhatId": row.get("WhatId"),
                "EndDateTime": row.get("EndDateTime") or row.get("ActivityDate"),
                "followup_sla_hours": sla_hours,
                "followup_deadline": followup_deadline.isoformat(),
            },
            reason=reason,
            reason_code="MEETING_FOLLOWUP_BREACH",
            severity=self.definition.severity_default,
            related_ids=related_ids,
        )

    def _queue_action(
        self,
        *,
        event_id: str,
        owner_id: str,
        sla_hours: int,
        followup_deadline: datetime,
    ) -> None:
        if event_id in self._action_keys:
            return
        self._action_keys.add(event_id)
        self._recommended_actions.append(
            RecommendedAction(
                action_type="followup_enforcement",
                label="Enforce meeting follow-up",
                risk_level="low",
                details={
                    "reason_code": "MEETING_FOLLOWUP_BREACH",
                    "event_id": event_id,
                    "owner_id": owner_id,
                    "due_at": datetime.now(timezone.utc).isoformat(),
                    "followup_sla_hours": sla_hours,
                    "followup_deadline": followup_deadline.isoformat(),
                    "apply_stage_hold": False,
                    "notify_slack": False,
                },
            )
        )

    def _load_followup_touch_index(self, events: list[dict[str, Any]]) -> dict[str, list[datetime]]:
        index: dict[str, list[datetime]] = {}
        if not events:
            return index

        who_ids: set[str] = set()
        what_ids: set[str] = set()
        for row in events:
            who_id = str(row.get("WhoId") or "").strip()
            what_id = str(row.get("WhatId") or "").strip()
            if who_id:
                who_ids.add(who_id)
            if what_id:
                what_ids.add(what_id)

        lookback_days = max(1, self._to_int(self.params.get("lookback_days"), default=14))
        self._record_task_touches(index, who_ids, what_ids, lookback_days)
        if bool(self.params.get("include_email_messages", True)):
            self._record_email_touches(index, who_ids | what_ids, lookback_days)
        if bool(self.params.get("include_notes", True)):
            self._record_note_touches(index, who_ids | what_ids, lookback_days)
        if bool(self.params.get("include_opportunity_updates", True)):
            self._record_opportunity_touches(index, what_ids, lookback_days)
        return index

    def _record_task_touches(
        self,
        index: dict[str, list[datetime]],
        who_ids: set[str],
        what_ids: set[str],
        lookback_days: int,
    ) -> None:
        if not self.provider.supports_object("Task"):
            return
        available = self._available_fields("Task")
        task_id_sets: list[tuple[str, set[str]]] = []
        if "whoid" in available and who_ids:
            task_id_sets.append(("WhoId", who_ids))
        if "whatid" in available and what_ids:
            task_id_sets.append(("WhatId", what_ids))
        if not task_id_sets:
            return

        timestamp_field = "CreatedDate" if "createddate" in available else ("LastModifiedDate" if "lastmodifieddate" in available else "")
        if not timestamp_field:
            self._warnings.append("Task.CreatedDate/LastModifiedDate unavailable; task follow-up evidence skipped.")
            return

        for field_name, id_set in task_id_sets:
            for id_chunk in chunked(sorted(id_set), size=200):
                filters: list[FilterExpr] = [
                    Filter(field_name, FilterOp.IN, list(id_chunk)),
                    Filter(timestamp_field, FilterOp.LAST_N_DAYS, lookback_days),
                ]
                fields = [field_name, timestamp_field]
                if field_name == "WhoId" and "whatid" in available:
                    fields.append("WhatId")
                if field_name == "WhatId" and "whoid" in available:
                    fields.append("WhoId")

                rows = self.provider.query(
                    "Task",
                    fields,
                    filters,
                    order_by=timestamp_field,
                    order_dir="DESC",
                    limit=max(self.top_n * 20, 300),
                )
                self._collect_provider_meta()
                for row in rows:
                    touched_at = self._parse_datetime(row.get(timestamp_field))
                    if touched_at is None:
                        continue
                    for rel_field in ("WhoId", "WhatId"):
                        rel_id = str(row.get(rel_field) or "").strip()
                        if rel_id:
                            self._add_touch(index, rel_id, touched_at)

    def _record_email_touches(
        self,
        index: dict[str, list[datetime]],
        related_ids: set[str],
        lookback_days: int,
    ) -> None:
        if not related_ids or not self.provider.supports_object("EmailMessage"):
            return
        available = self._available_fields("EmailMessage")
        if "relatedtoid" not in available:
            self._warnings.append("EmailMessage.RelatedToId unavailable; email follow-up evidence skipped.")
            return

        timestamp_field = "CreatedDate" if "createddate" in available else ("LastModifiedDate" if "lastmodifieddate" in available else "")
        if not timestamp_field:
            self._warnings.append("EmailMessage timestamps unavailable; email follow-up evidence skipped.")
            return

        for id_chunk in chunked(sorted(related_ids), size=200):
            rows = self.provider.query(
                "EmailMessage",
                ["RelatedToId", timestamp_field],
                [
                    Filter("RelatedToId", FilterOp.IN, list(id_chunk)),
                    Filter(timestamp_field, FilterOp.LAST_N_DAYS, lookback_days),
                ],
                order_by=timestamp_field,
                order_dir="DESC",
                limit=max(self.top_n * 20, 300),
            )
            self._collect_provider_meta()
            for row in rows:
                rel_id = str(row.get("RelatedToId") or "").strip()
                touched_at = self._parse_datetime(row.get(timestamp_field))
                if rel_id and touched_at is not None:
                    self._add_touch(index, rel_id, touched_at)

    def _record_note_touches(
        self,
        index: dict[str, list[datetime]],
        related_ids: set[str],
        lookback_days: int,
    ) -> None:
        if not related_ids or not self.provider.supports_object("Note"):
            return
        available = self._available_fields("Note")
        if "parentid" not in available:
            self._warnings.append("Note.ParentId unavailable; note follow-up evidence skipped.")
            return

        timestamp_field = "CreatedDate" if "createddate" in available else ("LastModifiedDate" if "lastmodifieddate" in available else "")
        if not timestamp_field:
            self._warnings.append("Note timestamps unavailable; note follow-up evidence skipped.")
            return

        for id_chunk in chunked(sorted(related_ids), size=200):
            rows = self.provider.query(
                "Note",
                ["ParentId", timestamp_field],
                [
                    Filter("ParentId", FilterOp.IN, list(id_chunk)),
                    Filter(timestamp_field, FilterOp.LAST_N_DAYS, lookback_days),
                ],
                order_by=timestamp_field,
                order_dir="DESC",
                limit=max(self.top_n * 20, 300),
            )
            self._collect_provider_meta()
            for row in rows:
                rel_id = str(row.get("ParentId") or "").strip()
                touched_at = self._parse_datetime(row.get(timestamp_field))
                if rel_id and touched_at is not None:
                    self._add_touch(index, rel_id, touched_at)

    def _record_opportunity_touches(
        self,
        index: dict[str, list[datetime]],
        what_ids: set[str],
        lookback_days: int,
    ) -> None:
        opp_ids = {value for value in what_ids if value.startswith("006")}
        if not opp_ids or not self.provider.supports_object("Opportunity"):
            return
        available = self._available_fields("Opportunity")
        timestamp_field = "LastModifiedDate" if "lastmodifieddate" in available else ("CreatedDate" if "createddate" in available else "")
        if not timestamp_field:
            return

        for id_chunk in chunked(sorted(opp_ids), size=200):
            rows = self.provider.query(
                "Opportunity",
                ["Id", timestamp_field],
                [
                    Filter("Id", FilterOp.IN, list(id_chunk)),
                    Filter(timestamp_field, FilterOp.LAST_N_DAYS, lookback_days),
                ],
                order_by=timestamp_field,
                order_dir="DESC",
                limit=max(self.top_n * 20, 300),
            )
            self._collect_provider_meta()
            for row in rows:
                opp_id = str(row.get("Id") or "").strip()
                touched_at = self._parse_datetime(row.get(timestamp_field))
                if opp_id and touched_at is not None:
                    self._add_touch(index, opp_id, touched_at)

    @staticmethod
    def _add_touch(index: dict[str, list[datetime]], related_id: str, touched_at: datetime) -> None:
        if not related_id:
            return
        index.setdefault(related_id, []).append(touched_at)

    @staticmethod
    def _related_touches(index: dict[str, list[datetime]], related_ids: list[str]) -> list[datetime]:
        touches: list[datetime] = []
        for related_id in related_ids:
            touches.extend(index.get(related_id, []))
        return touches

    def _event_filters(self, available_fields: set[str]) -> list[FilterExpr]:
        filters: list[FilterExpr] = []
        if "enddatetime" in available_fields:
            filters.append(Filter("EndDateTime", FilterOp.BEFORE_TODAY))
        elif "activitydate" in available_fields:
            filters.append(Filter("ActivityDate", FilterOp.BEFORE_TODAY))

        lookback_days = max(1, self._to_int(self.params.get("lookback_days"), default=14))
        if "enddatetime" in available_fields:
            filters.append(Filter("EndDateTime", FilterOp.LAST_N_DAYS, lookback_days))
        elif "activitydate" in available_fields:
            filters.append(Filter("ActivityDate", FilterOp.LAST_N_DAYS, lookback_days))
        elif "lastmodifieddate" in available_fields:
            filters.append(Filter("LastModifiedDate", FilterOp.LAST_N_DAYS, lookback_days))
        return filters

    def _event_fields(self, available_fields: set[str]) -> tuple[list[str], str | None]:
        fields: list[str] = ["Id"]
        for candidate in ("Subject", "OwnerId", "WhoId", "WhatId", "EndDateTime", "ActivityDate", "LastModifiedDate"):
            if candidate.lower() in available_fields and candidate not in fields:
                fields.append(candidate)
        if "enddatetime" in available_fields:
            return fields, "EndDateTime"
        if "activitydate" in available_fields:
            return fields, "ActivityDate"
        if "lastmodifieddate" in available_fields:
            return fields, "LastModifiedDate"
        return fields, None

    def _event_end_time(self, row: dict[str, Any]) -> datetime | None:
        end_datetime = self._parse_datetime(row.get("EndDateTime"))
        if end_datetime is not None:
            return end_datetime
        activity_date = self._parse_datetime(row.get("ActivityDate"))
        if activity_date is not None:
            return activity_date
        return None

    def _event_related_ids(self, row: dict[str, Any]) -> list[str]:
        values = [
            str(row.get("Id") or "").strip(),
            str(row.get("WhoId") or "").strip(),
            str(row.get("WhatId") or "").strip(),
        ]
        return self._unique_non_blank(values)

    def _sla_hours_for_event(self, row: dict[str, Any]) -> int:
        subject = str(row.get("Subject") or "").strip().lower()
        if "negotiat" in subject:
            return max(1, self._to_int(self.params.get("negotiation_sla_hours"), default=2))
        if "demo" in subject:
            return max(1, self._to_int(self.params.get("demo_sla_hours"), default=4))
        if "discover" in subject:
            return max(1, self._to_int(self.params.get("discovery_sla_hours"), default=24))
        return max(1, self._to_int(self.params.get("default_sla_hours"), default=24))

    def _query_limit(self) -> int:
        default_limit = max(self.top_n * 5, 100)
        configured = self._to_int(self.params.get("max_candidates"), default=default_limit)
        return max(self.top_n, configured)

    def _available_fields(self, object_type: str) -> set[str]:
        available_method = getattr(self.provider, "available_fields", None)
        if callable(available_method):
            try:
                return {str(name).lower() for name in available_method(object_type)}
            except Exception:
                pass

        try:
            describe = self.provider.describe(object_type)
        except Exception:
            return set()
        fields = describe.get("result", {}).get("fields", []) if isinstance(describe, dict) else []
        if not isinstance(fields, list):
            return set()
        return {
            str(item.get("name")).lower()
            for item in fields
            if isinstance(item, dict) and item.get("name")
        }

    @staticmethod
    def _parse_datetime(value: Any) -> datetime | None:
        if value is None:
            return None
        if isinstance(value, datetime):
            parsed = value
        elif isinstance(value, date):
            parsed = datetime(value.year, value.month, value.day, tzinfo=timezone.utc)
        else:
            text = str(value).strip()
            if not text:
                return None
            try:
                parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
            except Exception:
                try:
                    parsed_date = date.fromisoformat(text)
                except Exception:
                    return None
                parsed = datetime(parsed_date.year, parsed_date.month, parsed_date.day, tzinfo=timezone.utc)
        if parsed.tzinfo is None:
            return parsed.replace(tzinfo=timezone.utc)
        return parsed.astimezone(timezone.utc)

    @staticmethod
    def _to_int(value: Any, *, default: int) -> int:
        try:
            return int(value)
        except Exception:
            return default

    @staticmethod
    def _unique_non_blank(values: list[str]) -> list[str]:
        ordered: list[str] = []
        seen: set[str] = set()
        for raw in values:
            value = str(raw or "").strip()
            if not value or value in seen:
                continue
            seen.add(value)
            ordered.append(value)
        return ordered


__all__ = ["MeetingFollowupBreachCheck"]
