"""Subagent result post-processing: summary budget/spill, tool-trace summaries, lifecycle hooks and cost rollup."""

from __future__ import annotations

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

logger = logging.getLogger("tools.delegate_tool")  # log-record parity with the origin module

def _stringify_tool_content(content: Any) -> str:
    """Stable text for tool-result content. Some OpenAI-compatible paths return
    content-block lists; observability must never crash on them."""
    if content is None:
        return ""
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        return "\n".join(
            item["text"] if isinstance(item, dict) and isinstance(item.get("text"), str)
            else json.dumps(item, ensure_ascii=False, default=str) if isinstance(item, dict)
            else str(item)
            for item in content
        )
    if isinstance(content, dict):
        return json.dumps(content, ensure_ascii=False, default=str)
    return str(content)

def _looks_like_error_output(content: Any) -> bool:
    """Conservative error detector for tool-result previews: structured JSON with
    an ``error`` key or an error/failed ``status``, or a first line starting with a
    classic error marker. (Substring "error" alone painted normal output red.)"""
    content = _stringify_tool_content(content)
    if not content:
        return False
    if content.lstrip().startswith(("{", "[")):
        try:
            parsed = json.loads(content)
        except Exception:
            parsed = None
        if isinstance(parsed, dict) and (
            parsed.get("error") or str(parsed.get("status") or "").strip().lower() in {"error", "failed", "failure", "timeout"}
        ):
            return True
    first = content.splitlines()[0].strip().lower() if content.splitlines() else ""
    return first.startswith(("error:", "failed:", "traceback ", "exception:"))

def _extract_output_tail(result: Dict[str, Any], *, max_entries: int = 12, max_chars: int = 8000) -> List[Dict[str, Any]]:
    """Last N tool-call results ``{tool, preview, is_error}`` from a child's conversation (the overlay's "Output"
    section), chronological order. Content blocks are flattened first so a block-wrapped "Error: ..." is still
    flagged; line structure is preserved (capped at ``max_chars``) so the overlay shows real output rather than a
    whitespace-collapsed blob."""
    messages = result.get("messages") if isinstance(result, dict) else None
    if not isinstance(messages, list):
        return []
    name_by_call_id = {
        tc["id"]: str((tc.get("function") or {}).get("name") or "tool")
        for msg in messages if isinstance(msg, dict) and msg.get("role") == "assistant"
        for tc in msg.get("tool_calls") or [] if tc.get("id")
    }
    tail: List[Dict[str, Any]] = []
    for msg in reversed(messages):  # newest first, then restore order below
        if len(tail) >= max_entries:
            break
        if not isinstance(msg, dict) or msg.get("role") != "tool":
            continue
        content = _stringify_tool_content(msg.get("content") or "")
        tool_name = name_by_call_id.get(msg.get("tool_call_id") or "", "tool")
        tail.append({"tool": tool_name, "preview": content[:max_chars], "is_error": _looks_like_error_output(content)})
    tail.reverse()
    return tail

_TOOL_INPUT_TARGET_KEYS = frozenset({
    "cwd", "destination_path", "directory", "dst", "endpoint", "file_path", "new_path", "old_path", "path",
    "source_path", "src", "target_path", "url", "urls",
})
_TOOL_INPUT_URL_KEYS = frozenset({"endpoint", "url", "urls"})

def _sanitize_tool_target(key: str, value: Any) -> Any:
    """Keep bounded side-effect targets while dropping URL secrets."""
    if isinstance(value, list):
        cleaned = [item for item in (_sanitize_tool_target(key, item) for item in value[:16]) if item is not None]
        return cleaned or None
    if not isinstance(value, str) or not value:
        return None
    bounded = value[:1024]
    if key in _TOOL_INPUT_URL_KEYS:
        try:
            parsed = urlsplit(bounded)
            if parsed.scheme and parsed.netloc:
                hostname = parsed.hostname
                if not hostname:
                    return None
                # ``SplitResult.netloc`` includes ``user:password@``. Rebuild the authority from parsed host/port so
                # hook-visible history cannot carry URL credentials. Bracket IPv6 literals before appending a
                # validated port.
                host = f"[{hostname}]" if ":" in hostname else hostname
                netloc = f"{host}:{parsed.port}" if parsed.port is not None else host
                return urlunsplit((parsed.scheme, netloc, parsed.path, "", ""))
        except ValueError:
            return None
    return bounded

