# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
PromptBuilder — extracted from agent.py (PR 3).

Builds the system prompt with constitutional personality, user context,
tool guide, skill docs, and token counting. Also handles episodic,
RAG, mesh, and planning context injection.
"""

import logging
import platform
from datetime import datetime, timezone
from typing import Optional

from . import defaults
from .context import get_token_counter
from .episodic_memory import (
    RetrievalStrategy as EpisodicRetrievalStrategy,
)
from .security import SecureSession

try:
    from .providers import OllamaProvider as _OllamaProvider
except ImportError:
    _OllamaProvider = None

# Phase 1: Multi-user support
try:
    from .users import UserContext

    HAS_USER_MANAGEMENT = True
except ImportError:
    HAS_USER_MANAGEMENT = False
    UserContext = None

logger = logging.getLogger(__name__)


class PromptBuilder:
    """Builds system prompts with constitutional personality and context injection."""

    def __init__(self, agent_ref):
        self._agent = agent_ref

    def build_system_prompt(
        self,
        session: Optional[SecureSession] = None,
        user_context: Optional["UserContext"] = None,
        current_message: Optional[str] = None,
        channel: str = "default",
        is_proactive: bool = False,
        routed_skills: Optional[set] = None,
        job_class_key: Optional[str] = None,
    ) -> str:
        """
        Build the system prompt as a single string.

        For prompt-caching support, prefer build_system_prompt_parts() which
        returns (static, dynamic) so providers can set cache breakpoints.
        """
        static, dynamic = self.build_system_prompt_parts(
            session, user_context, current_message, channel, is_proactive, routed_skills,
            job_class_key=job_class_key,
        )
        if dynamic:
            return static + "\n\n" + dynamic
        return static

    def build_system_prompt_parts(
        self,
        session: Optional[SecureSession] = None,
        user_context: Optional["UserContext"] = None,
        current_message: Optional[str] = None,
        channel: str = "default",
        is_proactive: bool = False,
        routed_skills: Optional[set] = None,
        job_class_key: Optional[str] = None,
    ) -> tuple:
        """
        Build the system prompt split into (static, dynamic) parts.

        Static: Constitutional identity, job class, tool guide, error recovery,
        skill docs — stable within a session, ideal for prompt caching.

        Dynamic: User context (budget), memory context (query-dependent),
        session context (triage, docs) — changes per turn.

        Returns:
            (static_prompt: str, dynamic_prompt: str)
        """
        agent = self._agent
        static_parts = []
        dynamic_parts = []

        # Determine user_id for relationship tracking
        user_id = "default"
        if user_context and HAS_USER_MANAGEMENT:
            user_id = user_context.user.email or user_context.user.id or "default"
        elif session:
            user_id = getattr(session, "user_id", "default")

        # Constitutional identity (Phase 1)
        try:
            from .constitution import (
                InteractionTracker,
                get_constitutional_prompt,
            )

            if not hasattr(agent, "_interaction_tracker"):
                agent._interaction_tracker = InteractionTracker()

            agent._interaction_tracker.record_interaction(user_id)

            # Species voice directive (Personality Triangle — Species layer)
            _species_voice = ""
            try:
                from ..creature.species import get_species_voice
                from ..creature.state import load_creature

                _creature = load_creature()
                if _creature:
                    _species_voice = get_species_voice(_creature.species)
            except Exception:
                pass  # creature module optional

            constitutional_prompt = get_constitutional_prompt(
                agent_name=agent.config.agent.name,
                user_id=user_id,
                channel=channel,
                provider_name=agent.provider.name,
                is_proactive=is_proactive,
                persona_overlay=getattr(agent, "_persona_overlay", ""),
                species_voice=_species_voice,
                interaction_tracker=agent._interaction_tracker,
            )
            static_parts.append(constitutional_prompt)

            # Job class role injection — STATIC
            try:
                from ..jobs import get_active_job_class, get_job_class

                _job_class = get_job_class(job_class_key) if job_class_key else get_active_job_class()
                if _job_class and _job_class.prompt_fragment:
                    _jc_prompt = _job_class.prompt_fragment
                    if _creature and _job_class.species_flavor.get(_creature.species):
                        _jc_prompt += f"\n{_job_class.species_flavor[_creature.species]}"
                    static_parts.append(f"<job_class>\n{_jc_prompt}\n</job_class>")
            except ImportError:
                pass  # jobs module optional

        except ImportError:
            logger.debug("Constitution module not available, using legacy prompt")
            static_parts.append(f"""You are {agent.config.agent.name}, {agent.config.agent.persona}.

