"""Shared data-access layer over the A360 Control Room.

Every domain tool reaches the Control Room through this module. Centralizing
the calls here is what makes three behaviours uniform:

1. **A360 list endpoints are POST with a JSON body, not GET with query params.**
   The body carries ``{filter, sort, fields, page}`` and the response carries
   ``{page, list}``. Building that body in one place keeps filter/sort/page
   shapes consistent across tools instead of each tool reinventing them.
2. **Repeated reads within a session are wasteful.** A short TTL cache absorbs
   identical list/get calls; metadata listings (file/repository) get a longer
   TTL because they mutate rarely. Only successful responses are cached — a
   cached error would outlive the transient that caused it.
3. **Auth failures must never become empty results.** ``A360AuthError`` is
   never caught here; it propagates so callers can tell "not logged in" apart
   from "0 records". Conflating them sends the LLM hunting for records that
   were simply invisible.

Callers mock ``make_request`` for the HTTP layer, or the ``list_page`` /
``get_record`` helpers for the tool layer (two-layer mocking convention).
"""

import logging
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any

import requests

from ..auth.auth_manager import AuthManager

logger = logging.getLogger(__name__)

# --------------------------------------------------------------------------- #
# Constants
# --------------------------------------------------------------------------- #
CACHE_TTL_SECONDS = 30
METADATA_CACHE_TTL_SECONDS = 600  # file/repository listings mutate rarely
CACHE_MAX_ENTRIES = 256

COLUMNAR_MIN_ROWS = 3  # columnar encoding only pays off on many rows

DEFAULT_PAGE_LENGTH = 100
MAX_PAGE_LENGTH = 10_000  # /v3/activity/list hard cap
HEAVY_FIELD_LIMIT_CLAMP = 5  # heavy fields explode payloads — clamp the page

# Fields whose presence bloats a list response. Requesting any of these clamps
# the page length so a single call cannot pull megabytes of bot JSON.
HEAVY_FIELDS = frozenset({"content", "dependencies", "lines", "commands"})

ERROR_BODY_CAP = 500

# Paths whose contents change rarely get the longer cache TTL.
METADATA_PATH_PREFIXES = frozenset({"/v2/repository/file/list"})

# [UNVERIFIED] The exact operator set is not confirmed from the OpenAPI spec.
# This frozenset is a reference seed of documented/common operators; the filter
# builder stays permissive at runtime (unknown operators are debug-logged, not
# rejected) so live verification can reveal the real set without code changes.
KNOWN_FILTER_OPERATORS = frozenset(
    {
        # logical
        "and",
        "or",
        "not",
        # comparison
        "eq",
        "ne",
        "gt",
        "gte",
        "lt",
        "lte",
        # set / range
        "in",
        "not_in",
        "between",
        # string
        "like",
        "contains",
        "startswith",
        "endswith",
        # null
        "is_null",
        "is_not_null",
    }
)


# --------------------------------------------------------------------------- #
# Errors and result types
# --------------------------------------------------------------------------- #
class A360ApiError(RuntimeError):
    """An HTTP-level error from a Control Room data call.

    Carries the status code, the path, and a capped body so callers can surface
    a useful message without dumping a multi-KB HTML login page into context.
    """

    def __init__(self, message: str, *, status_code: int, path: str, body: str = "") -> None:
        super().__init__(message)
        self.status_code = status_code
        self.path = path
        self.body = body


@dataclass(frozen=True)
class ListPage:
    """One page of list results, with the server-reported total when known."""

    rows: list[dict]
    total: int | None
    offset: int
    length: int


# --------------------------------------------------------------------------- #
# Pure helpers — no network, no side effects
# --------------------------------------------------------------------------- #
def strip_empty_fields(record: dict) -> dict:
    """Drop keys whose value is None, "", {}, or [].

    Keeps ``0``, ``False``, ``"0"``, ``"false"`` — those are meaningful values,
    not "unset". Field absence is read as "not set" by callers. For sparse
    records (most A360 records are sparse) empty fields can be a large share of
    payload tokens, so stripping them pays off before the response budget runs.
    """
    return {k: v for k, v in record.items() if v is not None and v != "" and v != {} and v != []}


def to_columnar(rows: list[dict], preferred_order: list[str] | None = None) -> dict:
    """Re-encode a list of (possibly ragged) record dicts as ``{columns, data}``.

    Columns are ordered by ``preferred_order`` for the fields that actually
    appear, then any remaining keys in first-seen order — so the shape is
    deterministic and mirrors what the caller asked for. A field absent from a
    record becomes ``None`` in that row's array, read as "empty for this row"
    exactly like a missing dict key.
    """
    seen: OrderedDict[str, None] = OrderedDict()
    for name in preferred_order or []:
        key = name.strip()
        if key:
            seen.setdefault(key, None)
    for row in rows:
        for key in row:
            seen.setdefault(key, None)
    # Keep only columns present in at least one row.
    present = {k for row in rows for k in row}
    columns = [c for c in seen if c in present]
    data = [[row.get(c) for c in columns] for row in rows]
    return {"columns": columns, "data": data}


