"""Native OpenAI Responses server-side compaction on supported OpenAI routes.

``context_management=[{"type": "compaction", "compact_threshold": N}]`` makes the server
summarize older context into an opaque ``compaction`` item once the input crosses N tokens.
Deliberately narrow: gpt-5.6 on api.openai.com or the ChatGPT Codex backend, plus gpt-6-astra
(and its ``-900k`` picker alias) on official Codex OAuth. The local compressor
stays armed as fallback (native threshold clamped below the local trigger); compaction items
ride the ``codex_reasoning_items`` sidecar. No transport imports (shared gate, no cycles).
"""

from __future__ import annotations

import logging
from typing import Any, Dict, List, Optional
from urllib.parse import urlsplit

from agent.codex_headers import is_official_codex_base_url
from agent.context_compressor import is_compaction_summary_message
from agent.message_content import flatten_message_text
from agent.reasoning_effort import is_astra_model

logger = logging.getLogger(__name__)

# Native compaction fires this far below the local trigger so the server gets the first shot.
LOCAL_TRIGGER_SAFETY_MARGIN = 8_192
# Fallback when automatic mode has no local trigger to follow.
DEFAULT_COMPACT_THRESHOLD = 200_000
# Substring match so dated snapshots and variants (gpt-5.6-mini) stay eligible.
_ELIGIBLE_MODEL_MARKER = "gpt-5.6"


def is_native_compaction_model(
    model: Optional[str], *, provider: Optional[str] = None, base_url: Optional[str] = None,
) -> bool:
    """Preserve gpt-5.6 eligibility; Astra (``-900k`` is a picker alias of the same wire slug)
    additionally requires official Codex OAuth."""
    return _ELIGIBLE_MODEL_MARKER in (model or "").lower() or (
        is_astra_model(model)
        and (provider or "").strip().lower() == "openai-codex"
        and is_official_codex_base_url(base_url or "")
    )


def resolve_native_compaction_capabilities(
    *, model: Optional[str], base_url: Optional[str], provider: Optional[str] = None, is_codex_backend: bool = False,
) -> Dict[str, bool]:
    """Resolve the native-compaction capability for a runtime destination (a resolved ``False``
    is distinct from "unresolved" and must survive model switches unchanged)."""
    direct_default = (provider or "").strip().lower() == "openai" and not base_url
    return {"native_compaction": is_native_compaction_model(model, provider=provider, base_url=base_url) and (
        direct_default or is_direct_openai_route(base_url, is_codex_backend=is_codex_backend))}


def is_direct_openai_route(base_url: Optional[str], *, is_codex_backend: bool = False) -> bool:
    """True for api.openai.com or the ChatGPT Codex backend — nothing else."""
    if is_codex_backend:
        return True
    try:
        hostname = (urlsplit(base_url or "").hostname or "").lower()
    except ValueError:
        return False
    return hostname == "api.openai.com"


def _positive_int(value: Any, *, reject: tuple = (bool,)) -> Optional[int]:
    """``int(value)`` when it is a positive integer-like (never a bool), else None."""
    if value is None or isinstance(value, reject):
        return None
    try:
        parsed = int(value)
    except (TypeError, ValueError):
        return None
    return parsed if parsed > 0 else None


def resolve_compact_threshold(configured_threshold: Any, local_trigger_tokens: Any = None) -> int:
    """Resolve automatic mode or clamp an explicit native threshold.

    Omitted/invalid follows the local compressor trigger minus the safety margin. An
    explicit positive integer is absolute unless it must be clamped so native compaction
    fires first. Booleans are never thresholds.
    """
    local = _positive_int(local_trigger_tokens)
    upper = None if local is None else max(
        1_024, local - LOCAL_TRIGGER_SAFETY_MARGIN if local > LOCAL_TRIGGER_SAFETY_MARGIN else int(local * 0.8))
    configured = _positive_int(configured_threshold, reject=(bool, float))
    if configured is None:
        return upper if upper is not None else DEFAULT_COMPACT_THRESHOLD
    if upper is None:
        return configured
    return max(1_024, min(configured, upper))


_checkpoint_suppression_logged = False


