"""Build LLM context lists for agent turns.

Extracted from agent.py to reduce the Agent class surface area.
"""

from pathlib import Path
from typing import TYPE_CHECKING

import structlog

from navi.llm.base import Message

import navi.config as _config

log = structlog.get_logger()


# Roles whose content may be head/tail-truncated when a single message is too
# large for the context window. User messages carry intent and are never
# truncated; system/summary messages are already compact.
_TRUNCATABLE_ROLES = {"tool", "assistant"}


def _truncate_content(content: str, budget_tokens: int) -> str:
    """Head/tail-truncate ``content`` to roughly ``budget_tokens`` tokens.

    Keeps the head and tail verbatim with a marker naming the elided middle, so
    the model retains the beginning and end of a large tool result (usually the
    most informative parts) without the whole thing blowing the context window.
    """
    budget_chars = budget_tokens * 3
    if len(content) <= budget_chars:
        return content
    half = budget_chars // 2
    dropped_tokens = (len(content) - 2 * half) // 3
    marker = f"\n[…truncated ~{dropped_tokens:,} tokens…]\n"
    return content[:half] + marker + content[-half:]


def _fact_value_is_abs_path_outside(value: str, cwd: str) -> bool:
    """True when a fact's value is an absolute filesystem path outside `cwd`.

    Used to keep context-dependent path facts (e.g. "project_root → /home/.../navi-1")
    from being injected into sessions running in a different project, where they
    would misdirect the agent. Relative values are left through (we cannot know
    what they were recorded relative to). Non-path values always pass.
    """
    v = str(value).strip()
    if not v or not (v.startswith("/") or v.startswith("~")):
        return False
    try:
        p = Path(v).expanduser().resolve()
        base = Path(cwd).expanduser().resolve()
    except Exception:
        return True  # unresolvable path — treat as out-of-scope, drop it
    try:
        p.relative_to(base)
        return False  # within the session cwd — keep
    except ValueError:
        return True  # absolute path outside the cwd — drop

if TYPE_CHECKING:
    from navi.context_providers._loader import ContextProviderRegistry
    from navi.memory.store import MemoryStore
    from navi.profiles.base import AgentProfile


async def render_todo_lines(session_id: str) -> list[str]:
    """Return a list of formatted todo lines for goal anchoring."""
    try:
        from navi.tools.todo import render_todo_lines as _rtl

        return await _rtl(session_id)
    except Exception:
        return []


