"""
HubSpot infra-as-code helpers (properties, groups, drift/plan/apply).
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional

from g_gremlin.base import AuthenticationError, IntegrationError, RateLimitError, TransientError
from g_gremlin.errors import missing_pyyaml_error
from g_gremlin.hubspot import HubSpotClient
from g_gremlin.hubspot_schema import HubSpotSchemaCache

DEFAULT_SPEC_VERSION = 1


class HubSpotInfraError(Exception):
    """Base error for HubSpot infra helpers."""


class InvalidTokenError(HubSpotInfraError):
    """Raised when access token is invalid."""


class MissingScopeError(HubSpotInfraError):
    """Raised when required scopes are missing."""

    def __init__(self, required_scopes: List[str]):
        self.required_scopes = required_scopes
        msg = "Missing required scopes."
        if required_scopes:
            msg = f"Missing required scopes: {', '.join(required_scopes)}"
        super().__init__(msg)


def _parse_required_scopes(payload: Any) -> Optional[List[str]]:
    if not isinstance(payload, dict):
        return None
    context = payload.get("context") or {}
    required = context.get("requiredScopes") or context.get("requiredScope")
    if isinstance(required, str):
        required = [required]
    if isinstance(required, list):
        return [str(s) for s in required]
    message = str(payload.get("message") or payload.get("error") or "").lower()
    if "scope" in message:
        return []
    return None


def _raise_for_auth_response(status_code: int, payload: Any) -> None:
    required = _parse_required_scopes(payload)
    if required is not None:
        raise MissingScopeError(required)
    raise InvalidTokenError(
        "Invalid HubSpot access token. Run g-gremlin hubspot doctor. "
        "For scopes, run g-gremlin hubspot setup --pack <dir> or --full-beta."
    )


def _request_with_scope_handling(
    client: HubSpotClient,
    method: str,
    url: str,
    *,
    params: Dict[str, Any] | None = None,
    json_body: Dict[str, Any] | None = None,
) -> Any:
    resp = client._request(  # pylint: disable=protected-access
        method,
        url,
        params=params,
        json=json_body,
        headers=client._headers(),  # pylint: disable=protected-access
        allow_statuses=(401, 403),
    )
    if resp.status_code in (401, 403):
        payload = {}
        try:
            payload = resp.json()
        except Exception:
            payload = {}
        _raise_for_auth_response(resp.status_code, payload)
    return resp


@dataclass
class PropertyGroupSpec:
    name: str
    label: str
    display_order: Optional[int] = None


@dataclass
class PropertySpec:
    name: str
    label: str
    type: str
    field_type: str
    group_name: str
    description: Optional[str] = None
    options: List[Dict[str, Any]] = field(default_factory=list)
    has_unique_value: Optional[bool] = None
    archived: bool = False


@dataclass
class PropertyObjectSpec:
    object_type: str
    groups: List[PropertyGroupSpec]
    properties: List[PropertySpec]


@dataclass
class HubSpotPropertySpec:
    version: int
    objects: List[PropertyObjectSpec]


@dataclass
class PropsPlanResult:
    object_type: str
    group_create: List[Dict[str, Any]] = field(default_factory=list)
    group_update: List[Dict[str, Any]] = field(default_factory=list)
    property_create: List[Dict[str, Any]] = field(default_factory=list)
    property_update: List[Dict[str, Any]] = field(default_factory=list)
    property_archive: List[Dict[str, Any]] = field(default_factory=list)
    incompatible: List[Dict[str, Any]] = field(default_factory=list)
    unmanaged_groups: List[str] = field(default_factory=list)
    unmanaged_properties: List[str] = field(default_factory=list)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "object_type": self.object_type,
            "groups": {
                "create": self.group_create,
                "update": self.group_update,
                "unmanaged": self.unmanaged_groups,
            },
            "properties": {
                "create": self.property_create,
                "update": self.property_update,
                "archive": self.property_archive,
                "incompatible": self.incompatible,
                "unmanaged": self.unmanaged_properties,
            },
        }


def load_property_spec(path: Path) -> HubSpotPropertySpec:
    if not path.exists():
        raise HubSpotInfraError(f"Spec not found: {path}")
    raw_text = path.read_text(encoding="utf-8")
    data: Dict[str, Any]
    if path.suffix.lower() in {".yaml", ".yml"}:
        try:
            import yaml  # type: ignore
        except Exception as exc:
            raise missing_pyyaml_error() from exc
        data = yaml.safe_load(raw_text) or {}
    else:
        data = json.loads(raw_text)

    version = int(data.get("version") or data.get("schema_version") or DEFAULT_SPEC_VERSION)
    objects_payload = data.get("objects") or {}

    if not objects_payload:
        # Detect common mis-formats and give actionable guidance.
        _OBJECT_TYPES = {"contacts", "companies", "deals", "tickets", "company", "contact", "deal", "ticket"}
        top_keys = set(data.keys()) - {"version", "schema_version"}
        looks_like_object = top_keys & _OBJECT_TYPES
        has_properties_key = "properties" in data

        hint = ""
        if looks_like_object:
            hint = (
                f" Found top-level keys {sorted(looks_like_object)} — "
                "these should be nested under 'objects:'."
            )
        elif has_properties_key:
            hint = " Found top-level 'properties:' key — properties should be nested under 'objects: <object_type>: properties:'."

        raise HubSpotInfraError(
            f"Spec parsed 0 object definitions from {path.name}.{hint}\n"
            "Expected format:\n"
            "  objects:\n"
            "    company:\n"
            "      groups:\n"
            "        - name: my_group\n"
            "          label: My Group\n"
            "      properties:\n"
            "        - name: my_prop\n"
            "          label: My Prop\n"
            "          type: string\n"
            "          field_type: text\n"
            "          group_name: my_group"
        )

    objects: List[PropertyObjectSpec] = []

    for object_type, obj_spec in objects_payload.items():
        groups_raw = (obj_spec or {}).get("groups") or []
        properties_raw = (obj_spec or {}).get("properties") or []
        groups = [_normalize_group_spec(item) for item in groups_raw]
        properties = [_normalize_property_spec(item) for item in properties_raw]
        objects.append(
            PropertyObjectSpec(
                object_type=str(object_type).lower(),
                groups=groups,
                properties=properties,
            )
        )

    return HubSpotPropertySpec(version=version, objects=objects)


def _normalize_group_spec(raw: Dict[str, Any]) -> PropertyGroupSpec:
    name = raw.get("name") or raw.get("group_name")
    if not name:
        raise HubSpotInfraError("Group spec missing 'name'.")
    label = raw.get("label") or raw.get("name")
    display_order = raw.get("display_order") or raw.get("displayOrder")
    return PropertyGroupSpec(name=str(name), label=str(label), display_order=display_order)


def _normalize_property_spec(raw: Dict[str, Any]) -> PropertySpec:
    name = raw.get("name")
    label = raw.get("label") or name
    prop_type = raw.get("type")
    field_type = raw.get("field_type") or raw.get("fieldType")
    group_name = raw.get("group_name") or raw.get("groupName")
    if not all([name, label, prop_type, field_type, group_name]):
        raise HubSpotInfraError("Property spec missing required fields (name, label, type, field_type, group_name).")
    return PropertySpec(
        name=str(name),
        label=str(label),
        type=str(prop_type),
        field_type=str(field_type),
        group_name=str(group_name),
        description=raw.get("description"),
        options=raw.get("options") or [],
        has_unique_value=raw.get("has_unique_value") if "has_unique_value" in raw else raw.get("hasUniqueValue"),
        archived=bool(raw.get("archived") or False),
    )


class HubSpotPropsManager:
    def __init__(
        self,
        client: HubSpotClient,
        portal_id: Optional[str] = None,
        cache_dir: Optional[Path] = None,
    ) -> None:
        self.client = client
        self.portal_id = portal_id
        self.cache_dir = cache_dir

    def _get_portal_id(self) -> str:
        if self.portal_id:
            return str(self.portal_id)
        identity = self.client.whoami()
        portal_id = identity.get("portal_id") or ""
        if not portal_id:
            raise HubSpotInfraError("Portal ID unavailable from HubSpot whoami.")
        self.portal_id = str(portal_id)
        return str(portal_id)

    def _load_cache(self, refresh: bool) -> Optional[HubSpotSchemaCache]:
        portal_id = self._get_portal_id()
        cache = HubSpotSchemaCache(portal_id, cache_dir=self.cache_dir) if self.cache_dir else HubSpotSchemaCache(portal_id)
        if refresh or not cache.cache_path.exists():
            try:
                cache.sync(self.client)
            except Exception:
                return None
        return cache

    def plan(self, spec: HubSpotPropertySpec, *, refresh_cache: bool = False) -> List[PropsPlanResult]:
        cache = self._load_cache(refresh_cache)
        results: List[PropsPlanResult] = []

        for obj_spec in spec.objects:
            object_type = obj_spec.object_type
            plan = PropsPlanResult(object_type=object_type)

            remote_groups = self._get_property_groups(object_type)
            remote_group_index = {g.get("name"): g for g in remote_groups if g.get("name")}
            desired_group_index = {g.name: g for g in obj_spec.groups}

            for name, desired in desired_group_index.items():
                if name not in remote_group_index:
                    plan.group_create.append(
                        {
                            "name": desired.name,
                            "label": desired.label,
                            "display_order": desired.display_order,
                        }
                    )
                else:
                    current = remote_group_index[name]
                    if _group_needs_update(current, desired):
                        plan.group_update.append(
                            {
                                "name": desired.name,
                                "label": desired.label,
                                "display_order": desired.display_order,
                            }
                        )

            plan.unmanaged_groups = sorted(
                [name for name in remote_group_index if name not in desired_group_index]
            )

            needs_live = any(
                p.description is not None or p.options or p.has_unique_value is not None
                for p in obj_spec.properties
            )
            remote_props = self._get_properties(object_type, None if needs_live else cache)
            remote_prop_index = {p.get("name"): p for p in remote_props if p.get("name")}
            desired_prop_index = {p.name: p for p in obj_spec.properties}

            for name, desired in desired_prop_index.items():
                current = remote_prop_index.get(name)
                if not current:
                    plan.property_create.append(_serialize_property(desired))
                    continue

                incompatible = _find_incompatible_fields(current, desired)
                if incompatible:
                    plan.incompatible.append(
                        {
                            "name": name,
                            "fields": incompatible,
                            "current": {k: current.get(_camel(k)) for k in incompatible},
                            "desired": {k: getattr(desired, k) for k in incompatible},
                        }
                    )
                    continue

                updates = _diff_property(current, desired)
                if updates:
                    plan.property_update.append(
                        {
                            "name": name,
                            "updates": updates,
                        }
                    )

                if desired.archived and not current.get("archived", False):
                    plan.property_archive.append({"name": name})

            plan.unmanaged_properties = sorted(
                [name for name in remote_prop_index if name not in desired_prop_index]
            )

            results.append(plan)

        return results

    def apply(
        self,
        spec: HubSpotPropertySpec,
        plan: List[PropsPlanResult],
        *,
        allow_archive: bool = False,
    ) -> List[Dict[str, Any]]:
        plan_index = {p.object_type: p for p in plan}
        results: List[Dict[str, Any]] = []

        for obj_spec in spec.objects:
            object_type = obj_spec.object_type
            obj_plan = plan_index.get(object_type)
            if not obj_plan:
                continue

            summary = {
                "object_type": object_type,
                "groups_created": 0,
                "groups_updated": 0,
                "properties_created": 0,
                "properties_updated": 0,
                "properties_archived": 0,
            }

            for group in obj_plan.group_create:
                self._create_group(object_type, group)
                summary["groups_created"] += 1

            for group in obj_plan.group_update:
                self._update_group(object_type, group["name"], group)
                summary["groups_updated"] += 1

            for prop in obj_plan.property_create:
                self._create_property(object_type, prop)
                summary["properties_created"] += 1

            for prop in obj_plan.property_update:
                payload = _build_property_update_payload(prop["updates"])
                self._update_property(object_type, prop["name"], payload)
                summary["properties_updated"] += 1

            if obj_plan.property_archive and not allow_archive:
                names = ", ".join([p["name"] for p in obj_plan.property_archive])
                raise HubSpotInfraError(
                    f"Archive requested for properties [{names}] but --allow-archive not set."
                )

            for prop in obj_plan.property_archive:
                self._archive_property(object_type, prop["name"])
                summary["properties_archived"] += 1

            results.append(summary)

        return results

    def _get_property_groups(self, object_type: str) -> List[Dict[str, Any]]:
        url = f"{self.client.BASE_URL}/crm/v3/properties/{object_type}/groups"
        try:
            resp = _request_with_scope_handling(self.client, "GET", url)
            payload = resp.json()
            return payload.get("results", []) or []
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotInfraError(str(exc)) from exc

    def _get_properties(self, object_type: str, cache: Optional[HubSpotSchemaCache]) -> List[Dict[str, Any]]:
        if cache:
            try:
                cached = cache.get_properties(object_type)
                return [
                    {
                        "name": p.name,
                        "label": p.label,
                        "type": p.type,
                        "fieldType": p.field_type,
                        "groupName": p.group_name,
                        "archived": False,
                    }
                    for p in cached
                ]
            except Exception:
                pass

        url = f"{self.client.BASE_URL}/crm/v3/properties/{object_type}"
        try:
            resp = _request_with_scope_handling(self.client, "GET", url)
            payload = resp.json()
            return payload.get("results", []) or []
        except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
            raise HubSpotInfraError(str(exc)) from exc

    def _create_group(self, object_type: str, group: Dict[str, Any]) -> None:
        url = f"{self.client.BASE_URL}/crm/v3/properties/{object_type}/groups"
        payload = {
            "name": group.get("name"),
            "label": group.get("label"),
        }
        if group.get("display_order") is not None:
            payload["displayOrder"] = group.get("display_order")
        _request_with_scope_handling(self.client, "POST", url, json_body=payload)

    def _update_group(self, object_type: str, group_name: str, group: Dict[str, Any]) -> None:
        url = f"{self.client.BASE_URL}/crm/v3/properties/{object_type}/groups/{group_name}"
        payload = {
            "label": group.get("label"),
        }
        if group.get("display_order") is not None:
            payload["displayOrder"] = group.get("display_order")
        _request_with_scope_handling(self.client, "PATCH", url, json_body=payload)

    def _create_property(self, object_type: str, prop: Dict[str, Any]) -> None:
        url = f"{self.client.BASE_URL}/crm/v3/properties/{object_type}"
        payload = _build_property_payload(prop)
        _request_with_scope_handling(self.client, "POST", url, json_body=payload)

    def _update_property(self, object_type: str, name: str, payload: Dict[str, Any]) -> None:
        url = f"{self.client.BASE_URL}/crm/v3/properties/{object_type}/{name}"
        _request_with_scope_handling(self.client, "PATCH", url, json_body=payload)

    def _archive_property(self, object_type: str, name: str) -> None:
        url = f"{self.client.BASE_URL}/crm/v3/properties/{object_type}/{name}"
        _request_with_scope_handling(self.client, "DELETE", url)


def _group_needs_update(current: Dict[str, Any], desired: PropertyGroupSpec) -> bool:
    if desired.label and current.get("label") != desired.label:
        return True
    if desired.display_order is not None and current.get("displayOrder") != desired.display_order:
        return True
    return False


def _find_incompatible_fields(current: Dict[str, Any], desired: PropertySpec) -> List[str]:
    incompatible = []
    if current.get("type") and current.get("type") != desired.type:
        incompatible.append("type")
    if current.get("fieldType") and current.get("fieldType") != desired.field_type:
        incompatible.append("field_type")
    if desired.has_unique_value is not None:
        current_unique = current.get("hasUniqueValue")
        if current_unique is not None and bool(current_unique) != bool(desired.has_unique_value):
            incompatible.append("has_unique_value")
    return incompatible


def _diff_property(current: Dict[str, Any], desired: PropertySpec) -> Dict[str, Any]:
    updates: Dict[str, Any] = {}
    if current.get("label") != desired.label:
        updates["label"] = desired.label
    if current.get("groupName") != desired.group_name:
        updates["group_name"] = desired.group_name
    if desired.description is not None and current.get("description") != desired.description:
        updates["description"] = desired.description
    if desired.options:
        current_options = current.get("options") or []
        if current_options != desired.options:
            updates["options"] = desired.options
    return updates


def _build_property_payload(prop: Dict[str, Any]) -> Dict[str, Any]:
    payload = {
        "name": prop.get("name"),
        "label": prop.get("label"),
        "type": prop.get("type"),
        "fieldType": prop.get("field_type") or prop.get("fieldType"),
        "groupName": prop.get("group_name") or prop.get("groupName"),
    }
    if prop.get("description") is not None:
        payload["description"] = prop.get("description")
    if prop.get("options"):
        payload["options"] = prop.get("options")
    if prop.get("has_unique_value") is not None:
        payload["hasUniqueValue"] = prop.get("has_unique_value")
    return payload


def _build_property_update_payload(updates: Dict[str, Any]) -> Dict[str, Any]:
    payload: Dict[str, Any] = {}
    if "label" in updates:
        payload["label"] = updates["label"]
    if "group_name" in updates:
        payload["groupName"] = updates["group_name"]
    if "description" in updates:
        payload["description"] = updates["description"]
    if "options" in updates:
        payload["options"] = updates["options"]
    return payload


def _serialize_property(spec: PropertySpec) -> Dict[str, Any]:
    payload = {
        "name": spec.name,
        "label": spec.label,
        "type": spec.type,
        "field_type": spec.field_type,
        "group_name": spec.group_name,
        "description": spec.description,
        "options": spec.options,
    }
    if spec.has_unique_value is not None:
        payload["has_unique_value"] = spec.has_unique_value
    return payload


def _camel(field_name: str) -> str:
    if field_name == "field_type":
        return "fieldType"
    if field_name == "group_name":
        return "groupName"
    return field_name


def format_props_plan(plan: Iterable[PropsPlanResult]) -> str:
    lines: List[str] = []
    for result in plan:
        lines.append(f"Object: {result.object_type}")
        if result.group_create or result.group_update:
            lines.append("Groups:")
            for group in result.group_create:
                lines.append(f"  + {group['name']} (create)")
            for group in result.group_update:
                lines.append(f"  ~ {group['name']} (update)")
        if result.property_create or result.property_update or result.property_archive or result.incompatible:
            lines.append("Properties:")
            for prop in result.property_create:
                lines.append(f"  + {prop['name']} (create)")
            for prop in result.property_update:
                lines.append(f"  ~ {prop['name']} (update)")
            for prop in result.property_archive:
                lines.append(f"  - {prop['name']} (archive)")
            for prop in result.incompatible:
                lines.append(f"  ! {prop['name']} (incompatible)")
        if not any(
            [
                result.group_create,
                result.group_update,
                result.property_create,
                result.property_update,
                result.property_archive,
                result.incompatible,
            ]
        ):
            lines.append("  No changes.")
        lines.append("")
    return "\n".join(lines).strip()