def condition(field: str, operator: str, value: Any) -> dict:
    """Shorthand for one filter condition: ``{operator, field, value}``.

    Does not validate field names or values — that is the server's job. The
    operator is checked against ``KNOWN_FILTER_OPERATORS`` for a debug log only.
    """
    if operator not in KNOWN_FILTER_OPERATORS:
        logger.debug(
            "filter operator '%s' is not in the known set [UNVERIFIED] — "
            "confirm against a live Control Room",
            operator,
        )
    return {"operator": operator, "field": field, "value": value}


def build_filter(*conditions: dict, operator: str = "and") -> dict | None:
    """Combine zero or more conditions into an A360 filter object.

    Returns ``None`` when there are no conditions (caller should omit the key).
    A single condition is returned bare (no wrapping). Multiple conditions are
    wrapped in ``{"operator": <and|or>, "operands": [...]}``.
    """
    if not conditions:
        return None
    if len(conditions) == 1:
        return conditions[0]
    return {"operator": operator, "operands": list(conditions)}


# --------------------------------------------------------------------------- #
# Payload safety
# --------------------------------------------------------------------------- #
def apply_payload_safety(
    path: str, length: int, fields: list[str] | None
) -> tuple[int, list[str] | None, str | None]:
    """Clamp page length and flag heavy-field requests.

    Returns ``(safe_length, safe_fields, notice)``. A360 field sets vary wildly
    per endpoint, so unlike the sibling ServiceNow project there is no universal
    default projection — projection is the caller's responsibility. The only
    clamp here is on heavy fields, which would explode a full page.
    """
    safe_length = min(length, MAX_PAGE_LENGTH)
    notice = None
    if fields and any(f in HEAVY_FIELDS for f in fields):
        if safe_length > HEAVY_FIELD_LIMIT_CLAMP:
            safe_length = HEAVY_FIELD_LIMIT_CLAMP
            notice = (
                f"Page length clamped to {HEAVY_FIELD_LIMIT_CLAMP} because a heavy "
                f"field was requested. Fetch remaining items with a tighter filter."
            )
    return safe_length, fields, notice


# --------------------------------------------------------------------------- #
# TTL cache — successful reads only
# --------------------------------------------------------------------------- #
_CacheKey = tuple  # (path, filter_repr, sort_repr, fields_repr, offset, length)
_query_cache: "OrderedDict[_CacheKey, tuple[float, float, ListPage]]" = OrderedDict()
_cache_lock = threading.Lock()


def _ttl_for_path(path: str) -> float:
    return METADATA_CACHE_TTL_SECONDS if path in METADATA_PATH_PREFIXES else CACHE_TTL_SECONDS


def _cache_key(
    path: str,
    filter_obj: dict | None,
    sort: list | None,
    fields: list[str] | None,
    offset: int,
    length: int,
) -> _CacheKey:
    return (path, repr(filter_obj), repr(sort), repr(fields), offset, length)


def _cache_get(key: _CacheKey) -> ListPage | None:
    with _cache_lock:
        entry = _query_cache.get(key)
        if entry is None:
            return None
        ts, ttl, value = entry
        if time.monotonic() - ts > ttl:
            del _query_cache[key]
            return None
        _query_cache.move_to_end(key)
        return value


def _cache_put(key: _CacheKey, value: ListPage, *, ttl: float) -> None:
    with _cache_lock:
        if key in _query_cache:
            _query_cache[key] = (time.monotonic(), ttl, value)
            _query_cache.move_to_end(key)
            return
        if len(_query_cache) >= CACHE_MAX_ENTRIES:
            _query_cache.popitem(last=False)
        _query_cache[key] = (time.monotonic(), ttl, value)


def invalidate_cache() -> int:
    """Clear the entire query cache. Returns the number of entries removed."""
    with _cache_lock:
        removed = len(_query_cache)
        _query_cache.clear()
        return removed


def invalidate_cache_for_path(path: str) -> int:
    """Clear cache entries whose key starts with ``path``. Returns count removed."""
    with _cache_lock:
        keys = [k for k in _query_cache if isinstance(k, tuple) and k and k[0] == path]
        for k in keys:
            del _query_cache[k]
        return len(keys)


