"""
Canonicalization helpers for Forecast Operating Sheet.

Transforms raw Salesforce Opportunity records into a canonical schema consumed by
the Sheets add-on. Closed status is derived from IsWon/IsClosed to avoid the
ambiguity of ForecastCategoryName = "Closed".
"""

from __future__ import annotations

import hashlib
import json
from datetime import datetime
from typing import Any, Dict, List, MutableMapping, Optional


class ForecastCanonicalizer:
    """Convert raw Salesforce opportunity dictionaries to canonical rows."""

    def __init__(self, config: Dict[str, Any] | None, mappings: List[Dict[str, Any]] | None):
        self.config = config or {}
        self.mappings = self._index_mappings(mappings or [])

    def canonicalize(self, raw_opp: MutableMapping[str, Any]) -> Dict[str, Any]:
        """Transform a raw SFDC Opportunity into the canonical forecast schema."""
        raw_opp = raw_opp or {}

        period_key = self._compute_period_key(raw_opp.get("CloseDate"))
        pull_ts = datetime.utcnow().isoformat()

        return {
            "sfdc_id": raw_opp.get("Id"),
            "opportunity_name": raw_opp.get("Name"),
            "account_id": raw_opp.get("AccountId"),
            "account_name": self._get_nested(raw_opp, "Account.Name"),
            "revenue_value": self._get_revenue(raw_opp),
            "close_date": raw_opp.get("CloseDate"),
            "stage_name": raw_opp.get("StageName"),
            "probability": self._to_number(raw_opp.get("Probability")),
            "is_won": bool(raw_opp.get("IsWon", False)),
            "is_closed": bool(raw_opp.get("IsClosed", False)),
            "forecast_category_raw": raw_opp.get(self.config.get("forecast_category_source", "ForecastCategoryName")),
            "forecast_category_norm": self._normalize_category(raw_opp),
            "is_renewal": self._evaluate_renewal(raw_opp),
            "credit_owner_id": self._get_credit_owner(raw_opp),
            "credit_owner_name": self._get_nested(raw_opp, "Owner.Name"),
            "period_key": period_key,
            "created_date": raw_opp.get("CreatedDate"),
            "last_modified_date": raw_opp.get("LastModifiedDate"),
            "_pull_timestamp": pull_ts,
            "_row_hash": self._compute_hash(raw_opp, period_key, pull_ts),
        }

    # --------------------------------------------------------------------- #
    # Mapping helpers
    # --------------------------------------------------------------------- #
    def _index_mappings(self, mappings: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
        bucket: Dict[str, List[Dict[str, Any]]] = {}
        for m in mappings:
            mtype = (m or {}).get("mapping_type")
            if not mtype:
                continue
            bucket.setdefault(mtype, []).append(m)

        # Sort by priority for deterministic evaluation
        for mtype, items in bucket.items():
            items.sort(key=lambda m: m.get("priority", 1))
            bucket[mtype] = items
        return bucket

    def _get_revenue(self, raw: MutableMapping[str, Any]) -> float:
        # Check mapping override first
        field_mapping = next(
            (
                m
                for m in self.mappings.get("field", [])
                if m.get("canonical_name") == "revenue_value" and m.get("active", True)
            ),
            None,
        )
        field_name = field_mapping.get("source_field") if field_mapping else None
        if not field_name:
            field_name = self.config.get("revenue_field", "Amount")
        return self._to_number(self._get_nested(raw, field_name))

    def _get_credit_owner(self, raw: MutableMapping[str, Any]) -> Optional[str]:
        rule = next(
            (
                m
                for m in self.mappings.get("credit_rule", [])
                if m.get("active", True)
            ),
            None,
        )
        field_name = (rule or {}).get("source_field") or self.config.get("credit_owner_field") or "OwnerId"
        return self._get_nested(raw, field_name)

    # --------------------------------------------------------------------- #
    # Category + renewal logic
    # --------------------------------------------------------------------- #
    def _normalize_category(self, raw: MutableMapping[str, Any]) -> str:
        # Priority: IsWon / IsClosed from Salesforce standard fields
        is_won = bool(raw.get("IsWon", False))
        is_closed = bool(raw.get("IsClosed", False))
        if is_won:
            return "closed_won"
        if is_closed and not is_won:
            return "closed_lost"

        source_field = self.config.get("forecast_category_source", "ForecastCategoryName")
        raw_value = str(raw.get(source_field, "") or "")
        if raw_value.lower() == "omitted":
            return "omitted"

        for mapping in self.mappings.get("category", []):
            if not mapping.get("active", True):
                continue
            if raw_value.lower() == str(mapping.get("source_field", "")).lower():
                return mapping.get("canonical_name") or "pipeline"

        return "pipeline"

    def _evaluate_renewal(self, raw: MutableMapping[str, Any]) -> bool:
        for rule in self.mappings.get("renewal_rule", []):
            if not rule.get("active", True):
                continue
            value = self._get_nested(raw, rule.get("source_field"))
            if self._apply_transform(value, rule.get("transform")):
                return True
        return False

    def _apply_transform(self, value: Any, transform: Optional[str]) -> bool:
        if not transform:
            return False
        transform = str(transform)
        if transform.startswith("eq:"):
            return str(value) == transform.split(":", 1)[1]
        if transform.startswith("regex:"):
            import re

            pattern = transform.split(":", 1)[1]
            try:
                return bool(re.search(pattern, str(value or "")))
            except re.error:
                return False
        if transform.startswith("contains:"):
            needle = transform.split(":", 1)[1].lower()
            return needle in str(value or "").lower()
        if transform.lower() in ("direct", "checkbox"):
            return bool(value)
        return False

    # --------------------------------------------------------------------- #
    # Period + hash helpers
    # --------------------------------------------------------------------- #
    def _compute_period_key(self, close_date: Optional[str]) -> str:
        if not close_date:
            return "UNKNOWN"
        try:
            dt = datetime.fromisoformat(str(close_date).replace("Z", "+00:00")).date()
        except Exception:
            return "UNKNOWN"

        start_month = int(self.config.get("fiscal_year_start_month") or 1)
        if start_month < 1 or start_month > 12:
            start_month = 1

        fiscal_month = ((dt.month - start_month) % 12) + 1
        fiscal_year = dt.year if dt.month >= start_month else dt.year - 1
        quarter = ((fiscal_month - 1) // 3) + 1
        return f"FY{str(fiscal_year)[-2:]}-Q{quarter}"

    def _compute_hash(self, raw: MutableMapping[str, Any], period_key: str, pull_ts: str) -> str:
        material = {
            "Id": raw.get("Id"),
            "LastModifiedDate": raw.get("LastModifiedDate"),
            "CloseDate": raw.get("CloseDate"),
            "StageName": raw.get("StageName"),
            "ForecastCategory": raw.get(self.config.get("forecast_category_source", "ForecastCategoryName")),
            "period": period_key,
            "pulled_at": pull_ts,
        }
        payload = json.dumps(material, sort_keys=True, default=str)
        return hashlib.sha256(payload.encode("utf-8")).hexdigest()

    # --------------------------------------------------------------------- #
    # Utilities
    # --------------------------------------------------------------------- #
    def _get_nested(self, obj: MutableMapping[str, Any], path: Optional[str]) -> Any:
        if not path:
            return None
        parts = str(path).split(".")
        current: Any = obj
        for part in parts:
            if isinstance(current, MutableMapping) and part in current:
                current = current.get(part)
            else:
                return None
        return current

    def _to_number(self, value: Any) -> float:
        try:
            if value is None or value == "":
                return 0.0
            return float(value)
        except Exception:
            return 0.0
