# 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

"""
Matrix Channel

Connect to Matrix/Element via matrix-nio.

Setup:
1. Install matrix-nio:
   pip install familiar-agent[matrix]

2. Set environment variables:
   MATRIX_HOMESERVER=https://matrix.org
   MATRIX_USER=@familiar:matrix.org
   MATRIX_PASSWORD=password          # or use MATRIX_ACCESS_TOKEN
   MATRIX_ACCESS_TOKEN=syt_xxx       # alternative to password
   OWNER_MATRIX_ID=@owner:matrix.org

3. Optional: pip install matrix-nio[e2e] for E2EE support (requires libolm)
"""

import asyncio
import logging
import os
from pathlib import Path
from threading import Thread

logger = logging.getLogger(__name__)

# Matrix configuration from environment
MATRIX_HOMESERVER = os.environ.get("MATRIX_HOMESERVER", "")
MATRIX_USER = os.environ.get("MATRIX_USER", "")
MATRIX_PASSWORD = os.environ.get("MATRIX_PASSWORD", "")
MATRIX_ACCESS_TOKEN = os.environ.get("MATRIX_ACCESS_TOKEN", "")
OWNER_MATRIX_ID = os.environ.get("OWNER_MATRIX_ID", "")


def _has_e2ee() -> bool:
    """Check if matrix-nio E2EE support is available."""
    try:
        from nio.crypto import OlmDevice  # noqa: F401

        return True
    except ImportError:
        return False


def _get_store_path() -> str:
    """Get the path for matrix-nio crypto store."""
    try:
        from familiar.core.paths import DATA_DIR

        store = DATA_DIR / "matrix_store"
    except ImportError:
        store = Path.home() / ".familiar" / "data" / "matrix_store"
    store.mkdir(parents=True, exist_ok=True)
    return str(store)


from .connect_wizard import ConnectMixin  # noqa: E402
from .formatting import FormatMode  # noqa: E402


