"""
Tool-call reliability under context pressure — helpers for trimming, stubs, reminders.

Defaults preserve legacy behavior unless [agent_loop_tool_context] enables features.
"""

from __future__ import annotations

from typing import Any, Sequence

from utils.transcript_roles import tool_result_text_for_trim


def proactive_context_cap_tokens(
    *,
    ctx_len: int,
    cap_fraction: float,
    tighter_background_cap: bool,
) -> int:
    cap = max(1, int(ctx_len * cap_fraction))
    if tighter_background_cap:
        cap = max(1, int(cap * 0.75))
    return cap


def context_pressure_ratio(
    *,
    estimated_tokens: int,
    ctx_len: int,
    cap_fraction: float,
    tighter_background_cap: bool,
) -> float:
    cap = proactive_context_cap_tokens(
        ctx_len=ctx_len,
        cap_fraction=cap_fraction,
        tighter_background_cap=tighter_background_cap,
    )
    if cap <= 0:
        return 0.0
    return float(estimated_tokens) / float(cap)


def coerce_agent_loop_tool_context_config(raw: dict | None) -> dict:
    """Normalize [agent_loop_tool_context] keys with parity defaults (everything off/null)."""
    sec: dict[str, Any] = dict(raw or {})
    defaults: dict[str, Any] = {
        "pressure_trim_enabled": False,
        "pressure_tier1": 0.72,
        "pressure_tier2": 0.85,
        "tier1_other_preview_chars": 80,
        "tier2_other_preview_chars": 48,
        "tier1_research_preview_flat": 220,
        "tier2_research_preview_flat": 140,
        "tier2_reduce_other_keep_last_n": False,
        "soft_stub_enabled": False,
        "soft_stub_min_pressure": 0.78,
        "soft_stub_min_body_chars": 4000,
        "soft_stub_tail_carriers_keep": 3,
        "soft_stub_placeholder": (
            "[Older tool output omitted under context pressure. "
            "Re-invoke the tool if you still need exact results.]"
        ),
        "tool_format_reminder_enabled": False,
        "tool_round_temperature_cap": "",
        "ephemeral_parse_retry_enabled": False,
        "parse_retry_content_max_chars": 480,
        "parse_retry_log_verbose": False,
        "log_pressure_trim_stats": False,
        "allow_quick_panel_dashboard_pressure": False,
        # Ephemeral nudge tail (research strict / proactive / only-in-request skill) — format
        # reminder and malformed-parse repair are appended separately, closest to the API call.
        "ephemeral_tail_max_nudge_messages": 12,
        "ephemeral_tail_max_nudge_chars": 0,
        "auto_tool_reminder_pressure": 0.92,
        # Recency-boosted kanban close-contract reminder (reliability codification Phase 0.5).
        # When a kanban worker nears its tool-round budget, re-surface the kanban-result fence
        # format close to the API call so it is not thousands of tokens back at close time.
        # Additive + native-only: the completion contract still lives in system_override and every
        # salvage/synthesis fallback remains underneath. Default OFF (operator opt-in; measure the
        # delta with the tool-chaining eval before relying on it).
        "kanban_close_contract_reminder_enabled": False,
        "kanban_close_contract_reminder_window": 3,
        # Phase 2b: pin the most-recent kanban handoff-evidence tool results (render/lint/web_act/
        # research-save/write outputs) against round trimming so the model — and the Phase 2a
        # close turn — can self-author a clean kanban-result fence instead of falling back to
        # ledger synthesis. Strictly bounded so it can never re-introduce a context-overflow 500:
        # at most `pins` carriers AND at most `pin_budget_chars` total bytes; a single body larger
        # than the budget is never pinned; emergency (500-recovery) trimming ignores pins entirely.
        # Set pins to 0 to disable. Synthesis + the ledger remain the backup underneath.
        "kanban_handoff_evidence_pins": 4,
        "kanban_handoff_evidence_pin_budget_chars": 24000,
        # Auto-checkpoint run log (Point 4): the harness persists per-round tool
        # digests to the thread working buffer once pressure crosses the
        # threshold, and compression sites inject them back. Default ON — this
        # is the durability safety net for long-running tasks; the kill switch
        # exists for emergencies only.
        "auto_checkpoint_enabled": True,
        "auto_checkpoint_pressure": 0.75,
        # Core diet / tool working set (Point 5): foreground chat carries the
        # slim CORE_TOOLS schema set plus this thread's earned working set
        # instead of the full ~50-tool tier 0. Discovered/used tools promote
        # to native schemas and persist per-thread. Default ON (eval-gated):
        # awareness stays constant via find_tools, carriage is earned.
        "core_toolset_enabled": True,
        "tool_working_set_cap": 15,
        # Chat → Kanban handoff (Point 6): foreground chat runs that become
        # multi-step builds get nudged onto the durable board, plus one
        # last-chance handoff round at the hard-stop/max-rounds cliff and
        # origin-thread auto-subscribe so results flow back. Default ON.
        "chat_kanban_handoff_enabled": True,
        "chat_kanban_handoff_min_rounds": 6,
        "chat_kanban_handoff_min_build_calls": 3,
        "chat_kanban_auto_subscribe": True,
    }
    for k, dv in defaults.items():
        sec.setdefault(k, dv)
    return sec