# --------------------------------------------------------------------------- #
# Network helpers
# --------------------------------------------------------------------------- #
def _raise_for_status(response: requests.Response, path: str) -> None:
    """Raise A360ApiError on HTTP errors, preserving a capped body."""
    if response.status_code < 400:
        return
    body = _extract_error_body(response)
    raise A360ApiError(
        f"Control Room returned HTTP {response.status_code} for {path}: {body}",
        status_code=response.status_code,
        path=path,
        body=body,
    )


def _extract_error_body(response: requests.Response) -> str:
    """Pull a short error message out of an error response, capped."""
    try:
        data = response.json()
    except (ValueError, TypeError):
        return (response.text or "")[:ERROR_BODY_CAP]
    if isinstance(data, dict):
        # A360 error bodies commonly carry a `message` field.
        message = data.get("message")
        if isinstance(message, str) and message.strip():
            return message[:ERROR_BODY_CAP]
    try:
        return response.text[:ERROR_BODY_CAP]
    except Exception:  # pragma: no cover - defensive
        return "<unreadable response body>"


def _build_list_body(
    *,
    filter_obj: dict | None,
    sort: list | None,
    fields: list[str] | None,
    offset: int,
    length: int,
) -> dict:
    """Assemble the POST body, omitting keys whose value is None/empty.

    Sending ``"filter": null`` is pointless and some endpoints reject it, so
    only keys with real values are included.
    """
    body: dict = {"page": {"offset": offset, "length": length}}
    if filter_obj is not None:
        body["filter"] = filter_obj
    if sort:
        body["sort"] = sort
    if fields:
        body["fields"] = fields
    return body


def list_page(
    auth: AuthManager,
    path: str,
    *,
    filter: dict | None = None,
    sort: list | None = None,
    fields: list[str] | None = None,
    offset: int = 0,
    length: int = DEFAULT_PAGE_LENGTH,
) -> ListPage:
    """Fetch one page from a ``POST .../list`` endpoint.

    The body is ``{filter, sort, fields, page: {offset, length}}`` with
    ``None``/empty keys omitted. Successful responses are cached by full
    argument tuple; errors are never cached. Raises ``A360ApiError`` on HTTP
    errors and lets ``A360AuthError`` propagate (never silenced).
    """
    safe_length, safe_fields, notice = apply_payload_safety(path, length, fields)
    if notice:
        logger.debug("%s — %s", path, notice)

    key = _cache_key(path, filter, sort, safe_fields, offset, safe_length)
    cached = _cache_get(key)
    if cached is not None:
        return cached

    body = _build_list_body(
        filter_obj=filter,
        sort=sort,
        fields=safe_fields,
        offset=offset,
        length=safe_length,
    )
    response = auth.make_request("POST", path, json=body)
    _raise_for_status(response, path)

    payload = response.json()
    rows = payload.get("list", []) if isinstance(payload, dict) else []
    page_info = payload.get("page", {}) if isinstance(payload, dict) else {}
    total = page_info.get("total") if isinstance(page_info, dict) else None
    page_result = ListPage(
        rows=rows,
        total=int(total) if total is not None else None,
        offset=offset,
        length=safe_length,
    )
    _cache_put(key, page_result, ttl=_ttl_for_path(path))
    return page_result


def list_all(
    auth: AuthManager,
    path: str,
    *,
    filter: dict | None = None,
    sort: list | None = None,
    fields: list[str] | None = None,
    max_items: int,
    page_size: int = DEFAULT_PAGE_LENGTH,
) -> list[dict]:
    """Paginate a list endpoint sequentially until ``max_items`` or end-of-data.

    ``max_items`` is mandatory — there is no default, because an unbounded walk
    can pull an entire activity history. Stops when: collected >= ``max_items``,
    a page returns fewer rows than requested (end-of-data), or the server total
    is reached. Each row is stripped of empty fields. No parallelism: A360 does
    not benefit from it and stacking concurrent calls risks the session budget.
    """
    if max_items <= 0:
        return []

    collected: list[dict] = []
    offset = 0
    while len(collected) < max_items:
        fetch = min(page_size, max_items - len(collected))
        page = list_page(
            auth,
            path,
            filter=filter,
            sort=sort,
            fields=fields,
            offset=offset,
            length=fetch,
        )
        if not page.rows:
            break
        collected.extend(strip_empty_fields(row) for row in page.rows)
        # Fewer rows than requested => last page.
        if len(page.rows) < fetch:
            break
        # Server-reported total reached.
        if page.total is not None and len(collected) >= page.total:
            break
        offset += len(page.rows)
    return collected[:max_items]


def get_record(auth: AuthManager, path: str) -> dict:
    """GET a single record endpoint, returning the parsed JSON dict.

    Raises ``A360ApiError`` on HTTP errors; lets ``A360AuthError`` propagate.
    """
    response = auth.make_request("GET", path)
    _raise_for_status(response, path)
    payload = response.json()
    return payload if isinstance(payload, dict) else {"data": payload}