class MatrixChannel(ConnectMixin):
    """
    Matrix integration via matrix-nio.

    Usage:
        channel = MatrixChannel(agent)
        channel.run()       # blocking
        channel.run_async()  # background thread
    """

    # ConnectMixin adapter properties
    format_mode = FormatMode.PLAIN
    supports_buttons = False
    supports_message_deletion = False

    def __init__(
        self,
        agent,
        homeserver: str = None,
        user_id: str = None,
        password: str = None,
        access_token: str = None,
        allowed_users: list = None,
        allowed_rooms: list = None,
        respond_to_dms: bool = True,
        respond_to_mentions: bool = True,
        auth_mode=None,
    ):
        self.agent = agent
        self.homeserver = homeserver or MATRIX_HOMESERVER
        self.user_id = user_id or MATRIX_USER
        self.password = password or MATRIX_PASSWORD
        self.access_token = access_token or MATRIX_ACCESS_TOKEN
        self.owner_id = OWNER_MATRIX_ID
        self.allowed_users = set(allowed_users) if allowed_users else None
        self.allowed_rooms = set(allowed_rooms) if allowed_rooms else None
        self.respond_to_dms = respond_to_dms
        self.respond_to_mentions = respond_to_mentions
        self.auth_mode = auth_mode

        self._client = None
        self._running = False
        self._authenticator = None
        self._pending_confirm = {}  # sender -> token
        self._loop = None
        self.encryption_enabled = False

    # ── ConnectMixin adapter methods ────────────────────────────────

    async def wizard_send(self, recipient_id: str, text: str) -> None:
        # recipient_id is a room_id for Matrix
        await self._send_to_room(recipient_id, text)

    async def wizard_delete_message(self, recipient_id: str, message_ref) -> bool:
        return False  # Matrix cannot easily delete other user's messages

    async def _connect(self):
        """Connect to the Matrix homeserver and register callbacks."""
        try:
            import nio
        except ImportError:
            from familiar.core.deps import MATRIX_PACKAGES, ensure_packages

            ok, _ = ensure_packages(MATRIX_PACKAGES)
            if ok:
                import nio
            else:
                raise RuntimeError("matrix-nio not available. Check server logs.")

        if _has_e2ee():
            store_path = _get_store_path()
            self._client = nio.AsyncClient(
                self.homeserver,
                self.user_id,
                store_path=store_path,
            )
            self.encryption_enabled = True
            logger.info("Matrix E2EE support enabled")
        else:
            self._client = nio.AsyncClient(self.homeserver, self.user_id)
            logger.warning("Matrix E2EE not available — running in plaintext mode")

        # Login
        if self.access_token:
            self._client.access_token = self.access_token
            self._client.user_id = self.user_id
            # Verify the token works
            resp = await self._client.whoami()
            if hasattr(resp, "user_id"):
                logger.info(f"Matrix authenticated as {resp.user_id} (access token)")
            else:
                raise RuntimeError(f"Matrix auth failed: {resp}")
        elif self.password:
            resp = await self._client.login(self.password)
            if hasattr(resp, "access_token"):
                logger.info(f"Matrix logged in as {self.user_id}")
            else:
                raise RuntimeError(f"Matrix login failed: {resp}")
        else:
            raise RuntimeError("No Matrix credentials. Set MATRIX_PASSWORD or MATRIX_ACCESS_TOKEN.")

        # If E2EE is enabled, trust all devices in joined rooms
        if self.encryption_enabled:
            try:
                if hasattr(self._client, "keys_upload"):
                    await self._client.keys_upload()
            except Exception as e:
                logger.warning(f"Key upload failed (non-fatal): {e}")

        # Register callbacks
        self._client.add_event_callback(self._on_message, nio.RoomMessageText)
        self._client.add_event_callback(self._on_invite, nio.InviteMemberEvent)

    async def _on_invite(self, room, event):
        """Auto-join rooms we're invited to (only from owner or allowed users)."""
        try:
            import importlib.util

            if importlib.util.find_spec("nio") is None:
                return
        except ImportError:
            return
        if not self._client:
            return

        # Only accept invites from the owner or explicitly allowed users
        sender = event.sender
        if self.owner_id and sender == self.owner_id:
            pass  # owner can always invite
        elif self.allowed_users and sender in self.allowed_users:
            pass  # allowed user
        else:
            logger.warning(
                f"Ignoring room invite from unauthorized user {sender} (room {room.room_id})"
            )
            return

        try:
            await self._client.join(room.room_id)
            logger.info(f"Joined room {room.room_id} (invited by {sender})")
        except Exception as e:
            logger.warning(f"Failed to join {room.room_id}: {e}")

    async def _on_message(self, room, event):
        """Handle incoming text messages."""
        # Ignore own messages
        if event.sender == self.user_id:
            return

        sender = event.sender
        text = event.body

        if not text:
            return

        # Check allowed users
        if self.allowed_users and sender not in self.allowed_users:
            if sender != self.owner_id:
                logger.debug(f"Ignoring message from non-allowed user {sender}")
                return

        # Check allowed rooms
        if self.allowed_rooms and room.room_id not in self.allowed_rooms:
            logger.debug(f"Ignoring message from non-allowed room {room.room_id}")
            return

        # Determine if this is a DM or a room mention
        is_dm = room.member_count == 2 if hasattr(room, "member_count") else True
        is_mention = self.user_id in text or (
            hasattr(room, "display_name") and room.display_name in text
        )

        if not is_dm and not is_mention:
            # In group rooms, only respond to mentions
            if self.respond_to_mentions:
                return
            return

        if is_dm and not self.respond_to_dms:
            return

        # Owner auto-promote
        if self.owner_id and sender == self.owner_id:
            try:
                from ..core.security import TrustLevel

                session = self.agent.sessions.get_or_create_session(sender, "matrix")
                if session.trust_level != TrustLevel.OWNER:
                    session.set_trust_level(TrustLevel.OWNER)
                    session.daily_budget = 50.0
                    self.agent.sessions.save_session(session)
            except (ImportError, AttributeError):
                pass

        logger.info(f"Matrix from {sender}: {text[:50]}...")

        # Command dispatcher
        if text.startswith("/"):
            parts = text[1:].split(maxsplit=1)
            cmd = parts[0].lower()
            args = parts[1] if len(parts) > 1 else ""
            if cmd == "connect":
                try:
                    from ..core.security import TrustLevel

                    session = self.agent.sessions.get_or_create_session(sender, "matrix")
                    if session.trust_level != TrustLevel.OWNER:
                        await self._send_to_room(
                            room.room_id, "🔒 /connect requires OWNER trust level."
                        )
                        return
                except ImportError:
                    pass
                connect_args = args.split() if args else []
                await self.handle_connect_command(room.room_id, connect_args)
                return
            if cmd == "browse":
                try:
                    from ..core.security import TrustLevel

                    session = self.agent.sessions.get_or_create_session(sender, "matrix")
                    if session.trust_level != TrustLevel.OWNER:
                        await self._send_to_room(
                            room.room_id, "🔒 /browse requires OWNER trust level."
                        )
                        return
                except ImportError:
                    pass
                await self.handle_browse_command(room.room_id)
                return
            if cmd == "search":
                try:
                    from ..core.security import TrustLevel

                    session = self.agent.sessions.get_or_create_session(sender, "matrix")
                    if session.trust_level != TrustLevel.OWNER:
                        await self._send_to_room(
                            room.room_id, "🔒 /search requires OWNER trust level."
                        )
                        return
                except ImportError:
                    pass
                search_args = args.split() if args else []
                await self.handle_search_command(room.room_id, search_args)
                return
            response = self._handle_command(cmd, args, sender)
            if response is not None:
                await self._send_to_room(room.room_id, response)
                return

        # Intercept messages during wizard flows (ConnectMixin)
        if await self.handle_wizard_message(room.room_id, text):
            return

        # Check pending confirmations
        if sender in self._pending_confirm:
            token = self._pending_confirm.pop(sender)
            session = self.agent.sessions.get_or_create_session(sender, "matrix")
            pending = session.pending_confirmations.get(token)
            answer = text.strip().lower()
            if pending and answer.startswith("y"):
                session.pending_confirmations.pop(token, None)
                tool_input = dict(pending["tool_input"])
                tool_input["_confirmed"] = True
                try:
                    result = self.agent.tools.execute(
                        pending["tool_name"],
                        tool_input,
                        context={
                            "session": session,
                            "session_manager": self.agent.sessions,
                            "agent": self.agent,
                            "user_id": sender,
                            "channel": "matrix",
                        },
                    )
                    try:
                        from familiar.core.agent import _capture_context_from_result

                        _capture_context_from_result(
                            pending["tool_name"],
                            tool_input,
                            result,
                            session,
                            self.agent.sessions,
                        )
                    except ImportError:
                        pass
                    await self._send_to_room(room.room_id, result or "Done.")
                except Exception as e:
                    await self._send_to_room(room.room_id, f"Action failed: {e}")
            elif pending and answer.startswith("n"):
                session.pending_confirmations.pop(token, None)
                self.agent.sessions.save_session(session)
                await self._send_to_room(room.room_id, "Cancelled.")
            else:
                self._pending_confirm[sender] = token
                await self._send_to_room(room.room_id, "Reply Y to confirm or N to cancel.")
            return

        # Process through agent
        try:
            response = self.agent.chat(text, user_id=sender, channel="matrix")

            from familiar.core.confirmations import SENTINEL_PREFIX

            if response and response.startswith(SENTINEL_PREFIX):
                token = response[len(SENTINEL_PREFIX) :]
                session = self.agent.sessions.get_or_create_session(sender, "matrix")
                pending = session.pending_confirmations.get(token)
                if pending:
                    self._pending_confirm[sender] = token
                    preview = pending.get("preview", "This action is ready to execute.")
                    risk = pending.get("risk", "medium")
                    flag = "WARNING: " if risk == "high" else ""
                    await self._send_to_room(
                        room.room_id,
                        flag + preview + "\nReply Y to confirm or N to cancel.",
                    )
                else:
                    await self._send_to_room(
                        room.room_id, "Confirmation expired. Please try again."
                    )
            elif response:
                await self._send_to_room(room.room_id, response)

        except Exception as e:
            logger.error(f"Error processing Matrix message: {e}")
            await self._send_to_room(room.room_id, "Sorry, I encountered an error.")

    def _handle_command(self, cmd: str, args: str, sender: str):
        """Handle slash commands. Returns response string or None."""
        try:
            from ..core.security import TrustLevel
        except ImportError:
            TrustLevel = None

        try:
            session = self.agent.sessions.get_or_create_session(sender, "matrix")
        except Exception:
            session = None

        if cmd in ("help", "start"):
            try:
                status = self.agent.get_status()
                model = status.get("provider", "unknown")
            except Exception:
                model = "unknown"
            return (
                "Familiar Commands\n\n"
                "/status - System status\n"
                "/trust - Your trust level\n"
                "/budget - Spending status\n"
                "/caps - Your capabilities\n"
                "/model - Show/switch provider\n"
                "/remember <key> <value> - Store memory\n"
                "/recall <query> - Search memories\n"
                "/clear - Clear conversation\n"
                "/link <email> - Link your account\n"
                "/confirm <code> - Confirm account link\n"
                "/whoami - Show your identity\n"
                f"\nModel: {model}"
            )

        elif cmd == "status":
            try:
                status = self.agent.get_status()
                trust = (
                    session.trust_level.value.upper()
                    if session and hasattr(session, "trust_level")
                    else "N/A"
                )
                budget = (
                    f"${session.remaining_budget:.2f}"
                    if session and hasattr(session, "remaining_budget")
                    else "N/A"
                )
                return (
                    f"Status\n"
                    f"Trust: {trust}\n"
                    f"Budget: {budget} left\n"
                    f"Model: {status.get('provider', 'N/A')}\n"
                    f"Memory: {status.get('memory_entries', 0)} entries\n"
                    f"Skills: {status.get('skills_loaded', 0)}"
                )
            except Exception:
                return "Could not retrieve status."

        elif cmd == "trust":
            if not session or not TrustLevel:
                return "Trust system not available."
            levels = list(TrustLevel)
            lines = ["Trust Level", ""]
            for lvl in levels:
                if session.trust_level == lvl:
                    m = "-> "
                elif levels.index(session.trust_level) > levels.index(lvl):
                    m = "ok "
                else:
                    m = "   "
                lines.append(f"{m}{lvl.value.upper()}")
            if session.trust_level == TrustLevel.STRANGER:
                prog = f"{session.positive_interactions}/10 to KNOWN"
            elif session.trust_level == TrustLevel.KNOWN:
                prog = f"{session.positive_interactions}/50 to TRUSTED"
            else:
                prog = "Maximum level"
            lines += ["", f"Score: {session.trust_score:.1f}", f"Progress: {prog}"]
            return "\n".join(lines)

        elif cmd == "budget":
            if not session:
                return "Session not available."
            pct = (
                min(100, session.spent_today / session.daily_budget * 100)
                if session.daily_budget > 0
                else 0
            )
            bar = f"[{'|' * int(pct / 10)}{' ' * (10 - int(pct / 10))}]"
            return (
                f"Budget\n"
                f"Limit: ${session.daily_budget:.2f}\n"
                f"{bar} {pct:.1f}%\n"
                f"Spent: ${session.spent_today:.4f}\n"
                f"Remaining: ${session.remaining_budget:.4f}"
            )

        elif cmd == "caps":
            if not session:
                return "Session not available."
            caps = sorted([c.value for c in session.capabilities])
            cap_list = "\n".join(f"  {c}" for c in caps) or "(none yet)"
            return f"Capabilities ({len(caps)})\n{cap_list}"

        elif cmd == "model":
            if not args:
                try:
                    from ..core.providers import get_available_providers

                    providers = get_available_providers()
                    return f"Current: {self.agent.provider.name}\nAvailable: {', '.join(providers)}\nUsage: /model <name>"
                except Exception:
                    return "Could not list providers."
            try:
                result = self.agent.switch_provider(args)
                return f"OK: {result}"
            except Exception as e:
                return f"Error: {e}"

        elif cmd == "remember":
            if not session:
                return "Session not available."
            try:
                from ..core.security import Capability

                if not session.has_capability(Capability.WRITE_MEMORY):
                    return "No write memory permission yet."
            except ImportError:
                pass
            parts = args.split(maxsplit=1)
            if len(parts) < 2:
                return "Usage: /remember <key> <value>"
            self.agent.remember(parts[0], parts[1])
            return f"Remembered: {parts[0]}"

        elif cmd == "recall":
            if not session:
                return "Session not available."
            try:
                from ..core.security import Capability

                if not session.has_capability(Capability.READ_MEMORY):
                    return "No read memory permission yet."
            except ImportError:
                pass
            if not args:
                return "Usage: /recall <query>"
            results = self.agent.recall(args)
            if not results:
                return f"No memories matching '{args}'"
            return "\n".join(f"{e.key}: {e.value}" for e in results[:10])

        elif cmd == "clear":
            self.agent.clear_history(sender, "matrix")
            return "Conversation cleared."

        elif cmd == "link":
            if not args:
                return "Usage: /link your@email.org"
            try:
                from .auth import ChannelType, create_authenticator

                if self._authenticator is None:
                    self._authenticator = create_authenticator()
                success, msg = self._authenticator.start_link(ChannelType.MATRIX, sender, args)
                return msg
            except Exception as e:
                return f"Link error: {e}"

        elif cmd == "confirm":
            if not args:
                return "Usage: /confirm <code>"
            try:
                from .auth import ChannelType, create_authenticator

                if self._authenticator is None:
                    self._authenticator = create_authenticator()
                success, msg = self._authenticator.confirm_link(ChannelType.MATRIX, sender, args)
                return msg
            except Exception as e:
                return f"Confirm error: {e}"

        elif cmd == "whoami":
            return f"Matrix ID: {sender}"

        elif cmd == "grant":
            if not session or not TrustLevel:
                return "Trust system not available."
            return "Grant command requires admin access via the CLI."

        elif cmd == "revoke":
            if not session or not TrustLevel:
                return "Trust system not available."
            return "Revoke command requires admin access via the CLI."

        return None

    async def _send_to_room(self, room_id: str, text: str):
        """Send a text message to a Matrix room."""
        if not self._client:
            return
        try:
            await self._client.room_send(
                room_id,
                message_type="m.room.message",
                content={
                    "msgtype": "m.text",
                    "body": text,
                },
            )
        except Exception as e:
            logger.error(f"Failed to send Matrix message to {room_id}: {e}")

    def send_message(self, recipient: str, text: str) -> bool:
        """
        Send a message to a Matrix room or user (sync wrapper).

        Args:
            recipient: Room ID (e.g. !abc:matrix.org) or user ID (@user:matrix.org)
            text: Message text

        Returns:
            True if sent successfully
        """
        if not self._client:
            logger.error("Matrix client not connected")
            return False

        async def _send():
            # If recipient is a user ID, find or create DM room
            if recipient.startswith("@"):
                try:
                    resp = await self._client.room_create(
                        is_direct=True,
                        invite=[recipient],
                    )
                    if hasattr(resp, "room_id"):
                        room_id = resp.room_id
                    else:
                        logger.error(f"Could not create DM room with {recipient}")
                        return False
                except Exception as e:
                    logger.error(f"DM room creation failed: {e}")
                    return False
            else:
                room_id = recipient

            await self._send_to_room(room_id, text)
            return True

        try:
            if self._loop and self._loop.is_running():
                future = asyncio.run_coroutine_threadsafe(_send(), self._loop)
                return future.result(timeout=30)
            else:
                return asyncio.run(_send())
        except Exception as e:
            logger.error(f"Matrix send error: {e}")
            return False

    def run(self):
        """Start the Matrix channel (blocking)."""
        if not self.homeserver:
            logger.error("MATRIX_HOMESERVER not set")
            print("Set MATRIX_HOMESERVER environment variable")
            return

        if not self.user_id:
            logger.error("MATRIX_USER not set")
            print("Set MATRIX_USER environment variable")
            return

        logger.info("Starting Matrix channel...")
        print(f"Matrix channel active as {self.user_id}")

        self._running = True
        self._loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self._loop)

        try:
            self._loop.run_until_complete(self._run_async())
        except KeyboardInterrupt:
            pass
        finally:
            self._running = False
            if self._client:
                self._loop.run_until_complete(self._client.close())
            self._loop.close()

    async def _run_async(self):
        """Async main loop."""
        await self._connect()

        # Start scheduler
        if hasattr(self.agent, "start_scheduler"):
            self.agent.start_scheduler()

        # Wire scheduler delivery
        if hasattr(self.agent, "scheduler") and self.agent.scheduler:
            owner = self.owner_id
            if owner:
                _self = self

                def _deliver_to_matrix(message, channel, chat_id):
                    target = chat_id or owner
                    if target:
                        _self.send_message(target, f"[Scheduled] {message}")

                self.agent.scheduler.set_delivery_callback(_deliver_to_matrix)
                logger.info(f"Scheduler delivery -> Matrix ({owner})")

        # Sync loop
        try:
            import nio

            while self._running:
                resp = await self._client.sync(timeout=30000, full_state=False)
                if isinstance(resp, nio.SyncError):
                    logger.error(f"Matrix sync error: {resp.message}")
                    await asyncio.sleep(5)
        except Exception as e:
            logger.error(f"Matrix sync loop error: {e}")

    def run_async(self):
        """Start in background thread."""
        thread = Thread(target=self.run, daemon=True)
        thread.start()
        return thread

    def stop(self):
        """Stop the channel."""
        self._running = False