def _sanitize_targets(mapping: Dict[str, Any]) -> Dict[str, Any]:
    """Keep only known side-effect target keys, each sanitized (URL secrets dropped)."""
    targets: Dict[str, Any] = {}
    for raw_key, value in mapping.items():
        key = str(raw_key).lower()
        if key in _TOOL_INPUT_TARGET_KEYS:
            cleaned = _sanitize_tool_target(key, value)
            if cleaned is not None:
                targets[key] = cleaned
    return targets

def _input_summary(keys: Any, targets: Any) -> Dict[str, Any]:
    """``{argument_keys, targets}`` with bounded, sanitized contents (empty on bad shapes)."""
    return {
        "argument_keys": [str(key)[:128] for key in keys[:64]] if isinstance(keys, list) else [],
        "targets": _sanitize_targets(targets) if isinstance(targets, dict) else {},
    }

def _summarize_tool_arguments(arguments: Any) -> Dict[str, Any]:
    """Summarize argument names and side-effect targets without raw payloads."""
    try:
        parsed = json.loads(arguments) if isinstance(arguments, str) else None
    except (TypeError, ValueError):
        parsed = None
    if not isinstance(parsed, dict):
        return _input_summary([], {})
    return _input_summary(sorted(str(key)[:128] for key in parsed), parsed)

def _subagent_stop_tool_call_history(tool_trace: Any) -> List[Dict[str, Any]]:
    """Detached, metadata-only tool history for lifecycle hooks (input summaries re-sanitized)."""
    if not isinstance(tool_trace, list):
        return []

    def _byte_count(item, key: str) -> int:
        value = item.get(key, 0)
        return max(0, int(value)) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0

    history: List[Dict[str, Any]] = []
    for item in tool_trace:
        if not isinstance(item, dict):
            continue
        status = str(item.get("status") or "unknown").lower()
        summary = item.get("input_summary")
        summary = summary if isinstance(summary, dict) else {}
        history.append({
            "tool_name": str(item.get("tool") or "unknown")[:256],
            "tool_input": _input_summary(summary.get("argument_keys"), summary.get("targets")),
            "input_bytes": _byte_count(item, "args_bytes"), "output_bytes": _byte_count(item, "result_bytes"),
            "status": status if status in {"ok", "error"} else "unknown",
        })
    return history

# Hard per-summary character ceiling layered on top of the dynamic headroom budget (see _apply_summary_budget):
# belt-and-suspenders for models that ignore "be concise". 0 disables the ceiling.
DEFAULT_MAX_SUMMARY_CHARS = 24000
# Fraction of the parent's *remaining* context headroom the whole batch of summaries may consume, split per summary,
# so N children can't collectively blow the parent's window (the compression/429 death spiral).
_SUMMARY_HEADROOM_FRACTION = 0.5
# Floor so a single summary always gets a usable slice even when the parent is
# already nearly full — below this we'd be truncating to noise.
_MIN_SUMMARY_CHARS = 2000

def _spill_summary_to_file(task_index: int, summary: str) -> Optional[str]:
    """Write the full summary under ``cache/delegation`` (mounted read-only into remote backends via
    ``credential_files._CACHE_DIRS``, so the parent's terminal/``read_file`` can page it on any backend). Absolute
    path, or None on failure — the trimmed head+tail is still returned regardless."""
    try:
        from hermes_constants import get_hermes_dir
        import datetime as _dt
        cache_dir = get_hermes_dir("cache/delegation", "delegation_cache")
        cache_dir.mkdir(parents=True, exist_ok=True)
        path = cache_dir / f"subagent-summary-{task_index}-{_dt.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.txt"
        from tools.spill_safety import write_text_exclusive
        # Exclusive symlink-refusing create; not private because cache/delegation is bind-mounted read-only into
        # remote backends whose container UID must be able to read it.
        write_text_exclusive(path, summary, private=False)
        return str(path)
    except Exception as exc:
        logger.debug("Failed to spill subagent summary to file: %s", exc)
        return None

