"""Tests for the strategy marketplace module."""

from __future__ import annotations

import json
import time
from datetime import datetime, timezone, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from horizon.marketplace import (
    Listing,
    Marketplace,
    MarketplaceEarnings,
    Purchase,
    Signal,
    Subscription,
    _SignalFollowerState,
    _is_signal_stale,
    _signal_to_order,
    _to_listing,
    _to_purchase,
    _to_signal,
    _to_subscription,
    marketplace,
    signal_follower,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

SAMPLE_LISTING = {
    "id": "lst-abc12345",
    "strategy_id": "strat-xyz",
    "author_id": "usr-author",
    "author_name": "TestAuthor",
    "name": "BTC MM Alpha",
    "description": "High-sharpe BTC market making",
    "mode": "signal",
    "price": 49.99,
    "currency": "USD",
    "tags": ["btc", "market-making"],
    "exchange": "polymarket",
    "markets": ["btc-100k"],
    "max_sales": None,
    "status": "active",
    "total_pnl": 1234.56,
    "sharpe_ratio": 2.1,
    "max_drawdown": -0.15,
    "win_rate": 0.62,
    "total_subscribers": 42,
    "avg_monthly_return": 0.08,
    "live_since": "2026-01-15T00:00:00Z",
    "created_at": "2026-01-15T00:00:00Z",
    "updated_at": "2026-03-10T00:00:00Z",
}

SAMPLE_SUBSCRIPTION = {
    "id": "sub-xyz789",
    "listing_id": "lst-abc12345",
    "listing_name": "BTC MM Alpha",
    "status": "active",
    "size_scale": 0.5,
    "max_position": 500.0,
    "started_at": "2026-03-01T00:00:00Z",
    "expires_at": None,
    "total_signals": 150,
    "total_pnl": 89.50,
}

SAMPLE_PURCHASE = {
    "id": "pur-def456",
    "listing_id": "lst-pkg789",
    "listing_name": "ETH Scalper Pro",
    "status": "completed",
    "license_key": "lk-abcdef123456",
    "checkout_url": "",
    "purchased_at": "2026-03-10T00:00:00Z",
    "expires_at": None,
}

SAMPLE_SIGNAL = {
    "id": "sig-001",
    "subscription_id": "sub-xyz789",
    "market_id": "will-btc-hit-100k",
    "side": "yes",
    "order_side": "buy",
    "price": 0.62,
    "size": 50.0,
    "timestamp": datetime.now(timezone.utc).isoformat(),
    "metadata": {"signal_type": "quote"},
}


def _make_signal(overrides: dict | None = None) -> dict:
    sig = dict(SAMPLE_SIGNAL)
    if overrides:
        sig.update(overrides)
    return sig


# ---------------------------------------------------------------------------
# Dataclass construction
# ---------------------------------------------------------------------------


class TestDataclasses:
    def test_listing_from_dict(self):
        listing = _to_listing(SAMPLE_LISTING)
        assert listing.id == "lst-abc12345"
        assert listing.name == "BTC MM Alpha"
        assert listing.mode == "signal"
        assert listing.price == 49.99
        assert listing.sharpe_ratio == 2.1
        assert listing.total_subscribers == 42
        assert listing.tags == ["btc", "market-making"]

    def test_listing_repr(self):
        listing = _to_listing(SAMPLE_LISTING)
        r = repr(listing)
        assert "BTC MM Alpha" in r
        assert "signal" in r
        assert "$49.99" in r

    def test_listing_from_empty_dict(self):
        listing = _to_listing({})
        assert listing.id == ""
        assert listing.mode == "signal"
        assert listing.price == 0.0

    def test_subscription_from_dict(self):
        sub = _to_subscription(SAMPLE_SUBSCRIPTION)
        assert sub.id == "sub-xyz789"
        assert sub.listing_name == "BTC MM Alpha"
        assert sub.status == "active"
        assert sub.size_scale == 0.5
        assert sub.total_signals == 150

    def test_subscription_repr(self):
        sub = _to_subscription(SAMPLE_SUBSCRIPTION)
        r = repr(sub)
        assert "BTC MM Alpha" in r
        assert "active" in r

    def test_purchase_from_dict(self):
        p = _to_purchase(SAMPLE_PURCHASE)
        assert p.id == "pur-def456"
        assert p.listing_name == "ETH Scalper Pro"
        assert p.license_key == "lk-abcdef123456"
        assert p.status == "completed"

    def test_purchase_repr(self):
        p = _to_purchase(SAMPLE_PURCHASE)
        r = repr(p)
        assert "ETH Scalper Pro" in r

    def test_signal_from_dict(self):
        sig = _to_signal(SAMPLE_SIGNAL)
        assert sig.id == "sig-001"
        assert sig.market_id == "will-btc-hit-100k"
        assert sig.side == "yes"
        assert sig.order_side == "buy"
        assert sig.price == 0.62
        assert sig.size == 50.0

    def test_signal_repr(self):
        sig = _to_signal(SAMPLE_SIGNAL)
        r = repr(sig)
        assert "BUY" in r
        assert "yes" in r

    def test_earnings_repr(self):
        e = MarketplaceEarnings(
            listing_id="lst-abc",
            total_revenue=100.0,
            author_share=90.0,
            platform_fee=10.0,
            total_subscribers=5,
        )
        r = repr(e)
        assert "$90.00" in r
        assert "5 subs" in r


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


class TestSignalFollowerState:
    def test_dedup_marks_seen(self):
        state = _SignalFollowerState("sub-test")
        assert not state.is_duplicate("sig-1")
        state.mark_seen("sig-1")
        assert state.is_duplicate("sig-1")

    def test_dedup_idempotent_mark(self):
        state = _SignalFollowerState("sub-test")
        state.mark_seen("sig-1")
        state.mark_seen("sig-1")  # should not crash or double-add
        assert state.is_duplicate("sig-1")
        assert len(state._seen_deque) == 1

    def test_dedup_eviction(self):
        import sys
        mp_module = sys.modules["horizon.marketplace"]
        original = mp_module._SIGNAL_DEDUP_CAPACITY
        mp_module._SIGNAL_DEDUP_CAPACITY = 5

        state = _SignalFollowerState("sub-test")
        for i in range(10):
            state.mark_seen(f"sig-{i}")

        # FIFO eviction should cap the deque
        assert len(state._seen_deque) <= 10
        # Earlier IDs should have been evicted
        assert not state.is_duplicate("sig-0")
        # Recent IDs should still be present
        assert state.is_duplicate("sig-9")

        mp_module._SIGNAL_DEDUP_CAPACITY = original


# ---------------------------------------------------------------------------
# Signal staleness
# ---------------------------------------------------------------------------


class TestSignalStaleness:
    def test_fresh_signal_not_stale(self):
        sig = _to_signal(_make_signal())
        assert not _is_signal_stale(sig, 60.0)

    def test_old_signal_is_stale(self):
        old_time = (datetime.now(timezone.utc) - timedelta(seconds=120)).isoformat()
        sig = _to_signal(_make_signal({"timestamp": old_time}))
        assert _is_signal_stale(sig, 60.0)

    def test_unparseable_timestamp_rejected_as_stale(self):
        """Malformed timestamps are rejected as stale (safe default)."""
        sig = _to_signal(_make_signal({"timestamp": "not-a-date"}))
        assert _is_signal_stale(sig, 60.0)

    def test_zero_ttl_rejects_everything(self):
        sig = _to_signal(_make_signal())
        # Even a fresh signal is "stale" with TTL=0
        # Actually with TTL=0, the age (even 0.001s) > 0, so it depends on timing
        # Just test with a clearly old signal
        old_time = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
        sig = _to_signal(_make_signal({"timestamp": old_time}))
        assert _is_signal_stale(sig, 0.0)


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


class TestSignalToOrder:
    def _make_engine(self):
        engine = MagicMock()
        engine.snapshot_exposure.return_value = {"market_notional": 0.0}
        return engine

    def test_buy_yes(self):
        sig = _to_signal(_make_signal({"side": "yes", "order_side": "buy"}))
        order = _signal_to_order(sig, 1.0, 1000.0, self._make_engine())
        assert order is not None
        assert order.size == 50.0
        assert order.price == 0.62

    def test_sell_no(self):
        sig = _to_signal(_make_signal({"side": "no", "order_side": "sell", "price": 0.40, "size": 30.0}))
        order = _signal_to_order(sig, 1.0, 1000.0, self._make_engine())
        assert order is not None
        assert order.size == 30.0

    def test_size_scaling(self):
        sig = _to_signal(_make_signal({"size": 100.0}))
        order = _signal_to_order(sig, 0.5, 10000.0, self._make_engine())
        assert order is not None
        assert order.size == 50.0

    def test_invalid_price_zero(self):
        sig = _to_signal(_make_signal({"price": 0.0}))
        order = _signal_to_order(sig, 1.0, 1000.0, self._make_engine())
        assert order is None

    def test_invalid_price_one(self):
        sig = _to_signal(_make_signal({"price": 1.0}))
        order = _signal_to_order(sig, 1.0, 1000.0, self._make_engine())
        assert order is None

    def test_tiny_size_rejected(self):
        sig = _to_signal(_make_signal({"size": 0.001}))
        order = _signal_to_order(sig, 0.1, 1000.0, self._make_engine())
        assert order is None

    def test_position_limit_clamping(self):
        engine = self._make_engine()
        engine.snapshot_exposure.return_value = {"market_notional": 900.0}
        sig = _to_signal(_make_signal({"size": 500.0, "price": 0.50}))
        order = _signal_to_order(sig, 1.0, 1000.0, engine)
        # Only $100 remaining, at price 0.50 -> 200 tokens max
        assert order is not None
        assert order.size <= 200.0

    def test_position_limit_reached(self):
        engine = self._make_engine()
        engine.snapshot_exposure.return_value = {"market_notional": 1000.0}
        sig = _to_signal(_make_signal({"side": "yes", "order_side": "buy"}))
        order = _signal_to_order(sig, 1.0, 1000.0, engine)
        assert order is None

    def test_sell_not_clamped_by_position(self):
        engine = self._make_engine()
        engine.snapshot_exposure.return_value = {"market_notional": 1000.0}
        sig = _to_signal(_make_signal({"side": "yes", "order_side": "sell", "price": 0.50, "size": 100.0}))
        order = _signal_to_order(sig, 1.0, 1000.0, engine)
        assert order is not None
        assert order.size == 100.0

    def test_exposure_check_failure_proceeds(self):
        engine = self._make_engine()
        engine.snapshot_exposure.side_effect = Exception("not available")
        sig = _to_signal(_make_signal())
        order = _signal_to_order(sig, 1.0, 1000.0, engine)
        assert order is not None


# ---------------------------------------------------------------------------
# Marketplace client (mocked cloud)
# ---------------------------------------------------------------------------


class TestMarketplaceBrowse:
    def _mock_cloud(self):
        cloud = MagicMock()
        return cloud

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_browse_returns_listings(self, mock_gc):
        cloud = self._mock_cloud()
        cloud._get.return_value = {"data": [SAMPLE_LISTING, SAMPLE_LISTING]}
        mock_gc.return_value = cloud

        mp = Marketplace()
        results = mp.browse()
        assert len(results) == 2
        assert results[0].name == "BTC MM Alpha"
        cloud._get.assert_called_once()

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_browse_with_filters(self, mock_gc):
        cloud = self._mock_cloud()
        cloud._get.return_value = {"data": []}
        mock_gc.return_value = cloud

        mp = Marketplace()
        mp.browse(query="btc", mode="signal", exchange="polymarket", sort_by="pnl")
        args, kwargs = cloud._get.call_args
        assert args[0] == "/marketplace/listings"
        assert kwargs["query"] == "btc"
        assert kwargs["mode"] == "signal"

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_browse_empty(self, mock_gc):
        cloud = self._mock_cloud()
        cloud._get.return_value = {"data": []}
        mock_gc.return_value = cloud

        mp = Marketplace()
        assert mp.browse() == []

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_get_listing(self, mock_gc):
        cloud = self._mock_cloud()
        cloud._get.return_value = {"data": SAMPLE_LISTING}
        mock_gc.return_value = cloud

        mp = Marketplace()
        listing = mp.get_listing("lst-abc12345")
        assert listing.id == "lst-abc12345"

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_leaderboard(self, mock_gc):
        cloud = self._mock_cloud()
        cloud._get.return_value = {"data": [SAMPLE_LISTING]}
        mock_gc.return_value = cloud

        mp = Marketplace()
        top = mp.leaderboard(timeframe="7d", limit=10)
        assert len(top) == 1
        cloud._get.assert_called_with("/marketplace/leaderboard", timeframe="7d", limit=10)


# ---------------------------------------------------------------------------
# Publishing
# ---------------------------------------------------------------------------


class TestMarketplacePublish:
    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_publish_signal(self, mock_gc):
        cloud = MagicMock()
        cloud._post.return_value = {"data": SAMPLE_LISTING}
        mock_gc.return_value = cloud

        mp = Marketplace()
        listing = mp.publish(
            "strat-xyz",
            mode="signal",
            price=49.99,
            description="Test strategy",
            tags=["btc"],
        )
        assert listing.name == "BTC MM Alpha"
        payload = cloud._post.call_args[0][1]
        assert payload["mode"] == "signal"
        assert payload["price"] == 49.99

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_publish_package(self, mock_gc):
        cloud = MagicMock()
        cloud._post.return_value = {"data": {**SAMPLE_LISTING, "mode": "package"}}
        mock_gc.return_value = cloud

        mp = Marketplace()
        listing = mp.publish("strat-xyz", mode="package", price=299.99, max_sales=1)
        assert listing.mode == "package"
        payload = cloud._post.call_args[0][1]
        assert payload["max_sales"] == 1

    def test_publish_invalid_mode(self):
        mp = Marketplace()
        mp._cloud = MagicMock()
        with pytest.raises(Exception, match="mode must be"):
            mp.publish("strat-xyz", mode="invalid")

    def test_publish_negative_price(self):
        mp = Marketplace()
        mp._cloud = MagicMock()
        with pytest.raises(Exception, match="price must be"):
            mp.publish("strat-xyz", price=-10.0)

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_update_listing(self, mock_gc):
        cloud = MagicMock()
        cloud._request.return_value = {"data": SAMPLE_LISTING}
        mock_gc.return_value = cloud

        mp = Marketplace()
        mp.update_listing("lst-abc", price=39.99)
        cloud._request.assert_called_once()

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_pause_listing(self, mock_gc):
        cloud = MagicMock()
        cloud._post.return_value = {"data": {**SAMPLE_LISTING, "status": "paused"}}
        mock_gc.return_value = cloud

        mp = Marketplace()
        listing = mp.pause_listing("lst-abc")
        assert listing.status == "paused"

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_delist(self, mock_gc):
        cloud = MagicMock()
        cloud._post.return_value = {"data": {"success": True}}
        mock_gc.return_value = cloud

        mp = Marketplace()
        result = mp.delist("lst-abc")
        assert result["success"] is True

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_my_listings(self, mock_gc):
        cloud = MagicMock()
        cloud._get.return_value = {"data": [SAMPLE_LISTING]}
        mock_gc.return_value = cloud

        mp = Marketplace()
        listings = mp.my_listings()
        assert len(listings) == 1


# ---------------------------------------------------------------------------
# Subscriptions
# ---------------------------------------------------------------------------


class TestMarketplaceSubscribe:
    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_subscribe(self, mock_gc):
        cloud = MagicMock()
        cloud._post.return_value = {"data": SAMPLE_SUBSCRIPTION}
        mock_gc.return_value = cloud

        mp = Marketplace()
        sub = mp.subscribe("lst-abc", size_scale=0.5, max_position=500.0)
        assert sub.id == "sub-xyz789"
        assert sub.status == "active"
        payload = cloud._post.call_args[0][1]
        assert payload["size_scale"] == 0.5

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_unsubscribe(self, mock_gc):
        cloud = MagicMock()
        cloud._post.return_value = {"data": {"success": True}}
        mock_gc.return_value = cloud

        mp = Marketplace()
        result = mp.unsubscribe("sub-xyz789")
        assert result["success"] is True

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_my_subscriptions(self, mock_gc):
        cloud = MagicMock()
        cloud._get.return_value = {"data": [SAMPLE_SUBSCRIPTION]}
        mock_gc.return_value = cloud

        mp = Marketplace()
        subs = mp.my_subscriptions()
        assert len(subs) == 1
        assert subs[0].listing_name == "BTC MM Alpha"

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_signals(self, mock_gc):
        cloud = MagicMock()
        cloud._get.return_value = {"data": [SAMPLE_SIGNAL, _make_signal({"id": "sig-002"})]}
        mock_gc.return_value = cloud

        mp = Marketplace()
        signals = mp.signals("sub-xyz789", limit=10)
        assert len(signals) == 2
        assert signals[0].id == "sig-001"

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_signals_with_since(self, mock_gc):
        cloud = MagicMock()
        cloud._get.return_value = {"data": []}
        mock_gc.return_value = cloud

        mp = Marketplace()
        mp.signals("sub-xyz789", since="sig-001")
        _, kwargs = cloud._get.call_args
        assert kwargs["after"] == "sig-001"


# ---------------------------------------------------------------------------
# Purchases
# ---------------------------------------------------------------------------


class TestMarketplacePurchase:
    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_purchase(self, mock_gc):
        cloud = MagicMock()
        cloud._post.return_value = {"data": SAMPLE_PURCHASE}
        mock_gc.return_value = cloud

        mp = Marketplace()
        p = mp.purchase("lst-pkg789")
        assert p.id == "pur-def456"
        assert p.license_key == "lk-abcdef123456"

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_purchase_with_checkout(self, mock_gc):
        cloud = MagicMock()
        data = {**SAMPLE_PURCHASE, "status": "pending_payment", "checkout_url": "https://checkout.stripe.com/..."}
        cloud._post.return_value = {"data": data}
        mock_gc.return_value = cloud

        mp = Marketplace()
        p = mp.purchase("lst-pkg789")
        assert p.status == "pending_payment"
        assert "stripe.com" in p.checkout_url

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_my_purchases(self, mock_gc):
        cloud = MagicMock()
        cloud._get.return_value = {"data": [SAMPLE_PURCHASE]}
        mock_gc.return_value = cloud

        mp = Marketplace()
        purchases = mp.my_purchases()
        assert len(purchases) == 1

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_download(self, mock_gc, tmp_path):
        cloud = MagicMock()
        cloud._get.return_value = {
            "data": {
                "name": "Test Strategy",
                "encrypted_code": "ENCRYPTED_CONTENT_HERE",
                "listing_id": "lst-pkg789",
            }
        }
        mock_gc.return_value = cloud

        mp = Marketplace()
        result = mp.download("pur-def456", directory=str(tmp_path))
        assert result["encrypted"] is True
        assert (tmp_path / "test_strategy" / "strategy.enc").exists()
        assert (tmp_path / "test_strategy" / "manifest.json").exists()

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_activate(self, mock_gc, tmp_path):
        cloud = MagicMock()
        cloud._post.return_value = {
            "data": {
                "code": "import horizon as hz\nhz.run()",
                "name": "Test Strategy",
                "license_key_hash": "abc123",
                "expires_at": None,
            }
        }
        mock_gc.return_value = cloud

        mp = Marketplace()
        result = mp.activate("pur-def456", license_key="lk-abc", directory=str(tmp_path))
        assert result["activated"] is True
        code_path = tmp_path / "test_strategy" / "strategy.py"
        assert code_path.exists()
        assert "hz.run()" in code_path.read_text()

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_verify_license(self, mock_gc):
        cloud = MagicMock()
        cloud._get.return_value = {"data": {"valid": True, "expires_at": None}}
        mock_gc.return_value = cloud

        mp = Marketplace()
        result = mp.verify_license("pur-def456")
        assert result["valid"] is True


# ---------------------------------------------------------------------------
# Payments
# ---------------------------------------------------------------------------


class TestMarketplacePayments:
    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_connect_payments(self, mock_gc):
        cloud = MagicMock()
        cloud._post.return_value = {
            "data": {"onboarding_url": "https://connect.stripe.com/...", "account_id": "acct_123"}
        }
        mock_gc.return_value = cloud

        mp = Marketplace()
        result = mp.connect_payments()
        assert "onboarding_url" in result

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_payment_status(self, mock_gc):
        cloud = MagicMock()
        cloud._get.return_value = {"data": {"connected": True, "payouts_enabled": True}}
        mock_gc.return_value = cloud

        mp = Marketplace()
        result = mp.payment_status()
        assert result["connected"] is True

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_listing_earnings(self, mock_gc):
        cloud = MagicMock()
        cloud._get.return_value = {
            "data": {
                "total_revenue": 500.0,
                "author_share": 450.0,
                "platform_fee": 50.0,
                "total_subscribers": 10,
                "total_purchases": 0,
                "currency": "USD",
            }
        }
        mock_gc.return_value = cloud

        mp = Marketplace()
        earnings = mp.listing_earnings("lst-abc")
        assert earnings.total_revenue == 500.0
        assert earnings.author_share == 450.0
        assert earnings.platform_fee == 50.0
        assert earnings.total_subscribers == 10


# ---------------------------------------------------------------------------
# Pipeline function (signal_follower)
# ---------------------------------------------------------------------------


class TestSignalFollower:
    @patch("horizon.marketplace._SignalFollowerState.poll_signals")
    def test_signal_follower_basic(self, mock_poll):
        sig_data = _to_signal(SAMPLE_SIGNAL)
        mock_poll.return_value = [sig_data]

        fn = signal_follower("sub-xyz", size_scale=1.0, dry_run=True)
        assert fn.__name__ == "signal_follower"

        ctx = MagicMock()
        engine = MagicMock()
        engine.snapshot_exposure.return_value = {"market_notional": 0.0}
        ctx.params = {"engine": engine}

        fn(ctx)

        results = ctx.params["signal_follow_results"]
        assert len(results) == 1
        assert results[0]["status"] == "dry_run"

    @patch("horizon.marketplace._SignalFollowerState.poll_signals")
    def test_signal_follower_no_engine(self, mock_poll):
        fn = signal_follower("sub-xyz")
        ctx = MagicMock()
        ctx.params = {}
        fn(ctx)
        mock_poll.assert_not_called()

    @patch("horizon.marketplace._SignalFollowerState.poll_signals")
    def test_signal_follower_dedup(self, mock_poll):
        sig = _to_signal(SAMPLE_SIGNAL)
        mock_poll.return_value = [sig, sig]  # Same signal twice

        fn = signal_follower("sub-xyz", dry_run=True)
        ctx = MagicMock()
        engine = MagicMock()
        engine.snapshot_exposure.return_value = {"market_notional": 0.0}
        ctx.params = {"engine": engine}

        fn(ctx)

        results = ctx.params["signal_follow_results"]
        assert len(results) == 1  # Deduped

    @patch("horizon.marketplace._SignalFollowerState.poll_signals")
    def test_signal_follower_stale_rejected(self, mock_poll):
        old_time = (datetime.now(timezone.utc) - timedelta(seconds=120)).isoformat()
        sig = _to_signal(_make_signal({"timestamp": old_time}))
        mock_poll.return_value = [sig]

        fn = signal_follower("sub-xyz", signal_ttl=60.0, dry_run=True)
        ctx = MagicMock()
        engine = MagicMock()
        ctx.params = {"engine": engine}

        fn(ctx)

        results = ctx.params["signal_follow_results"]
        assert len(results) == 0

    @patch("horizon.marketplace._SignalFollowerState.poll_signals")
    def test_signal_follower_submit(self, mock_poll):
        sig = _to_signal(SAMPLE_SIGNAL)
        mock_poll.return_value = [sig]

        fn = signal_follower("sub-xyz", dry_run=False)
        ctx = MagicMock()
        engine = MagicMock()
        engine.snapshot_exposure.return_value = {"market_notional": 0.0}
        engine.submit_order.return_value = "order-123"
        ctx.params = {"engine": engine}

        fn(ctx)

        results = ctx.params["signal_follow_results"]
        assert len(results) == 1
        assert results[0]["status"] == "submitted"
        assert results[0]["order_id"] == "order-123"

    @patch("horizon.marketplace._SignalFollowerState.poll_signals")
    def test_signal_follower_submit_error(self, mock_poll):
        sig = _to_signal(SAMPLE_SIGNAL)
        mock_poll.return_value = [sig]

        fn = signal_follower("sub-xyz", dry_run=False)
        ctx = MagicMock()
        engine = MagicMock()
        engine.snapshot_exposure.return_value = {"market_notional": 0.0}
        engine.submit_order.side_effect = Exception("risk rejected")
        ctx.params = {"engine": engine}

        fn(ctx)

        results = ctx.params["signal_follow_results"]
        assert len(results) == 1
        assert results[0]["status"] == "error"
        assert "risk rejected" in results[0]["error"]

    @patch("horizon.marketplace._SignalFollowerState.poll_signals")
    def test_signal_follower_accumulates_results(self, mock_poll):
        sig1 = _to_signal(_make_signal({"id": "sig-A"}))
        sig2 = _to_signal(_make_signal({"id": "sig-B"}))

        fn = signal_follower("sub-xyz", dry_run=True)
        ctx = MagicMock()
        engine = MagicMock()
        engine.snapshot_exposure.return_value = {"market_notional": 0.0}
        ctx.params = {"engine": engine}

        # First cycle
        mock_poll.return_value = [sig1]
        fn(ctx)
        assert len(ctx.params["signal_follow_results"]) == 1

        # Second cycle
        mock_poll.return_value = [sig2]
        fn(ctx)
        assert len(ctx.params["signal_follow_results"]) == 2


# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------


class TestSingleton:
    def test_module_level_marketplace_exists(self):
        assert marketplace is not None
        assert isinstance(marketplace, Marketplace)

    def test_marketplace_repr(self):
        assert repr(marketplace) == "Marketplace()"


# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------


class TestErrorHandling:
    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_browse_api_error(self, mock_gc):
        from horizon.cloud import HorizonCloudError
        cloud = MagicMock()
        cloud._get.side_effect = HorizonCloudError("API error", status_code=500)
        mock_gc.return_value = cloud

        mp = Marketplace()
        with pytest.raises(HorizonCloudError):
            mp.browse()

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_subscribe_payment_required(self, mock_gc):
        from horizon.cloud import HorizonCloudError
        cloud = MagicMock()
        cloud._post.side_effect = HorizonCloudError("Payment required", status_code=402)
        mock_gc.return_value = cloud

        mp = Marketplace()
        with pytest.raises(HorizonCloudError, match="Payment required"):
            mp.subscribe("lst-abc")

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_publish_plan_limit(self, mock_gc):
        from horizon.cloud import HorizonCloudError
        cloud = MagicMock()
        cloud._post.side_effect = HorizonCloudError("Pro plan required", status_code=403)
        mock_gc.return_value = cloud

        mp = Marketplace()
        with pytest.raises(HorizonCloudError, match="Pro plan required"):
            mp.publish("strat-xyz")

    @patch("horizon.marketplace.Marketplace._get_cloud")
    def test_purchase_not_found(self, mock_gc):
        from horizon.cloud import HorizonCloudError
        cloud = MagicMock()
        cloud._post.side_effect = HorizonCloudError("Listing not found", status_code=404)
        mock_gc.return_value = cloud

        mp = Marketplace()
        with pytest.raises(HorizonCloudError, match="Listing not found"):
            mp.purchase("nonexistent")