def truthy_pref(val: Any) -> bool:
    if isinstance(val, bool):
        return val
    if isinstance(val, (int, float)):
        return val != 0
    if isinstance(val, str):
        return val.strip().lower() in ("1", "true", "yes", "on")
    return False


def _float_opt(val: Any) -> float | None:
    if val is None:
        return None
    if isinstance(val, (int, float)):
        x = float(val)
        return x if x > 0 else None
    if isinstance(val, str):
        s = val.strip()
        if not s:
            return None
        try:
            x = float(s)
            return x if x > 0 else None
        except ValueError:
            return None
    return None


def effective_tool_round_temperature_cap(tc_cfg: dict) -> float | None:
    v = tc_cfg.get("tool_round_temperature_cap")
    return _float_opt(v if isinstance(v, str) else v)


def tiered_placeholder_limits(
    pressure: float | None,
    *,
    tc_cfg: dict,
) -> tuple[int, int, int]:
    """Return (other_preview_chars, research_flat_max, subtract_from_other_keep_last).

    Subtract 1 when tier2 + tier2_reduce_other_keep_last_n tighten keep-last-one to keep-zero.
    """
    if not truthy_pref(tc_cfg.get("pressure_trim_enabled")) or pressure is None:
        return 120, 300, 0
    tier1 = float(tc_cfg.get("pressure_tier1") or 0.72)
    tier2 = float(tc_cfg.get("pressure_tier2") or 0.85)
    o1 = int(tc_cfg.get("tier1_other_preview_chars") or 80)
    o2 = int(tc_cfg.get("tier2_other_preview_chars") or 48)
    r1 = int(tc_cfg.get("tier1_research_preview_flat") or 220)
    r2 = int(tc_cfg.get("tier2_research_preview_flat") or 140)
    reduce_other = truthy_pref(tc_cfg.get("tier2_reduce_other_keep_last_n"))

    def _clamp_ov(v: int) -> int:
        return max(32, min(200, v))

    def _clamp_rs(v: int) -> int:
        return max(64, min(400, v))

    if pressure >= tier2:
        subtract = 1 if reduce_other else 0
        return (_clamp_ov(o2), _clamp_rs(r2), subtract)
    if pressure >= tier1:
        return (_clamp_ov(o1), _clamp_rs(r1), 0)
    return 120, 300, 0


def apply_ephemeral_nudge_cap(
    nudges: list[dict[str, Any]],
    tc_cfg: dict,
) -> list[dict[str, Any]]:
    """Cap non-persisted user nudges (strict research / proactive / skill).

    When over ``ephemeral_tail_max_nudge_messages``, drops **oldest** entries first
    (stable order: same as injection order). When ``ephemeral_tail_max_nudge_chars``
    is > 0, also drops from the front until the concatenated string length fits.
    """
    if not nudges:
        return []
    max_n = int(tc_cfg.get("ephemeral_tail_max_nudge_messages") or 12)
    max_n = max(1, min(48, max_n))
    out = list(nudges)
    if len(out) > max_n:
        out = out[-max_n:]
    max_chars = int(tc_cfg.get("ephemeral_tail_max_nudge_chars") or 0)
    if max_chars <= 0:
        return [dict(m) for m in out]
    while len(out) > 1:
        joined = "".join(
            str(m.get("content") or "") if isinstance(m, dict) else "" for m in out
        )
        if len(joined) <= max_chars:
            break
        out = out[1:]
    return [dict(m) for m in out]


