"""Cloudflare SDK wrapper for phase rulesets."""

from __future__ import annotations

import json as _json
import logging
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field

import cloudflare
import httpx
from cloudflare import (
    APIConnectionError,
    APIError,
    AuthenticationError,
    BadRequestError,
    DefaultHttpxClient,
    NotFoundError,
    PermissionDeniedError,
)

from octorules.config import ConfigError
from octorules.phases import (
    ACCOUNT_CF_PHASES,
    ALL_CF_PHASES,
    LIST_ITEM_API_FIELDS,
    PAGE_SHIELD_POLICY_API_FIELDS,
    ZONE_CF_PHASES,
)

_ZONE_PHASE_SET: frozenset[str] = frozenset(ZONE_CF_PHASES)
_ACCOUNT_PHASE_SET: frozenset[str] = frozenset(ACCOUNT_CF_PHASES)

log = logging.getLogger("octorules")


@dataclass
class Scope:
    zone_id: str | None = None
    account_id: str | None = None
    label: str = ""
    _api_kwargs: dict[str, str] | None = field(default=None, repr=False, compare=False)

    @property
    def api_kwargs(self) -> dict[str, str]:
        if self._api_kwargs is not None:
            return self._api_kwargs
        if self.account_id:
            kw = {"account_id": self.account_id}
        elif self.zone_id:
            kw = {"zone_id": self.zone_id}
        else:
            raise ValueError("Scope must have either zone_id or account_id")
        self._api_kwargs = kw
        return kw

    @property
    def is_account(self) -> bool:
        return self.account_id is not None


def _fmt_scope(scope: Scope) -> str:
    """Format scope for log messages."""
    if scope.label:
        kw = scope.api_kwargs
        key = next(iter(kw))
        return f"{scope.label} ({key}={kw[key]})"
    kw = scope.api_kwargs
    key = next(iter(kw))
    return f"{key}={kw[key]}"


class PhaseRulesResult(dict):
    """Dict mapping cf_phase → rules, with tracking of phases that failed to fetch.

    Behaves as a normal dict everywhere, but carries a ``failed_phases`` list so
    callers can distinguish "phase has no rules" from "phase fetch failed".
    """

    failed_phases: list[str]

    def __init__(self, data=None, *, failed_phases: list[str] | None = None):
        super().__init__(data or {})
        self.failed_phases = failed_phases or []


