"""Unit tests: encryption at rest, password hashing, JWT, error classification,
sender rotation, subject/template/phone selectors."""
import time
from datetime import datetime, timedelta, timezone

import pytest

from app.core.encryption import decrypt, encrypt
from app.core.security import (
    create_access_token,
    decode_token,
    hash_password,
    verify_password,
)
from app.tasks.send_tasks import classify_error
from app.services.campaign_engine import (
    PhoneSelector,
    RotationAssigner,
    SubjectSelector,
    TemplateSequencer,
)


class TestEncryption:
    def test_roundtrip(self):
        s = "ya29.a0AfH6SMB..."  # sample refresh token
        ct = encrypt(s)
        assert ct != s and "ya29" not in ct
        assert decrypt(ct) == s

    def test_ciphertext_changes_each_call(self):
        a, b = encrypt("same-value"), encrypt("same-value")
        assert a != b  # Fernet includes random IV/timestamp
        assert decrypt(a) == decrypt(b) == "same-value"

    def test_empty_values(self):
        assert encrypt(None) is None
        assert decrypt("") == ""
        assert decrypt(None) == ""

    def test_unicode_roundtrip(self):
        s = "tokéy—€£¥ 中文 🎉"
        assert decrypt(encrypt(s)) == s

    def test_plaintext_never_in_ciphertext(self):
        secret = "SUPER-SECRET-REFRESH-TOKEN-VALUE"
        ct = encrypt(secret)
        assert "SUPER-SECRET" not in ct
        assert "REFRESH" not in ct


class TestPasswords:
    def test_hash_and_verify(self):
        h = hash_password("Admin#12345")
        assert h != "Admin#12345"
        assert verify_password("Admin#12345", h) is True
        assert verify_password("wrong", h) is False

    def test_unique_salts(self):
        assert hash_password("x") != hash_password("x")

    def test_bcrypt_format(self):
        h = hash_password("x" * 30)
        assert h.startswith("$2") and len(h) >= 50


class TestJwt:
    def test_create_and_decode(self):
        tok = create_access_token(42, expires_minutes=5)
        payload = decode_token(tok)
        assert payload is not None and payload["sub"] == "42"

    def test_expired_token_rejected(self):
        tok = create_access_token(1, expires_minutes=-1)
        assert decode_token(tok) is None

    def test_garbage_token_rejected(self):
        assert decode_token("not.a.jwt") is None
        assert decode_token("") is None

    def test_tampered_signature_rejected(self):
        tok = create_access_token(7, expires_minutes=5)
        assert decode_token(tok + "x") is None


class TestErrorClassification:
    @pytest.mark.parametrize("msg", [
        "Rate limit exceeded", "429 Too Many Requests", "quota exceeded",
        "Backend error", "503 Service Unavailable", "connection reset",
        "temporarily unavailable", "try again later",
    ])
    def test_temporary_patterns(self, msg):
        assert classify_error(msg) == "temporary"

    @pytest.mark.parametrize("msg", [
        "Invalid Grant", "401 Unauthorized", "403 forbidden",
        "invalidrecipient", "550 no such user", "message rejected",
        "policy violation", "FailedPrecondition",
    ])
    def test_permanent_patterns(self, msg):
        assert classify_error(msg) == "permanent"

    def test_unknown_when_no_pattern(self):
        assert classify_error("something odd happened") == "unknown"
        assert classify_error("") == "unknown"


# ---------------------------------------------------------------------------
# Lightweight stand-in objects for rotation tests
# ---------------------------------------------------------------------------
class FakeAccount:
    def __init__(self, aid, email, status="authorized", active=True, cooldown=None):
        self.id = aid
        self.email = email
        self.status = status
        self.is_active = active
        self.cooldown_until = cooldown


class FakeSubject:
    def __init__(self, sid, text):
        self.id = sid
        self.subject = text


class FakeTemplate:
    def __init__(self, tid, name):
        self.id = tid
        self.name = name


class FakePhone:
    def __init__(self, pid, phone, active=True):
        self.id = pid
        self.phone = phone
        self.is_active = active


