"""
HubSpot query aggregation helpers.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Dict, List, Sequence

import pandas as pd

from g_gremlin.hubspot import HubSpotClient, SEARCH_TOTAL_CAP


BLANK_LABEL = "(blank)"


@dataclass
class QueryPlan:
    object_type: str
    properties: List[str] = field(default_factory=list)
    limit: int = 0
    limit_explicit: bool = False
    group_by: List[str] = field(default_factory=list)
    sum_fields: List[str] = field(default_factory=list)
    avg_fields: List[str] = field(default_factory=list)
    min_fields: List[str] = field(default_factory=list)
    max_fields: List[str] = field(default_factory=list)
    count_rows: bool = False
    bucket_date: str | None = None
    bucket_interval: str | None = None

    @property
    def aggregation_mode(self) -> bool:
        return bool(
            self.group_by
            or self.sum_fields
            or self.avg_fields
            or self.min_fields
            or self.max_fields
            or self.count_rows
            or self.bucket_date
        )

    @property
    def bucket_column(self) -> str | None:
        if not self.bucket_date or not self.bucket_interval:
            return None
        return f"{self.bucket_date}_{self.bucket_interval.lower()}"

    def required_properties(self) -> List[str]:
        required: List[str] = []
        seen: set[str] = set()
        for name in self.properties + self.group_by + self.sum_fields + self.avg_fields + self.min_fields + self.max_fields:
            token = str(name or "").strip()
            if token and token not in seen and token != "id":
                seen.add(token)
                required.append(token)
        if self.bucket_date:
            token = str(self.bucket_date).strip()
            if token and token not in seen and token != "id":
                seen.add(token)
                required.append(token)
        return required


@dataclass
class QueryExecutionResult:
    rows: List[Dict[str, Any]]
    warnings: List[str] = field(default_factory=list)
    fetched_rows: int = 0
    aggregated: bool = False


def execute_query_plan(
    client: HubSpotClient,
    plan: QueryPlan,
    *,
    filter_groups: List[Dict[str, Any]] | None = None,
    sorts: List[str] | None = None,
) -> QueryExecutionResult:
    required_properties = plan.required_properties() if plan.aggregation_mode else (plan.properties or ["id", "hs_object_id"])
    results = client.search_all(
        plan.object_type,
        filter_groups=filter_groups,
        properties=required_properties,
        sorts=sorts,
        limit=plan.limit,
    )
    rows = flatten_search_results(results)
    warnings: List[str] = []
    if plan.aggregation_mode and plan.limit_explicit and plan.limit > 0:
        warnings.append(
            f"Aggregation used explicit --limit={plan.limit}; results are partial."
        )
    if plan.aggregation_mode and plan.limit <= 0 and len(results) >= SEARCH_TOTAL_CAP:
        warnings.append(
            f"HubSpot Search API capped results at {SEARCH_TOTAL_CAP} records; aggregates may be partial."
        )
    if not plan.aggregation_mode:
        return QueryExecutionResult(rows=rows, warnings=warnings, fetched_rows=len(rows), aggregated=False)
    aggregated_rows = aggregate_rows(client, plan, rows)
    return QueryExecutionResult(rows=aggregated_rows, warnings=warnings, fetched_rows=len(rows), aggregated=True)


def flatten_search_results(results: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
    rows: List[Dict[str, Any]] = []
    for item in results:
        row = {"id": item.get("id")}
        row.update(item.get("properties") or {})
        rows.append(row)
    return rows


def aggregate_rows(client: HubSpotClient, plan: QueryPlan, rows: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
    if not rows:
        return _build_empty_rows(plan)

    df = pd.DataFrame(list(rows))
    for name in ["id"] + plan.required_properties():
        if name not in df.columns:
            df[name] = pd.NA

    group_columns = list(plan.group_by)
    if plan.bucket_date and plan.bucket_interval:
        bucket_column = plan.bucket_column
        if bucket_column:
            df[bucket_column] = _bucket_series(df[plan.bucket_date], plan.bucket_interval)
            group_columns.append(bucket_column)

    for column in group_columns:
        df[column] = df[column].map(_normalize_group_value)

    named_aggs: Dict[str, Any] = {}
    if plan.count_rows:
        named_aggs["count"] = pd.NamedAgg(column="id", aggfunc="size")
    for field in plan.sum_fields:
        named_aggs[f"sum_{field}"] = pd.NamedAgg(column=field, aggfunc=_sum_agg)
    for field in plan.avg_fields:
        named_aggs[f"avg_{field}"] = pd.NamedAgg(column=field, aggfunc=_avg_agg)
    for field in plan.min_fields:
        named_aggs[f"min_{field}"] = pd.NamedAgg(column=field, aggfunc=_min_agg)
    for field in plan.max_fields:
        named_aggs[f"max_{field}"] = pd.NamedAgg(column=field, aggfunc=_max_agg)

    if group_columns:
        if named_aggs:
            aggregated = df.groupby(group_columns, dropna=False).agg(**named_aggs).reset_index()
        else:
            aggregated = df[group_columns].drop_duplicates().reset_index(drop=True)
        if group_columns:
            aggregated = aggregated.sort_values(by=group_columns, kind="stable").reset_index(drop=True)
    else:
        data: Dict[str, Any] = {}
        if plan.count_rows:
            data["count"] = len(df.index)
        for field in plan.sum_fields:
            data[f"sum_{field}"] = _sum_agg(df[field])
        for field in plan.avg_fields:
            data[f"avg_{field}"] = _avg_agg(df[field])
        for field in plan.min_fields:
            data[f"min_{field}"] = _min_agg(df[field])
        for field in plan.max_fields:
            data[f"max_{field}"] = _max_agg(df[field])
        aggregated = pd.DataFrame([data]) if data else pd.DataFrame()

    aggregated_rows = [_clean_row(row) for row in aggregated.to_dict(orient="records")]
    if not aggregated_rows:
        return []
    return _apply_label_resolution(client, plan, aggregated_rows)


def _build_empty_rows(plan: QueryPlan) -> List[Dict[str, Any]]:
    group_columns = list(plan.group_by)
    if plan.bucket_column:
        group_columns.append(plan.bucket_column)
    if group_columns:
        return []
    if not plan.aggregation_mode:
        return []

    row: Dict[str, Any] = {}
    if plan.count_rows:
        row["count"] = 0
    for field in plan.sum_fields:
        row[f"sum_{field}"] = None
    for field in plan.avg_fields:
        row[f"avg_{field}"] = None
    for field in plan.min_fields:
        row[f"min_{field}"] = None
    for field in plan.max_fields:
        row[f"max_{field}"] = None
    return [row] if row else []


def _apply_label_resolution(
    client: HubSpotClient,
    plan: QueryPlan,
    rows: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
    resolved_rows = rows
    if "hubspot_owner_id" in plan.group_by:
        owner_map = _build_owner_map(client)
        resolved_rows = _insert_label_column(resolved_rows, "hubspot_owner_id", "hubspot_owner_name", owner_map)
    if plan.object_type.lower() == "deals":
        pipeline_map, stage_map = _build_deal_label_maps(client)
        if "pipeline" in plan.group_by:
            resolved_rows = _insert_label_column(resolved_rows, "pipeline", "pipeline_label", pipeline_map)
        if "dealstage" in plan.group_by:
            resolved_rows = _insert_label_column(resolved_rows, "dealstage", "dealstage_label", stage_map)
    return resolved_rows


def _insert_label_column(
    rows: List[Dict[str, Any]],
    key_column: str,
    label_column: str,
    mapping: Dict[str, str],
) -> List[Dict[str, Any]]:
    updated: List[Dict[str, Any]] = []
    for row in rows:
        value = row.get(key_column)
        key = "" if value is None else str(value)
        label = mapping.get(key)
        if not label:
            label = BLANK_LABEL if not key or key == BLANK_LABEL else key
        new_row: Dict[str, Any] = {}
        for existing_key, existing_value in row.items():
            new_row[existing_key] = existing_value
            if existing_key == key_column:
                new_row[label_column] = label
        if key_column not in row:
            new_row[label_column] = label
        updated.append(new_row)
    return updated


def _build_owner_map(client: HubSpotClient) -> Dict[str, str]:
    owner_map: Dict[str, str] = {}
    owners = client.get_owners(include_archived=True)
    for owner in owners:
        owner_id = owner.get("id") or owner.get("ownerId") or owner.get("userId")
        if not owner_id:
            continue
        first = str(owner.get("firstName") or "").strip()
        last = str(owner.get("lastName") or "").strip()
        email = str(owner.get("email") or "").strip()
        label = " ".join(part for part in [first, last] if part).strip() or email or str(owner_id)
        owner_map[str(owner_id)] = label
    return owner_map


def _build_deal_label_maps(client: HubSpotClient) -> tuple[Dict[str, str], Dict[str, str]]:
    pipeline_map: Dict[str, str] = {}
    stage_map: Dict[str, str] = {}
    pipelines = client.get_pipelines("deals")
    for pipeline in pipelines:
        pipeline_id = pipeline.get("id")
        if not pipeline_id:
            continue
        pipeline_map[str(pipeline_id)] = str(pipeline.get("label") or pipeline_id)
        stages = pipeline.get("stages") or []
        if not stages:
            try:
                detail = client.get_pipeline("deals", str(pipeline_id))
            except Exception:
                detail = {}
            stages = detail.get("stages") or []
        for stage in stages:
            stage_id = stage.get("id")
            if not stage_id:
                continue
            stage_map[str(stage_id)] = str(stage.get("label") or stage_id)
    return pipeline_map, stage_map


def _normalize_group_value(value: Any) -> Any:
    if value is None:
        return BLANK_LABEL
    if isinstance(value, str):
        stripped = value.strip()
        return stripped or BLANK_LABEL
    try:
        if pd.isna(value):
            return BLANK_LABEL
    except Exception:
        pass
    return value


def _bucket_series(series: pd.Series, interval: str) -> pd.Series:
    timestamps = _parse_datetime_series(series)
    base = timestamps.dt.tz_convert("UTC").dt.tz_localize(None)
    token = str(interval or "").strip().lower()
    if token == "week":
        bucketed = base.dt.to_period("W-SUN")
        result = bucketed.dt.start_time.dt.strftime("%Y-%m-%d")
    elif token == "month":
        result = base.dt.to_period("M").astype("string")
    elif token == "quarter":
        result = base.dt.to_period("Q").astype("string").str.replace("Q", "-Q", regex=False)
    else:
        raise ValueError(f"Unsupported bucket interval: {interval}")
    return result.fillna(BLANK_LABEL)


def _parse_datetime_series(series: pd.Series) -> pd.Series:
    text = series.astype("string")
    numeric = pd.to_numeric(text, errors="coerce")
    numeric_dt = pd.to_datetime(numeric, unit="ms", utc=True, errors="coerce")
    text_dt = pd.to_datetime(text.where(numeric.isna()), utc=True, errors="coerce")
    return numeric_dt.where(numeric.notna(), text_dt)


def _clean_agg_series(series: pd.Series) -> pd.Series:
    values: List[Any] = []
    for value in series.tolist():
        if value is None:
            continue
        if isinstance(value, str) and not value.strip():
            continue
        try:
            if pd.isna(value):
                continue
        except Exception:
            pass
        values.append(value)
    return pd.Series(values, dtype="object")


def _sum_agg(series: pd.Series) -> Any:
    numeric = pd.to_numeric(series, errors="coerce")
    return numeric.sum(min_count=1)


def _avg_agg(series: pd.Series) -> Any:
    numeric = pd.to_numeric(series, errors="coerce")
    return numeric.mean()


def _min_agg(series: pd.Series) -> Any:
    cleaned = _clean_agg_series(series)
    if cleaned.empty:
        return None
    numeric = pd.to_numeric(cleaned, errors="coerce")
    if numeric.notna().sum() == len(cleaned):
        return numeric.min()
    return cleaned.min()


def _max_agg(series: pd.Series) -> Any:
    cleaned = _clean_agg_series(series)
    if cleaned.empty:
        return None
    numeric = pd.to_numeric(cleaned, errors="coerce")
    if numeric.notna().sum() == len(cleaned):
        return numeric.max()
    return cleaned.max()


def _clean_row(row: Dict[str, Any]) -> Dict[str, Any]:
    cleaned: Dict[str, Any] = {}
    for key, value in row.items():
        cleaned[key] = _clean_value(value)
    return cleaned


def _clean_value(value: Any) -> Any:
    if value is None:
        return None
    if isinstance(value, pd.Timestamp):
        if value.tzinfo is not None:
            return value.isoformat()
        return value.isoformat()
    try:
        if pd.isna(value):
            return None
    except Exception:
        pass
    if hasattr(value, "item") and callable(value.item):
        try:
            return value.item()
        except Exception:
            return value
    return value


__all__ = [
    "BLANK_LABEL",
    "QueryExecutionResult",
    "QueryPlan",
    "aggregate_rows",
    "execute_query_plan",
    "flatten_search_results",
]
