# Copyright (c) 2018-2026 Visual Collaboration Technologies Inc.
# All Rights Reserved.

import collections
import re
from concurrent.futures import ThreadPoolExecutor

import pytest

from vcti.shortuid import ShortUID, __version__
from vcti.shortuid.short_uid import _MAX_LENGTH, _MIN_LENGTH

URL_SAFE_RE = re.compile(r"^[A-Za-z0-9_-]+$")


class TestVersion:
    """Tests for package version metadata."""

    def test_version_is_string(self):
        assert isinstance(__version__, str)

    def test_version_not_empty(self):
        assert len(__version__) > 0

    def test_version_format(self):
        parts = __version__.split(".")
        assert len(parts) >= 2
        assert all(part.isdigit() for part in parts)


class TestShortUIDInit:
    """Tests for ShortUID constructor defaults and validation."""

    def test_default_entropy_is_none(self):
        uid = ShortUID()
        assert uid.entropy is None

    def test_default_length_is_eight(self):
        uid = ShortUID()
        assert uid.length == 8

    def test_custom_entropy(self):
        uid = ShortUID(entropy="seed")
        assert uid.entropy == "seed"

    def test_custom_length_stored(self):
        uid = ShortUID(length=16)
        assert uid.length == 16

    def test_length_zero_raises(self):
        with pytest.raises(ValueError, match="between"):
            ShortUID(length=0)

    def test_negative_length_raises(self):
        with pytest.raises(ValueError, match="between"):
            ShortUID(length=-5)

    def test_length_exceeds_max_raises(self):
        with pytest.raises(ValueError, match="between"):
            ShortUID(length=_MAX_LENGTH + 1)

    def test_length_at_max_allowed(self):
        uid = ShortUID(length=_MAX_LENGTH)
        assert uid.length == _MAX_LENGTH

    def test_length_at_min_allowed(self):
        uid = ShortUID(length=_MIN_LENGTH)
        assert uid.length == _MIN_LENGTH

    def test_non_integer_length_raises(self):
        with pytest.raises(TypeError, match="integer"):
            ShortUID(length="8")  # type: ignore[arg-type]

    def test_float_length_raises(self):
        with pytest.raises(TypeError, match="integer"):
            ShortUID(length=8.0)  # type: ignore[arg-type]

    def test_bool_length_raises(self):
        with pytest.raises(TypeError, match="integer"):
            ShortUID(length=True)  # type: ignore[arg-type]

    def test_non_string_entropy_raises(self):
        with pytest.raises(TypeError, match="entropy must be a string"):
            ShortUID(entropy=123)  # type: ignore[arg-type]

    def test_list_entropy_raises(self):
        with pytest.raises(TypeError, match="entropy must be a string"):
            ShortUID(entropy=["a"])  # type: ignore[arg-type]

    def test_empty_entropy_normalized_to_none(self):
        uid = ShortUID(entropy="")
        assert uid.entropy is None

    def test_empty_entropy_equals_none_entropy(self):
        assert ShortUID(entropy="") == ShortUID(entropy=None)

    def test_slots(self):
        uid = ShortUID()
        with pytest.raises(AttributeError):
            uid.extra = "not allowed"  # type: ignore[attr-defined]


class TestShortUIDRepr:
    """Tests for ShortUID.__repr__."""

    def test_repr_default(self):
        uid = ShortUID()
        assert repr(uid) == "ShortUID(entropy=None, length=8)"

    def test_repr_custom(self):
        uid = ShortUID(entropy="app", length=12)
        assert repr(uid) == "ShortUID(entropy='app', length=12)"


class TestShortUIDEquality:
    """Tests for ShortUID.__eq__ and __hash__."""

    def test_equal_instances(self):
        a = ShortUID(entropy="x", length=10)
        b = ShortUID(entropy="x", length=10)
        assert a == b

    def test_unequal_entropy(self):
        a = ShortUID(entropy="x")
        b = ShortUID(entropy="y")
        assert a != b

    def test_unequal_length(self):
        a = ShortUID(length=8)
        b = ShortUID(length=12)
        assert a != b

    def test_not_equal_to_other_type(self):
        assert ShortUID() != "not a ShortUID"

    def test_hash_equal_instances(self):
        a = ShortUID(entropy="x", length=10)
        b = ShortUID(entropy="x", length=10)
        assert hash(a) == hash(b)

    def test_usable_in_set(self):
        a = ShortUID(entropy="x", length=10)
        b = ShortUID(entropy="x", length=10)
        c = ShortUID(entropy="y", length=10)
        assert len({a, b, c}) == 2


