# 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
from typing import Optional

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,
    ) -> str:
        """
        Build the system prompt with constitutional personality.

        Phase 1 integration: Uses the Familiar Constitution to generate
        relationship-stage-aware, channel-appropriate system prompts.
        Falls back to legacy prompt if constitution module unavailable.
        """
        agent = self._agent
        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,
            )
            parts.append(constitutional_prompt)

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

Current time: {datetime.now().strftime("%Y-%m-%d %H:%M %Z")}
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 (takes precedence over session)
        if user_context and HAS_USER_MANAGEMENT:
            user = user_context.user
            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 (fallback)
        elif session:
            caps = [c.value for c in session.capabilities]
            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
        parts.append("""<tool_guide>
- **Web searches**: Always use `web_search` for looking up information, news, or topics online. 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).

Examples:
- "Search for how to make sourdough bread" → use `web_search` (general information lookup)
- "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
        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
        if agent.memory:
            if current_message:
                relevant = agent.memory.get_relevant_context(query=current_message, max_results=10)
                if relevant:
                    parts.append(relevant)

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

        # Phase 4: Session context injection
        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:
                        pass
                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:
                parts.append("<session_context>\n" + "\n".join(_ctx_lines) + "\n</session_context>")

        # Skills documentation (progressive disclosure: summaries in prompt,
        # full docs available on-demand via the get_skill_docs tool)
        _skill_doc_text = ""
        if agent.config.agent.skills_enabled:
            is_local_model = getattr(agent.provider, "name", "").lower() in ("ollama",)
            if not is_local_model:
                _skill_doc_text = agent.skills.get_skill_summaries(skill_names=routed_skills)
                if _skill_doc_text:
                    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>"
                    )

        prompt = "\n\n".join(parts)

        try:
            _tc = get_token_counter()
            _total = _tc.count(prompt)
            _skill = _tc.count(_skill_doc_text) if _skill_doc_text else 0
            logger.info("System prompt: %d tokens total, %d skill doc tokens", _total, _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 prompt

    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", 400) if _max_episodic else 400

        try:
            _ep_results = _episodic_ms.retrieve(
                query=message,
                top_k=5,
                strategy=EpisodicRetrievalStrategy.COMBINED,
                min_strength=0.1,
            )
            _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_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=1500)
                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=1.5)
                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 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)")

        return system