def _warn_native_compaction_suppressed_by_checkpoint_gate() -> None:
    """Log once per process; the suppression itself is re-evaluated per request."""
    global _checkpoint_suppression_logged
    if not _checkpoint_suppression_logged:
        _checkpoint_suppression_logged = True
        logger.warning(
            "compression.checkpoint_required is enabled: server-side native "
            "compaction (context_management) is disabled for this agent so the "
            "checkpoint-aware Hermes compressor stays authoritative."
        )


def native_compaction_context_management(agent: Any, *, is_codex_backend: bool, is_xai_responses: bool = False,
                                         is_github_responses: bool = False) -> Optional[List[Dict[str, Any]]]:
    """Return the ``context_management`` payload for this request, or None ("do not send").

    Every gate is re-checked per request so a mid-session model switch or the in-session
    kill switch (``agent.codex_responses_native_compaction = False``) takes effect next call.
    """
    capabilities = getattr(agent, "runtime_capabilities", None)
    if isinstance(capabilities, dict) and not capabilities.get("native_compaction", False):
        return None
    # compression.enabled: false disables ALL automatic compaction, native included.
    if not getattr(agent, "codex_responses_native_compaction", False) or not getattr(agent, "compression_enabled", True):
        return None
    # Server-side compaction is a lossy boundary the provider owns (no pre-compress checkpoint
    # can run first), so the checkpoint-aware compressor stays authoritative. Explicit-True
    # matches compress_context().
    if getattr(agent, "compression_checkpoint_required", False) is True:
        _warn_native_compaction_suppressed_by_checkpoint_gate()
        return None
    if is_xai_responses or is_github_responses or not is_native_compaction_model(
        getattr(agent, "model", None), provider=getattr(agent, "provider", None),
        base_url=getattr(agent, "base_url", None),
    ):
        return None
    trusted_proxy = bool(getattr(agent, "capabilities", {}).get("openai_native_compaction", False))
    if not trusted_proxy and not is_direct_openai_route(getattr(agent, "base_url", None), is_codex_backend=is_codex_backend):
        return None

    compressor = getattr(agent, "context_compressor", None)
    local_trigger = getattr(compressor, "threshold_tokens", None) if compressor is not None else None
    threshold = resolve_compact_threshold(getattr(agent, "codex_responses_compact_threshold", None), local_trigger)
    return [{"type": "compaction", "compact_threshold": threshold}]


# Retention budgets for plaintext user messages / local summaries carried across a native
# compaction boundary (mirrors Codex CLI's RETAINED_MESSAGE_TOKEN_BUDGET).
RETAINED_USER_MESSAGE_TOKEN_BUDGET = 64_000
RETAINED_SUMMARY_TOKEN_BUDGET = 32_000


def _approx_tokens(text: str) -> int:
    """Retention cost of one carried-over text; never 0 so an empty item still consumes budget."""
    from agent.model_metadata import estimate_tokens_rough
    return max(1, estimate_tokens_rough(text))


def _extract_item_text(item: Any) -> Optional[str]:
    """Measurable text from a Responses item (string/multipart/metadata), or None."""
    if not isinstance(item, dict):
        return None
    content = item.get("content")
    if content is None and "output_text" in item:
        content = item.get("output_text")
    if isinstance(content, str):
        return content if content.strip() else None
    if not isinstance(content, list):
        return None
    parts = []
    for part in content:
        candidates: tuple = (part,)  # non-str, non-dict parts filter out below
        if isinstance(part, dict):
            part_meta = part.get("metadata")
            candidates = (part.get("text") or part.get("input_text") or part.get("output_text"),
                          part_meta.get("text") if isinstance(part_meta, dict) else None)
        parts.extend(c.strip() for c in candidates if isinstance(c, str) and c.strip())
    text = " ".join(parts)
    return text if text.strip() else None


def _has_retainable_image_content(item: Any) -> bool:
    """True for a converted Responses message with a valid ``input_image`` part (only the
    adapter-owned shape counts, so empty multipart placeholders never become durable history)."""
    content = item.get("content") if isinstance(item, dict) else None
    return isinstance(content, list) and any(
        isinstance(part, dict) and str(part.get("type") or "").strip().lower() == "input_image"
        and isinstance(part.get("image_url"), str) and part["image_url"].strip() for part in content
    )


