# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Licensed under the MIT License

"""
Tor Hidden Service Transport for Mesh Network

Wraps the existing WebSocket protocol with Tor for private P2P communication:
- Inbound: Tor hidden service → localhost:gateway_port (MeshGateway)
- Outbound: websockets via SOCKS5 (127.0.0.1:9050) → peer.onion:port

Dependencies (all optional, graceful degradation):
- stem: Tor controller library for ephemeral hidden services
- python-socks: SOCKS5 proxy for outbound connections
- tor: System binary (tor daemon)
"""

import json
import logging
import shutil
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Optional

logger = logging.getLogger(__name__)

# ── Optional dependency flags ────────────────────────────────────────

HAS_STEM = False
try:
    from stem.control import Controller  # noqa: F401

    HAS_STEM = True
except ImportError:
    Controller = None  # type: ignore[assignment,misc]

HAS_SOCKS = False
try:
    from python_socks.async_.asyncio import Proxy

    HAS_SOCKS = True
except ImportError:
    pass

HAS_TOR_BINARY = shutil.which("tor") is not None

# ── Paths ────────────────────────────────────────────────────────────

try:
    from ..paths import MESH_DIR
except ImportError:
    MESH_DIR = Path.home() / ".familiar" / "data" / "mesh"

TOR_DIR = MESH_DIR / "tor"
TOR_STATE_FILE = TOR_DIR / "tor_state.json"

# ── Defaults ─────────────────────────────────────────────────────────

DEFAULT_CONTROL_PORT = 9051
DEFAULT_SOCKS_PORT = 9050


@dataclass
class TorState:
    """Persisted Tor transport state."""

    onion_address: str = ""
    private_key_type: str = ""
    private_key: str = ""
    created_at: str = ""
    last_started: str = ""

    def to_dict(self) -> dict:
        return asdict(self)

    @classmethod
    def from_dict(cls, d: dict) -> "TorState":
        known = {f.name for f in field()} if False else set(cls.__dataclass_fields__)
        return cls(**{k: v for k, v in d.items() if k in known})


