"""
Network Runtime for CLM Network IO Operations.

Implements the Network IO specification for Python PTR, providing:
- HTTP request/response handling with retry and caching
- MCard transfer protocol
- Collection synchronization
- Security policy enforcement
- Rate limiting

Matches the TypeScript NetworkRuntime implementation.
"""

import asyncio
import base64
import hashlib
import json
import logging
import os
import re
import time
import sys
import subprocess
from dataclasses import asdict
from http.server import HTTPServer, BaseHTTPRequestHandler
from threading import Thread
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
from urllib.parse import urlencode, urlparse

# Optional aiohttp import - required for HTTP operations
try:
    import aiohttp
    AIOHTTP_AVAILABLE = True
except ImportError:
    aiohttp = None  # type: ignore
    AIOHTTP_AVAILABLE = False

from mcard import MCard
from mcard.model.card import MCardFromData
from mcard.model.card_collection import CardCollection

from .network_config import (
    BackoffStrategy,
    CacheConfig,
    HttpError,
    HttpErrorResponse,
    HttpSuccessResponse,
    HttpTiming,
    MCardPayload,
    NetworkSecurityConfig,
    RetryConfig,
    SecurityViolationError,
    SyncMode,
    SyncStats,
)
from .runtime import RuntimeExecutor


logger = logging.getLogger(__name__)