Current time: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
System: {platform.node()} ({platform.system()})
Model: {agent.provider.name}

You have access to tools that let you interact with the local system, manage files,
run commands, and more. Use them when needed to actually accomplish tasks.

Be concise and helpful. When asked to do something, do it - don't just describe how.""")

        # Phase 1: User context — DYNAMIC (budget changes per turn)
        if user_context and HAS_USER_MANAGEMENT:
            user = user_context.user
            dynamic_parts.append(f"""<user_context>
User: {user.display_name}
Email: {user.email}
Role: {user.role.value.upper()}
Can Write: {"Yes" if user_context.can_write else "No (read-only)"}

Note: Respect user's role permissions. Read-only users cannot make changes.
</user_context>""")

        # Legacy security context — DYNAMIC (budget changes)
        elif session:
            caps = [c.value for c in session.capabilities]
            dynamic_parts.append(f"""<user_context>
Trust Level: {session.trust_level.value.upper()}
Capabilities: {len(caps)} granted
Daily Budget: ${session.remaining_budget:.2f} remaining

Note: Only use tools the user has capability for. If they lack permission,
explain what they need to unlock it (more interactions = more trust).
</user_context>""")

        # Tool usage guidance — STATIC
        static_parts.append("""<tool_guide>
- **Web searches**: You HAVE web search capability via the `web_search` tool. ALWAYS use it when the user asks about current events, news, prices, citations, sources, or any factual question you're unsure about. Never say "I don't have web access" or "I can't search the web" — you CAN and MUST use `web_search`. Do NOT use browser tools for general searches.
- **Browser tools** (`browser_navigate`, etc.): Only for interacting with a specific known URL — filling forms, clicking buttons, reading a page you already navigated to.
- **web_read** / **web_summarize**: For fetching and extracting content from a specific URL after finding it via web_search.
- **Notes vs Memory vs Knowledge**: `joplin_*` = user's personal notes app. `remember`/`recall` = agent's own memory. `search_knowledge` = ingested documents. `search_knowledge_base` = saved snippets. Four different systems — do not confuse them.
- **Files — local vs cloud**: `nextcloud_files_*` = cloud/WebDAV server. `read_document`/`write_file` = local disk. "my files" / "my cloud" → Nextcloud. Local path → local tools.
- **Calendar — Google vs CalDAV**: `calendar_*` = Google Calendar. `nextcloud_calendar_*` = CalDAV/self-hosted. If both are configured, ask which calendar the user means.
- **Smart home**: `ha_*` for lights, switches, sensors, temperature, locks, covers, climate, automations via Home Assistant.
- **Media server**: `jf_*` for movies, shows, music, media library on Jellyfin. NOT for streaming services or local media files.
- **Self-hosted repos**: `gitea_*` for Gitea/Forgejo repos, issues, and PRs. `run_shell` + git for local repos. NOT for GitHub or GitLab.
- **DNS / ad blocking**: `pihole_*` for DNS query logs, blocked domains, and blocking statistics via Pi-hole or AdGuard Home.
- **VPN / Proton**: `proton_*` for Proton VPN control and Bridge health. NOT for sending email (use email tools) or DNS blocking (use pihole tools).
- **Service setup / connecting services**: When the user asks how to set up, connect, or configure a service (Vaultwarden, Gitea, Mealie, etc.), use `check_setup_status` and `auto_configure_service` — do NOT web search for setup guides. Familiar has built-in provisioning and connect wizards for all supported services. The dashboard Settings → Connections panel handles the full setup flow.

Examples:
- "Search for how to make sourdough bread" → use `web_search` (general information lookup)
- "How do I set up Vaultwarden?" → use `check_setup_status` (service setup — built-in, not web search)
- "Turn off the living room lights" → use `ha_call_service` (smart home via Home Assistant)
- "What did Sarah email me about?" → use `recall` first (check memory), then `search_emails` if needed
</tool_guide>""")

        # Error recovery guidance — STATIC
        static_parts.append("""<error_recovery>
When a tool call fails:
- Permission errors: Tell the user. Do not retry — permissions cannot change mid-conversation.
- Not found errors: Try different search terms, check for typos, or ask the user to clarify.
- Validation errors: Read the error message, fix the parameters, and retry once.
- Connection/timeout errors: Tell the user the service is temporarily unavailable. Do not retry immediately.
- Rate limit errors: Tell the user. Do not retry.

General rules:
- Never retry the same tool call with identical parameters.
- After 2 failed attempts on the same tool, stop and explain the situation.
- If a tool returns an error, acknowledge it and adjust your approach.
- When multiple tools could accomplish a task, try an alternative if the first fails.
</error_recovery>""")

        # Memory context — DYNAMIC (query-dependent)
        if agent.memory:
            if current_message:
                relevant = agent.memory.get_relevant_context(query=current_message, max_results=10)
                if relevant:
                    dynamic_parts.append(relevant)

            global_ctx = agent.memory.get_context_string(max_entries=5)
            if global_ctx:
                dynamic_parts.append(global_ctx)

        # Phase 4: Session context injection — DYNAMIC
        if session and session.session_context:
            _ctx = session.session_context
            _ctx_lines = []
            _triage_emails = _ctx.get("triage_emails", [])
            _triage_at = _ctx.get("triage_at", "")
            if _triage_emails:
                import datetime as _dt4c

                _stale = False
                if _triage_at:
                    try:
                        _age = _dt4c.datetime.now(
                            _dt4c.timezone.utc
                        ) - _dt4c.datetime.fromisoformat(_triage_at)
                        _stale = _age.total_seconds() > 14400
                    except Exception as e:
                        logger.debug("Error suppressed: %s", e)
                if _stale:
                    _ctx_lines.append(
                        "Note: These triage results are older than 4 hours and may be outdated."
                    )
                else:
                    _ctx_lines.append("Recent triage email list (use #N index to reference):")
                    _pri_emoji = {"urgent": "\U0001f534", "action": "\U0001f7e1", "fyi": "\u26aa"}
                    for _em in _triage_emails[:50]:
                        _p = _pri_emoji.get(_em.get("priority", "fyi"), "\u26aa")
                        _sub = _em.get("subject", "")[:60]
                        _ctx_lines.append(
                            "  #"
                            + str(_em["index"])
                            + " "
                            + _p
                            + " "
                            + _em.get("from_name", "?")
                            + " <"
                            + _em.get("from_email", "")
                            + "> "
                            + chr(8212)
                            + " "
                            + _sub
                            + " ["
                            + _em.get("category", "")
                            + "]"
                            + " [id_source="
                            + _em.get("id_source", "")
                            + "]"
                        )
            if _ctx.get("last_doc_id"):
                _ctx_lines.append(
                    "Last created doc: "
                    + repr(_ctx.get("last_doc_title", ""))
                    + " (ID: "
                    + _ctx["last_doc_id"]
                    + ") "
                    "use this ID when the user says 'that doc'."
                )
            if _ctx.get("last_event_title"):
                _ctx_lines.append(
                    "Last created event: "
                    + repr(_ctx["last_event_title"])
                    + " reference when the user says 'that event'."
                )
            if _ctx_lines:
                dynamic_parts.append(
                    "<session_context>\n" + "\n".join(_ctx_lines) + "\n</session_context>"
                )

        # Skills documentation — STATIC (routed per message, but stable within session)
        _skill_doc_text = ""
        if agent.config.agent.skills_enabled:
            is_local_model = getattr(agent.provider, "name", "").lower() in ("ollama",)
            if is_local_model:
                # Local models get a compact skill summary to save context
                _skill_names = [
                    s.name for s in agent.skills.skills.values() if s.enabled
                ]
                if _skill_names:
                    static_parts.append(
                        "<available_skills>\n"
                        "You have these skills available. Use the matching tools "
                        "instead of web_search for these tasks:\n"
                        + ", ".join(sorted(_skill_names))
                        + "\n\nKey: For service setup/configuration, use "
                        "`check_setup_status` and `auto_configure_service` tools."
                        "\n</available_skills>"
                    )
            else:
                _skill_doc_text = agent.skills.get_skill_summaries(skill_names=routed_skills)
                if _skill_doc_text:
                    static_parts.append(
                        "<available_skills>\n"
                        + _skill_doc_text
                        + "\n\nFor detailed instructions on any skill, "
                        "call the `get_skill_docs` tool with the skill name.\n"
                        "</available_skills>"
                    )

        # Credential warnings — DYNAMIC (changes when services need attention)
        _cred_warnings = self._get_credential_warnings()
        if _cred_warnings:
            dynamic_parts.append(_cred_warnings)

        static = "\n\n".join(static_parts)
        dynamic = "\n\n".join(dynamic_parts)

        try:
            _tc = get_token_counter()
            _total = _tc.count(static) + _tc.count(dynamic)
            _skill = _tc.count(_skill_doc_text) if _skill_doc_text else 0
            _static_tokens = _tc.count(static)
            logger.info(
                "System prompt: %d tokens total (%d static/cacheable, %d dynamic), "
                "%d skill doc tokens",
                _total, _static_tokens, _total - _static_tokens, _skill,
            )

            # Warn if system prompt consumes >50% of the provider's context window
            try:
                from .providers import get_context_window

                _ctx_window = get_context_window(agent.provider.model_name)
                if _total > _ctx_window * 0.5:
                    logger.warning(
                        "System prompt (%d tokens) exceeds 50%% of %s context window (%d tokens)",
                        _total,
                        agent.provider.model_name,
                        _ctx_window,
                    )
            except Exception:
                pass  # provider may not expose model_name yet
        except Exception as exc:
            logger.debug("Token counting unavailable: %s", exc)

        return static, dynamic

    def inject_episodic_context(self, system: str, message: str, user_id: str) -> str:
        """Inject past conversation context from episodic memory."""
        agent = self._agent

        _is_local_provider = _OllamaProvider is not None and isinstance(
            agent.provider, _OllamaProvider
        )
        if _is_local_provider:
            return system

        _episodic_ms = agent._memory_manager.get_episodic_memory(user_id)
        if _episodic_ms is None:
            return system

        _max_episodic = getattr(agent.config, "context", None)
        _max_episodic = (
            getattr(_max_episodic, "max_episodic_tokens", defaults.EPISODIC_DEFAULT_MAX_TOKENS)
            if _max_episodic else defaults.EPISODIC_DEFAULT_MAX_TOKENS
        )

        try:
            _ep_results = _episodic_ms.retrieve(
                query=message,
                top_k=defaults.EPISODIC_TOP_K,
                strategy=EpisodicRetrievalStrategy.COMBINED,
                min_strength=defaults.EPISODIC_MIN_STRENGTH,
            )
            _wm_ctx = _episodic_ms.working_memory.get_context_string(max_tokens=_max_episodic)
            _wm_ids = {m.id for m in _episodic_ms.working_memory}
            _extra_lines = [f"- {m.content[:200]}" for m in _ep_results if m.id not in _wm_ids]
            _extra_ctx = "\n".join(_extra_lines)

            _ep_block_parts = []
            if _wm_ctx.strip():
                _ep_block_parts.append(_wm_ctx)
            if _extra_ctx.strip():
                _ep_block_parts.append(_extra_ctx)

            if _ep_block_parts:
                _ep_block = "\n".join(_ep_block_parts)
                _ep_char_limit = _max_episodic * 4
                if len(_ep_block) > _ep_char_limit:
                    _ep_block = _ep_block[:_ep_char_limit] + "…"
                system = f"{system}\n\n<episodic_context>\n{_ep_block}\n</episodic_context>"
                logger.debug(
                    f"EpisodicMemory: injected {len(_ep_results)} memories for user {user_id}"
                )
        except Exception as e:
            logger.debug(f"EpisodicMemory retrieval skipped: {e}")

        return system

    def inject_markdown_memory_context(
        self, system: str, message: str, user_id: str
    ) -> str:
        """Inject relevant markdown memory snippets into the system prompt."""
        agent = self._agent

        _is_local_provider = _OllamaProvider is not None and isinstance(
            agent.provider, _OllamaProvider
        )
        if _is_local_provider:
            return system

        try:
            from familiar.core.markdown_memory import _get_store

            store = _get_store(user_id)
            results = store.search(message, limit=3)
            # Filter low-relevance results
            results = [(slug, score, preview) for slug, score, preview in results if score > 0.1]

            if results:
                parts = []
                char_budget = defaults.MARKDOWN_MEMORY_CHAR_BUDGET
                for slug, score, preview in results:
                    entry = f"- **{slug}**: {preview}"
                    if len("\n".join(parts) + entry) > char_budget:
                        break
                    parts.append(entry)

                if parts:
                    block = "\n".join(parts)
                    system = (
                        f"{system}\n\n<markdown_memories>\n{block}\n</markdown_memories>"
                    )
                    logger.debug(
                        "Markdown memories: injected %d topics for user %s",
                        len(parts),
                        user_id,
                    )
        except Exception as e:
            logger.debug("Markdown memory injection skipped: %s", e)

        return system

    def inject_memory_tree_context(self, system: str, user_id: str) -> str:
        """Inject curated working-tier facts from the MemoryTree.

        Working-tier memories are always included (not query-dependent)
        because they represent core curated facts about the user.
        """
        agent = self._agent

        _is_local_provider = _OllamaProvider is not None and isinstance(
            agent.provider, _OllamaProvider
        )
        if _is_local_provider:
            return system

        try:
            from familiar.core.memory_tree import get_memory_tree

            tree = get_memory_tree()
            entries = tree.list_by_tier("working", user_id=user_id, limit=50)
            if not entries:
                return system

            _max_tokens = getattr(agent.config, "context", None)
            _max_tokens = (
                getattr(
                    _max_tokens, "max_memory_tree_tokens",
                    defaults.MEMORY_TREE_DEFAULT_MAX_TOKENS,
                )
                if _max_tokens
                else defaults.MEMORY_TREE_DEFAULT_MAX_TOKENS
            )
            char_budget = _max_tokens * 4

            parts = []
            total_chars = 0
            for entry in entries:
                line = f"- **{entry.title}**: {entry.body[:200]}"
                if total_chars + len(line) > char_budget:
                    break
                parts.append(line)
                total_chars += len(line)

            if parts:
                block = "\n".join(parts)
                system = (
                    f"{system}\n\n<memory_tree_working>\n{block}\n</memory_tree_working>"
                )
                logger.debug(
                    "MemoryTree: injected %d working-tier facts for user %s",
                    len(parts),
                    user_id,
                )
        except Exception as e:
            logger.debug("MemoryTree injection skipped: %s", e)

        return system

    def inject_rag_context(self, system: str, message: str) -> str:
        """Inject retrieved document chunks from the RAG pipeline."""
        agent = self._agent

        _is_local_provider = _OllamaProvider is not None and isinstance(
            agent.provider, _OllamaProvider
        )
        if _is_local_provider:
            return system

        if agent.retriever.count > 0:
            try:
                _rag_ctx = agent._retrieve_rag_context(
                    message, max_tokens=defaults.RAG_MAX_TOKENS
                )
                if _rag_ctx:
                    system = f"{system}\n\n{_rag_ctx}"
                    logger.debug(f"RAG context injected: {agent.retriever.count} docs indexed")
            except Exception as e:
                logger.debug(f"RAG context skipped: {e}")

        return system

    def inject_mesh_context(self, system: str, message: str) -> str:
        """Inject cross-device memories from peer mesh nodes."""
        agent = self._agent

        _is_local_provider = _OllamaProvider is not None and isinstance(
            agent.provider, _OllamaProvider
        )
        if _is_local_provider:
            return system

        _max_mesh = getattr(agent.config, "context", None)
        _max_mesh = getattr(_max_mesh, "max_mesh_tokens", 800) if _max_mesh else 800

        if agent.mesh_gateway and agent.mesh_gateway._running:
            try:
                _mesh_ctx = agent.get_mesh_context(
                    message, timeout=defaults.MESH_CONTEXT_TIMEOUT
                )
                if _mesh_ctx.strip():
                    _tc = get_token_counter()
                    _mesh_ctx = _tc.truncate_to_limit(_mesh_ctx, _max_mesh)
                    system = f"{system}\n\n{_mesh_ctx}"
                    logger.debug("Mesh context injected into system prompt")
            except Exception as e:
                logger.debug(f"Mesh context skipped: {e}")

        return system

    def _get_credential_warnings(self) -> str:
        """Build a credential warnings block for services needing attention.

        Appended to the dynamic prompt so the LLM knows which services are
        broken and can proactively inform the user instead of attempting
        tool calls that will fail.
        """
        try:
            from .credential_health import get_attention_needed

            attention = get_attention_needed()
            if not attention:
                return ""

            lines = []
            for status in attention:
                err = status.last_error or "needs re-authorization"
                lines.append(f"- **{status.service_key}**: {err}")

            return (
                "<credential_warnings>\n"
                "The following integrations need the user's attention before "
                "their tools can be used. If the user asks you to use a tool "
                "that depends on one of these services, let them know the "
                "credential needs to be fixed in Settings > Credentials.\n\n"
                + "\n".join(lines)
                + "\n</credential_warnings>"
            )
        except Exception as e:
            logger.debug("Credential warnings skipped: %s", e)
            return ""

    def inject_planning_context(self, system: str, message: str, tool_schemas) -> str:
        """Inject planning context if the message warrants a plan."""
        agent = self._agent

        _max_planning = getattr(agent.config, "context", None)
        _max_planning = (
            getattr(_max_planning, "max_planning_tokens", 1000) if _max_planning else 1000
        )

        if agent.planner and agent.planner.should_plan(message, tool_schemas):
            memory_ctx = ""
            if agent.memory:
                memory_ctx = agent.memory.get_relevant_context(message, max_results=5)
            plan = agent.planner.create_plan(message, tool_schemas, memory_ctx)
            if plan:
                _plan_text = plan.to_prompt()
                _tc = get_token_counter()
                _plan_text = _tc.truncate_to_limit(_plan_text, _max_planning)
                system += "\n\n" + _plan_text
                logger.info(f"Plan: {plan.goal} ({len(plan.steps)} steps)")

                # Attach a PlanTracker so the tool loop can update progress
                from .planner import PlanTracker

                agent._current_plan_tracker = PlanTracker(plan)

        return system