def _trim_summary_with_footer(summary: str, cap: int, task_index: int) -> tuple[str, Optional[str]]:
    """``(model_text, spill_path)`` for one over-budget summary: a ~75% head / ~25% tail window snapped to line
    boundaries (so the opening AND the closing outcomes/files-changed/issues both survive), the full text spilled
    to disk, and a footer giving the exact ``read_file offset=`` for the omitted middle."""
    original_len = len(summary)
    head_budget = int(cap * 0.75)
    tail_budget = cap - head_budget
    head, tail = summary[:head_budget], summary[-tail_budget:]
    nl = head.rfind("\n")
    if nl > head_budget * 0.5:
        head = head[:nl]
    nl = tail.find("\n")
    if 0 <= nl < tail_budget * 0.5:
        tail = tail[nl + 1:]

    spill_path = _spill_summary_to_file(task_index, summary)
    if spill_path:
        # Agent-visible path: the parent's read_file runs inside the active backend (#81984, #77015).
        from tools.credential_files import to_agent_visible_cache_path
        spill_path = to_agent_visible_cache_path(spill_path)
    footer_lines = [
        "", "─" * 8 + " [SUMMARY TRUNCATED] " + "─" * 8,
        f"Showing {len(head):,} chars (head) + {len(tail):,} chars (tail) "
        f"of {original_len:,} total — trimmed to protect the parent's context window.",
    ]
    if spill_path:
        # read_file is 1-indexed; +2 moves past the last head line shown.
        footer_lines.append(f"Full subagent output saved to: {spill_path}")
        footer_lines.append(
            f'To read the omitted middle: read_file path="{spill_path}" '
            f"offset={head.count(chr(10)) + 2} limit=200  (the file is the complete "
            f"summary; raise/lower offset to page through it)."
        )
    else:
        footer_lines.append("Full output could not be stored to disk; the head+tail above is all that was preserved.")
    footer_lines.append("─" * 37)
    return head + "\n\n[... middle omitted — see footer ...]\n\n" + tail + "\n".join(footer_lines), spill_path

def _parent_prompt_size_tokens(parent_agent) -> Optional[int]:
    """The parent's current prompt size: the aggregator's own last ``prompt_tokens`` (pre-MoA-fold), else
    the last provider usage. ``None`` when no request has completed yet: the caller then applies the
    static ceiling only. Treating "no usage yet" as zero handed a 190K/200K parent a 384K-char budget."""
    size = getattr(parent_agent, "_last_prompt_size_tokens", None)
    if isinstance(size, (int, float)) and size > 0:
        return int(size)
    last_usage = getattr(parent_agent, "_last_turn_usage", None) or {}
    used = last_usage.get("prompt_tokens") if isinstance(last_usage, dict) else None
    if isinstance(used, (int, float)) and used > 0:
        return int(used)
    return None


