"""Strategy marketplace -- browse, publish, subscribe to signals, and purchase strategies.

Two modes:
    **Signal Mode (Copy Trading)**: Author's strategy runs on Horizon Cloud.
    Subscribers receive order signals in real-time without seeing code.
    Author earns a flat subscription fee (90% to author, 10% platform).

    **Package Mode (Direct Sale)**: Author packages an encrypted strategy.
    Buyer purchases and receives a license key to decrypt and run locally.

Usage:
    import horizon as hz

    # Browse the marketplace
    hz.marketplace.browse(query="btc", sort_by="sharpe")
    hz.marketplace.leaderboard(timeframe="30d")

    # Subscribe to a signal-mode strategy
    sub = hz.marketplace.subscribe("listing-id", size_scale=0.5)
    hz.marketplace.follow(sub.id)  # blocking loop, executes signals locally

    # Or as a pipeline function inside hz.run():
    hz.run(
        markets=["btc-100k"],
        pipeline=[hz.signal_follower("sub-id", size_scale=0.5)],
        interval=5.0,
    )

    # Purchase a package-mode strategy
    purchase = hz.marketplace.purchase("listing-id")
    hz.marketplace.activate(purchase.id, license_key=purchase.license_key)
"""

from __future__ import annotations

import logging
import math
import os
import signal as _signal
import time
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable

logger = logging.getLogger("horizon.marketplace")

_MARKETPLACE_DIR = Path.home() / ".horizon" / "marketplace"
_SIGNAL_DEDUP_CAPACITY = 10_000
_DEFAULT_SIGNAL_TTL = 60.0  # seconds - reject signals older than this


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class Listing:
    """A marketplace listing for a strategy."""

    id: str
    strategy_id: str
    author_id: str
    author_name: str
    name: str
    description: str = ""
    mode: str = "signal"  # "signal" or "package"
    price: float = 0.0  # monthly (signal) or one-time (package)
    currency: str = "USD"
    tags: list[str] = field(default_factory=list)
    exchange: str = ""
    markets: list[str] = field(default_factory=list)
    max_sales: int | None = None  # None = unlimited, 1 = exclusive
    status: str = "active"
    visibility: str = "public"  # "public" or "unlisted"
    invite_code: str | None = None  # Only set for unlisted listings (visible to owner)
    # Performance (public, anonymized)
    total_pnl: float = 0.0
    sharpe_ratio: float = 0.0
    max_drawdown: float = 0.0
    win_rate: float = 0.0
    total_subscribers: int = 0
    avg_monthly_return: float = 0.0
    live_since: str = ""
    created_at: str = ""
    updated_at: str = ""
    # Verification badges
    verified_performance: bool = False
    verified_trader: bool = False

    def __repr__(self) -> str:
        tag = f" [{self.mode}]" if self.mode else ""
        price = f" ${self.price:.2f}" if self.price else " free"
        return f"Listing({self.name!r}{tag}{price}, id={self.id[:8]}...)"


@dataclass
class VerifiedPerformance:
    """Verified Performance badge data for a listing."""
    verified: bool = False
    verified_at: str = ""
    sharpe: float = 0.0
    total_pnl: float = 0.0
    win_rate: float = 0.0
    max_drawdown: float = 0.0
    total_trades: int = 0
    measurement_period_days: int = 0


@dataclass
class VerifiedTrader:
    """Verified Trader badge data for an author."""
    verified: bool = False
    verified_at: str = ""
    exchanges: list = field(default_factory=list)
    aggregate_pnl: float = 0.0
    aggregate_win_rate: float = 0.0
    time_active_days: int = 0


@dataclass
class VerificationRequest:
    """A pending or completed verification request."""
    id: str = ""
    type: str = ""
    exchange: str = ""
    status: str = "pending"
    result: dict = field(default_factory=dict)
    error_message: str = ""
    created_at: str = ""
    completed_at: str = ""


@dataclass
class Subscription:
    """A user's subscription to a signal-mode listing."""

    id: str
    listing_id: str
    listing_name: str
    status: str = "active"  # active, paused, cancelled, expired
    size_scale: float = 1.0
    max_position: float = 1000.0
    started_at: str = ""
    expires_at: str | None = None
    total_signals: int = 0
    total_pnl: float = 0.0

    def __repr__(self) -> str:
        return f"Subscription({self.listing_name!r} [{self.status}], id={self.id[:8]}...)"


@dataclass
class Purchase:
    """A completed package-mode purchase."""

    id: str
    listing_id: str
    listing_name: str
    status: str = "completed"  # completed, refunded
    license_key: str = ""  # returned once on purchase
    checkout_url: str = ""  # Stripe checkout URL (before payment)
    purchased_at: str = ""
    expires_at: str | None = None  # None = perpetual

    def __repr__(self) -> str:
        return f"Purchase({self.listing_name!r} [{self.status}], id={self.id[:8]}...)"


@dataclass
class Signal:
    """A single signal from a signal-mode subscription."""

    id: str
    subscription_id: str
    market_id: str
    side: str  # "yes" / "no"
    order_side: str  # "buy" / "sell"
    price: float
    size: float
    timestamp: str
    metadata: dict = field(default_factory=dict)

    def __repr__(self) -> str:
        return (
            f"Signal({self.order_side.upper()} {self.side} "
            f"{self.size:.2f}@{self.price:.4f} on {self.market_id[:20]})"
        )


