"""
Tests for the Táillí License Client.

These tests mock HTTP responses to test client behavior without
requiring a running License Manager server.
"""

import json
import threading
import time
import unittest
from unittest.mock import patch, MagicMock
from urllib.error import HTTPError, URLError
from io import BytesIO

from tailli_license import (
    LicenseClient,
    Seat,
    LicenseError,
    SeatLimitError,
    LicenseExpiredError,
)
from tailli_license.client import ConnectionError


def mock_response(data: dict, status: int = 200):
    """Create a mock HTTP response."""
    response = MagicMock()
    response.read.return_value = json.dumps(data).encode('utf-8')
    response.status = status
    response.__enter__ = MagicMock(return_value=response)
    response.__exit__ = MagicMock(return_value=False)
    return response


def mock_http_error(data: dict, status: int):
    """Create a mock HTTPError."""
    error = HTTPError(
        url="http://test",
        code=status,
        msg="Error",
        hdrs={},
        fp=BytesIO(json.dumps(data).encode('utf-8'))
    )
    return error


class TestLicenseClient(unittest.TestCase):
    """Tests for LicenseClient class."""

    def setUp(self):
        self.client = LicenseClient(
            base_url="http://localhost:8787",
            product_id="test-product"
        )

    @patch('tailli_license.client.urlopen')
    def test_status_success(self, mock_urlopen):
        """Test successful status request."""
        mock_urlopen.return_value = mock_response({
            "productId": "test-product",
            "seats": 10,
            "seatsInUse": 3,
            "seatsAvailable": 7,
            "expiry": "2026-01-01T00:00:00Z",
            "features": ["core", "premium"],
            "valid": True
        })

        status = self.client.status()

        self.assertEqual(status["productId"], "test-product")
        self.assertEqual(status["seats"], 10)
        self.assertEqual(status["seatsAvailable"], 7)
        self.assertIn("premium", status["features"])

    @patch('tailli_license.client.urlopen')
    def test_status_expired_license(self, mock_urlopen):
        """Test status with expired license raises LicenseExpiredError."""
        mock_urlopen.side_effect = mock_http_error(
            {"error": "License has expired"},
            410
        )

        with self.assertRaises(LicenseExpiredError):
            self.client.status()

    @patch('tailli_license.client.urlopen')
    def test_allocate_success(self, mock_urlopen):
        """Test successful seat allocation."""
        mock_urlopen.return_value = mock_response({
            "seatId": "seat-123",
            "token": "token-abc",
            "expiresAt": "2025-01-01T01:00:00Z"
        })

        seat = self.client.allocate(auto_refresh=False)

        self.assertEqual(seat.seat_id, "seat-123")
        self.assertEqual(seat.token, "token-abc")
        self.assertEqual(seat.product_id, "test-product")

    @patch('tailli_license.client.urlopen')
    def test_allocate_seat_limit(self, mock_urlopen):
        """Test allocation when seats are exhausted."""
        mock_urlopen.side_effect = mock_http_error(
            {"error": "All seats are in use"},
            429
        )

        with self.assertRaises(SeatLimitError):
            self.client.allocate()

    @patch('tailli_license.client.urlopen')
    def test_allocate_with_context(self, mock_urlopen):
        """Test allocation with context metadata."""
        mock_urlopen.return_value = mock_response({
            "seatId": "seat-456",
            "token": "token-def",
            "expiresAt": "2025-01-01T01:00:00Z"
        })

        seat = self.client.allocate(
            context={"user": "alice", "workstation": "ws-01"},
            auto_refresh=False
        )

        self.assertEqual(seat.seat_id, "seat-456")
        # Verify request included context
        call_args = mock_urlopen.call_args
        request = call_args[0][0]
        body = json.loads(request.data.decode('utf-8'))
        self.assertEqual(body["context"]["user"], "alice")

    @patch('tailli_license.client.urlopen')
    def test_seat_context_manager(self, mock_urlopen):
        """Test seat context manager allocates and releases."""
        # First call: allocate, Second call: release
        mock_urlopen.side_effect = [
            mock_response({
                "seatId": "seat-789",
                "token": "token-ghi",
                "expiresAt": "2025-01-01T01:00:00Z"
            }),
            mock_response({"success": True})
        ]

        with self.client.seat(auto_refresh=False) as seat:
            self.assertEqual(seat.seat_id, "seat-789")

        # Verify release was called
        self.assertEqual(mock_urlopen.call_count, 2)

    @patch('tailli_license.client.urlopen')
    def test_seat_context_manager_releases_on_exception(self, mock_urlopen):
        """Test seat is released even when exception occurs."""
        mock_urlopen.side_effect = [
            mock_response({
                "seatId": "seat-abc",
                "token": "token-xyz",
                "expiresAt": "2025-01-01T01:00:00Z"
            }),
            mock_response({"success": True})
        ]

        with self.assertRaises(ValueError):
            with self.client.seat(auto_refresh=False) as seat:
                raise ValueError("Test error")

        # Verify release was still called
        self.assertEqual(mock_urlopen.call_count, 2)

    @patch('tailli_license.client.urlopen')
    def test_verify_key_valid(self, mock_urlopen):
        """Test verifying a valid key."""
        mock_urlopen.return_value = mock_response({
            "valid": True,
            "payload": {
                "sub": "user-123",
                "productId": "test-product",
                "features": ["core"]
            }
        })

        payload = self.client.verify_key("test-token")

        self.assertEqual(payload["sub"], "user-123")

    @patch('tailli_license.client.urlopen')
    def test_verify_key_invalid(self, mock_urlopen):
        """Test verifying an invalid key raises error."""
        mock_urlopen.return_value = mock_response({
            "valid": False,
            "error": "Token expired"
        })

        with self.assertRaises(LicenseError) as ctx:
            self.client.verify_key("bad-token")

        self.assertIn("Token expired", str(ctx.exception))

    @patch('tailli_license.client.urlopen')
    def test_get_entitlements(self, mock_urlopen):
        """Test getting entitlements."""
        mock_urlopen.return_value = mock_response({
            "features": ["core", "ai"],
            "scopes": ["read", "write"],
            "maxKeys": 100,
            "keysIssued": 25,
            "keysAvailable": 75,
            "expiry": "2026-01-01T00:00:00Z"
        })

        ent = self.client.get_entitlements()

        self.assertIn("ai", ent["features"])
        self.assertEqual(ent["keysAvailable"], 75)

    @patch('tailli_license.client.urlopen')
    def test_connection_error(self, mock_urlopen):
        """Test connection error handling."""
        mock_urlopen.side_effect = URLError("Connection refused")

        with self.assertRaises(ConnectionError):
            self.client.status()

    def test_base_url_trailing_slash_removed(self):
        """Test that trailing slash is removed from base URL."""
        client = LicenseClient(
            base_url="http://localhost:8787/",
            product_id="test"
        )
        self.assertEqual(client.base_url, "http://localhost:8787")