def _parent_summary_char_budget(parent_agent, n_summaries: int) -> Optional[int]:
    """Per-summary char budget from the parent's *remaining* context headroom (context length − the parent's
    current prompt size − the compressor's output reserve), a fraction of it split across the batch at ~4
    chars/token. None when the parent's context state is unknown — caller then uses the static ceiling only.

    "Current prompt size" is the last API call's ``prompt_tokens`` (``_last_turn_usage``), never
    ``session_prompt_tokens``: that field is the running SUM over every call in the session, so after a
    few hundred calls it exceeds any context window and every summary collapsed to the 2,000-char floor
    (measured: all 1,393 child summaries in one run, each spilled to disk, the orchestrator working from
    the stub)."""
    try:
        compressor = getattr(parent_agent, "context_compressor", None)
        context_length = getattr(compressor, "context_length", None)
        if not isinstance(context_length, int) or context_length <= 0:
            return None
        used_tokens = _parent_prompt_size_tokens(parent_agent)
        if used_tokens is None:
            return None  # no usage yet and nothing to estimate from: static ceiling only, never "zero context"
        headroom_tokens = context_length - int(used_tokens) - int(getattr(compressor, "max_tokens", 0) or 0)
        if headroom_tokens <= 0:
            return _MIN_SUMMARY_CHARS  # parent already over budget: floor only
        per_summary_tokens = int(headroom_tokens * _SUMMARY_HEADROOM_FRACTION) // max(1, n_summaries)
        return max(_MIN_SUMMARY_CHARS, per_summary_tokens * 4)
    except Exception:
        logger.debug("Summary budget computation failed", exc_info=True)
        return None

def _apply_summary_budget(results: List[Dict[str, Any]], parent_agent) -> None:
    """Trim subagent summaries in-place so a batch can't overflow the parent's context window (full text spilled to
    disk). Per-summary cap = MIN(dynamic headroom budget, static ``delegation.max_summary_chars`` ceiling; 0 =
    disabled); over-cap summaries become head+tail plus a pointer to the spill file."""
    from tools.delegate_tool import _load_config
    summaries = [r for r in results if isinstance(r, dict) and isinstance(r.get("summary"), str) and r["summary"]]
    if not summaries:
        return
    try:
        static_ceiling = int(_load_config().get("max_summary_chars", DEFAULT_MAX_SUMMARY_CHARS))
    except (TypeError, ValueError):
        static_ceiling = DEFAULT_MAX_SUMMARY_CHARS
    candidates = [c for c in (static_ceiling, _parent_summary_char_budget(parent_agent, len(summaries))) if c and c > 0]
    if not candidates:
        return  # both disabled / unknown → leave summaries untouched
    cap = min(candidates)
    for entry in summaries:
        summary = entry["summary"]
        if len(summary) <= cap:
            continue
        model_text, spill_path = _trim_summary_with_footer(summary, cap, entry.get("task_index", -1))
        entry["summary"] = model_text
        entry["summary_truncated"] = True
        if spill_path:
            entry["summary_full_path"] = spill_path
        logger.debug(
            "[subagent-%s] summary trimmed %d → ~%d chars (spill=%s)", entry.get("task_index", "?"), len(summary), cap,
            spill_path or "none",
        )

_PARENT_FINALIZATION_LOCK_GUARD = threading.Lock()
_PARENT_FINALIZATION_FALLBACK_LOCK = threading.RLock()
_CHILD_CONSTRUCTION_LOCK = threading.RLock()

def _build_child_preserving_parent_tools(**kwargs):
    """Build a child without leaking its resolved toolset into the parent."""
    from tools.delegate_tool import _build_child_agent
    import model_tools
    with _CHILD_CONSTRUCTION_LOCK:
        parent_tool_names = list(model_tools._last_resolved_tool_names)
        try:
            child = _build_child_agent(**kwargs)
        finally:
            model_tools._last_resolved_tool_names = parent_tool_names
    child._delegate_saved_tool_names = parent_tool_names
    return child

def _parent_finalization_lock(parent_agent) -> threading.RLock:
    """Per-parent lock serializing lifecycle side effects (created once under the guard)."""
    if parent_agent is None:
        return _PARENT_FINALIZATION_FALLBACK_LOCK
    lock = getattr(parent_agent, "_subagent_finalization_lock", None)
    if lock is not None:
        return lock
    with _PARENT_FINALIZATION_LOCK_GUARD:
        lock = getattr(parent_agent, "_subagent_finalization_lock", None)
        if lock is None:
            lock = threading.RLock()
            try:
                setattr(parent_agent, "_subagent_finalization_lock", lock)
            except Exception:
                return _PARENT_FINALIZATION_FALLBACK_LOCK
    return lock