class NetworkRuntime(RuntimeExecutor):
    """
    Network Runtime for handling declarative network operations.
    
    Supports builtins:
    - http_request (general)
    - http_get
    - http_post
    - load_url (enhanced)
    - mcard_send (send MCard to remote)
    - listen_http (receive MCards)
    - mcard_sync (synchronize collection)
    - listen_sync (listen for sync requests)
    
    Security:
    Configure via environment variables:
    - CLM_ALLOWED_DOMAINS: Comma-separated allowed domains
    - CLM_BLOCKED_DOMAINS: Comma-separated blocked domains (takes precedence)
    - CLM_BLOCK_LOCALHOST: Set to "true" to block localhost
    - CLM_BLOCK_PRIVATE_IPS: Set to "true" to block private IP ranges
    """
    
    DEFAULT_RATE_LIMIT = {"tokens_per_second": 10, "max_burst": 20}
    DEFAULT_RETRY_STATUSES = [408, 429, 500, 502, 503, 504]
    
    def __init__(self, collection: Optional[CardCollection] = None):
        super().__init__()
        self.collection = collection
        self.security_config = self._load_security_config_from_env()
        self._response_cache: Dict[str, Tuple[HttpSuccessResponse, float]] = {}
        self._rate_limiter: Dict[str, Tuple[float, float]] = {}  # domain -> (tokens, last_refill)
        self._sessions: Dict[str, Any] = {}  # session_id -> P2PChatSession
        self._active_processes: Dict[str, subprocess.Popen] = {}
        self._servers: Dict[str, Any] = {}
    
    # ============ Security Configuration ============
    
    def _load_security_config_from_env(self) -> NetworkSecurityConfig:
        """Load security configuration from environment variables."""
        def parse_list(value: Optional[str]) -> Optional[List[str]]:
            if not value:
                return None
            return [s.strip() for s in value.split(",") if s.strip()]
        
        return NetworkSecurityConfig(
            allowed_domains=parse_list(os.environ.get("CLM_ALLOWED_DOMAINS")),
            blocked_domains=parse_list(os.environ.get("CLM_BLOCKED_DOMAINS")),
            allowed_protocols=parse_list(os.environ.get("CLM_ALLOWED_PROTOCOLS")),
            block_private_ips=os.environ.get("CLM_BLOCK_PRIVATE_IPS", "").lower() == "true",
            block_localhost=os.environ.get("CLM_BLOCK_LOCALHOST", "").lower() == "true",
        )
    
    def _validate_url_security(self, url_string: str) -> None:
        """Validate URL against security policy. Raises Exception if blocked."""
        try:
            parsed = urlparse(url_string)
        except Exception:
            raise SecurityError(f"Invalid URL: {url_string}")
        
        hostname = (parsed.hostname or "").lower()
        protocol = parsed.scheme.lower()
        
        # 1. Check blocked domains (takes precedence)
        if self.security_config.blocked_domains:
            for pattern in self.security_config.blocked_domains:
                if self._match_domain_pattern(hostname, pattern):
                    raise SecurityError(f"Domain '{hostname}' is blocked by security policy")
        
        # 2. Check allowed domains (if configured)
        if self.security_config.allowed_domains:
            is_allowed = any(
                self._match_domain_pattern(hostname, pattern)
                for pattern in self.security_config.allowed_domains
            )
            if not is_allowed:
                raise SecurityError(f"Domain '{hostname}' is not in the allowed list")
        
        # 3. Check allowed protocols
        if self.security_config.allowed_protocols:
            if protocol not in self.security_config.allowed_protocols:
                raise SecurityError(
                    f"Protocol '{protocol}' is not allowed. "
                    f"Allowed: {', '.join(self.security_config.allowed_protocols)}"
                )
        
        # 4. Check localhost blocking
        if self.security_config.block_localhost:
            if hostname in ("localhost", "127.0.0.1", "::1"):
                raise SecurityError("Localhost access is blocked by security policy")
        
        # 5. Check private IP blocking
        if self.security_config.block_private_ips:
            if self._is_private_ip(hostname):
                raise SecurityError(f"Private IP '{hostname}' is blocked by security policy")
    
    def _match_domain_pattern(self, hostname: str, pattern: str) -> bool:
        """Match hostname against domain pattern (supports wildcards)."""
        pattern_lower = pattern.lower()
        
        if pattern_lower.startswith("*."):
            # Wildcard: *.example.com matches sub.example.com
            suffix = pattern_lower[1:]  # .example.com
            return hostname.endswith(suffix) or hostname == pattern_lower[2:]
        
        return hostname == pattern_lower
    
    def _is_private_ip(self, hostname: str) -> bool:
        """Check if hostname is a private IP address."""
        private_patterns = [
            r"^10\.\d+\.\d+\.\d+$",                    # 10.x.x.x
            r"^192\.168\.\d+\.\d+$",                   # 192.168.x.x
            r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$",   # 172.16-31.x.x
            r"^169\.254\.\d+\.\d+$",                   # Link-local
            r"^fc00:",                                  # IPv6 private
            r"^fd00:",                                  # IPv6 private
        ]
        return any(re.match(p, hostname, re.IGNORECASE) for p in private_patterns)
    
    # ============ MCard Serialization Helpers ============
    
    def _serialize_mcard(self, card: MCard) -> dict:
        """Serialize an MCard to a JSON-safe payload."""
        content = card.get_content()
        if isinstance(content, str):
            content = content.encode("utf-8")
        # Handle hash_function which might be an enum
        hash_func = getattr(card, "hash_function", "sha256")
        if hasattr(hash_func, "value"):
            hash_func = hash_func.value
        elif hasattr(hash_func, "name"):
            hash_func = hash_func.name
        else:
            hash_func = str(hash_func)
        
        return {
            "hash": card.hash,
            "content": base64.b64encode(content).decode("ascii"),
            "g_time": card.g_time,
            "content_type": getattr(card, "content_type", "application/octet-stream"),
            "hash_function": hash_func,
        }
    
    def _deserialize_mcard(self, payload: dict) -> MCard:
        """Deserialize a JSON payload back to an MCard."""
        content_b64 = payload.get("content")
        if not content_b64:
            raise ValueError("Missing content in MCard payload")
        
        content = base64.b64decode(content_b64)
        
        if payload.get("hash") and payload.get("g_time"):
            # Reconstruct with existing identity using MCardFromData
            return MCardFromData(content, payload["hash"], payload["g_time"])
        
        # Create new identity
        return MCard(content)
    
    # ============ Retry Logic ============
    
    def _calculate_backoff_delay(
        self,
        attempt: int,
        strategy: BackoffStrategy,
        base_delay: int,
        max_delay: Optional[int] = None
    ) -> int:
        """Calculate delay for retry attempt based on backoff strategy."""
        import random
        
        if strategy == BackoffStrategy.EXPONENTIAL:
            delay = base_delay * (2 ** (attempt - 1))
        elif strategy == BackoffStrategy.LINEAR:
            delay = base_delay * attempt
        else:  # CONSTANT
            delay = base_delay
        
        # Add jitter (±10%)
        jitter = delay * 0.1 * (random.random() * 2 - 1)
        delay = int(delay + jitter)
        
        if max_delay:
            delay = min(delay, max_delay)
        
        return delay
    
    def _should_retry_status(self, status: int, retry_on: Optional[List[int]] = None) -> bool:
        """Check if HTTP status code should trigger a retry."""
        retry_statuses = retry_on or self.DEFAULT_RETRY_STATUSES
        return status in retry_statuses
    
    # ============ Response Caching ============
    
    def _generate_cache_key(self, method: str, url: str, body: Optional[str] = None) -> str:
        """Generate cache key from request config."""
        key_data = f"{method}:{url}:{body or ''}"
        return f"cache_{hashlib.md5(key_data.encode()).hexdigest()[:16]}"
    
    def _get_cached_response(self, cache_key: str) -> Optional[HttpSuccessResponse]:
        """Get cached response if valid."""
        cached = self._response_cache.get(cache_key)
        if cached and cached[1] > time.time():
            response = cached[0]
            # Return copy with cached=True
            return HttpSuccessResponse(
                success=True,
                status=response.status,
                headers=response.headers,
                body=response.body,
                timing=HttpTiming(total=0),
                mcard_hash=response.mcard_hash,
                cached=True,
            )
        # Clean up expired
        if cached:
            del self._response_cache[cache_key]
        return None
    
    def _cache_response(self, cache_key: str, response: HttpSuccessResponse, ttl: int) -> None:
        """Cache a response with TTL."""
        expires_at = time.time() + ttl
        self._response_cache[cache_key] = (response, expires_at)
    
    async def _cache_to_persistent_storage(
        self, cache_key: str, response: HttpSuccessResponse, ttl: int
    ) -> None:
        """Store response in MCard collection for persistent caching."""
        if not self.collection:
            return
        
        cache_entry = {
            "key": cache_key,
            "response": asdict(response),
            "expires_at": time.time() + ttl,
            "cached_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        }
        
        card = MCard(json.dumps(cache_entry))
        await asyncio.to_thread(self.collection.add, card)
    
    # ============ Rate Limiting ============
    
    def _check_rate_limit(self, domain: str) -> bool:
        """Token bucket rate limiter. Returns True if request should proceed."""
        now = time.time()
        tokens, last_refill = self._rate_limiter.get(domain, (
            self.DEFAULT_RATE_LIMIT["max_burst"],
            now
        ))
        
        # Refill tokens
        elapsed = now - last_refill
        refill = elapsed * self.DEFAULT_RATE_LIMIT["tokens_per_second"]
        tokens = min(self.DEFAULT_RATE_LIMIT["max_burst"], tokens + refill)
        
        if tokens >= 1:
            tokens -= 1
            self._rate_limiter[domain] = (tokens, now)
            return True
        
        self._rate_limiter[domain] = (tokens, now)
        return False
    
    async def _wait_for_rate_limit(self, domain: str) -> None:
        """Wait until rate limit allows request."""
        while not self._check_rate_limit(domain):
            await asyncio.sleep(0.1)
    
    # ============ Variable Interpolation ============
    
    def _interpolate(self, text: str, context: Any) -> str:
        """Simple variable interpolation: ${key} or ${input.key}."""
        if not text or not isinstance(text, str):
            return text
        
        def replacer(match):
            path = match.group(1)
            keys = path.split(".")
            val = context
            for key in keys:
                if isinstance(val, dict) and key in val:
                    val = val[key]
                else:
                    return ""  # Not found
            return str(val)
        
        return re.sub(r"\$\{([^}]+)\}", replacer, text)
    
    def _interpolate_headers(
        self, headers: Dict[str, str], context: Any
    ) -> Dict[str, str]:
        """Interpolate variables in headers."""
        return {k: self._interpolate(v, context) for k, v in headers.items()}
    
    # ============ Main Execute Entry Point ============
    
    def execute(
        self,
        concrete_impl: Dict[str, Any],
        target: MCard,
        context: Dict[str, Any]
    ) -> Any:
        """Execute network operation (sync wrapper for async operations)."""
        builtin = concrete_impl.get("builtin")
        config = concrete_impl.get("config", {})
        
        if not builtin:
            raise ValueError("NetworkRuntime requires 'builtin' to be defined in config.")
        
        # Run async operations in event loop
        try:
            loop = asyncio.get_event_loop()
        except RuntimeError:
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
        
        if builtin == "http_request":
            return loop.run_until_complete(self._handle_http_request(config, context))
        elif builtin == "http_get":
            return loop.run_until_complete(self._handle_http_get(config, context))
        elif builtin == "http_post":
            return loop.run_until_complete(self._handle_http_post(config, context))
        elif builtin == "load_url":
            return loop.run_until_complete(self._handle_load_url(config, context))
        elif builtin == "mcard_send":
            return loop.run_until_complete(self._handle_mcard_send(config, context))
        elif builtin == "listen_http":
            return self._handle_listen_http(config, context)
        elif builtin == "mcard_sync":
            return loop.run_until_complete(self._handle_mcard_sync(config, context))
        elif builtin == "listen_sync":
            return self._handle_listen_sync(config, context)
        elif builtin == "session_record":
            return self._handle_session_record(config, context)
        elif builtin == "mcard_read":
            return self._handle_mcard_read(config, context)
        elif builtin == "webrtc_connect":
            return self._handle_webrtc_connect(config, context)
        elif builtin == "webrtc_listen":
            return self._handle_webrtc_listen(config, context)
        elif builtin == "signaling_server":
            return self._handle_signaling_server(config, context)
        elif builtin == "clm_orchestrator":
            return self._handle_clm_orchestrator(config, context)
        else:
            raise ValueError(f"Unknown network builtin: {builtin}")
    
    # ============ HTTP Handlers ============
    
    async def _handle_http_get(self, config: dict, context: Any) -> dict:
        """Handle http_get builtin."""
        return await self._handle_http_request({**config, "method": "GET"}, context)
    
    async def _handle_http_post(self, config: dict, context: Any) -> dict:
        """Handle http_post builtin."""
        params = {**config, "method": "POST"}
        if config.get("json"):
            params["headers"] = {**params.get("headers", {}), "Content-Type": "application/json"}
            params["body"] = json.dumps(config["json"])
        return await self._handle_http_request(params, context)
    
    async def _handle_http_request(self, config: dict, context: Any) -> dict:
        """Handle http_request builtin with retry, caching, rate limiting."""
        start_time = time.time()
        
        # 1. Interpolate variables
        url = self._interpolate(config.get("url", ""), context)
        self._validate_url_security(url)
        
        method = config.get("method", "GET")
        headers = self._interpolate_headers(config.get("headers", {}), context)
        body = config.get("body")
        
        if isinstance(body, str):
            body = self._interpolate(body, context)
        elif isinstance(body, dict):
            body = json.dumps(body)
        
        # Add query params
        parsed_url = urlparse(url)
        query_params = config.get("query_params", {})
        if query_params:
            interpolated_params = {
                k: self._interpolate(str(v), context) 
                for k, v in query_params.items()
            }
            url_with_params = f"{url}{'&' if parsed_url.query else '?'}{urlencode(interpolated_params)}"
        else:
            url_with_params = url
        
        # 2. Check cache (GET only)
        cache_config = config.get("cache")
        cache_key = self._generate_cache_key(method, url_with_params, body)
        
        if cache_config and cache_config.get("enabled") and method == "GET":
            cached = self._get_cached_response(cache_key)
            if cached:
                logger.info(f"[Network] Cache hit for {url}")
                return asdict(cached)
        
        # 3. Rate limiting
        domain = urlparse(url).hostname or ""
        await self._wait_for_rate_limit(domain)
        
        # 4. Retry configuration
        retry_config = config.get("retry", {})
        max_attempts = retry_config.get("max_attempts", 1)
        backoff = BackoffStrategy(retry_config.get("backoff", "exponential"))
        base_delay = retry_config.get("base_delay", 1000)
        max_delay = retry_config.get("max_delay", 30000)
        retry_on = retry_config.get("retry_on")
        
        last_error: Optional[Exception] = None
        last_status: Optional[int] = None
        retries_attempted = 0
        
        timeout_config = config.get("timeout", 30000)
        if isinstance(timeout_config, dict):
            timeout_ms = timeout_config.get("total", 30000)
        else:
            timeout_ms = timeout_config
        timeout_sec = timeout_ms / 1000
        
        for attempt in range(1, max_attempts + 1):
            try:
                ttfb_start = time.time()
                
                async with aiohttp.ClientSession() as session:
                    async with session.request(
                        method=method,
                        url=url_with_params,
                        headers=headers,
                        data=body,
                        timeout=aiohttp.ClientTimeout(total=timeout_sec),
                        ssl=config.get("validate_ssl", True),
                    ) as response:
                        ttfb_time = int((time.time() - ttfb_start) * 1000)
                        
                        # Check if should retry based on status
                        if not response.ok and self._should_retry_status(response.status, retry_on):
                            last_status = response.status
                            if attempt < max_attempts:
                                retries_attempted += 1
                                delay = self._calculate_backoff_delay(
                                    attempt, backoff, base_delay, max_delay
                                )
                                logger.info(
                                    f"[Network] Retry {attempt}/{max_attempts} for {url} "
                                    f"(status: {response.status}, delay: {delay}ms)"
                                )
                                await asyncio.sleep(delay / 1000)
                                continue
                        
                        # 5. Process response
                        response_type = config.get("response_type", "json")
                        
                        if response_type == "json":
                            try:
                                response_body = await response.json()
                            except Exception:
                                response_body = await response.text()
                        elif response_type == "text":
                            response_body = await response.text()
                        elif response_type == "binary":
                            raw = await response.read()
                            response_body = base64.b64encode(raw).decode("ascii")
                        else:
                            response_body = await response.text()
                        
                        total_time = int((time.time() - start_time) * 1000)
                        
                        # 6. Calculate mcard_hash
                        mcard_hash: Optional[str] = None
                        try:
                            body_str = (
                                response_body if isinstance(response_body, str)
                                else json.dumps(response_body)
                            )
                            card = MCard(body_str)
                            mcard_hash = card.hash
                        except Exception:
                            pass
                        
                        result = HttpSuccessResponse(
                            success=True,
                            status=response.status,
                            headers=dict(response.headers),
                            body=response_body,
                            timing=HttpTiming(
                                dns=0,
                                connect=0,
                                ttfb=ttfb_time,
                                total=total_time,
                            ),
                            mcard_hash=mcard_hash,
                        )
                        
                        # 7. Cache successful GET responses
                        if (cache_config and cache_config.get("enabled") and 
                            method == "GET" and response.ok):
                            ttl = cache_config.get("ttl", 300)
                            self._cache_response(cache_key, result, ttl)
                            if cache_config.get("storage") == "mcard":
                                await self._cache_to_persistent_storage(cache_key, result, ttl)
                        
                        return asdict(result)
            
            except asyncio.TimeoutError as e:
                last_error = e
                if attempt < max_attempts:
                    retries_attempted += 1
                    delay = self._calculate_backoff_delay(
                        attempt, backoff, base_delay, max_delay
                    )
                    logger.info(
                        f"[Network] Retry {attempt}/{max_attempts} for {url} "
                        f"(error: timeout, delay: {delay}ms)"
                    )
                    await asyncio.sleep(delay / 1000)
                    continue
            
            except Exception as e:
                last_error = e
                if attempt < max_attempts:
                    retries_attempted += 1
                    delay = self._calculate_backoff_delay(
                        attempt, backoff, base_delay, max_delay
                    )
                    logger.info(
                        f"[Network] Retry {attempt}/{max_attempts} for {url} "
                        f"(error: {e}, delay: {delay}ms)"
                    )
                    await asyncio.sleep(delay / 1000)
                    continue
        
        # All retries exhausted
        error_code = "TIMEOUT" if isinstance(last_error, asyncio.TimeoutError) else "HTTP_ERROR"
        return asdict(HttpErrorResponse(
            success=False,
            error=HttpError(
                code=error_code,
                message=str(last_error) if last_error else "Request failed after retries",
                status=last_status,
                retries_attempted=retries_attempted,
            )
        ))
    
    async def _handle_load_url(self, config: dict, context: Any) -> dict:
        """Handle load_url builtin."""
        url = self._interpolate(config.get("url", ""), context)
        self._validate_url_security(url)
        
        try:
            async with aiohttp.ClientSession() as session:
                async with session.get(url) as response:
                    content = await response.text()
                    
                    # Calculate mcard_hash
                    mcard_hash: Optional[str] = None
                    try:
                        card = MCard(content)
                        mcard_hash = card.hash
                    except Exception:
                        pass
                    
                    return {
                        "url": url,
                        "content": content,
                        "status": response.status,
                        "headers": dict(response.headers),
                        "mcard_hash": mcard_hash,
                    }
        except Exception as e:
            return {"success": False, "error": str(e)}
    
    async def _handle_mcard_send(self, config: dict, context: Any) -> dict:
        """Handle mcard_send builtin."""
        if not self.collection:
            raise ValueError("MCard Send requires a CardCollection.")
        
        hash_val = self._interpolate(config.get("hash", ""), context)
        url = self._interpolate(config.get("url", ""), context)
        
        card = self.collection.get(hash_val)
        if not card:
            return {"success": False, "error": f"MCard not found: {hash_val}"}
        
        payload = self._serialize_mcard(card)
        
        return await self._handle_http_post({
            "url": url,
            "json": payload,
            "headers": config.get("headers", {}),
        }, context)
    
    # ============ MCard Sync ============
    
    async def _handle_mcard_sync(self, config: dict, context: Any) -> dict:
        """Handle mcard_sync builtin with bidirectional support."""
        if not self.collection:
            raise ValueError("MCard Sync requires a CardCollection.")
        
        mode = self._interpolate(config.get("mode", "pull"), context)
        url = self._interpolate(config.get("url", ""), context).rstrip("/")
        
        # 1. Get local manifest
        local_cards = list(self.collection.get_all_mcards_raw())
        local_hashes = {c.hash for c in local_cards}
        
        # 2. Get remote manifest
        manifest_res = await self._handle_http_request({
            "url": f"{url}/manifest",
            "method": "GET",
        }, context)
        
        if not manifest_res.get("success", True):
            raise ValueError(f"Failed to fetch remote manifest: {manifest_res.get('error')}")
        
        remote_hashes: Set[str] = set(manifest_res.get("body", []))
        
        stats = {
            "mode": mode,
            "local_total": len(local_hashes),
            "remote_total": len(remote_hashes),
            "synced": 0,
        }
        
        async def push_cards() -> int:
            """Push local cards to remote."""
            to_send = [c for c in local_cards if c.hash not in remote_hashes]
            if not to_send:
                return 0
            
            payload = {"cards": [self._serialize_mcard(c) for c in to_send]}
            push_res = await self._handle_http_post({
                "url": f"{url}/batch",
                "json": payload,
                "headers": config.get("headers", {}),
            }, context)
            
            if not push_res.get("success", True):
                raise ValueError(f"Failed to push batch: {push_res.get('error')}")
            
            return len(to_send)
        
        async def pull_cards() -> int:
            """Pull remote cards to local."""
            needed = [h for h in remote_hashes if h not in local_hashes]
            if not needed:
                return 0
            
            fetch_res = await self._handle_http_post({
                "url": f"{url}/get",
                "json": {"hashes": needed},
                "headers": config.get("headers", {}),
            }, context)
            
            if not fetch_res.get("success", True):
                raise ValueError(f"Failed to pull batch: {fetch_res.get('error')}")
            
            received = fetch_res.get("body", {}).get("cards", [])
            for card_json in received:
                card = self._deserialize_mcard(card_json)
                self.collection.add(card)
            
            return len(received)
        
        if mode == "push":
            stats["synced"] = await push_cards()
        elif mode == "pull":
            stats["synced"] = await pull_cards()
        elif mode in ("both", "bidirectional"):
            pushed = await push_cards()
            pulled = await pull_cards()
            stats["pushed"] = pushed
            stats["pulled"] = pulled
            stats["synced"] = pushed + pulled
        else:
            raise ValueError(f"Unknown sync mode: {mode}. Valid: push, pull, both")
        
        return {"success": True, "stats": stats}
    
    # ============ HTTP Listeners ============
    
    def _handle_listen_http(self, config: dict, context: Any) -> dict:
        """Handle listen_http builtin (starts HTTP server for receiving MCards)."""
        port = int(self._interpolate(str(config.get("port", 3000)), context))
        path = self._interpolate(config.get("path", "/mcard"), context)
        
        runtime = self  # Capture for handler
        
        class MCardHandler(BaseHTTPRequestHandler):
            def do_POST(self):
                if self.path == path:
                    try:
                        content_length = int(self.headers.get("Content-Length", 0))
                        body = self.rfile.read(content_length)
                        payload = json.loads(body.decode("utf-8"))
                        
                        card = runtime._deserialize_mcard(payload)
                        
                        if runtime.collection:
                            runtime.collection.add(card)
                        
                        self.send_response(200)
                        self.send_header("Content-Type", "application/json")
                        self.end_headers()
                        self.wfile.write(json.dumps({
                            "success": True,
                            "hash": card.hash
                        }).encode())
                    except Exception as e:
                        self.send_response(400)
                        self.send_header("Content-Type", "application/json")
                        self.end_headers()
                        self.wfile.write(json.dumps({
                            "success": False,
                            "error": str(e)
                        }).encode())
                else:
                    self.send_response(404)
                    self.end_headers()
            
            def log_message(self, format, *args):
                logger.debug(f"[Network] {args[0]}")
        
        server = HTTPServer(("", port), MCardHandler)
        thread = Thread(target=server.serve_forever, daemon=True)
        thread.start()
        
        logger.info(f"[Network] Listening on port {port} at {path}")
        
        return {
            "success": True,
            "message": f"Server started on port {port}",
            "port": port,
            "path": path,
        }
    
    def _handle_listen_sync(self, config: dict, context: Any) -> dict:
        """Handle listen_sync builtin (starts sync server)."""
        if not self.collection:
            raise ValueError("Listen Sync requires a CardCollection.")
        
        port = int(self._interpolate(str(config.get("port", 3000)), context))
        base_path = self._interpolate(config.get("base_path", "/sync"), context)
        
        runtime = self
        
        class SyncHandler(BaseHTTPRequestHandler):
            def do_GET(self):
                if self.path == f"{base_path}/manifest":
                    all_cards = list(runtime.collection.get_all_mcards_raw())
                    hashes = [c.hash for c in all_cards]
                    self.send_response(200)
                    self.send_header("Content-Type", "application/json")
                    self.end_headers()
                    self.wfile.write(json.dumps(hashes).encode())
                else:
                    self.send_response(404)
                    self.end_headers()
            
            def do_POST(self):
                try:
                    content_length = int(self.headers.get("Content-Length", 0))
                    body = json.loads(self.rfile.read(content_length).decode("utf-8"))
                    
                    if self.path == f"{base_path}/batch":
                        # Receive push
                        cards = body.get("cards", [])
                        added = 0
                        for card_json in cards:
                            card = runtime._deserialize_mcard(card_json)
                            runtime.collection.add(card)
                            added += 1
                        
                        self.send_response(200)
                        self.send_header("Content-Type", "application/json")
                        self.end_headers()
                        self.wfile.write(json.dumps({
                            "success": True,
                            "added": added
                        }).encode())
                    
                    elif self.path == f"{base_path}/get":
                        # Serve pull
                        requested = body.get("hashes", [])
                        found = []
                        for h in requested:
                            card = runtime.collection.get(h)
                            if card:
                                found.append(runtime._serialize_mcard(card))
                        
                        self.send_response(200)
                        self.send_header("Content-Type", "application/json")
                        self.end_headers()
                        self.wfile.write(json.dumps({
                            "success": True,
                            "cards": found
                        }).encode())
                    
                    else:
                        self.send_response(404)
                        self.end_headers()
                
                except Exception as e:
                    self.send_response(500)
                    self.send_header("Content-Type", "application/json")
                    self.end_headers()
                    self.wfile.write(json.dumps({
                        "success": False,
                        "error": str(e)
                    }).encode())
            
            def log_message(self, format, *args):
                logger.debug(f"[Network] {args[0]}")
        
        server = HTTPServer(("", port), SyncHandler)
        thread = Thread(target=server.serve_forever, daemon=True)
        thread.start()
        
        logger.info(f"[Network] Sync listening on port {port} at {base_path}")
        
        return {
            "success": True,
            "message": f"Sync Server started on port {port}",
            "port": port,
            "base_path": base_path,
        }
    
    # ============ Session Recording ============
    
    def _handle_session_record(self, config: dict, context: Any) -> dict:
        """Handle session_record builtin for P2P session management."""
        from .p2p_session import P2PChatSession
        
        operation = config.get("operation", "add")
        session_id = self._interpolate(config.get("session_id", config.get("sessionId", "")), context)
        
        if not session_id:
            return {"success": False, "error": "session_id is required"}
        
        if operation == "batch":
            # Batch mode: execute multiple operations in sequence
            return self._handle_session_batch(config, context)
        
        # Get or create session
        if session_id not in self._sessions:
            initial_hash = config.get("initial_head_hash") or config.get("initialHeadHash")
            max_buffer = config.get("max_buffer_size", config.get("maxBufferSize", 5))
            self._sessions[session_id] = P2PChatSession(
                collection=self.collection,
                session_id=session_id,
                max_buffer_size=max_buffer,
                initial_head_hash=initial_hash
            )
        
        session = self._sessions[session_id]
        
        if operation == "init":
            # Initialize or reinitialize session
            initial_hash = config.get("initial_head_hash") or config.get("initialHeadHash")
            max_buffer = config.get("max_buffer_size", config.get("maxBufferSize", 5))
            self._sessions[session_id] = P2PChatSession(
                collection=self.collection,
                session_id=session_id,
                max_buffer_size=max_buffer,
                initial_head_hash=initial_hash
            )
            return {
                "success": True,
                "operation": "init",
                "session_id": session_id,
                "head_hash": initial_hash
            }
        
        elif operation == "add":
            sender = self._interpolate(config.get("sender", ""), context)
            content = self._interpolate(config.get("content", ""), context)
            
            if not sender or not content:
                return {"success": False, "error": "sender and content are required for add"}
            
            checkpoint_hash = session.add_message(sender, content)
            return {
                "success": True,
                "operation": "add",
                "session_id": session_id,
                "checkpoint_hash": checkpoint_hash,
                "head_hash": session.get_head_hash()
            }
        
        elif operation == "flush":
            checkpoint_hash = session.checkpoint()
            return {
                "success": True,
                "operation": "flush",
                "session_id": session_id,
                "head_hash": checkpoint_hash
            }
        
        elif operation == "summarize":
            keep_originals = config.get("keep_originals", config.get("keepOriginals", True))
            summary_hash = session.summarize(keep_originals=keep_originals)
            
            # Read summary content
            summary_content = None
            if self.collection and summary_hash:
                summary_card = self.collection.get(summary_hash)
                if summary_card:
                    raw = summary_card.get_content()
                    if isinstance(raw, bytes):
                        raw = raw.decode("utf-8")
                    try:
                        summary_content = json.loads(raw)
                    except json.JSONDecodeError:
                        summary_content = raw
            
            return {
                "success": True,
                "operation": "summarize",
                "session_id": session_id,
                "summary_hash": summary_hash,
                "content": summary_content
            }
        
        else:
            return {"success": False, "error": f"Unknown operation: {operation}"}
    
    def _handle_session_batch(self, config: dict, context: Any) -> dict:
        """Handle batch session operations."""
        from .p2p_session import P2PChatSession
        
        operations = config.get("operations", [])
        
        # Also check context for operations (for CLM pass-through)
        if not operations and isinstance(context, dict):
            operations = context.get("params", {}).get("operations", [])
            if not operations:
                operations = context.get("operations", [])
        
        if not operations:
            return {"success": False, "error": "No operations provided for batch"}
        
        session_id = self._interpolate(
            config.get("session_id", config.get("sessionId", "")),
            context
        )
        
        results = []
        for op in operations:
            # Merge session_id into each operation if not present
            op_config = {**op}
            if not op_config.get("session_id") and not op_config.get("sessionId"):
                op_config["session_id"] = session_id
            
            result = self._handle_session_record(op_config, context)
            results.append(result)
            
            if not result.get("success", True):
                # Stop on first failure
                break
        
        return {
            "success": all(r.get("success", True) for r in results),
            "operation": "batch",
            "results": results
        }
    
    def _handle_mcard_read(self, config: dict, context: Any) -> dict:
        """Handle mcard_read builtin to read MCard content by hash."""
        if not self.collection:
            return {"success": False, "error": "No CardCollection available"}
        
        hash_val = self._interpolate(config.get("hash", ""), context)
        if not hash_val:
            return {"success": False, "error": "hash is required"}
        
        card = self.collection.get(hash_val)
        if not card:
            return {"success": False, "error": f"MCard not found: {hash_val}"}
        
        content = card.get_content()
        if isinstance(content, bytes):
            content = content.decode("utf-8", errors="replace")
        
        parse_json = config.get("parse_json", True)
        if parse_json:
            try:
                content = json.loads(content)
            except json.JSONDecodeError:
                pass  # Return as string if not valid JSON
        
        return {
            "success": True,
            "hash": hash_val,
            "content": content,
            "g_time": card.g_time if hasattr(card, "g_time") else None
        }
    
    # ============ WebRTC Handlers (Placeholder) ============
    
    def _handle_webrtc_connect(self, config: dict, context: Any) -> dict:
        """
        Handle webrtc_connect builtin.
        
        Note: Full WebRTC requires browser or native bindings. This provides
        a mock/placeholder for testing until aiortc is integrated.
        """
        signaling_url = self._interpolate(config.get("signaling_url", ""), context)
        target_peer = self._interpolate(config.get("target_peer_id", ""), context)
        my_peer = config.get("peer_id") or f"peer_{int(time.time() * 1000)}"
        
        # Mock mode for testing
        if signaling_url == "mock://p2p":
            return {
                "success": True,
                "peer_id": my_peer,
                "channel": config.get("channel_label", "mcard-sync"),
                "status": "connected",
                "mock": True
            }
        
        # Real WebRTC requires aiortc
        try:
            import aiortc
            # TODO: Implement real WebRTC connection
            return {
                "success": False,
                "error": "WebRTC real connection not yet implemented. Use mock://p2p for testing."
            }
        except ImportError:
            return {
                "success": False,
                "error": "WebRTC requires aiortc. Install with: pip install aiortc"
            }
    
    def _handle_webrtc_listen(self, config: dict, context: Any) -> dict:
        """
        Handle webrtc_listen builtin.
        
        Note: Full WebRTC requires browser or native bindings. This provides
        a mock/placeholder for testing until aiortc is integrated.
        """
        signaling_url = self._interpolate(config.get("signaling_url", ""), context)
        my_peer = config.get("peer_id") or f"listener_{int(time.time() * 1000)}"
        
        # Mock mode for testing
        if signaling_url == "mock://p2p":
            return {
                "success": True,
                "peer_id": my_peer,
                "status": "listening",
                "mock": True
            }
        
        return {
            "success": True,
            "peer_id": my_peer,
            "status": "listening",
            "note": "WebRTC listen requires signaling server implementation"
        }
    
    def _handle_signaling_server(self, config: dict, context: Any) -> Dict[str, Any]:
        """
        Handle signaling_server builtin.
        
        Starts the PTR's builtin signaling server for WebRTC coordination.
        This uses the Python signaling server implementation.
        
        Config:
            port: int = 3000 - Port to listen on
            background: bool = True - Run in background thread
        
        Returns:
            Dict with success, port, and server info
        """
        from mcard.ptr.core.signaling_server import run_server
        
        port = config.get("port", 3000)
        background = config.get("background", True)
        
        logger.info(f"[Network] Starting signaling server on port {port}...")
        
        try:
            server = run_server(port=port, background=background, auto_find_port=True)
            actual_port = getattr(server, 'actual_port', port)
            
            # Store server reference for potential cleanup
            self._signaling_server = server
            
            return {
                "success": True,
                "port": actual_port,
                "message": f"Signaling server started on port {actual_port}"
            }
        except Exception as e:
            logger.error(f"[Network] Failed to start signaling server: {e}")
            return {
                "success": False,
                "error": str(e)
            }
    

    def _handle_clm_orchestrator(self, config: dict, context: Any) -> Dict[str, Any]:
        """
        Handle clm_orchestrator builtin.
        Executes a sequence of steps defined in the config.
        """
        steps = config.get("steps", [])
        state = {}
        all_success = True
        
        logger.info(f"[Orchestrator] Starting sequence with {len(steps)} steps.")
        
        for step in steps:
            action = step.get("action")
            name = step.get("name", action)
            logger.info(f"[Orchestrator] Step: {name}")
            
            try:
                if action == "start_process":
                    cmd_template = step.get("command", "")
                    cmd = self._interpolate(cmd_template, context)
                    
                    env = os.environ.copy()
                    env.update(step.get("env", {}))
                    
                    parts = cmd.split()
                    proc = subprocess.Popen(
                        parts,
                        env=env,
                        cwd=os.getcwd(),
                        stdout=sys.stdout, 
                        stderr=sys.stderr
                    )
                    
                    id_key = step.get("id_key")
                    pid_key = step.get("pid_key") # For backward compat or mixed keys
                    key = id_key or pid_key
                    
                    if key:
                        self._active_processes[key] = proc
                        state[key] = proc.pid
                        logger.info(f"[Orchestrator] Process started (PID: {proc.pid}) stored in '{key}'")
                    else:
                        logger.info(f"[Orchestrator] Process started (PID: {proc.pid})")
                        
                    wait_after = step.get("wait_after")
                    if wait_after:
                        time.sleep(wait_after / 1000.0)
                        
                elif action == "start_signaling_server":
                    from mcard.ptr.core.signaling_server import run_server
                    port = step.get("port", 3000)
                    id_key = step.get("id_key", "server_id")
                    
                    # Force background=True for orchestrator
                    server = run_server(port=port, background=True, auto_find_port=True)
                    self._servers[id_key] = server
                    actual_port = getattr(server, 'actual_port', port)
                    logger.info(f"[Orchestrator] Signaling server started on port {actual_port}")
                    
                    wait_after = step.get("wait_after")
                    if wait_after:
                        time.sleep(wait_after / 1000.0)

                elif action == "stop_signaling_server":
                    id_key = step.get("id_key", "server_id")
                    server = self._servers.get(id_key)
                    if server:
                        server.shutdown()
                        # Also close socket if possible, though shutdown usually stops serve_forever
                        del self._servers[id_key]
                        logger.info(f"[Orchestrator] Signaling server stopped ({id_key})")
                    else:
                        logger.warning(f"[Orchestrator] Server {id_key} not found to stop")

                elif action == "stop_process":
                    id_key = step.get("id_key") or step.get("pid_key")
                    proc = self._active_processes.get(id_key) if id_key else None
                    
                    if proc:
                        proc.terminate()
                        try:
                            proc.wait(timeout=5)
                        except subprocess.TimeoutExpired:
                            proc.kill()
                        
                        if id_key:
                            del self._active_processes[id_key]
                        logger.info(f"[Orchestrator] Process stopped ({id_key})")
                    else:
                        logger.warning(f"[Orchestrator] Process {id_key} not found to stop")
                        
                elif action == "run_clm":
                    file = step.get("file")
                    input_data = step.get("input", {})
                    
                    # Resolve path
                    pcard_dir = context.get("pcard_dir")
                    if pcard_dir and not os.path.isabs(file):
                        # Try relative to pcard_dir
                        joined = os.path.join(pcard_dir, file)
                        if os.path.exists(joined):
                            file = joined
                        else:
                            # Try relative to chapters dir (common in existing yaml)
                            # Or just fallback to CWD resolution
                            pass
                            
                    run_clm_fn = context.get("run_clm")
                    if not run_clm_fn:
                         # Try fallback to CLMRunner import if context capability missing
                         from mcard.ptr.runner import CLMRunner
                         runner = CLMRunner()
                         def fallback_run(f, i): return runner.run_file(f, i)
                         run_clm_fn = fallback_run
                    
                    logger.info(f"[Orchestrator] Running CLM: {file}")
                    res = run_clm_fn(file, input_data)
                    
                    if res.get("status") != "success" and not step.get("continue_on_error"):
                        logger.error(f"[Orchestrator] CLM Failed: {file} - {res.get('result')}")
                        all_success = False
                        break
                    else:
                        logger.info(f"[Orchestrator] CLM Passed: {file}")

                elif action == "run_clm_background":
                    file = step.get("file")
                    config_env = step.get("env", {})
                    
                    # Resolve path
                    pcard_dir = context.get("pcard_dir")
                    if pcard_dir and not os.path.isabs(file):
                        # Try relative to pcard_dir
                        joined = os.path.join(pcard_dir, file)
                        if os.path.exists(joined):
                            file = joined
                        # Also if orchestrator uses 'chapter_08_P2P/...' but pcard_dir is chapter_08_P2P...
                        # This implies double nesting if we join.
                        # We should check if file exists relative to CWD too.
                        
                    # Use Run Single CLM script which uses valid python path
                    cmd = [sys.executable, "scripts/run_single_clm.py", file]
                    
                    # Handle env
                    env = os.environ.copy()
                    env.update(config_env)
                    
                    # Ensure PYTHONPATH includes current dir if needed
                    if "PYTHONPATH" not in env:
                         env["PYTHONPATH"] = os.getcwd()
                    else:
                         env["PYTHONPATH"] = os.getcwd() + os.pathsep + env["PYTHONPATH"]

                    proc = subprocess.Popen(
                        cmd,
                        env=env,
                        cwd=os.getcwd(),
                        stdout=sys.stdout,
                        stderr=sys.stderr
                    )
                    
                    id_key = step.get("id_key")
                    if id_key:
                        self._active_processes[id_key] = proc
                        state[id_key] = proc.pid
                    
                    logger.info(f"[Orchestrator] Background CLM started ({file}) PID: {proc.pid}")
                    
                    wait_after = step.get("wait_after")
                    if wait_after:
                        time.sleep(wait_after / 1000.0)

            except Exception as e:
                logger.error(f"[Orchestrator] Step failed: {e}")
                if not step.get("continue_on_error"):
                    all_success = False
                    break

        return {"success": all_success, "state": state}


    # ============ Runtime Interface ============
    
    def validate_environment(self) -> bool:
        """Network runtime requires aiohttp for HTTP operations."""
        return AIOHTTP_AVAILABLE
    
    def get_runtime_status(self) -> Dict[str, Any]:
        """Get detailed runtime status."""
        if not AIOHTTP_AVAILABLE:
            return {
                "available": False,
                "version": None,
                "command": "network",
                "details": "aiohttp not installed. Install with: pip install aiohttp",
            }
        return {
            "available": True,
            "version": "1.0.0",
            "command": "network",
            "details": "Network IO Runtime with retry, caching, and rate limiting",
        }


class SecurityError(Exception):
    """Security policy violation error."""
    pass