# Canonical provenance check. Deliberately NOT a second heuristic (no underscore-key scan,
# no ad-hoc headings) — either could promote adversarial content to durable history.
_is_summary_item = is_compaction_summary_message


def _is_compaction_item(item: Any) -> bool:
    return isinstance(item, dict) and item.get("type") == "compaction"


def prune_pre_checkpoint_items(
    items: List[Dict[str, Any]],
    retained_user_token_budget: int = RETAINED_USER_MESSAGE_TOKEN_BUDGET,
    retained_summary_token_budget: int = RETAINED_SUMMARY_TOKEN_BUDGET,
    enable_summary_retention: bool = True, item_sources: Optional[List[Any]] = None,
) -> List[Dict[str, Any]]:
    """Restructure Responses input around the newest compaction checkpoint.

    The server drops every input item preceding a replayed ``compaction`` item, erasing the
    user's plaintext asks and any local-compression summary. Rebuild as::

        [checkpoint run] + [retained user & summary messages (newest-first budget)] + [post]

    - The NEWEST contiguous run of checkpoints wins; relative order is preserved.
    - User messages are kept verbatim within ``retained_user_token_budget``; the boundary
      message is head-truncated when it only partially fits (string content only). A
      recognized image-only user message is retained whole at one-token cost.
    - Summaries are retained whole within ``retained_summary_token_budget``, never sliced
      (framing would corrupt) and never duplicated.
    - ``item_sources`` (parallel to ``items``) is the raw chat message each item came from.
      Conversion can be lossy for summaries (merge-into-tail carrier → typed
      ``function_call_output``; assistant carrier shadowed by a stale replay), so a source
      that is itself a canonical summary carrier is read from the SOURCE and retained as a
      synthesized ``role="assistant"`` message.
    - ``enable_summary_retention`` is a test override, not a config surface.

    The server drops every input item that precedes a replayed ``compaction`` item (live-verified Aug 2026),
    so sending pre-checkpoint history is dead weight AND silently erases the user's plaintext asks —
    including any local-compression summary the agent already produced, which previously vanished here
    because it carries ``role="assistant"``, not ``"user"`` (#90975).
    A summary is never byte/character-sliced: Hermes summaries carry structural framing (handoff prefix, end
    marker, merge-into-tail delimiters) that a blind slice can corrupt, so one that doesn't fit whole is
    dropped instead. A summary already retained once (identical text) is never duplicated, so repeated
    checkpoints stay idempotent. - ``enable_summary_retention`` is a function-level override (used by tests
    and callers that need the pre-#90975 behavior back); it is not wired to a user-facing config surface.
    Without ``item_sources`` (default), retention only sees what survived conversion, matching pre-#90976
    behavior (#90976).
    """
    if not isinstance(items, list) or not items:
        return items
    last_cp = max((i for i, item in enumerate(items) if _is_compaction_item(item)), default=None)
    if last_cp is None:
        return items
    first_cp = last_cp
    while first_cp > 0 and _is_compaction_item(items[first_cp - 1]):
        first_cp -= 1

    pre = items[:first_cp]
    has_sources = isinstance(item_sources, list) and len(item_sources) == len(items)
    pre_sources: List[Any] = item_sources[:first_cp] if has_sources else [None] * len(pre)

    retained_reversed: List[Dict[str, Any]] = []
    user_remaining = max(0, int(retained_user_token_budget))
    summary_remaining = max(0, int(retained_summary_token_budget))
    seen_summary_texts: set = set()

    def _retain_summary(text: Optional[str], retained_item: Dict[str, Any]) -> None:
        """Retain a summary whole when it fits the budget and is not a duplicate (never sliced)."""
        nonlocal summary_remaining
        if not text or summary_remaining <= 0 or text in seen_summary_texts:
            return
        cost = _approx_tokens(text)
        if cost <= summary_remaining:
            seen_summary_texts.add(text)
            retained_reversed.append(retained_item)
            summary_remaining -= cost

    for item, source in zip(reversed(pre), reversed(pre_sources)):
        if not isinstance(item, dict):
            continue
        # Source-based detection sees past a lossy conversion; it only fires
        # when the source itself is a provenance-tagged summary carrier.
        # Canonical source-based summary detection: reads the ORIGINAL chat message's own content, so it
        # sees past a lossy conversion (a typed `function_call_output` wrapper, or a stale exact-replay
        # message) that erased the summary from `item` itself (#90976).
        if enable_summary_retention and isinstance(source, dict) and _is_summary_item(source):
            text = flatten_message_text(source.get("content"))
            _src_role = source.get("role")
            _retain_summary(text if text.strip() else None,
                            {"type": "message", "role": _src_role if _src_role in ("user", "assistant") else "assistant",
                             "content": text})
            continue
        # Typed non-message items never carry role=user or a summary flag.
        if "type" in item and item.get("type") != "message":
            continue
        is_summary = enable_summary_retention and _is_summary_item(item)
        is_user = item.get("role") == "user"
        if not is_user and not is_summary:
            continue
        text = _extract_item_text(item)
        if text is None:
            if not (is_user and _has_retainable_image_content(item)):
                continue
            text = ""
        if is_summary:
            _retain_summary(text, item)
        elif user_remaining > 0:
            cost = _approx_tokens(text)
            if cost <= user_remaining:
                retained_reversed.append(item)
                user_remaining -= cost
            elif isinstance(item.get("content"), str):
                truncated = {**item, "content": item["content"][: user_remaining * 4]}
                if truncated["content"].strip():
                    retained_reversed.append(truncated)
                user_remaining = 0

    result = items[first_cp : last_cp + 1] + list(reversed(retained_reversed)) + items[last_cp + 1 :]
    logger.debug("Pruned pre-checkpoint items: %d input -> %d retained (user_rem=%d, summary_rem=%d)",
                 len(items), len(result), user_remaining, summary_remaining)
    return result