def _notify_memory_manager(results, task_list, child_by_index, parent_agent) -> None:
    memory = getattr(parent_agent, "_memory_manager", None) if parent_agent else None
    if not memory:
        return
    for entry in results:
        try:
            task_index = entry.get("task_index", -1)
            in_range = isinstance(task_index, int) and 0 <= task_index < len(task_list)
            memory.on_delegation(
                task=task_list[task_index]["goal"] if in_range else "", result=entry.get("summary", "") or "",
                child_session_id=getattr(child_by_index.get(task_index), "session_id", ""),
            )
        except Exception:
            pass

def _fire_subagent_stop_hooks(results, child_by_index, parent_agent) -> float:
    """Pop the model-hidden ``_child_role`` / ``_child_cost_usd`` fields from every
    entry, fire ``subagent_stop`` per child, and return the summed child cost."""
    try:
        from hermes_cli.plugins import invoke_hook as invoke_hook
    except Exception:
        invoke_hook = None
    children_cost_total = 0.0
    for entry in results:
        child_role = entry.pop("_child_role", None)
        child_cost = entry.pop("_child_cost_usd", 0.0)
        try:
            if child_cost:
                children_cost_total += float(child_cost)
        except (TypeError, ValueError):
            pass
        if invoke_hook is None:
            continue
        try:
            child = child_by_index.get(entry.get("task_index", -1))
            invoke_hook(
                "subagent_stop", parent_session_id=getattr(parent_agent, "session_id", None),
                parent_turn_id=getattr(parent_agent, "_current_turn_id", "") or "",
                child_session_id=getattr(child, "session_id", None), child_role=child_role,
                child_summary=entry.get("summary"), child_status=entry.get("status"),
                tool_call_history=_subagent_stop_tool_call_history(entry.get("tool_trace")),
                duration_ms=int((entry.get("duration_seconds") or 0) * 1000),
            )
        except Exception:
            logger.debug("subagent_stop hook invocation failed", exc_info=True)
    return children_cost_total

def _rollup_children_cost(parent_agent, children_cost_total: float) -> None:
    """Fold the children's spend into the parent's session cost (source/status
    only set when the parent had none of its own)."""
    if children_cost_total <= 0.0:
        return
    try:
        current = float(getattr(parent_agent, "session_estimated_cost_usd", 0.0) or 0.0)
        parent_agent.session_estimated_cost_usd = current + children_cost_total
        if getattr(parent_agent, "session_cost_source", "none") in {None, "", "none"}:
            parent_agent.session_cost_source = "subagent"
        if getattr(parent_agent, "session_cost_status", "unknown") in {None, "", "unknown"}:
            parent_agent.session_cost_status = "estimated"
    except Exception:
        logger.debug("Subagent cost rollup failed", exc_info=True)

def _finalize_child_results(
    results: List[Dict[str, Any]], task_list: List[Dict[str, Any]], children: List[tuple[int, Dict[str, Any], Any]],
    parent_agent,
) -> None:
    """Apply host-owned summary, memory, hook, and cost contracts once."""
    with _parent_finalization_lock(parent_agent):
        _apply_summary_budget(results, parent_agent)
        child_by_index = {index: child for index, _task, child in children}
        _notify_memory_manager(results, task_list, child_by_index, parent_agent)
        _rollup_children_cost(parent_agent, _fire_subagent_stop_hooks(results, child_by_index, parent_agent))

def _run_child_lifecycle(task_index: int, goal: str, child=None, parent_agent=None) -> Dict[str, Any]:
    """Run one child and apply the same host lifecycle used by delegate_task."""
    from tools.delegate_tool import _run_single_child
    result = _run_single_child(task_index, goal, child, parent_agent)
    result.setdefault("task_index", task_index)
    task = {"goal": goal}
    _finalize_child_results([result], [{"goal": ""} for _ in range(task_index)] + [task], [(task_index, task, child)], parent_agent)
    return result