class CloudflareProvider:
    """Wraps the Cloudflare Python SDK for ruleset phase operations."""

    def __init__(
        self,
        token: str,
        *,
        max_retries: int = 2,
        timeout: float | None = None,
        max_workers: int = 1,
        client: cloudflare.Cloudflare | None = None,
    ):
        if client is not None:
            self._client = client
        else:
            kwargs: dict = {"api_token": token, "max_retries": max_retries}
            if timeout is not None:
                kwargs["timeout"] = timeout
            # Scale connection pool to match concurrency
            if max_workers > 1:
                pool_size = max_workers * len(ALL_CF_PHASES)
                kwargs["http_client"] = DefaultHttpxClient(
                    limits=httpx.Limits(
                        max_connections=max(100, pool_size),
                        max_keepalive_connections=max(20, max_workers * 4),
                    ),
                )
            self._client = cloudflare.Cloudflare(**kwargs)
        self._max_workers = max_workers
        self._account_id: str | None = None
        self._account_name: str | None = None

    @property
    def max_workers(self) -> int:
        """Maximum concurrent workers for parallel operations."""
        return self._max_workers

    @property
    def account_id(self) -> str | None:
        return self._account_id

    @property
    def account_name(self) -> str | None:
        return self._account_name

    def resolve_zone_id(self, zone_name: str) -> str:
        """Resolve a zone name to its Cloudflare zone ID.

        Raises ConfigError if zero or more than one zone matches.
        Also stashes account info from the first successful resolution.
        """
        result = self._client.zones.list(name=zone_name)
        matches = [z for z in result if z.name == zone_name]
        if len(matches) == 0:
            raise ConfigError(f"No zone found for {zone_name!r}")
        if len(matches) > 1:
            raise ConfigError(f"Multiple zones found for {zone_name!r}")
        zone = matches[0]
        if getattr(zone, "account", None) and not self._account_id:
            self._account_id = zone.account.id
            self._account_name = zone.account.name
        return zone.id

    def get_phase_rules(self, scope: Scope, cf_phase: str) -> list[dict]:
        """Fetch rules for a single phase. Returns empty list if no ruleset exists."""
        log.debug("GET rulesets/phases/%s %s", cf_phase, _fmt_scope(scope))
        try:
            ruleset = self._client.rulesets.phases.get(
                cf_phase,
                **scope.api_kwargs,
            )
            rules = ruleset.rules or []
            return [_rule_to_dict(r) for r in rules]
        except NotFoundError:
            return []
        except BadRequestError as e:
            # Cloudflare returns 400 for phases the zone/account doesn't
            # support (e.g. SBFM without the entitlement).  Treat the same
            # as "no ruleset" so callers aren't surprised.
            log.debug("Phase %s not supported for %s: %s", cf_phase, _fmt_scope(scope), e)
            return []
        except PermissionDeniedError as e:
            # Token lacks permission for this specific phase (e.g. ddos_l7,
            # http_log_custom_fields).  Skip rather than aborting the entire
            # operation — the caller can still process other phases.
            log.debug("Phase %s not permitted for %s: %s", cf_phase, _fmt_scope(scope), e)
            return []

    def put_phase_rules(self, scope: Scope, cf_phase: str, rules: list[dict]) -> int:
        """Atomically replace all rules in a phase.

        Returns the number of rules in the response (for verification).
        """
        sl = _fmt_scope(scope)
        log.debug("PUT rulesets/phases/%s %s rules=%d", cf_phase, sl, len(rules))
        result = self._client.rulesets.phases.update(
            cf_phase,
            **scope.api_kwargs,
            rules=rules,
        )
        response_rules = result.rules or []
        response_count = len(response_rules)
        if response_count != len(rules):
            log.warning(
                "PUT %s %s: sent %d rule(s) but response contains %d",
                cf_phase,
                sl,
                len(rules),
                response_count,
            )
        return response_count

    def get_all_phase_rules(
        self, scope: Scope, *, cf_phases: list[str] | None = None
    ) -> PhaseRulesResult:
        """Fetch rules for supported phases in parallel. Returns cf_phase → rules mapping.

        AuthenticationError and PermissionDeniedError propagate immediately
        (permanent errors). Transient errors (rate limit, server error,
        connection) are logged and the phase is recorded in ``result.failed_phases``.

        For account scopes, automatically restricts to account-compatible phases.

        Args:
            scope: The Scope (zone or account) to fetch rules for.
            cf_phases: Optional list of CF phase identifiers to fetch.
                       Defaults to all supported phases.
        """
        phases_to_fetch = cf_phases if cf_phases is not None else ALL_CF_PHASES
        if scope.is_account:
            phases_to_fetch = [p for p in phases_to_fetch if p in _ACCOUNT_PHASE_SET]
        else:
            phases_to_fetch = [p for p in phases_to_fetch if p in _ZONE_PHASE_SET]
        sl = _fmt_scope(scope)
        log.debug("Fetching %d phase(s) for %s", len(phases_to_fetch), sl)
        rules: dict[str, list[dict]] = {}
        failed: list[str] = []

        if not phases_to_fetch:
            return PhaseRulesResult(rules, failed_phases=failed)

        workers = min(self._max_workers, len(phases_to_fetch))
        with ThreadPoolExecutor(max_workers=workers) as executor:
            futures = {executor.submit(self.get_phase_rules, scope, p): p for p in phases_to_fetch}
            for future in as_completed(futures):
                cf_phase = futures[future]
                try:
                    phase_rules = future.result()
                except (AuthenticationError, PermissionDeniedError):
                    # Cancel remaining futures before propagating
                    for f in futures:
                        f.cancel()
                    raise
                except (APIError, APIConnectionError) as e:
                    log.warning("Failed to fetch phase %s for %s: %s", cf_phase, sl, e)
                    failed.append(cf_phase)
                    continue
                if phase_rules:
                    rules[cf_phase] = phase_rules
        return PhaseRulesResult(rules, failed_phases=failed)

    def list_custom_rulesets(self, scope: Scope) -> list[dict]:
        """List custom rulesets (kind == 'custom') for a scope.

        Returns list of {id, name, phase, description} dicts.
        """
        sl = _fmt_scope(scope)
        log.debug("LIST rulesets (custom) %s", sl)
        result = self._client.rulesets.list(**scope.api_kwargs)
        custom = []
        for rs in result:
            rs_dict = _ruleset_to_dict(rs)
            if rs_dict.get("kind") != "custom":
                continue
            custom.append(
                {
                    "id": rs_dict["id"],
                    "name": rs_dict.get("name", ""),
                    "phase": rs_dict.get("phase", ""),
                    "description": rs_dict.get("description", ""),
                }
            )
        return custom

    def get_custom_ruleset(self, scope: Scope, ruleset_id: str) -> list[dict]:
        """Fetch rules inside a single custom ruleset. Returns list of rule dicts."""
        sl = _fmt_scope(scope)
        log.debug("GET rulesets/%s %s", ruleset_id, sl)
        ruleset = self._client.rulesets.get(ruleset_id, **scope.api_kwargs)
        rules = ruleset.rules or []
        return [_rule_to_dict(r) for r in rules]

    def put_custom_ruleset(self, scope: Scope, ruleset_id: str, rules: list[dict]) -> int:
        """Replace all rules in a custom ruleset. Returns rule count from response."""
        sl = _fmt_scope(scope)
        log.debug("PUT rulesets/%s %s rules=%d", ruleset_id, sl, len(rules))
        result = self._client.rulesets.update(ruleset_id, **scope.api_kwargs, rules=rules)
        response_rules = result.rules or []
        response_count = len(response_rules)
        if response_count != len(rules):
            log.warning(
                "PUT ruleset %s %s: sent %d rule(s) but response contains %d",
                ruleset_id,
                sl,
                len(rules),
                response_count,
            )
        return response_count

    def get_all_custom_rulesets(
        self, scope: Scope, *, ruleset_ids: list[str] | None = None
    ) -> dict[str, dict]:
        """Fetch all custom rulesets in parallel.

        Returns {ruleset_id: {"name": ..., "phase": ..., "rules": [...]}}.
        If ruleset_ids is None, discovers via list_custom_rulesets.
        """
        if ruleset_ids is None:
            rulesets_meta = self.list_custom_rulesets(scope)
        else:
            rulesets_meta = [{"id": rid, "name": "", "phase": ""} for rid in ruleset_ids]

        if not rulesets_meta:
            return {}

        sl = _fmt_scope(scope)
        log.debug("Fetching %d custom ruleset(s) for %s", len(rulesets_meta), sl)
        meta_by_id = {rs["id"]: rs for rs in rulesets_meta}
        result: dict[str, dict] = {}

        workers = min(self._max_workers, len(rulesets_meta))
        with ThreadPoolExecutor(max_workers=workers) as executor:
            futures = {
                executor.submit(self.get_custom_ruleset, scope, rs["id"]): rs["id"]
                for rs in rulesets_meta
            }
            for future in as_completed(futures):
                rs_id = futures[future]
                try:
                    rules = future.result()
                except (AuthenticationError, PermissionDeniedError):
                    for f in futures:
                        f.cancel()
                    raise
                except (APIError, APIConnectionError) as e:
                    log.warning("Failed to fetch custom ruleset %s for %s: %s", rs_id, sl, e)
                    continue
                meta = meta_by_id[rs_id]
                result[rs_id] = {
                    "name": meta.get("name", ""),
                    "phase": meta.get("phase", ""),
                    "rules": rules,
                }
        return result

    # --- Lists API ---

    def list_lists(self, scope: Scope) -> list[dict]:
        """List all account-level lists.

        Returns list of {id, name, kind, description} dicts.
        """
        sl = _fmt_scope(scope)
        log.debug("LIST rules/lists %s", sl)
        result = self._client.rules.lists.list(**scope.api_kwargs)
        lists = []
        for item in result:
            d = _ruleset_to_dict(item)
            lists.append(
                {
                    "id": d.get("id", ""),
                    "name": d.get("name", ""),
                    "kind": d.get("kind", ""),
                    "description": d.get("description", ""),
                }
            )
        return lists

    def create_list(self, scope: Scope, name: str, kind: str, description: str = "") -> dict:
        """Create a new list. Returns created list metadata dict."""
        sl = _fmt_scope(scope)
        log.debug("CREATE rules/lists %s name=%s kind=%s", sl, name, kind)
        result = self._client.rules.lists.create(
            **scope.api_kwargs, kind=kind, name=name, description=description
        )
        return _ruleset_to_dict(result)

    def delete_list(self, scope: Scope, list_id: str) -> None:
        """Delete a list by ID."""
        sl = _fmt_scope(scope)
        log.debug("DELETE rules/lists/%s %s", list_id, sl)
        self._client.rules.lists.delete(list_id, **scope.api_kwargs)

    def update_list_description(self, scope: Scope, list_id: str, description: str) -> None:
        """Update list metadata (description)."""
        sl = _fmt_scope(scope)
        log.debug("UPDATE rules/lists/%s %s description=%r", list_id, sl, description)
        self._client.rules.lists.update(list_id, **scope.api_kwargs, description=description)

    def get_list_items(self, scope: Scope, list_id: str, *, _page_retries: int = 2) -> list[dict]:
        """Fetch all items in a list, handling cursor-based pagination.

        Strips LIST_ITEM_API_FIELDS from each item.
        Uses raw responses to extract pagination cursors, since the SDK
        returns a plain list without cursor metadata.

        Each page is retried up to *_page_retries* times on transient errors
        (``APIError``, ``APIConnectionError``).  ``AuthenticationError`` and
        ``PermissionDeniedError`` propagate immediately.
        """
        sl = _fmt_scope(scope)
        log.debug("GET rules/lists/%s/items %s", list_id, sl)
        all_items: list[dict] = []
        cursor: str | None = None
        while True:
            kwargs: dict = {**scope.api_kwargs, "per_page": 500}
            if cursor:
                kwargs["cursor"] = cursor
            last_exc: APIError | APIConnectionError | None = None
            for attempt in range(_page_retries + 1):
                try:
                    raw = self._client.rules.lists.items.with_raw_response.list(list_id, **kwargs)
                    body = _json.loads(raw.http_response.text)
                    last_exc = None
                    break
                except (AuthenticationError, PermissionDeniedError):
                    raise
                except (APIError, APIConnectionError) as e:
                    last_exc = e
                    if attempt < _page_retries:
                        delay = (attempt + 1) * 1.0
                        log.warning(
                            "Retrying page fetch for list %s (%s, attempt %d/%d): %s",
                            list_id,
                            sl,
                            attempt + 1,
                            _page_retries + 1,
                            e,
                        )
                        time.sleep(delay)
            if last_exc is not None:
                raise last_exc
            for item in body.get("result", []):
                cleaned = {k: v for k, v in item.items() if k not in LIST_ITEM_API_FIELDS}
                all_items.append(cleaned)
            # Extract next cursor from result_info
            cursor = None
            result_info = body.get("result_info")
            if result_info:
                cursors = result_info.get("cursors")
                if cursors:
                    cursor = cursors.get("after") or None
            if not cursor:
                break
        return all_items

    def put_list_items(self, scope: Scope, list_id: str, items: list[dict]) -> str:
        """Replace all items in a list (async). Returns operation_id."""
        sl = _fmt_scope(scope)
        log.debug("PUT rules/lists/%s/items %s items=%d", list_id, sl, len(items))
        result = self._client.rules.lists.items.update(list_id, **scope.api_kwargs, body=items)
        d = _ruleset_to_dict(result)
        return d.get("operation_id", "")

    def poll_bulk_operation(
        self, scope: Scope, operation_id: str, *, timeout: float = 120.0, interval: float = 2.0
    ) -> str:
        """Poll a bulk operation until completion.

        Returns "completed". Raises APIError on "failed", TimeoutError on timeout.
        """
        sl = _fmt_scope(scope)
        log.debug("POLL bulk_operations/%s %s", operation_id, sl)
        start = time.monotonic()
        while True:
            result = self._client.rules.lists.bulk_operations.get(operation_id, **scope.api_kwargs)
            d = _ruleset_to_dict(result)
            status = d.get("status", "")
            if status == "completed":
                return "completed"
            if status == "failed":
                error = d.get("error", "unknown error")
                raise APIError(
                    f"Bulk operation {operation_id} failed: {error}",
                    request=None,
                    body=None,
                )
            elapsed = time.monotonic() - start
            if elapsed >= timeout:
                raise TimeoutError(
                    f"Bulk operation {operation_id} timed out after {timeout}s (status={status})"
                )
            time.sleep(interval)

    def get_all_lists(
        self, scope: Scope, *, list_names: list[str] | None = None
    ) -> dict[str, dict]:
        """Fetch all lists and their items in parallel.

        Returns {list_name: {"id": ..., "kind": ..., "description": ..., "items": [...]}}.
        If list_names is provided, filters to those names only.
        """
        all_meta = self.list_lists(scope)
        if list_names is not None:
            name_set = set(list_names)
            all_meta = [m for m in all_meta if m["name"] in name_set]

        if not all_meta:
            return {}

        sl = _fmt_scope(scope)
        log.debug("Fetching items for %d list(s) for %s", len(all_meta), sl)
        result: dict[str, dict] = {}

        workers = min(self._max_workers, len(all_meta))
        with ThreadPoolExecutor(max_workers=workers) as executor:
            futures = {executor.submit(self.get_list_items, scope, m["id"]): m for m in all_meta}
            for future in as_completed(futures):
                meta = futures[future]
                try:
                    items = future.result()
                except (AuthenticationError, PermissionDeniedError):
                    for f in futures:
                        f.cancel()
                    raise
                except (APIError, APIConnectionError) as e:
                    log.warning(
                        "Failed to fetch items for list %s for %s: %s",
                        meta["name"],
                        sl,
                        e,
                    )
                    continue
                result[meta["name"]] = {
                    "id": meta["id"],
                    "kind": meta["kind"],
                    "description": meta["description"],
                    "items": items,
                }
        return result

    # --- Page Shield Policies API ---

    def list_page_shield_policies(self, scope: Scope) -> list[dict]:
        """List all Page Shield policies for a zone.

        Returns list of {id, description, action, expression, enabled, value} dicts.
        """
        sl = _fmt_scope(scope)
        log.debug("LIST page_shield/policies %s", sl)
        result = self._client.page_shield.policies.list(zone_id=scope.zone_id)
        policies = []
        for item in result:
            d = _ruleset_to_dict(item)
            policies.append(
                {
                    "id": d.get("id", ""),
                    "description": d.get("description", ""),
                    "action": d.get("action", ""),
                    "expression": d.get("expression", ""),
                    "enabled": d.get("enabled", False),
                    "value": d.get("value", ""),
                }
            )
        return policies

    def create_page_shield_policy(
        self,
        scope: Scope,
        *,
        description: str,
        action: str,
        expression: str,
        enabled: bool,
        value: str,
    ) -> dict:
        """Create a new Page Shield policy. Returns created policy dict."""
        sl = _fmt_scope(scope)
        log.debug("CREATE page_shield/policies %s description=%r", sl, description)
        result = self._client.page_shield.policies.create(
            zone_id=scope.zone_id,
            description=description,
            action=action,
            expression=expression,
            enabled=enabled,
            value=value,
        )
        return _ruleset_to_dict(result)

    def update_page_shield_policy(
        self,
        scope: Scope,
        policy_id: str,
        *,
        description: str,
        action: str,
        expression: str,
        enabled: bool,
        value: str,
    ) -> dict:
        """Update an existing Page Shield policy. Returns updated policy dict."""
        sl = _fmt_scope(scope)
        log.debug("UPDATE page_shield/policies/%s %s", policy_id, sl)
        result = self._client.page_shield.policies.update(
            policy_id,
            zone_id=scope.zone_id,
            description=description,
            action=action,
            expression=expression,
            enabled=enabled,
            value=value,
        )
        return _ruleset_to_dict(result)

    def delete_page_shield_policy(self, scope: Scope, policy_id: str) -> None:
        """Delete a Page Shield policy by ID."""
        sl = _fmt_scope(scope)
        log.debug("DELETE page_shield/policies/%s %s", policy_id, sl)
        self._client.page_shield.policies.delete(policy_id, zone_id=scope.zone_id)

    def get_all_page_shield_policies(self, scope: Scope) -> list[dict]:
        """Fetch all Page Shield policies, stripping API-only fields.

        Returns list of {description, action, expression, enabled, value} dicts.
        """
        policies = self.list_page_shield_policies(scope)
        return [
            {k: v for k, v in p.items() if k not in PAGE_SHIELD_POLICY_API_FIELDS} for p in policies
        ]


def _ruleset_to_dict(ruleset) -> dict:
    """Convert a Cloudflare SDK ruleset object to a plain dict."""
    if isinstance(ruleset, dict):
        return ruleset
    if hasattr(ruleset, "model_dump"):
        return ruleset.model_dump(exclude_none=True)
    if hasattr(ruleset, "to_dict"):
        return ruleset.to_dict()
    try:
        return dict(ruleset)
    except (TypeError, ValueError):
        return {}


def _rule_to_dict(rule) -> dict:
    """Convert a Cloudflare SDK rule object to a plain dict."""
    if isinstance(rule, dict):
        return rule
    # The cloudflare SDK returns Pydantic-like model objects
    if hasattr(rule, "model_dump"):
        return rule.model_dump(exclude_none=True)
    if hasattr(rule, "to_dict"):
        return rule.to_dict()
    try:
        return dict(rule)
    except (TypeError, ValueError) as e:
        raise TypeError(f"Cannot convert rule of type {type(rule).__name__} to dict: {e}") from e