_REJECTION_MARKERS = (
    "unknown", "unsupported", "invalid", "unexpected", "not permitted",
    "not allowed", "unrecognized", "extra field", "no such", "bad request",
    "not supported",
)


def is_native_compaction_rejection(error: Any, status_code: Any = None) -> bool:
    """True when a provider error is a STRUCTURED rejection of ``context_management``.

    Drives one-shot recovery (strip, disable for the session, retry), so matching is narrow:
    a transient 5xx that merely ECHOES the request must not downgrade native compaction.
    Requires ``status_code`` 400 (or unknown) AND the field name with rejection language.

    See #82777.
    * ``status_code`` is 400 (or unknown/None — some transports surface only a message string; field-name
    matching alone is then the best available signal, preserving pre-#82777 behavior for them), and * the
    error text names ``context_management`` / ``compact_threshold`` alongside rejection language ("unknown",
    "unsupported", "invalid", "unexpected", "not permitted"...). A bare field-name echo without rejection
    language does not match.
    """
    text = str(error or "").lower()
    if "context_management" not in text and "compact_threshold" not in text:
        return False
    try:
        if status_code is not None and int(status_code) != 400:
            return False
    except (TypeError, ValueError):
        pass
    return any(marker in text for marker in _REJECTION_MARKERS)


def has_compaction_checkpoint(items: Any) -> bool:
    """Does this ``codex_reasoning_items`` sidecar carry a compaction checkpoint? A checkpoint is
    cumulative context living in exactly one place: rewrite/discard the sidecar only after asking."""
    return isinstance(items, list) and any(
        _is_compaction_item(item)
        and isinstance(item.get("encrypted_content"), str)
        and bool(item["encrypted_content"].strip())
        for item in items
    )


def merge_interim_reasoning_items(prior_items: Any, new_items: Any) -> List[Dict[str, Any]]:
    """Merge ``codex_reasoning_items`` across Codex incomplete-continuation dedup.

    A checkpoint on the EARLIER response is not re-emitted by the continuation, so a blind
    overwrite drops the only copy: newer items win, prior checkpoints are prepended unless
    the newer payload has its own.
    """
    prior = prior_items if isinstance(prior_items, list) else []
    kept_checkpoints = [item for item in prior if _is_compaction_item(item)]
    new_list = list(new_items) if isinstance(new_items, list) else []
    if has_compaction_checkpoint(new_list) or not kept_checkpoints:
        return new_list
    return kept_checkpoints + new_list