@dataclass
class MarketplaceEarnings:
    """Earnings summary for a listing author."""

    listing_id: str
    total_revenue: float = 0.0
    author_share: float = 0.0
    platform_fee: float = 0.0
    total_subscribers: int = 0
    total_purchases: int = 0
    currency: str = "USD"
    breakdown: list[dict] = field(default_factory=list)

    def __repr__(self) -> str:
        return f"Earnings(${self.author_share:.2f} earned, {self.total_subscribers} subs)"


# ---------------------------------------------------------------------------
# Signal dedup state
# ---------------------------------------------------------------------------


class _SignalFollowerState:
    """Track processed signals for dedup and watermarking."""

    def __init__(self, subscription_id: str, signal_ttl: float = _DEFAULT_SIGNAL_TTL):
        self.subscription_id = subscription_id
        self.signal_ttl = signal_ttl
        self._seen_deque: deque[str] = deque()
        self._seen_set: set[str] = set()
        self._last_signal_id: str | None = None
        self._cloud = None

    def _get_cloud(self):
        if self._cloud is None:
            from horizon.cloud import HorizonCloud
            self._cloud = HorizonCloud()
        return self._cloud

    def is_duplicate(self, signal_id: str) -> bool:
        return signal_id in self._seen_set

    def mark_seen(self, signal_id: str) -> None:
        if signal_id in self._seen_set:
            return
        self._seen_set.add(signal_id)
        self._seen_deque.append(signal_id)
        while len(self._seen_deque) > _SIGNAL_DEDUP_CAPACITY:
            evicted = self._seen_deque.popleft()
            self._seen_set.discard(evicted)

    def poll_signals(self) -> list[Signal]:
        """Fetch new signals from the platform."""
        cloud = self._get_cloud()
        params: dict[str, Any] = {"limit": 50}
        if self._last_signal_id:
            params["after"] = self._last_signal_id
        try:
            resp = cloud._get(
                f"/marketplace/signals/{self.subscription_id}", **params
            )
            raw_signals = resp.get("data", [])
            signals = [_to_signal(s) for s in raw_signals]
            if signals:
                self._last_signal_id = signals[-1].id
            return signals
        except Exception as e:
            logger.warning("[marketplace] Signal poll failed: %s", e)
            return []


# ---------------------------------------------------------------------------
# Signal-to-order mapping
# ---------------------------------------------------------------------------


def _signal_to_order(
    sig: Signal,
    size_scale: float,
    max_position: float,
    engine: Any,
) -> Any | None:
    """Convert a Signal to an OrderRequest, applying scaling and limits."""
    from horizon._horizon import OrderRequest, OrderSide, Side

    side = Side.Yes if sig.side.lower() == "yes" else Side.No
    order_side = OrderSide.Buy if sig.order_side.lower() == "buy" else OrderSide.Sell

    # Validate price and size (NaN/Inf check)
    if not math.isfinite(sig.price) or sig.price <= 0 or sig.price >= 1:
        logger.warning("[marketplace] Invalid price %.4f on %s, rejecting signal", sig.price, sig.market_id)
        return None
    if not math.isfinite(sig.size) or sig.size <= 0:
        logger.warning("[marketplace] Invalid size %.4f on %s, rejecting signal", sig.size, sig.market_id)
        return None

    scaled_size = sig.size * size_scale

    if not math.isfinite(scaled_size) or scaled_size < 0.01:
        return None

    # Clamp to max_position for buys
    if order_side == OrderSide.Buy and max_position > 0:
        try:
            exposure = engine.snapshot_exposure(sig.market_id)
            current = exposure.get("market_notional", 0.0) if isinstance(exposure, dict) else 0.0
            remaining = max(0.0, max_position - current)
            if remaining <= 0:
                logger.debug("[marketplace] Position limit reached for %s", sig.market_id)
                return None
            scaled_size = min(scaled_size, remaining / sig.price)
        except Exception as e:
            logger.warning("[marketplace] Exposure check failed for %s: %s, proceeding with full size", sig.market_id, e)

    if scaled_size < 0.01:
        return None

    return OrderRequest(
        market_id=sig.market_id,
        side=side,
        order_side=order_side,
        size=round(scaled_size, 4),
        price=round(sig.price, 4),
    )


def _is_signal_stale(sig: Signal, ttl: float) -> bool:
    """Check if a signal is too old to execute."""
    from datetime import datetime, timezone

    try:
        sig_time = datetime.fromisoformat(sig.timestamp.replace("Z", "+00:00"))
        age = (datetime.now(timezone.utc) - sig_time).total_seconds()
        return age > ttl
    except Exception as e:
        logger.warning("[marketplace] Unparseable signal timestamp: %s, rejecting as stale", e)
        return True  # Reject malformed timestamps as stale (safe default)


# ---------------------------------------------------------------------------
# Marketplace client
# ---------------------------------------------------------------------------