class TorTransport:
    """
    Tor hidden service transport for the mesh gateway.

    Creates an ephemeral hidden service that maps to the gateway's WebSocket port,
    allowing peers to connect via .onion addresses without knowing the gateway's IP.

    Usage:
        transport = TorTransport(control_port=9051, socks_port=9050)
        await transport.start(gateway_port=18789)
        print(transport.onion_address)  # e.g. "abc...xyz.onion"
        ws = await transport.connect_to_onion("peer.onion", 18789)
        await transport.stop()
    """

    def __init__(
        self,
        control_port: int = DEFAULT_CONTROL_PORT,
        socks_port: int = DEFAULT_SOCKS_PORT,
        control_password: str = "",
        tor_dir: Optional[Path] = None,
    ):
        self.control_port = control_port
        self.socks_port = socks_port
        self.control_password = control_password
        self.tor_dir = tor_dir or TOR_DIR
        self._controller: Any = None
        self._running = False
        self._state = TorState()
        self._service_id: str = ""

        # Ensure tor directory exists
        self.tor_dir.mkdir(parents=True, exist_ok=True)

        # Load persisted state
        self._load_state()

    @property
    def onion_address(self) -> str:
        """Full .onion address (without port)."""
        return self._state.onion_address

    @property
    def is_running(self) -> bool:
        return self._running

    def _load_state(self):
        """Load persisted Tor state from disk."""
        if TOR_STATE_FILE.exists():
            try:
                data = json.loads(TOR_STATE_FILE.read_text())
                self._state = TorState.from_dict(data)
            except (json.JSONDecodeError, IOError) as e:
                logger.debug(f"Failed to load Tor state: {e}")

    def _save_state(self):
        """Persist Tor state to disk."""
        self.tor_dir.mkdir(parents=True, exist_ok=True)
        state_file = self.tor_dir / "tor_state.json"
        try:
            state_file.write_text(json.dumps(self._state.to_dict(), indent=2))
            state_file.chmod(0o600)
        except (IOError, OSError) as e:
            logger.warning(f"Failed to save Tor state: {e}")

    async def start(self, gateway_port: int = 18789) -> bool:
        """
        Start Tor hidden service pointing to the gateway port.

        Returns True if the hidden service was created successfully.
        """
        if not HAS_STEM:
            logger.warning("stem library not available — cannot start Tor transport")
            return False

        if not HAS_TOR_BINARY:
            logger.warning("tor binary not found — cannot start Tor transport")
            return False

        try:
            self._controller = Controller.from_port(port=self.control_port)
            if self.control_password:
                self._controller.authenticate(password=self.control_password)
            else:
                self._controller.authenticate()

            # Create ephemeral hidden service (ED25519-V3 → 56-char .onion)
            kwargs = {
                "ports": {gateway_port: f"127.0.0.1:{gateway_port}"},
                "await_publication": True,
                "key_type": "NEW",
                "key_content": "ED25519-V3",
            }

            # Reuse existing key if we have one
            if self._state.private_key and self._state.private_key_type:
                kwargs["key_type"] = self._state.private_key_type
                kwargs["key_content"] = self._state.private_key

            response = self._controller.create_ephemeral_hidden_service(**kwargs)

            self._service_id = response.service_id
            self._state.onion_address = f"{response.service_id}.onion"

            # Save private key for persistence (only available on first creation)
            if hasattr(response, "private_key") and response.private_key:
                self._state.private_key = response.private_key
                self._state.private_key_type = response.private_key_type

            from datetime import datetime

            now = datetime.now().isoformat()
            if not self._state.created_at:
                self._state.created_at = now
            self._state.last_started = now

            self._save_state()
            self._running = True

            logger.info(f"Tor hidden service started: {self._state.onion_address}")
            return True

        except Exception as e:
            logger.error(f"Failed to start Tor hidden service: {e}")
            self._running = False
            return False

    async def stop(self):
        """Stop the Tor hidden service."""
        if self._controller and self._service_id:
            try:
                self._controller.remove_ephemeral_hidden_service(self._service_id)
                logger.info(f"Tor hidden service stopped: {self._state.onion_address}")
            except Exception as e:
                logger.debug(f"Error removing hidden service: {e}")

            try:
                self._controller.close()
            except Exception as e:
                logger.debug(f"Error closing Tor controller: {e}")

        self._controller = None
        self._service_id = ""
        self._running = False

    async def connect_to_onion(self, onion_address: str, port: int = 18789) -> Any:
        """
        Connect to a peer's .onion address via SOCKS5 proxy.

        Returns a websockets connection object, or None on failure.
        """
        if not HAS_SOCKS:
            logger.warning("python-socks not available — cannot connect via Tor")
            return None

        try:
            import websockets

            proxy = Proxy.from_url(f"socks5://127.0.0.1:{self.socks_port}")
            sock = await proxy.connect(dest_host=onion_address, dest_port=port)
            uri = f"ws://{onion_address}:{port}"
            ws = await websockets.connect(uri, sock=sock, open_timeout=60)
            logger.info(f"Connected to {onion_address}:{port} via Tor")
            return ws
        except Exception as e:
            logger.error(f"Failed to connect to {onion_address}:{port} via Tor: {e}")
            return None

    def get_status(self) -> dict:
        """Get current Tor transport status."""
        return {
            "running": self._running,
            "available": self.is_available(),
            "onion_address": self._state.onion_address if self._running else "",
            "control_port": self.control_port,
            "socks_port": self.socks_port,
            "has_stem": HAS_STEM,
            "has_socks": HAS_SOCKS,
            "has_tor_binary": HAS_TOR_BINARY,
            "created_at": self._state.created_at,
            "last_started": self._state.last_started,
        }

    @staticmethod
    def is_available() -> dict:
        """
        Check Tor availability (three-tier).

        Returns dict with availability flags and missing deps list.
        """
        missing = []
        if not HAS_TOR_BINARY:
            missing.append("tor (system binary)")
        if not HAS_STEM:
            missing.append("stem (pip install stem)")
        if not HAS_SOCKS:
            missing.append("python-socks (pip install python-socks[asyncio])")

        return {
            "available": HAS_TOR_BINARY and HAS_STEM and HAS_SOCKS,
            "tor_binary": HAS_TOR_BINARY,
            "stem_library": HAS_STEM,
            "socks_library": HAS_SOCKS,
            "missing": missing,
        }