class TestShortUIDGenerate:
    """Tests for ShortUID.generate."""

    def test_default_length(self):
        uid = ShortUID()
        result = uid.generate()
        assert len(result) == 8

    def test_custom_length(self):
        uid = ShortUID(length=12)
        result = uid.generate()
        assert len(result) == 12

    def test_length_one(self):
        uid = ShortUID(length=1)
        result = uid.generate()
        assert len(result) == 1

    def test_max_length(self):
        uid = ShortUID(length=_MAX_LENGTH)
        result = uid.generate()
        assert len(result) == _MAX_LENGTH

    def test_returns_string(self):
        uid = ShortUID()
        result = uid.generate()
        assert isinstance(result, str)

    def test_url_safe_characters_regex(self):
        """Every character must match [A-Za-z0-9_-]."""
        uid = ShortUID()
        for _ in range(50):
            result = uid.generate()
            assert URL_SAFE_RE.match(result), f"Non-URL-safe character in: {result}"

    def test_uniqueness_small_batch(self):
        uid = ShortUID()
        results = {uid.generate() for _ in range(100)}
        assert len(results) > 90

    def test_uniqueness_large_batch(self):
        """10k IDs at default length should have very few collisions."""
        uid = ShortUID()
        results = {uid.generate() for _ in range(10_000)}
        assert len(results) > 9_900

    def test_with_entropy(self):
        uid = ShortUID(entropy="test-app")
        result = uid.generate()
        assert len(result) == 8

    def test_with_salt(self):
        uid = ShortUID()
        result = uid.generate(salt="my-file.txt")
        assert len(result) == 8

    def test_entropy_and_salt(self):
        uid = ShortUID(entropy="app", length=10)
        result = uid.generate(salt="context")
        assert len(result) == 10

    def test_no_entropy_no_salt(self):
        uid = ShortUID(entropy=None)
        result = uid.generate(salt=None)
        assert len(result) == 8

    def test_empty_string_entropy(self):
        uid = ShortUID(entropy="")
        result = uid.generate()
        assert len(result) == 8
        assert URL_SAFE_RE.match(result)

    def test_empty_string_salt(self):
        uid = ShortUID()
        result = uid.generate(salt="")
        assert len(result) == 8
        assert URL_SAFE_RE.match(result)

    def test_different_timestamps_produce_different_ids(self):
        """Same entropy and salt at different times must differ."""
        uid = ShortUID(entropy="fixed", length=_MAX_LENGTH)
        ids = {uid.generate(salt="same") for _ in range(20)}
        assert len(ids) == 20

    def test_non_string_salt_raises(self):
        uid = ShortUID()
        with pytest.raises(TypeError, match="salt must be a string"):
            uid.generate(salt=42)  # type: ignore[arg-type]

    def test_list_salt_raises(self):
        uid = ShortUID()
        with pytest.raises(TypeError, match="salt must be a string"):
            uid.generate(salt=["x"])  # type: ignore[arg-type]


class TestShortUIDQuick:
    """Tests for ShortUID.quick classmethod."""

    def test_quick_default_length(self):
        result = ShortUID.quick()
        assert len(result) == 8

    def test_quick_custom_length(self):
        result = ShortUID.quick(length=16)
        assert len(result) == 16

    def test_quick_returns_string(self):
        assert isinstance(ShortUID.quick(), str)

    def test_quick_uniqueness(self):
        results = {ShortUID.quick() for _ in range(50)}
        assert len(results) > 45

    def test_quick_url_safe(self):
        for _ in range(20):
            assert URL_SAFE_RE.match(ShortUID.quick())

    def test_quick_invalid_length_raises(self):
        with pytest.raises(ValueError):
            ShortUID.quick(length=0)

    def test_quick_non_integer_length_raises(self):
        with pytest.raises(TypeError, match="integer"):
            ShortUID.quick(length="8")  # type: ignore[arg-type]


class TestShortUIDThreadSafety:
    """Tests for concurrent generation."""

    def test_concurrent_generation(self):
        """Generating IDs from multiple threads must not raise or produce empty strings."""
        uid = ShortUID(entropy="threaded")

        def _gen(_: int) -> str:
            return uid.generate()

        with ThreadPoolExecutor(max_workers=8) as pool:
            results = list(pool.map(_gen, range(200)))

        assert len(results) == 200
        assert all(len(r) == 8 for r in results)
        assert all(URL_SAFE_RE.match(r) for r in results)
        # Expect very high uniqueness even under concurrency
        assert len(set(results)) > 190


class TestShortUIDCrossEntropy:
    """Tests that different entropy/salt configs produce distinct IDs."""

    def test_different_entropy_produces_different_ids(self):
        """Two generators with different entropy should not collide."""
        gen_a = ShortUID(entropy="service-a", length=_MAX_LENGTH)
        gen_b = ShortUID(entropy="service-b", length=_MAX_LENGTH)
        ids_a = {gen_a.generate() for _ in range(500)}
        ids_b = {gen_b.generate() for _ in range(500)}
        overlap = ids_a & ids_b
        assert len(overlap) == 0, f"Unexpected collisions: {overlap}"

    def test_different_salt_produces_different_ids(self):
        """Same generator with different salts should not collide."""
        gen = ShortUID(entropy="shared", length=_MAX_LENGTH)
        ids_a = {gen.generate(salt="alpha") for _ in range(500)}
        ids_b = {gen.generate(salt="beta") for _ in range(500)}
        overlap = ids_a & ids_b
        assert len(overlap) == 0, f"Unexpected collisions: {overlap}"


class TestShortUIDDistribution:
    """Statistical quality checks on generated IDs."""

    def test_first_character_distribution(self):
        """First character should use a reasonable spread of the 64-char alphabet."""
        uid = ShortUID()
        firsts = [uid.generate()[0] for _ in range(5_000)]
        counts = collections.Counter(firsts)
        # With 64 possible chars and 5000 samples, expect most chars to appear.
        # A badly biased generator would concentrate on very few chars.
        assert len(counts) >= 40, f"Only {len(counts)} distinct first chars in 5000 IDs"

    def test_no_positional_bias(self):
        """Each position should show similar diversity."""
        uid = ShortUID(length=8)
        ids = [uid.generate() for _ in range(2_000)]
        for pos in range(8):
            chars_at_pos = {uid_str[pos] for uid_str in ids}
            assert len(chars_at_pos) >= 30, (
                f"Position {pos} only has {len(chars_at_pos)} distinct chars in 2000 IDs"
            )


class TestShortUIDImport:
    """Tests for public API re-exports."""

    def test_import_from_package(self):
        from vcti.shortuid import ShortUID as Cls

        assert Cls is ShortUID

    def test_import_from_module(self):
        from vcti.shortuid.short_uid import ShortUID as Cls

        assert Cls is ShortUID