class Marketplace:
    """Client for the Horizon strategy marketplace.

    Supports browsing, publishing, subscribing (signal mode),
    purchasing (package mode), and following signals.

    Uses your ``hz_sdk_`` key for authentication.
    """

    def __init__(self, api_key: str | None = None, base_url: str | None = None):
        self._api_key = api_key
        self._base_url = base_url
        self._cloud = None

    def _get_cloud(self):
        """Lazy-init the HorizonCloud client."""
        if self._cloud is None:
            from horizon.cloud import HorizonCloud
            self._cloud = HorizonCloud(api_key=self._api_key, base_url=self._base_url)
        return self._cloud

    # ── browse ─────────────────────────────────────────────────────────

    def browse(
        self,
        query: str = "",
        *,
        tags: list[str] | None = None,
        mode: str | None = None,
        exchange: str | None = None,
        sort_by: str = "sharpe",
        limit: int = 20,
        offset: int = 0,
        verified_only: bool = False,
    ) -> list[Listing]:
        """Browse public marketplace listings.

        Args:
            query: Search term (matches name, description, tags).
            tags: Filter by tags (e.g., ["btc", "market-making"]).
            mode: Filter by mode: ``"signal"`` or ``"package"``.
            exchange: Filter by exchange: ``"polymarket"``, ``"kalshi"``, etc.
            sort_by: Sort order: ``"sharpe"``, ``"pnl"``, ``"subscribers"``,
                ``"price"``, ``"newest"``.
            limit: Max results (default 20).
            offset: Pagination offset.
            verified_only: If True, only return listings with verification badges.

        Returns:
            List of Listing objects.
        """
        cloud = self._get_cloud()
        params: dict[str, Any] = {
            "sort_by": sort_by,
            "limit": limit,
            "offset": offset,
        }
        if query:
            params["query"] = query
        if tags:
            params["tags"] = ",".join(tags)
        if mode:
            params["mode"] = mode
        if exchange:
            params["exchange"] = exchange
        if verified_only:
            params["verified_only"] = True

        resp = cloud._get("/marketplace/listings", **params)
        return [_to_listing(d) for d in resp.get("data", [])]

    def get_listing(self, listing_id: str, *, invite_code: str | None = None) -> Listing:
        """Get details for a single listing.

        Args:
            listing_id: UUID of the listing.
            invite_code: Required for unlisted listings.
        """
        cloud = self._get_cloud()
        params: dict[str, Any] = {}
        if invite_code:
            params["invite_code"] = invite_code
        resp = cloud._get(f"/marketplace/listings/{listing_id}", **params)
        return _to_listing(resp.get("data", {}))

    def leaderboard(
        self,
        timeframe: str = "30d",
        limit: int = 20,
    ) -> list[Listing]:
        """Get top-performing strategies by timeframe.

        Args:
            timeframe: ``"7d"``, ``"30d"``, ``"90d"``, or ``"all"``.
            limit: Max results.
        """
        cloud = self._get_cloud()
        resp = cloud._get("/marketplace/leaderboard", timeframe=timeframe, limit=limit)
        return [_to_listing(d) for d in resp.get("data", [])]

    def listing_performance(self, listing_id: str) -> dict:
        """Get detailed performance data (equity curve, daily returns).

        Args:
            listing_id: UUID of the listing.
        """
        cloud = self._get_cloud()
        return cloud._get(f"/marketplace/listings/{listing_id}/performance").get("data", {})

    # ── author: publish ────────────────────────────────────────────────

    def publish(
        self,
        strategy_id: str,
        *,
        mode: str = "signal",
        price: float = 0.0,
        description: str = "",
        tags: list[str] | None = None,
        exchange: str = "",
        markets: list[str] | None = None,
        max_sales: int | None = None,
        visibility: str = "public",
    ) -> Listing:
        """Publish a strategy to the marketplace.

        The strategy must already exist on Horizon Cloud (via ``cloud.create_strategy()``).
        For signal mode, it must also be deployed. Requires the **Ultra** plan.

        Revenue split: 90% to author, 10% platform fee.

        Args:
            strategy_id: UUID of the cloud strategy.
            mode: ``"signal"`` (copy trading) or ``"package"`` (direct sale).
            price: Monthly fee (signal) or one-time price (package) in USD.
            description: Public description for the listing.
            tags: Searchable tags (e.g., ["btc", "market-making"]).
            exchange: Primary exchange (e.g., "polymarket").
            markets: Market slugs this strategy trades.
            max_sales: Max purchases allowed. ``1`` for exclusive sale,
                ``None`` for unlimited.
            visibility: ``"public"`` (visible in browse/search) or
                ``"unlisted"`` (only accessible via invite code).

        Returns:
            The created Listing. If ``visibility="unlisted"``, the listing
            includes an ``invite_code`` to share with specific people.

        Raises:
            HorizonCloudError: If strategy not found, not deployed (signal mode),
                or payments not connected.
        """
        if mode not in ("signal", "package"):
            from horizon.cloud import HorizonCloudError
            raise HorizonCloudError("mode must be 'signal' or 'package'")
        if price < 0:
            from horizon.cloud import HorizonCloudError
            raise HorizonCloudError("price must be >= 0")
        if visibility not in ("public", "unlisted"):
            from horizon.cloud import HorizonCloudError
            raise HorizonCloudError("visibility must be 'public' or 'unlisted'")

        cloud = self._get_cloud()
        payload: dict[str, Any] = {
            "strategy_id": strategy_id,
            "mode": mode,
            "price": price,
            "visibility": visibility,
        }
        if description:
            payload["description"] = description
        if tags:
            payload["tags"] = tags
        if exchange:
            payload["exchange"] = exchange
        if markets:
            payload["markets"] = markets
        if max_sales is not None:
            payload["max_sales"] = max_sales

        resp = cloud._post("/marketplace/listings", payload)
        return _to_listing(resp.get("data", {}))

    def update_listing(self, listing_id: str, **kwargs: Any) -> Listing:
        """Update a listing's metadata (price, description, tags, etc.).

        Args:
            listing_id: UUID of the listing.
            **kwargs: Fields to update (price, description, tags, max_sales).
        """
        cloud = self._get_cloud()
        resp = cloud._request("PATCH", f"/marketplace/listings/{listing_id}", json=kwargs)
        return _to_listing(resp.get("data", {}))

    def pause_listing(self, listing_id: str) -> Listing:
        """Pause a listing (stops new subscriptions/purchases, keeps existing active)."""
        cloud = self._get_cloud()
        resp = cloud._post(f"/marketplace/listings/{listing_id}/pause")
        return _to_listing(resp.get("data", {}))

    def resume_listing(self, listing_id: str) -> Listing:
        """Resume a paused listing."""
        cloud = self._get_cloud()
        resp = cloud._post(f"/marketplace/listings/{listing_id}/resume")
        return _to_listing(resp.get("data", {}))

    def delist(self, listing_id: str) -> dict:
        """Permanently delist a strategy from the marketplace.

        Active subscribers will be notified and no new signals will be sent.
        """
        cloud = self._get_cloud()
        return cloud._post(f"/marketplace/listings/{listing_id}/delist").get("data", {})

    def my_listings(self) -> list[Listing]:
        """List all your published marketplace listings."""
        cloud = self._get_cloud()
        resp = cloud._get("/marketplace/my-listings")
        return [_to_listing(d) for d in resp.get("data", [])]

    def listing_earnings(self, listing_id: str) -> MarketplaceEarnings:
        """Get earnings breakdown for a listing you published.

        Revenue split: 90% author / 10% platform.

        Args:
            listing_id: UUID of the listing.
        """
        cloud = self._get_cloud()
        resp = cloud._get(f"/marketplace/listings/{listing_id}/earnings")
        data = resp.get("data", {})
        return MarketplaceEarnings(
            listing_id=listing_id,
            total_revenue=data.get("total_revenue", 0.0),
            author_share=data.get("author_share", 0.0),
            platform_fee=data.get("platform_fee", 0.0),
            total_subscribers=data.get("total_subscribers", 0),
            total_purchases=data.get("total_purchases", 0),
            currency=data.get("currency", "USD"),
            breakdown=data.get("breakdown", []),
        )

    # ── payments ───────────────────────────────────────────────────────

    def connect_payments(self) -> dict:
        """Start Stripe Connect onboarding to receive marketplace payments.

        Returns a URL to complete Stripe onboarding (KYC, bank account).
        After completion, you can publish paid listings.

        Returns:
            ``{"onboarding_url": "https://connect.stripe.com/...", "account_id": "..."}``
        """
        cloud = self._get_cloud()
        return cloud._post("/marketplace/payments/connect").get("data", {})

    def payment_status(self) -> dict:
        """Check your Stripe Connect onboarding status.

        Returns:
            ``{"connected": bool, "payouts_enabled": bool, "account_id": "..."}``
        """
        cloud = self._get_cloud()
        return cloud._get("/marketplace/payments/status").get("data", {})

    # ── subscriber: signal mode ────────────────────────────────────────

    def subscribe(
        self,
        listing_id: str,
        *,
        size_scale: float = 1.0,
        max_position: float = 1000.0,
        invite_code: str | None = None,
    ) -> Subscription:
        """Subscribe to a signal-mode listing.

        Payment is processed via Stripe. If the listing has a price > 0,
        a checkout URL is returned for completing payment.

        Args:
            listing_id: UUID of the listing to subscribe to.
            size_scale: Multiplier for signal sizes (0.5 = half size).
            max_position: Maximum position per market in USD.
            invite_code: Required for unlisted listings.

        Returns:
            Subscription object. If payment required, ``status`` will be
            ``"pending_payment"`` until checkout is completed.
        """
        if not math.isfinite(size_scale) or size_scale <= 0 or size_scale > 10.0:
            from horizon.cloud import HorizonCloudError
            raise HorizonCloudError("size_scale must be between 0 and 10")
        if not math.isfinite(max_position) or max_position <= 0:
            from horizon.cloud import HorizonCloudError
            raise HorizonCloudError("max_position must be > 0")

        cloud = self._get_cloud()
        payload: dict[str, Any] = {
            "listing_id": listing_id,
            "size_scale": size_scale,
            "max_position": max_position,
        }
        if invite_code:
            payload["invite_code"] = invite_code
        resp = cloud._post("/marketplace/subscribe", payload)
        return _to_subscription(resp.get("data", {}))

    def unsubscribe(self, subscription_id: str) -> dict:
        """Cancel a subscription. Stops signals immediately.

        Args:
            subscription_id: UUID of the subscription.
        """
        cloud = self._get_cloud()
        return cloud._post(f"/marketplace/subscriptions/{subscription_id}/cancel").get("data", {})

    def my_subscriptions(self) -> list[Subscription]:
        """List all your active and past subscriptions."""
        cloud = self._get_cloud()
        resp = cloud._get("/marketplace/subscriptions")
        return [_to_subscription(d) for d in resp.get("data", [])]

    def signals(
        self,
        subscription_id: str,
        *,
        since: str | None = None,
        limit: int = 50,
    ) -> list[Signal]:
        """Fetch recent signals for a subscription.

        Args:
            subscription_id: UUID of the subscription.
            since: ISO timestamp or signal ID to fetch signals after.
            limit: Max signals to return (default 50).

        Returns:
            List of Signal objects, oldest first.
        """
        cloud = self._get_cloud()
        params: dict[str, Any] = {"limit": limit}
        if since:
            params["after"] = since
        resp = cloud._get(f"/marketplace/signals/{subscription_id}", **params)
        return [_to_signal(s) for s in resp.get("data", [])]

    # ── subscriber: follow (live signal execution) ─────────────────────

    def follow(
        self,
        subscription_id: str,
        *,
        size_scale: float = 1.0,
        max_position: float = 1000.0,
        signal_ttl: float = _DEFAULT_SIGNAL_TTL,
        poll_interval: float = 5.0,
        exchange: str = "paper",
        risk: Any = None,
        dry_run: bool = False,
        api_key: str | None = None,
    ) -> None:
        """Follow a subscription's signals in real-time (blocking loop).

        Creates a local Engine, polls for signals, converts them to
        OrderRequests, and submits through the local risk pipeline.

        Args:
            subscription_id: UUID of the subscription.
            size_scale: Multiplier for signal sizes.
            max_position: Maximum position per market in USD.
            signal_ttl: Reject signals older than this (seconds, default 60).
            poll_interval: Seconds between signal polls (default 5).
            exchange: Exchange backend (``"paper"`` or ``"polymarket"``).
            risk: Optional Risk or RiskConfig for the engine.
            dry_run: Log signals without submitting orders.
            api_key: Horizon API key override.
        """
        from horizon._horizon import Engine, RiskConfig

        # Validate parameters
        if not math.isfinite(size_scale) or size_scale <= 0 or size_scale > 10.0:
            raise ValueError("size_scale must be between 0 and 10")
        if not math.isfinite(max_position) or max_position <= 0:
            raise ValueError("max_position must be > 0")
        if not math.isfinite(signal_ttl) or signal_ttl < 0:
            raise ValueError("signal_ttl must be >= 0")
        if not math.isfinite(poll_interval) or poll_interval < 0.1:
            raise ValueError("poll_interval must be >= 0.1 seconds")

        if not logging.root.handlers:
            logging.basicConfig(level=logging.INFO, format="%(message)s")

        # Build risk config
        from horizon.risk import Risk
        risk_config: RiskConfig | None = None
        if isinstance(risk, Risk):
            risk_config = risk.to_config()
        elif isinstance(risk, RiskConfig):
            risk_config = risk

        # Create engine
        horizon_key = api_key or os.environ.get("HORIZON_API_KEY")
        if exchange == "polymarket":
            from horizon.exchanges import Polymarket
            poly = Polymarket()
            if poly.private_key and not poly.api_key:
                poly.derive_api_credentials()
            engine = Engine(
                risk_config=risk_config,
                exchange_type="polymarket",
                exchange_key=poly.api_key,
                exchange_secret=poly.api_secret,
                exchange_passphrase=poly.api_passphrase,
                clob_url=poly.clob_url,
                private_key=poly.private_key,
                api_key=horizon_key,
            )
        else:
            engine = Engine(risk_config=risk_config, api_key=horizon_key)

        state = _SignalFollowerState(subscription_id, signal_ttl=signal_ttl)

        # Signal handling
        running = True

        def handle_signal(sig, frame):
            nonlocal running
            logger.info("[marketplace] Shutting down...")
            running = False

        _signal.signal(_signal.SIGINT, handle_signal)
        _signal.signal(_signal.SIGTERM, handle_signal)

        logger.info("[marketplace] Following subscription %s", subscription_id[:12])
        logger.info("[marketplace] Scale: %.2f, Max position: %.0f, Poll: %.0fs",
                     size_scale, max_position, poll_interval)
        if dry_run:
            logger.info("[marketplace] DRY RUN: no orders will be submitted")

        cycle = 0
        total_executed = 0

        while running:
            cycle_start = time.monotonic()
            cycle += 1

            signals = state.poll_signals()

            for sig in signals:
                if state.is_duplicate(sig.id):
                    continue
                state.mark_seen(sig.id)

                # TTL check
                if _is_signal_stale(sig, signal_ttl):
                    logger.debug("[marketplace] Stale signal %s, skipping", sig.id[:8])
                    continue

                order = _signal_to_order(sig, size_scale, max_position, engine)
                if order is None:
                    continue

                if dry_run:
                    logger.info(
                        "[marketplace] DRY RUN: %s %s %.2f @ %.4f on %s",
                        sig.order_side.upper(), sig.side, order.size, order.price,
                        sig.market_id[:30],
                    )
                    total_executed += 1
                    continue

                try:
                    order_id = engine.submit_order(order)
                    logger.info(
                        "[marketplace] %s %s %.2f @ %.4f on %s -> %s",
                        sig.order_side.upper(), sig.side, order.size, order.price,
                        sig.market_id[:30], order_id,
                    )
                    total_executed += 1
                except Exception as e:
                    logger.warning("[marketplace] Order failed: %s", e)

            # Periodic status
            if cycle % 10 == 0:
                status = engine.status()
                logger.info(
                    "[marketplace] cycle=%d executed=%d orders=%d positions=%d pnl=%.2f",
                    cycle, total_executed, status.open_orders,
                    status.active_positions, status.total_pnl(),
                )

            elapsed = time.monotonic() - cycle_start
            sleep_time = max(0.0, poll_interval - elapsed)
            if sleep_time > 0 and running:
                time.sleep(sleep_time)

        logger.info("[marketplace] Stopped. Total signals executed: %d", total_executed)

    # ── buyer: package mode ────────────────────────────────────────────

    def purchase(self, listing_id: str, *, invite_code: str | None = None) -> Purchase:
        """Purchase a package-mode listing.

        Initiates payment via Stripe. Returns a Purchase object with a
        ``checkout_url`` to complete payment. After payment, the ``license_key``
        is available.

        Revenue split: 90% to author, 10% platform.

        Args:
            listing_id: UUID of the listing.
            invite_code: Required for unlisted listings.

        Returns:
            Purchase with ``checkout_url`` for payment, or ``license_key``
            if already paid (free listings).
        """
        cloud = self._get_cloud()
        payload: dict[str, Any] = {"listing_id": listing_id}
        if invite_code:
            payload["invite_code"] = invite_code
        resp = cloud._post("/marketplace/purchase", payload)
        return _to_purchase(resp.get("data", {}))

    def my_purchases(self) -> list[Purchase]:
        """List all your purchases."""
        cloud = self._get_cloud()
        resp = cloud._get("/marketplace/purchases")
        return [_to_purchase(d) for d in resp.get("data", [])]

    def download(
        self,
        purchase_id: str,
        *,
        directory: str | Path | None = None,
    ) -> dict:
        """Download the encrypted strategy package for a purchase.

        The package is saved to ``~/.horizon/marketplace/<name>/``.
        Use ``activate()`` with the license key to decrypt.

        Args:
            purchase_id: UUID of the purchase.
            directory: Override download directory.

        Returns:
            ``{"path": "...", "encrypted": True}``
        """
        cloud = self._get_cloud()
        resp = cloud._get(f"/marketplace/purchases/{purchase_id}/download")
        data = resp.get("data", {})

        name = data.get("name", purchase_id[:8])
        code = data.get("encrypted_code", "")

        import re
        safe_name = re.sub(r"[^\w\-]", "_", name.lower().strip())
        base_dir = Path(directory) if directory else _MARKETPLACE_DIR
        pkg_dir = base_dir / safe_name
        pkg_dir.mkdir(parents=True, exist_ok=True)

        # Write encrypted package
        pkg_path = pkg_dir / "strategy.enc"
        pkg_path.write_text(code, encoding="utf-8")

        # Write manifest
        import json
        manifest = {
            "purchase_id": purchase_id,
            "listing_id": data.get("listing_id", ""),
            "name": name,
            "encrypted": True,
            "downloaded_at": _now_iso(),
        }
        manifest_path = pkg_dir / "manifest.json"
        manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")

        logger.info("Downloaded package to %s", pkg_dir)
        return {"path": str(pkg_dir), "encrypted": True}

    def activate(
        self,
        purchase_id: str,
        *,
        license_key: str,
        directory: str | Path | None = None,
    ) -> dict:
        """Activate a purchased package with its license key.

        Sends the license key to the platform for verification, receives
        the decryption key, and decrypts the strategy locally.

        Args:
            purchase_id: UUID of the purchase.
            license_key: The license key received at purchase time.
            directory: Directory where the encrypted package was downloaded.

        Returns:
            ``{"path": "...", "code_path": "...", "activated": True}``
        """
        cloud = self._get_cloud()
        resp = cloud._post(
            f"/marketplace/purchases/{purchase_id}/activate",
            {"license_key": license_key},
        )
        data = resp.get("data", {})

        decrypted_code = data.get("code", "")
        name = data.get("name", purchase_id[:8])

        import re
        import json
        safe_name = re.sub(r"[^\w\-]", "_", name.lower().strip())
        base_dir = Path(directory) if directory else _MARKETPLACE_DIR
        pkg_dir = base_dir / safe_name
        pkg_dir.mkdir(parents=True, exist_ok=True)

        # Write decrypted code
        code_path = pkg_dir / "strategy.py"
        code_path.write_text(decrypted_code, encoding="utf-8")

        # Update manifest
        manifest_path = pkg_dir / "manifest.json"
        manifest = {}
        if manifest_path.exists():
            try:
                manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
            except Exception:
                pass
        manifest.update({
            "encrypted": False,
            "activated": True,
            "license_key_hash": data.get("license_key_hash", ""),
            "activated_at": _now_iso(),
            "expires_at": data.get("expires_at"),
        })
        manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")

        logger.info("Activated strategy at %s", code_path)
        return {"path": str(pkg_dir), "code_path": str(code_path), "activated": True}

    def verify_license(self, purchase_id: str) -> dict:
        """Verify that a license is still valid (not expired or revoked).

        Args:
            purchase_id: UUID of the purchase.

        Returns:
            ``{"valid": bool, "expires_at": "..." | None, "reason": "..."}``
        """
        cloud = self._get_cloud()
        return cloud._get(f"/marketplace/purchases/{purchase_id}/verify").get("data", {})

    # ── verification badges ──────────────────────────────────────────

    def verify_performance(self, listing_id: str) -> VerifiedPerformance:
        """Request Verified Performance badge for a listing.
        Requires signal-mode listing deployed >= 14 days with >= 10 trades.
        """
        cloud = self._get_cloud()
        resp = cloud._post(f"/marketplace/listings/{listing_id}/verify-performance")
        d = resp.get("data", resp)
        return VerifiedPerformance(
            verified=d.get("verified_performance", False),
            verified_at=d.get("verified_performance_at", ""),
            sharpe=d.get("sharpe", 0.0),
            total_pnl=d.get("total_pnl", 0.0),
            win_rate=d.get("win_rate", 0.0),
            max_drawdown=d.get("max_drawdown", 0.0),
            total_trades=d.get("total_trades", 0),
            measurement_period_days=d.get("measurement_period_days", 0),
        )

    def listing_badges(self, listing_id: str) -> dict:
        """Get verification badge details for a listing."""
        cloud = self._get_cloud()
        resp = cloud._get(f"/marketplace/listings/{listing_id}/badges")
        return resp.get("data", resp)

    def verify_trader(self, exchange: str, *, wallet_address: str | None = None, credential_id: str | None = None) -> VerificationRequest:
        """Start Verified Trader verification.
        For polymarket: provide wallet_address.
        For kalshi: provide credential_id.
        """
        from horizon.cloud import HorizonCloudError
        if exchange not in ("polymarket", "kalshi"):
            raise HorizonCloudError("exchange must be 'polymarket' or 'kalshi'")
        cloud = self._get_cloud()
        payload: dict[str, Any] = {"exchange": exchange}
        if wallet_address:
            payload["wallet_address"] = wallet_address
        if credential_id:
            payload["credential_id"] = credential_id
        resp = cloud._post("/marketplace/verify-trader", payload)
        d = resp.get("data", resp)
        return VerificationRequest(
            id=d.get("id", ""),
            type=d.get("type", "verified_trader"),
            exchange=d.get("exchange", exchange),
            status=d.get("status", "pending"),
            result=d.get("result", {}),
            error_message=d.get("error_message", ""),
            created_at=d.get("created_at", ""),
            completed_at=d.get("completed_at", ""),
        )

    def verification_status(self) -> list:
        """Check status of your verification requests."""
        cloud = self._get_cloud()
        resp = cloud._get("/marketplace/verify-trader/status")
        items = resp.get("data", [])
        return [VerificationRequest(
            id=d.get("id", ""),
            type=d.get("type", ""),
            exchange=d.get("exchange", ""),
            status=d.get("status", ""),
            result=d.get("result", {}),
            error_message=d.get("error_message", ""),
            created_at=d.get("created_at", ""),
            completed_at=d.get("completed_at", ""),
        ) for d in items]

    def author_badges(self, author_id: str) -> VerifiedTrader:
        """Get Verified Trader badge info for a marketplace author."""
        cloud = self._get_cloud()
        resp = cloud._get(f"/marketplace/author/{author_id}/badges")
        d = resp.get("data", resp)
        return VerifiedTrader(
            verified=d.get("verified_trader", False),
            verified_at=d.get("verified_trader_at", ""),
            exchanges=d.get("exchanges", []),
            aggregate_pnl=d.get("aggregate_pnl", 0.0),
            aggregate_win_rate=d.get("aggregate_win_rate", 0.0),
            time_active_days=d.get("time_active_days", 0),
        )

    # ── repr ──────────────────────────────────────────────────────────

    def __repr__(self) -> str:
        return "Marketplace()"