def redact_retry_snippet(text: str, max_chars: int) -> str:
    s = "" if text is None else str(text).replace("\r", "\n").strip()
    s = "".join(ch for ch in s if ch.isprintable() or ch in "\n\t")
    if len(s) > max_chars:
        s = s[: max(0, max_chars - 1)] + "…"
    return s


# Deprecated: client-side XML salvage must never flip ephemeral reminders to XML.
# Kept only so older tests/docs can grep the symbol; build_tool_format_reminder never returns this.
TOOL_REMINDER_XML = (
    "[Internal / format only] DEPRECATED — use native function tool_calls only."
)

TOOL_REMINDER_NATIVE = (
    "[Internal / format only] Respond with structured function tool_calls matching the advertised JSON schema "
    "with JSON-valid arguments. Do not bury tool payloads in conversational prose or legacy tool-call markup."
)


def build_tool_format_reminder(
    *,
    expects_xml_fallback: bool = False,
    response_format: dict | None,
    visible_tool_names: Sequence[str] | None = None,
) -> str | None:
    """Ephemeral per-turn tool-format tail — always native-first.

    ``expects_xml_fallback`` is ignored (kept for call-site stability). XML salvage runs
    only inside the agent loop when the server omits native tool_calls; the model must not
    be instructed to emit XML on later turns.
    """
    _ = expects_xml_fallback
    if should_skip_tool_format_reminder(response_format):
        return None
    base: str | None
    if isinstance(response_format, dict):
        if str(response_format.get("type") or "").strip().lower() == "json_object":
            base = (
                "[Internal / format only] Your response_format is json_object-oriented — emit valid native "
                "function tool_calls only (no legacy tool-call markup in prose)."
            )
        else:
            base = None
    else:
        base = None
    if base is None:
        base = TOOL_REMINDER_NATIVE
    if visible_tool_names:
        names = [str(n).strip() for n in visible_tool_names if str(n).strip()]
        if names:
            shown = ", ".join(names[:48])
            if len(names) > 48:
                shown += "…"
            base = base + f" Visible tool names this turn: {shown}."
    return base


# Native-only close-contract reminder. This re-surfaces the *kanban-result* completion fence
# (the intended completion channel), NOT any tool-call format — and never mentions XML. It does
# not replace the contract in system_override; it only keeps it recent for long worker runs.
TOOL_REMINDER_KANBAN_CLOSE = (
    "[Internal / close contract] You are a kanban worker nearing the end of your tool-round "
    "budget. When you have done enough to hand off (or are blocked), end your final message with "
    "a fenced kanban-result block, exactly:\n"
    "```kanban-result\n"
    "{\"summary\": \"<what you accomplished, or why you are blocked>\", "
    "\"metadata\": {\"status\": \"success|partial|blocked\"}}\n"
    "```\n"
    "Continue using native function tool_calls for any remaining work; do not place the "
    "kanban-result block inside a tool call, and do not omit it."
)


def build_kanban_close_contract_reminder() -> str:
    """Ephemeral close-contract tail for a kanban worker nearing its round budget (native-only)."""
    return TOOL_REMINDER_KANBAN_CLOSE


def should_inject_kanban_close_reminder(
    *,
    kanban_worker_mode: bool,
    round_index: int,
    max_tool_rounds: int,
    is_guard_retry: bool,
    enabled: bool,
    window: int,
) -> bool:
    """Inject the close-contract reminder when a kanban worker is within ``window`` rounds of its
    budget — the recency window where the original contract has scrolled far back.

    Skips guard-retry rounds (they carry their own one-shot prompts) and is a no-op unless the
    feature is enabled. Recency aid only: it never disables a fallback and never changes the
    completion contract — it just keeps it fresh.
    """
    if not enabled or not kanban_worker_mode or is_guard_retry:
        return False
    if max_tool_rounds <= 0 or window <= 0:
        return False
    return round_index >= max(0, max_tool_rounds - window)


def should_skip_tool_format_reminder(response_format: dict | None) -> bool:
    if not isinstance(response_format, dict) or not response_format:
        return False
    rtype = str(response_format.get("type") or "").strip().lower()
    if rtype == "json_schema":
        return True
    js = response_format.get("json_schema")
    if isinstance(js, dict) and js:
        return True
    return False


def conversation_messages(messages: Sequence[dict]) -> list[dict]:
    """Non-system transcript messages (for thread-only pressure and synthesis detection)."""
    return [
        m
        for m in (messages or [])
        if isinstance(m, dict) and (m.get("role") or "").strip() != "system"
    ]