class ContextBuilder:
    """Handles construction of the full LLM context for each turn."""

    def __init__(
        self,
        profile_registry,
        memory_store: "MemoryStore | None" = None,
        cp_registry: "ContextProviderRegistry | None" = None,
        mcp_manager=None,
    ) -> None:
        self._profiles = profile_registry
        self._memory = memory_store
        self._cp_registry = cp_registry
        self._mcp_manager = mcp_manager
        self._system_prompt_cache: dict[str, str] = {}

    def build_system_prompt(self, profile: "AgentProfile") -> str:
        """Build the system prompt string for a profile (cached per profile)."""
        cached = self._system_prompt_cache.get(profile.id)
        if cached is not None:
            return cached

        parts: list[str] = []
        persona = _config.settings.navi_persona.strip()
        if persona:
            parts.append(persona)
        parts.append(profile.system_prompt)

        other = [p for p in self._profiles.all() if p.id != profile.id]
        if other:
            lines = [
                "## Available profiles",
                f"Current: **{profile.id}**",
            ]
            for p in other:
                desc = p.short_description or p.description
                lines.append(f"· {p.id}: {desc}")
            lines.append(
                "→ Switch profiles on your own judgment — do not ask for permission. "
                "When a task clearly fits another profile, call switch_profile immediately, "
                "then inform the user which profile is now active and why. "
                "Use list_profiles if you need details about a profile's capabilities."
            )
            parts.append("\n".join(lines))

        result = "\n\n---\n\n".join(parts)
        self._system_prompt_cache[profile.id] = result
        return result

    def invalidate_system_prompt_cache(self, profile_id: str | None = None) -> None:
        if profile_id is None:
            self._system_prompt_cache.clear()
        else:
            self._system_prompt_cache.pop(profile_id, None)

    async def _memory_msg(self, user_id: str | None = None) -> "Message | None":
        if self._memory is None:
            return None
        summary = await self._memory.get_summary(user_id=user_id)
        if not summary:
            return None
        return Message(
            role="system",
            content=f"## What I remember about the user\n\n{summary}",
        )

    async def _memory_facts_msg(
        self,
        user_message: str,
        user_id: str | None = None,
        injected_ids: set[str] | None = None,
        scope_boundary_enabled: bool = False,
        session_cwd: str | None = None,
    ) -> "Message | None":
        if self._memory is None:
            log.info("memory_facts_msg.skip", reason="no_memory_store")
            return None
        msg = user_message.strip()
        if len(msg) <= 20:
            log.info("memory_facts_msg.skip", reason="too_short", length=len(msg))
            return None
        words = [w for w in msg.split() if any(c.isalpha() for c in w)]
        if len(words) < 2:
            log.info("memory_facts_msg.skip", reason="too_few_words", words=len(words))
            return None
        if len(msg) < 50:
            limit = 1
        elif len(msg) <= 150:
            limit = 2
        else:
            limit = 3
        log.info("memory_facts_msg.search", query=msg[:60], limit=limit, user_id=user_id)
        facts = await self._memory.search_facts(msg, user_id=user_id, limit=limit)
        log.info("memory_facts_msg.hits", count=len(facts) if facts else 0)
        if not facts:
            return None
        if injected_ids is not None:
            new_facts = [f for f in facts if f.get("id") not in injected_ids]
            skipped = len(facts) - len(new_facts)
            log.info("memory_facts_msg.dedup", new=len(new_facts), skipped=skipped)
            if not new_facts:
                return None
            for f in new_facts:
                injected_ids.add(f["id"])
        else:
            new_facts = facts

        # Bounded autonomy: drop facts whose value is an absolute path outside the
        # session cwd (e.g. "project_root → /home/.../navi-1" while the user works
        # in another project) so they cannot misdirect the agent. Facts are kept
        # when working inside that path (then they are correct). Gated by the
        # profile flag so free-flight stays reproducible by toggling it off.
        if scope_boundary_enabled and session_cwd:
            kept = [f for f in new_facts if not _fact_value_is_abs_path_outside(str(f.get("value", "")), session_cwd)]
            dropped = len(new_facts) - len(kept)
            if dropped:
                log.info("memory_facts_msg.scope_filtered", dropped=dropped, session_cwd=session_cwd)
            new_facts = kept
            if not new_facts:
                return None

        lines = ["Known facts about the user:"]
        for f in new_facts:
            conf = f.get("confidence", 70)
            lines.append(f"• [{f['category']}] {f['key']} → {f['value']} (confidence: {conf}%)")
        result = "\n".join(lines)
        log.info("memory_facts_msg.injected", lines=len(new_facts), length=len(result))
        return Message(role="system", content=result)

    async def _collect_context_injections(self, profile: "AgentProfile") -> list[Message]:
        if self._cp_registry is None:
            return []
        out: list[Message] = []
        for provider in self._cp_registry.all():
            if not provider.global_provider and provider.name not in profile.context_providers:
                continue
            try:
                text = await provider.get_context()
                if text:
                    out.append(Message(role="system", content=text))
            except Exception:
                pass
        return out

    async def _build_goal_anchor(self, session_id: str, user_message: str) -> Message:
        lines = [
            "[Goal anchor]",
            f"Original request: {user_message}",
        ]
        todo_lines = await render_todo_lines(session_id)
        if todo_lines:
            lines.append("Current todo:")
            lines.extend(todo_lines)
        lines.append("Stay on track — complete the remaining pending/in_progress steps.")
        lines.append(
            "Use 1-based todo indexes. Mark completed steps done only after verification, with validation."
        )
        lines.append(
            "Before final response, update todo for every completed step, including the final one."
        )
        return Message(role="system", content="\n".join(lines))

    def _scope_boundary_msg(self, profile: "AgentProfile") -> Message | None:
        """Bounded-autonomy rule: keep the agent within the requested scope.

        Returns None when the profile has not opted in (legacy free-flight behavior).
        """
        if not profile.scope_boundary_enabled:
            return None
        return Message(
            role="system",
            content=(
                "[Scope boundary]\n"
                "Act strictly within the user's literally requested scope. Do NOT expand to "
                "sibling or parent directories/projects beyond what was asked, and do NOT start "
                "executing discovered TODO lists, roadmaps, milestones, or backlog docs unless "
                "the user explicitly asks you to. If you encounter such docs while exploring, "
                "report what you found — do not act on it. Autonomy means finishing the requested "
                "task well, not enlarging it."
            ),
        )

    def _security_policy_msg(self) -> Message | None:
        """Build a dynamic security policy system message based on user role."""
        from navi.tools._internal.base import (
            current_user_id as _uid_var,
            current_user_role as _role_var,
        )

        user_id = _uid_var.get(None)
        role = _role_var.get()
        if role == "admin":
            return Message(
                role="system",
                content=(
                    "[Security policy]\n"
                    "Role: admin\n"
                    "Full system access. No restrictions apply.\n"
                    "You may use any tool, access any path, and execute any command."
                ),
            )
        if user_id:
            allowed = _config.settings.terminal_user_allowed_commands_list
            return Message(
                role="system",
                content=(
                    "[Security policy]\n"
                    f"Role: user (user_id={user_id})\n"
                    f"Filesystem sandbox: user_data/{user_id}/\n"
                    "You MUST NOT attempt to access paths outside your sandbox.\n"
                    f"Terminal allowed commands: {', '.join(allowed)}\n"
                    "You MUST NOT use terminal for: curl, wget, ssh, scp, sudo, system-wide destructive operations, "
                    "or any command not in the allowlist.\n"
                    "If a task requires admin privileges (e.g. system-wide changes, accessing another user's files, "
                    "installing packages globally), tell the user to contact an admin.\n"
                    "Always prefer filesystem tool over terminal for file operations."
                ),
            )
        # Legacy / single-user mode — no policy injected
        return None

    def _user_context_msg(self) -> Message | None:
        """Build a [User context] system message from current_user_info ContextVar."""
        from navi.tools._internal.base import (
            current_user_info as _uinfo_var,
            current_user_role as _role_var,
        )

        info = _uinfo_var.get(None)
        if not info:
            return None
        lines = ["[User context]"]
        if info.get("display_name"):
            lines.append(f"Name: {info['display_name']}")
        if info.get("username"):
            lines.append(f"Username: {info['username']}")
        if info.get("first_name") or info.get("last_name"):
            parts = []
            if info.get("first_name"):
                parts.append(info["first_name"])
            if info.get("last_name"):
                parts.append(info["last_name"])
            lines.append(f"Full name: {' '.join(parts)}")
        if info.get("email"):
            lines.append(f"Email: {info['email']}")
        if info.get("phone"):
            lines.append(f"Phone: {info['phone']}")
        if info.get("birth_date"):
            lines.append(f"Birth date: {info['birth_date']}")
        if info.get("country") or info.get("city"):
            parts = []
            if info.get("city"):
                parts.append(info["city"])
            if info.get("country"):
                parts.append(info["country"])
            lines.append(f"Location: {', '.join(parts)}")
        if info.get("locale"):
            lines.append(f"Locale: {info['locale']}")
        lines.append(f"Role: {_role_var.get()}")
        return Message(role="system", content="\n".join(lines))

    def _mcp_context_msg(self, profile: "AgentProfile | None" = None) -> "Message | None":
        """Build a system message with MCP server instructions.

        Combines server-provided instructions (from MCP initialize handshake)
        with overlay instructions from ``mcp_servers.d/*.json``.
        """
        if not self._mcp_manager:
            return None
        if profile is not None and not profile.get_agent_tools().mcp:
            return None
        server_names = set(profile.get_agent_tools().mcp.keys()) if profile is not None else None
        instructions = self._mcp_manager.get_instructions(server_names)
        if not instructions:
            return None
        lines = ["[MCP servers — external knowledge sources]"]
        for name, text in instructions.items():
            lines.append("")
            lines.append(f"## {name}")
            lines.append(text)
        return Message(role="system", content="\n".join(lines))

    def _truncate_oversized(self, conv: list[Message]) -> list[Message]:
        """Head/tail-truncate oversized tool/assistant messages for the LLM view.

        A single huge tool result (e.g. reading a large file) can alone exceed
        the context window, and the turn/message-count-based compressor cannot
        shrink a single message. This per-build pass caps each truncatable
        message at ``context_message_token_budget`` tokens (0 = num_ctx // 6),
        keeping head + tail so the model still sees the start and end of the
        result. Copies are returned — stored history (session.messages) is never
        mutated, so the user still sees full content and reloads are unaffected.
        """
        budget = _config.settings.context_message_token_budget
        if budget <= 0:
            budget = max(1, _config.settings.ollama_num_ctx // 6)
        cap_chars = budget * 3
        out: list[Message] = []
        for m in conv:
            if m.role in _TRUNCATABLE_ROLES and len(m.content or "") > cap_chars:
                out.append(m.model_copy(update={"content": _truncate_content(m.content or "", budget)}))
            else:
                out.append(m)
        return out

    def build(
        self,
        session_context: list[Message],
        profile: "AgentProfile",
        mem: "Message | None",
        iteration: int | None = None,
        max_iterations: int | None = None,
        extra_system: list[Message] | None = None,
        session_id: str | None = None,
        session_metadata: dict | None = None,
    ) -> list[Message]:
        system_prompt = self.build_system_prompt(profile)
        if session_id:
            system_prompt += (
                f"\n\n---\n\n"
                f"[Session context]\n"
                f"Session ID: {session_id}\n"
                f"Session files directory: {_config.settings.session_files_dir}/{session_id}/\n"
                f"When writing files the user should see, always use the session directory path above.\n"
                f"When calling MCP navi-3d tools (compile_scad, render_stl, lint_scad), pass ONLY the filename "
                f"(e.g. 'falcon9_rocket.scad') for source_path and output_path. Do NOT include the session_id or "
                f"the session_files directory in those paths — the MCP server resolves them automatically."
            )
        cwd = (session_metadata or {}).get("cwd")
        if cwd:
            system_prompt += (
                f"\n\n---\n\n"
                f"[Working directory]\n"
                f"The user launched Navi Code from: {cwd}\n"
                f"Treat this directory as the project root. Resolve relative paths against it "
                f"unless the user provides an absolute path. For filesystem/terminal/code_exec operations, "
                f"prefer this directory when no explicit working_dir is given."
            )
        system_msg = Message(role="system", content=system_prompt)
        conv = [m for m in session_context if m.role != "system"]
        conv = self._truncate_oversized(conv)
        result: list[Message] = [system_msg]
        if mem:
            result.append(mem)

        # Inject user profile context for multi-user mode
        user_ctx = self._user_context_msg()
        if user_ctx:
            result.append(user_ctx)

        # Inject security policy for multi-user mode
        policy = self._security_policy_msg()
        if policy:
            result.append(policy)

        # Inject bounded-autonomy scope boundary when the profile opts in
        boundary = self._scope_boundary_msg(profile)
        if boundary:
            result.append(boundary)

        # Inject MCP server instructions into context
        mcp_msg = self._mcp_context_msg(profile)
        if mcp_msg:
            result.append(mcp_msg)

        if extra_system:
            result.extend(extra_system)
        result.extend(conv)

        if (
            profile.iteration_budget_enabled
            and iteration is not None
            and max_iterations is not None
        ):
            remaining_after_this = max_iterations - iteration - 1
            if remaining_after_this <= 2:
                urgency = (
                    f" CRITICAL: only {remaining_after_this} iteration(s) left after this one — finish or produce "
                    "a partial result now, do not start new subtasks."
                )
            elif remaining_after_this <= 5:
                urgency = (
                    " Low iteration budget: complete the current step, continue necessary "
                    "verification/publishing, and avoid starting unrelated subtasks."
                )
            else:
                urgency = ""
            result.append(
                Message(
                    role="system",
                    content=(
                        f"[Iteration {iteration + 1}/{max_iterations} — "
                        f"{remaining_after_this} iteration(s) after this one.{urgency}]"
                    ),
                )
            )

        return result