# ---------------------------------------------------------------------------
# Pipeline function
# ---------------------------------------------------------------------------


def signal_follower(
    subscription_id: str,
    *,
    size_scale: float = 1.0,
    max_position: float = 1000.0,
    signal_ttl: float = _DEFAULT_SIGNAL_TTL,
    dry_run: bool = False,
) -> Callable:
    """Create a pipeline function that polls and executes marketplace signals.

    Returns a callable for use in ``hz.run(pipeline=[...])``.

    Usage::

        hz.run(
            name="signal-follower",
            markets=["btc-100k"],
            pipeline=[
                hz.signal_follower(subscription_id="sub-abc", size_scale=0.5),
            ],
            interval=5.0,
        )

    Args:
        subscription_id: UUID of the subscription to follow.
        size_scale: Multiplier for signal sizes.
        max_position: Maximum position per market in USD.
        signal_ttl: Reject signals older than this (seconds).
        dry_run: Log signals without submitting orders.

    Returns:
        Pipeline function: ``(Context) -> None``
    """
    state = _SignalFollowerState(subscription_id, signal_ttl=signal_ttl)
    from horizon.context import Context

    def _follow(ctx: Context) -> None:
        engine = ctx.params.get("engine")
        if engine is None:
            return

        signals = state.poll_signals()
        results: list[dict] = ctx.params.get("signal_follow_results", [])

        for sig in signals:
            if state.is_duplicate(sig.id):
                continue
            state.mark_seen(sig.id)

            if _is_signal_stale(sig, signal_ttl):
                continue

            order = _signal_to_order(sig, size_scale, max_position, engine)
            if order is None:
                continue

            result: dict[str, Any] = {
                "signal_id": sig.id,
                "market_id": sig.market_id,
                "side": sig.side,
                "order_side": sig.order_side,
                "price": sig.price,
                "size": order.size,
            }

            if dry_run:
                result["status"] = "dry_run"
                logger.info(
                    "[marketplace] DRY RUN: %s %s %.2f @ %.4f on %s",
                    sig.order_side.upper(), sig.side, order.size, order.price,
                    sig.market_id[:30],
                )
            else:
                try:
                    order_id = engine.submit_order(order)
                    result["status"] = "submitted"
                    result["order_id"] = order_id
                except Exception as e:
                    result["status"] = "error"
                    result["error"] = str(e)
                    logger.warning("[marketplace] Order failed: %s", e)

            results.append(result)

        ctx.params["signal_follow_results"] = results

    _follow.__name__ = "signal_follower"
    return _follow


# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------

marketplace = Marketplace()
"""Module-level marketplace instance. Use ``hz.marketplace.browse()``, etc."""


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _to_listing(raw: dict) -> Listing:
    return Listing(
        id=raw.get("id", ""),
        strategy_id=raw.get("strategy_id", ""),
        author_id=raw.get("author_id", ""),
        author_name=raw.get("author_name", ""),
        name=raw.get("name", ""),
        description=raw.get("description", ""),
        mode=raw.get("mode", "signal"),
        price=raw.get("price", 0.0),
        currency=raw.get("currency", "USD"),
        tags=raw.get("tags", []),
        exchange=raw.get("exchange", ""),
        markets=raw.get("markets", []),
        max_sales=raw.get("max_sales"),
        status=raw.get("status", "active"),
        visibility=raw.get("visibility", "public"),
        invite_code=raw.get("invite_code"),
        total_pnl=raw.get("total_pnl", 0.0),
        sharpe_ratio=raw.get("sharpe_ratio", 0.0),
        max_drawdown=raw.get("max_drawdown", 0.0),
        win_rate=raw.get("win_rate", 0.0),
        total_subscribers=raw.get("total_subscribers", 0),
        avg_monthly_return=raw.get("avg_monthly_return", 0.0),
        live_since=raw.get("live_since", ""),
        created_at=raw.get("created_at", ""),
        updated_at=raw.get("updated_at", ""),
        verified_performance=raw.get("verified_performance", False),
        verified_trader=raw.get("verified_trader", False),
    )