class TestRotationAssigner:
    def test_sequential_round_robin(self):
        accs = [FakeAccount(1, "a@x.com"), FakeAccount(2, "b@x.com"), FakeAccount(3, "c@x.com")]
        ra = RotationAssigner(accs, "sequential", max_per_account=10)
        order = [ra.next_account().id for _ in range(6)]
        assert order == [1, 2, 3, 1, 2, 3]

    def test_skips_unauthorized_and_inactive(self):
        accs = [FakeAccount(1, "a@x.com", status="revoked"),
                FakeAccount(2, "b@x.com", active=False),
                FakeAccount(3, "c@x.com")]
        ra = RotationAssigner(accs, "sequential", max_per_account=10)
        got = [ra.next_account() for _ in range(3)]
        assert all(g.id == 3 for g in got)

    def test_max_per_account_respected(self):
        accs = [FakeAccount(1, "a@x.com"), FakeAccount(2, "b@x.com")]
        ra = RotationAssigner(accs, "sequential", max_per_account=2)
        got = [ra.next_account() for _ in range(4)]
        assert got is not None and all(g is not None for g in got)
        assert [g.id for g in got] == [1, 2, 1, 2]
        assert ra.next_account() is None  # all at limit

    def test_cooldown_respected(self):
        future = datetime.now(timezone.utc) + timedelta(minutes=30)
        accs = [FakeAccount(1, "a@x.com", cooldown=future), FakeAccount(2, "b@x.com")]
        ra = RotationAssigner(accs, "sequential", max_per_account=10)
        ids = [ra.next_account().id for _ in range(3)]
        assert ids == [2, 2, 2]

    def test_cooldown_expiry_allows_reuse(self):
        past = datetime.now(timezone.utc) - timedelta(minutes=1)
        accs = [FakeAccount(1, "a@x.com", cooldown=past), FakeAccount(2, "b@x.com")]
        ra = RotationAssigner(accs, "sequential", max_per_account=10)
        ids = [ra.next_account().id for _ in range(4)]
        assert ids == [1, 2, 1, 2]

    def test_weighted_favors_heavy_account(self):
        accs = [FakeAccount(1, "a@x.com"), FakeAccount(2, "b@x.com")]
        ra = RotationAssigner(accs, "weighted", weights={"1": 1, "2": 9}, max_per_account=100)
        counts = {1: 0, 2: 0}
        for _ in range(100):
            counts[ra.next_account().id] += 1
        assert counts[2] > counts[1] * 3  # weighted 9:1

    def test_weighted_default_weight_one(self):
        accs = [FakeAccount(1, "a@x.com"), FakeAccount(2, "b@x.com")]
        ra = RotationAssigner(accs, "weighted", weights=None, max_per_account=100)
        counts = {1: 0, 2: 0}
        for _ in range(50):
            counts[ra.next_account().id] += 1
        assert counts[1] == counts[2] == 25  # equal weights -> equal use

    def test_no_accounts_returns_none(self):
        ra = RotationAssigner([], "sequential")
        assert ra.next_account() is None

    def test_status_reporting(self):
        accs = [FakeAccount(1, "a@x.com"), FakeAccount(2, "b@x.com")]
        ra = RotationAssigner(accs, "sequential", max_per_account=5)
        ra.next_account()
        st = ra.status()
        assert st["accounts"][0]["usage"] == 1
        assert st["accounts"][0]["limit"] == 5

    def test_explicit_account_id_pin(self):
        accs = [FakeAccount(1, "a@x.com"), FakeAccount(2, "b@x.com")]
        ra = RotationAssigner(accs, "sequential", max_per_account=5)
        assert ra.next_account(account_id=2).id == 2


class TestSubjectSelector:
    def test_round_robin(self):
        subs = [FakeSubject(1, "A"), FakeSubject(2, "B"), FakeSubject(3, "C")]
        ss = SubjectSelector(subs, "round_robin")
        got = [ss.next_subject().id for _ in range(6)]
        assert got == [1, 2, 3, 1, 2, 3]

    def test_random_variety(self):
        subs = [FakeSubject(i, f"S{i}") for i in range(1, 8)]
        ss = SubjectSelector(subs, "random")
        got = {ss.next_subject().id for _ in range(60)}
        assert len(got) > 1  # not deterministic

    def test_fixed_first(self):
        subs = [FakeSubject(1, "A"), FakeSubject(2, "B")]
        ss = SubjectSelector(subs, "fixed_first")
        assert all(ss.next_subject().id == 1 for _ in range(5))

    def test_empty(self):
        assert SubjectSelector([], "round_robin").next_subject() is None


class TestTemplateSequencer:
    def test_repeat_mode_cycles(self):
        tpls = [FakeTemplate(1, "T1"), FakeTemplate(2, "T2")]
        ts = TemplateSequencer(tpls, "repeat")
        got = [ts.next_template().id for _ in range(5)]
        assert got == [1, 2, 1, 2, 1]
        assert ts.stopped is False

    def test_stop_mode_halts(self):
        tpls = [FakeTemplate(1, "T1"), FakeTemplate(2, "T2")]
        ts = TemplateSequencer(tpls, "stop")
        assert ts.next_template().id == 1
        assert ts.next_template().id == 2
        assert ts.next_template() is None
        assert ts.stopped is True

    def test_random_mode(self):
        tpls = [FakeTemplate(i, f"T{i}") for i in range(1, 6)]
        ts = TemplateSequencer(tpls, "random")
        got = {ts.next_template().id for _ in range(50)}
        assert len(got) > 1

    def test_empty(self):
        assert TemplateSequencer([], "repeat").next_template() is None


class TestPhoneSelector:
    def test_round_robin(self):
        phones = [FakePhone(1, "+15550001"), FakePhone(2, "+15550002")]
        ps = PhoneSelector(phones, "round_robin")
        got = [ps.next_phone() for _ in range(4)]
        assert got == ["+15550001", "+15550002", "+15550001", "+15550002"]

    def test_fixed(self):
        phones = [FakePhone(1, "+15550001"), FakePhone(2, "+15550002")]
        assert PhoneSelector(phones, "fixed").next_phone() == "+15550001"

    def test_inactive_excluded(self):
        phones = [FakePhone(1, "+15550001", active=False), FakePhone(2, "+15550002")]
        ps = PhoneSelector(phones, "round_robin")
        assert all(p == "+15550002" for _ in range(3) for p in [ps.next_phone()])

    def test_empty_returns_blank(self):
        assert PhoneSelector([], "round_robin").next_phone() == ""