def is_post_tool_synthesis_round(messages: Sequence[dict]) -> bool:
    """True when the latest conversational message is not a fresh user turn."""
    conv = conversation_messages(messages)
    if not conv:
        return False
    return (conv[-1].get("role") or "").strip() != "user"


def conversation_pressure_ratio(
    *,
    messages: Sequence[dict],
    tools: Sequence[dict] | None,
    ctx_len: int,
    cap_fraction: float,
    tighter_background_cap: bool,
) -> float:
    """Pressure ratio excluding fixed system prompt overhead."""
    from utils.context_token_budget import estimate_message_tokens, estimate_tools_schema_tokens

    conv = conversation_messages(messages)
    msg_t = estimate_message_tokens(conv)
    tool_t = estimate_tools_schema_tokens(list(tools or []))
    return context_pressure_ratio(
        estimated_tokens=msg_t + tool_t,
        ctx_len=ctx_len,
        cap_fraction=cap_fraction,
        tighter_background_cap=tighter_background_cap,
    )


def should_inject_tool_format_reminder(
    *,
    messages: Sequence[dict],
    tools_for_api: Sequence[dict] | None,
    openai_tools: Sequence[dict] | None,
    fmt_reminder_enabled: bool,
    auto_reminder_pressure: float,
    ctx_len: int,
    cap_fraction: float,
    tighter_background_cap: bool,
    plain_chat_no_tools: bool,
    force_plain_chat: bool,
    is_heartbeat: bool,
    surface_ok: bool,
    mlx_native_probe_ok: bool | None,
) -> bool:
    """Decide whether to append the ephemeral native tool-format hint this round."""
    if not tools_for_api or not openai_tools:
        return False
    if plain_chat_no_tools or force_plain_chat or is_heartbeat or not surface_ok:
        return False
    from utils.active_memory import _messages_have_synthetic_init

    if _messages_have_synthetic_init(messages):
        return False
    if is_post_tool_synthesis_round(messages):
        return False
    if mlx_native_probe_ok is True:
        return False
    if fmt_reminder_enabled:
        return True
    conv_ratio = conversation_pressure_ratio(
        messages=messages,
        tools=openai_tools,
        ctx_len=ctx_len,
        cap_fraction=cap_fraction,
        tighter_background_cap=tighter_background_cap,
    )
    return conv_ratio >= auto_reminder_pressure


def append_tool_format_reminder(messages: list[dict], reminder_text: str) -> list[dict]:
    """Prefer system suffix over role:user so the model does not acknowledge it in chat."""
    if not reminder_text:
        return messages
    out = list(messages)
    if out and (out[0].get("role") or "").strip() == "system":
        sys0 = dict(out[0])
        prev = str(sys0.get("content") or "")
        sep = "\n\n" if prev else ""
        sys0["content"] = prev + sep + reminder_text
        out[0] = sys0
        return out
    return out + [{"role": "user", "content": reminder_text}]


# Phase 2b: tool names whose results carry the evidence a kanban worker needs to author its
# completion handoff (artifact paths, lint/render pass-fail, web_act fresh_run, saved-research
# pointers). Render/lint tools are matched by suffix. These are the carriers we pin against
# round trimming so the model can self-author a fence rather than relying on ledger synthesis.
_HANDOFF_EVIDENCE_TOOL_NAMES: frozenset[str] = frozenset({
    "save_research_findings",
    "generate_final_research_report",
    "save_research",
    "append_research_scratch",
    "run_web_script",
    "write_web_act_file",
    "write_text_file",
    "save_to_file",
    "append_to_text_file",
})


def _is_handoff_evidence_tool(name: str) -> bool:
    n = (name or "").strip()
    if not n:
        return False
    return n in _HANDOFF_EVIDENCE_TOOL_NAMES or n.endswith("_render") or n.endswith("_lint")