def send_matrix(data: dict) -> str:
    """Tool function to send a Matrix message."""
    recipient = data.get("to", "")
    message = data.get("message", "")

    if not recipient or not message:
        return "Please provide 'to' (Matrix ID or room ID) and 'message'"

    if not MATRIX_HOMESERVER or not MATRIX_USER:
        return "Matrix not configured. Set MATRIX_HOMESERVER and MATRIX_USER."

    if not MATRIX_ACCESS_TOKEN and not MATRIX_PASSWORD:
        return "Matrix credentials not set. Set MATRIX_ACCESS_TOKEN or MATRIX_PASSWORD."

    try:
        import nio
    except ImportError:
        from familiar.core.deps import MATRIX_PACKAGES, ensure_packages

        ok, _ = ensure_packages(MATRIX_PACKAGES)
        if not ok:
            return "❌ Matrix not available. Check server logs."
        import nio

    async def _send():
        if _has_e2ee():
            store_path = _get_store_path()
            client = nio.AsyncClient(MATRIX_HOMESERVER, MATRIX_USER, store_path=store_path)
        else:
            client = nio.AsyncClient(MATRIX_HOMESERVER, MATRIX_USER)

        try:
            if MATRIX_ACCESS_TOKEN:
                client.access_token = MATRIX_ACCESS_TOKEN
                client.user_id = MATRIX_USER
            elif MATRIX_PASSWORD:
                resp = await client.login(MATRIX_PASSWORD)
                if not hasattr(resp, "access_token"):
                    return f"Matrix login failed: {resp}"

            # If recipient is a user ID, create DM room
            if recipient.startswith("@"):
                resp = await client.room_create(is_direct=True, invite=[recipient])
                if hasattr(resp, "room_id"):
                    room_id = resp.room_id
                else:
                    return f"Could not create DM room: {resp}"
            else:
                room_id = recipient

            await client.room_send(
                room_id,
                message_type="m.room.message",
                content={"msgtype": "m.text", "body": message},
            )
            return f"Matrix message sent to {recipient}"
        finally:
            await client.close()

    try:
        return asyncio.run(_send())
    except Exception as e:
        return f"Matrix error: {e}"


# Tool definition
TOOLS = [
    {
        "name": "send_matrix",
        "description": "Send a Matrix message. Requires matrix-nio to be installed and configured.",
        "input_schema": {
            "type": "object",
            "properties": {
                "to": {
                    "type": "string",
                    "description": "Matrix user ID (@user:server) or room ID (!room:server)",
                },
                "message": {"type": "string", "description": "Message to send"},
            },
            "required": ["to", "message"],
        },
        "handler": send_matrix,
        "category": "messaging",
    }
]