class TestSeat(unittest.TestCase):
    """Tests for Seat class."""

    def setUp(self):
        self.client = LicenseClient(
            base_url="http://localhost:8787",
            product_id="test-product"
        )

    @patch('tailli_license.client.urlopen')
    def test_seat_release(self, mock_urlopen):
        """Test releasing a seat."""
        mock_urlopen.return_value = mock_response({"success": True})

        seat = Seat(
            seat_id="seat-123",
            token="token-abc",
            product_id="test-product",
            expires_at="2025-01-01T01:00:00Z",
            _client=self.client
        )

        seat.release()

        self.assertTrue(seat._released)
        mock_urlopen.assert_called_once()

    @patch('tailli_license.client.urlopen')
    def test_seat_double_release_idempotent(self, mock_urlopen):
        """Test releasing a seat twice only calls API once."""
        mock_urlopen.return_value = mock_response({"success": True})

        seat = Seat(
            seat_id="seat-123",
            token="token-abc",
            product_id="test-product",
            expires_at="2025-01-01T01:00:00Z",
            _client=self.client
        )

        seat.release()
        seat.release()  # Second release should be no-op

        self.assertEqual(mock_urlopen.call_count, 1)

    @patch('tailli_license.client.urlopen')
    def test_seat_refresh(self, mock_urlopen):
        """Test refreshing a seat token."""
        mock_urlopen.return_value = mock_response({
            "token": "new-token",
            "expiresAt": "2025-01-01T02:00:00Z"
        })

        seat = Seat(
            seat_id="seat-123",
            token="old-token",
            product_id="test-product",
            expires_at="2025-01-01T01:00:00Z",
            _client=self.client
        )

        seat.refresh()

        self.assertEqual(seat.token, "new-token")

    @patch('tailli_license.client.urlopen')
    def test_seat_auto_refresh(self, mock_urlopen):
        """Test auto-refresh background thread."""
        mock_urlopen.return_value = mock_response({
            "token": "refreshed-token",
            "expiresAt": "2025-01-01T02:00:00Z"
        })

        seat = Seat(
            seat_id="seat-123",
            token="initial-token",
            product_id="test-product",
            expires_at="2025-01-01T01:00:00Z",
            _client=self.client
        )

        # Start auto-refresh with very short interval
        seat._start_auto_refresh(interval_seconds=0.1)

        # Wait for at least one refresh
        time.sleep(0.25)

        # Stop and verify
        seat._stop_refresh.set()
        self.assertEqual(seat.token, "refreshed-token")


class TestErrorClasses(unittest.TestCase):
    """Tests for error class hierarchy."""

    def test_license_error_is_base(self):
        """Test LicenseError is base for all license errors."""
        self.assertTrue(issubclass(SeatLimitError, LicenseError))
        self.assertTrue(issubclass(LicenseExpiredError, LicenseError))
        self.assertTrue(issubclass(ConnectionError, LicenseError))

    def test_error_messages(self):
        """Test error messages are preserved."""
        error = SeatLimitError("Custom message")
        self.assertEqual(str(error), "Custom message")

        error = LicenseExpiredError("License expired on 2024-01-01")
        self.assertIn("2024-01-01", str(error))


if __name__ == '__main__':
    unittest.main()