def compute_kanban_handoff_pin_ids(
    messages: list,
    *,
    max_pins: int,
    max_budget_chars: int,
) -> set[str]:
    """Return native ``tool_call_id``s of the most-recent handoff-evidence results to pin.

    Bounded twice so pinning can never re-introduce a context-overflow 500: at most ``max_pins``
    carriers, and the running sum of pinned body lengths must stay within ``max_budget_chars``
    (a single body larger than the budget is skipped, not partially pinned). Selection walks
    newest-first so the freshest evidence wins the budget. Native ``role=tool`` carriers only —
    kanban workers emit native tool calls (the XML carrier path is salvage-only).

    Returns an empty set when disabled (``max_pins`` <= 0) or nothing qualifies. Never raises.
    """
    if max_pins <= 0 or max_budget_chars <= 0:
        return set()
    id_to_name: dict[str, str] = {}
    for m in messages:
        if not isinstance(m, dict) or m.get("role") != "assistant":
            continue
        for tc in m.get("tool_calls") or []:
            tid = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)
            fn = tc.get("function") if isinstance(tc, dict) else getattr(tc, "function", None)
            name = fn.get("name") if isinstance(fn, dict) else (getattr(fn, "name", None) if fn else None)
            if tid and name:
                id_to_name[str(tid)] = str(name)
    pinned: set[str] = set()
    budget = int(max_budget_chars)
    # Newest-first: iterate messages in reverse so the most recent evidence is pinned first.
    for m in reversed(messages):
        if len(pinned) >= max_pins:
            break
        if not isinstance(m, dict) or (m.get("role") or "").strip() != "tool":
            continue
        tid = m.get("tool_call_id")
        if not tid:
            continue
        tid = str(tid)
        if tid in pinned:
            continue
        if not _is_handoff_evidence_tool(id_to_name.get(tid, "")):
            continue
        body = tool_result_text_for_trim(m)
        blen = len(body) if isinstance(body, str) else 0
        if blen <= 0 or blen > budget:
            # Skip a body that alone would blow the budget; keep scanning older (smaller) ones.
            continue
        pinned.add(tid)
        budget -= blen
    return pinned


def stub_older_tool_carrier_bodies(
    messages: list,
    *,
    pressure: float | None,
    tc_cfg: dict,
    tighter_background_skip: bool,
    pinned_tool_call_ids: set[str] | None = None,
) -> list[tuple[int, int, int]]:
    """Shorten oldest large tool-result bodies (native ``role=tool`` or XML ``<tool_response>`` carriers).

    Uses the same transcript extract as trimming so native and Hermes/XML shapes participate in tail order.
    Returns (idx, old_len, new_len) deltas."""
    if (
        tighter_background_skip
        or not truthy_pref(tc_cfg.get("soft_stub_enabled"))
        or not truthy_pref(tc_cfg.get("pressure_trim_enabled"))
        or pressure is None
    ):
        return []
    try:
        min_p = float(tc_cfg.get("soft_stub_min_pressure") or 0.78)
    except (TypeError, ValueError):
        min_p = 0.78
    if pressure < min_p:
        return []
    min_chars = max(512, int(tc_cfg.get("soft_stub_min_body_chars") or 4000))
    keep_tail = max(1, int(tc_cfg.get("soft_stub_tail_carriers_keep") or 3))
    placeholder = str(
        tc_cfg.get("soft_stub_placeholder")
        or (
            "[Older tool output omitted under context pressure. "
            "Re-invoke the tool if you still need exact results.]"
        )
    )

    # Order by chronological tool-result carriers (must include native ``role=tool``, not only XML user turns).
    carrier_indices = [
        i for i, m in enumerate(messages) if tool_result_text_for_trim(m)
    ]
    if len(carrier_indices) <= keep_tail:
        return []
    stub_candidates = carrier_indices[:-keep_tail]
    pins = pinned_tool_call_ids or set()
    deltas: list[tuple[int, int, int]] = []

    for i in stub_candidates:
        msg = messages[i]
        # Phase 2b: never stub a pinned handoff-evidence carrier — the worker needs it to author
        # the kanban-result fence. The pin set is byte-bounded upstream so this stays 500-safe.
        if pins and str(msg.get("tool_call_id") or "") in pins:
            continue
        txt = tool_result_text_for_trim(msg)
        if not isinstance(txt, str) or len(txt) < min_chars:
            continue
        orig_full = msg.get("content")
        if not isinstance(orig_full, str):
            continue
        role = (msg.get("role") or "").strip()
        new_content = placeholder
        if role == "tool":
            messages[i] = {**msg, "content": new_content}
            deltas.append((i, len(orig_full), len(new_content)))
        elif role == "user" and "<tool_response>" in orig_full.lower():
            new_wrapped = f"<tool_response>\n{new_content}\n</tool_response>"
            messages[i] = {**msg, "content": new_wrapped}
            deltas.append((i, len(orig_full), len(new_wrapped)))
    return deltas
