"""
HubSpot custom objects + associations helpers.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, 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_infra import HubSpotInfraError, _request_with_scope_handling

SCHEMA_BASE_URL = "https://api.hubapi.com/crm/v3/schemas"
STANDARD_OBJECT_TYPE_IDS = {
    "contacts": "0-1",
    "companies": "0-2",
    "deals": "0-3",
    "tickets": "0-5",
}


class HubSpotObjectsError(HubSpotInfraError):
    """Schema objects API error."""


@dataclass
class ObjectSpec:
    name: str
    labels: Dict[str, str]
    primary_display_property: str
    searchable_properties: List[str]
    required_properties: List[str] = field(default_factory=list)
    properties: List[Dict[str, Any]] = field(default_factory=list)


@dataclass
class AssociationSpec:
    from_object: str
    to_object: str
    label: str


@dataclass
class ObjectsSpec:
    objects: List[ObjectSpec]
    associations: List[AssociationSpec]


@dataclass
class ObjectsPlan:
    create: List[ObjectSpec] = field(default_factory=list)
    update: List[Dict[str, Any]] = field(default_factory=list)
    no_change: List[str] = field(default_factory=list)
    association_create: List[AssociationSpec] = field(default_factory=list)
    blocked: List[str] = field(default_factory=list)

    def to_dict(self) -> Dict[str, Any]:
        return {
            "create": [o.name for o in self.create],
            "update": self.update,
            "no_change": self.no_change,
            "association_create": [
                {"from": a.from_object, "to": a.to_object, "label": a.label}
                for a in self.association_create
            ],
            "blocked": self.blocked,
        }


def load_objects_spec(path: Path) -> ObjectsSpec:
    if not path.exists():
        raise HubSpotObjectsError(f"Spec not found: {path}")
    raw_text = path.read_text(encoding="utf-8")
    if path.suffix.lower() in {".yaml", ".yml"}:
        try:
            import yaml  # type: ignore
        except Exception as exc:
            raise missing_pyyaml_error() from exc
        payload = yaml.safe_load(raw_text) or {}
    else:
        payload = json.loads(raw_text)

    objects: List[ObjectSpec] = []
    for item in payload.get("objects") or []:
        objects.append(
            ObjectSpec(
                name=item.get("name"),
                labels=item.get("labels") or {},
                primary_display_property=item.get("primaryDisplayProperty") or item.get("primary_display_property"),
                searchable_properties=item.get("searchableProperties") or item.get("searchable_properties") or [],
                required_properties=item.get("requiredProperties") or item.get("required_properties") or [],
                properties=item.get("properties") or [],
            )
        )

    associations: List[AssociationSpec] = []
    for assoc in payload.get("associations") or []:
        associations.append(
            AssociationSpec(
                from_object=assoc.get("from") or assoc.get("from_object"),
                to_object=assoc.get("to") or assoc.get("to_object"),
                label=assoc.get("label") or "",
            )
        )

    return ObjectsSpec(objects=objects, associations=associations)


def list_objects(client: HubSpotClient) -> List[Dict[str, Any]]:
    try:
        resp = _request_with_scope_handling(client, "GET", SCHEMA_BASE_URL)
        payload = resp.json()
        return payload.get("results", []) or []
    except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
        raise HubSpotObjectsError(str(exc)) from exc


def get_object_schema(client: HubSpotClient, object_type_id: str) -> Dict[str, Any]:
    try:
        resp = _request_with_scope_handling(client, "GET", f"{SCHEMA_BASE_URL}/{object_type_id}")
        return resp.json()
    except (AuthenticationError, IntegrationError, RateLimitError, TransientError) as exc:
        raise HubSpotObjectsError(str(exc)) from exc


def plan_objects(client: HubSpotClient, spec: ObjectsSpec) -> ObjectsPlan:
    plan = ObjectsPlan()
    existing = list_objects(client)
    existing_by_name = {obj.get("name"): obj for obj in existing if obj.get("name")}
    name_to_id = {obj.get("name"): obj.get("objectTypeId") for obj in existing if obj.get("name")}

    for obj in sorted(spec.objects, key=lambda o: o.name):
        current = existing_by_name.get(obj.name)
        if not current:
            plan.create.append(obj)
            continue
        if obj.labels and obj.labels != (current.get("labels") or {}):
            plan.blocked.append(f"{obj.name}:labels")
        updates = _diff_schema(current, obj)
        if updates:
            plan.update.append({"name": obj.name, "updates": updates, "objectTypeId": current.get("objectTypeId")})
        else:
            plan.no_change.append(obj.name)

    existing_assoc = _existing_associations(existing)
    for assoc in sorted(spec.associations, key=lambda a: (a.from_object, a.to_object, a.label)):
        to_id = name_to_id.get(assoc.to_object) or STANDARD_OBJECT_TYPE_IDS.get(assoc.to_object) or assoc.to_object
        key = f"{assoc.from_object}:{to_id}:{assoc.label}"
        if key not in existing_assoc:
            plan.association_create.append(assoc)

    return plan


def apply_objects(client: HubSpotClient, plan: ObjectsPlan, spec: ObjectsSpec) -> Dict[str, Any]:
    if plan.blocked:
        raise HubSpotObjectsError(f"Blocked changes: {', '.join(plan.blocked)}")
    results: Dict[str, Any] = {"created": [], "updated": [], "associations_created": []}
    name_to_spec = {o.name: o for o in spec.objects}
    for obj in plan.create:
        payload = _build_schema_payload(obj)
        created = _request_with_scope_handling(client, "POST", SCHEMA_BASE_URL, json_body=payload).json()
        results["created"].append(created.get("name") or obj.name)

    for update in plan.update:
        object_type_id = update.get("objectTypeId")
        if not object_type_id:
            raise HubSpotObjectsError("Missing objectTypeId for update.")
        payload = update["updates"]
        _request_with_scope_handling(client, "PATCH", f"{SCHEMA_BASE_URL}/{object_type_id}", json_body=payload)
        results["updated"].append(update.get("name"))

    existing = list_objects(client)
    name_to_id = {obj.get("name"): obj.get("objectTypeId") for obj in existing}
    for assoc in plan.association_create:
        from_id = name_to_id.get(assoc.from_object) or STANDARD_OBJECT_TYPE_IDS.get(assoc.from_object)
        to_id = (
            name_to_id.get(assoc.to_object)
            or STANDARD_OBJECT_TYPE_IDS.get(assoc.to_object)
            or assoc.to_object
        )
        if not from_id or not to_id:
            raise HubSpotObjectsError("Association object types not found.")
        payload = {"fromObjectTypeId": from_id, "toObjectTypeId": to_id, "name": assoc.label}
        _request_with_scope_handling(client, "POST", f"{SCHEMA_BASE_URL}/{from_id}/associations", json_body=payload)
        results["associations_created"].append(f"{assoc.from_object}->{assoc.to_object}")

    return results


def export_object_spec(schema: Dict[str, Any]) -> Dict[str, Any]:
    return {
        "name": schema.get("name"),
        "labels": schema.get("labels") or {},
        "primaryDisplayProperty": schema.get("primaryDisplayProperty"),
        "searchableProperties": schema.get("searchableProperties") or [],
        "requiredProperties": schema.get("requiredProperties") or [],
    }


def _diff_schema(current: Dict[str, Any], desired: ObjectSpec) -> Dict[str, Any]:
    updates: Dict[str, Any] = {}
    if desired.primary_display_property and desired.primary_display_property != current.get("primaryDisplayProperty"):
        updates["primaryDisplayProperty"] = desired.primary_display_property
    if desired.searchable_properties and desired.searchable_properties != current.get("searchableProperties"):
        updates["searchableProperties"] = desired.searchable_properties
    if desired.required_properties and desired.required_properties != current.get("requiredProperties"):
        updates["requiredProperties"] = desired.required_properties
    return updates


def _build_schema_payload(spec: ObjectSpec) -> Dict[str, Any]:
    payload = {
        "name": spec.name,
        "labels": spec.labels,
        "primaryDisplayProperty": spec.primary_display_property,
        "searchableProperties": spec.searchable_properties,
        "requiredProperties": spec.required_properties,
    }
    if spec.properties:
        payload["properties"] = spec.properties
    return payload


def _existing_associations(existing: List[Dict[str, Any]]) -> set[str]:
    associations = set()
    for schema in existing:
        from_name = schema.get("name")
        from_id = schema.get("objectTypeId")
        for assoc in schema.get("associatedObjects") or []:
            to_name = assoc.get("name")
            to_id = assoc.get("objectTypeId") or assoc.get("toObjectTypeId")
            assoc_type = assoc.get("associationTypeId")
            label = assoc.get("label") or assoc.get("name")
            if from_name and to_id and label:
                associations.add(f"{from_name}:{to_id}:{label}")
            if from_id and to_id and assoc_type:
                associations.add(f"{from_id}:{to_id}:{assoc_type}")
    return associations