def _to_subscription(raw: dict) -> Subscription:
    return Subscription(
        id=raw.get("id", ""),
        listing_id=raw.get("listing_id", ""),
        listing_name=raw.get("listing_name", ""),
        status=raw.get("status", "active"),
        size_scale=raw.get("size_scale", 1.0),
        max_position=raw.get("max_position", 1000.0),
        started_at=raw.get("started_at", ""),
        expires_at=raw.get("expires_at"),
        total_signals=raw.get("total_signals", 0),
        total_pnl=raw.get("total_pnl", 0.0),
    )


def _to_purchase(raw: dict) -> Purchase:
    return Purchase(
        id=raw.get("id", ""),
        listing_id=raw.get("listing_id", ""),
        listing_name=raw.get("listing_name", ""),
        status=raw.get("status", "completed"),
        license_key=raw.get("license_key", ""),
        checkout_url=raw.get("checkout_url", ""),
        purchased_at=raw.get("purchased_at", ""),
        expires_at=raw.get("expires_at"),
    )


def _to_signal(raw: dict) -> Signal:
    return Signal(
        id=raw.get("id", ""),
        subscription_id=raw.get("subscription_id", ""),
        market_id=raw.get("market_id", ""),
        side=raw.get("side", ""),
        order_side=raw.get("order_side", ""),
        price=raw.get("price", 0.0),
        size=raw.get("size", 0.0),
        timestamp=raw.get("timestamp", ""),
        metadata=raw.get("metadata", {}),
    )


def _now_iso() -> str:
    from datetime import datetime, timezone
    return datetime.now(timezone.utc).isoformat()
