"""Testy persistentnej vrstvy (db/ + api/history.py + api/auth.py).

Bezia na docasnom SQLite subore. Engine sa nepouziva priamo -- pre zapisovu
logiku staci lahky stub, ktory zrkadli rozhranie bridzik.Bridzik (series ->
rounds -> guesses/get_points_summary). Cisty engine ma vlastne testy v tests.py.
"""

import asyncio
import os
import tempfile
import unittest
import uuid
from types import SimpleNamespace

# Nastav DB/ENCRYPTION_KEY PRED importom db/api modulov -- engine sa vytvara pri importe.
_DB_FILE = os.path.join(tempfile.gettempdir(), f"bridzik_test_{uuid.uuid4().hex}.db")
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///" + _DB_FILE.replace("\\", "/")

from cryptography.fernet import Fernet  # noqa: E402

os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())

import pyotp  # noqa: E402
from sqlalchemy import select  # noqa: E402

from api import auth, history  # noqa: E402
from db import crypto  # noqa: E402
from db.db import async_session, init_db  # noqa: E402
from db.models import Player  # noqa: E402


def run(coro):
    return asyncio.run(coro)


def make_core(completed=True):
    """Stub jednej hry s jednym dohratym kolom (seria 0, kolo 0)."""
    rnd = SimpleNamespace(
        round_number=0,
        guesses={0: 2, 1: 1, 2: 0, 3: 1},
        is_completed=lambda: True,
        get_points_summary=lambda: [12, 0, 10, 11],
    )
    series = SimpleNamespace(
        series_number=0, rounds=[rnd], get_last_round=lambda: rnd
    )
    return SimpleNamespace(series=[series], is_completed=lambda: completed)


class HistoryCase(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        run(init_db())

    def _make_players(self, n=4):
        ids = []
        for _ in range(n):
            username = "u_" + uuid.uuid4().hex[:8]
            data = run(auth.register_account(username))
            ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
            ids.append(ident["player_id"])
        return ids

    @staticmethod
    def _next_step_code(secret):
        """Kod pre NASLEDUJUCI casovy krok -- confirm uz spotreboval aktualny."""
        return pyotp.TOTP(secret).at((auth._current_step() + 1) * auth.TOTP_PERIOD)

    def test_register_login_token(self):
        username = "alice_" + uuid.uuid4().hex[:6]
        data = run(auth.register_account(username))
        self.assertIn("otpauth_uri", data)

        # Nedokoncena registracia: opakovany register vyda NOVY secret
        data = run(auth.register_account(username))
        self.assertIn("otpauth_uri", data)

        # Login pred potvrdenim -> RegistrationIncomplete (novy QR namiesto chyby)
        with self.assertRaises(auth.RegistrationIncomplete):
            run(auth.login(username, "000000"))

        code = pyotp.TOTP(data["secret"]).now()
        ident = run(auth.confirm_account(username, code))
        self.assertEqual(ident["username"], username)
        self.assertTrue(ident["token"])

        # Po potvrdeni je uz meno obsadene
        with self.assertRaises(auth.AuthError):
            run(auth.register_account(username))

        # Token sa da spatne rozlustit na identitu
        resolved = run(auth.player_by_token(ident["token"]))
        self.assertEqual(resolved["player_id"], ident["player_id"])

        # Zly kod neprejde
        with self.assertRaises(auth.AuthError):
            run(auth.login(username, "000000"))

    def test_reissued_secret_invalidates_old_qr(self):
        username = "fred_" + uuid.uuid4().hex[:6]
        old = run(auth.register_account(username))
        new = run(auth.register_account(username))
        self.assertNotEqual(old["secret"], new["secret"])

        # Kod zo stareho QR uz neplati, z noveho ano
        with self.assertRaises(auth.AuthError):
            run(auth.confirm_account(username, pyotp.TOTP(old["secret"]).now()))
        ident = run(auth.confirm_account(username, pyotp.TOTP(new["secret"]).now()))
        self.assertEqual(ident["username"], username)

    def test_login_lockout_after_repeated_failures(self):
        username = "bob_" + uuid.uuid4().hex[:6]
        data = run(auth.register_account(username))
        run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))

        for _ in range(auth._LOGIN_ATTEMPT_LIMIT):
            with self.assertRaises(auth.AuthError):
                run(auth.login(username, "000000"))

        # Lockout odmietne aj spravny kod, kym neubehne okno
        with self.assertRaises(auth.AuthError):
            run(auth.login(username, self._next_step_code(data["secret"])))

    def test_successful_login_clears_failed_attempts(self):
        username = "carol_" + uuid.uuid4().hex[:6]
        data = run(auth.register_account(username))
        run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))

        for _ in range(auth._LOGIN_ATTEMPT_LIMIT - 1):
            with self.assertRaises(auth.AuthError):
                run(auth.login(username, "000000"))

        ident = run(auth.login(username, self._next_step_code(data["secret"])))
        self.assertEqual(ident["username"], username)
        self.assertNotIn(username, auth._failed_attempts)

    def test_record_rounds_and_idempotency(self):
        ids = self._make_players()
        gid = str(uuid.uuid4())
        core = make_core()

        run(history.record_game_started(gid, "Test", ids))
        run(history.record_completed_rounds(gid, core))
        # Opakovany zapis nesmie zalozit duplikaty.
        run(history.record_completed_rounds(gid, core))

        detail = run(history.get_game_detail(gid))
        self.assertIsNotNone(detail)
        self.assertEqual(len(detail["rounds"]), 4)  # 4 hraci x 1 kolo

        by_seat = {r["player_id"]: r for r in detail["rounds"]}
        # Body podla get_points_summary; won = points > 0.
        self.assertEqual(by_seat[ids[0]]["points"], 12)
        self.assertTrue(by_seat[ids[0]]["won"])
        self.assertEqual(by_seat[ids[1]]["points"], 0)
        self.assertFalse(by_seat[ids[1]]["won"])
        self.assertIsNotNone(detail["ended_at"])  # core.is_completed() -> ended

    def test_rebuild_core_position(self):
        # Z dvoch cisel sa obnovi spravna pozicia a karty sa rozdaju nanovo.
        core = history.rebuild_core(2, 3)
        self.assertEqual(core.series[-1].series_number, 2)
        last_round = core.series[-1].get_last_round()
        self.assertEqual(last_round.round_number, 3)
        # V kole 3 ma kazdy hrac 8 - 3 = 5 kariet.
        self.assertEqual(len(core.get_player_cards(0)), 5)

    def test_restore_game_from_db(self):
        ids = self._make_players()
        gid = str(uuid.uuid4())
        run(history.record_game_started(gid, "Test", ids))
        run(history.record_completed_rounds(gid, make_core()))  # zapise poziciu

        restored = run(history.restore_game_core(gid))
        self.assertIsNotNone(restored)
        # make_core() je seria 0, kolo 0 -> pozicia 0/0
        self.assertEqual(restored.series[-1].series_number, 0)
        self.assertEqual(restored.series[-1].get_last_round().round_number, 0)

    def test_unfinished_games_listed(self):
        ids = self._make_players()
        gid = str(uuid.uuid4())
        run(history.record_game_started(gid, "Nedohrata", ids))
        run(history.record_completed_rounds(gid, make_core(completed=False)))

        rows = run(history.get_unfinished_games())
        mine = next((g for g in rows if g["gid"] == gid), None)
        self.assertIsNotNone(mine)
        self.assertEqual(mine["name"], "Nedohrata")
        self.assertEqual(len(mine["seats"]), 4)
        self.assertEqual(mine["seats"][0][0], ids[0])  # player_id na sedadle 0
        self.assertIsNotNone(mine["core"])  # uz postaveny Bridzik

    def test_mark_game_ended_removes_from_unfinished(self):
        ids = self._make_players()
        gid = str(uuid.uuid4())
        run(history.record_game_started(gid, "Vzdana", ids))
        run(history.record_completed_rounds(gid, make_core(completed=False)))
        self.assertTrue(any(g["gid"] == gid for g in run(history.get_unfinished_games())))

        run(history.mark_game_ended(gid))
        self.assertFalse(any(g["gid"] == gid for g in run(history.get_unfinished_games())))

    def test_reopen_prematurely_ended_game(self):
        ids = self._make_players()
        gid = str(uuid.uuid4())
        run(history.record_game_started(gid, "Vzdana", ids))
        run(history.record_completed_rounds(gid, make_core(completed=False)))
        run(history.mark_game_ended(gid))

        # V historii sa ukazuje ako predcasne ukoncena (nie naplno dohrana).
        rows = run(history.get_player_history(ids[0]))
        mine = next(g for g in rows if g["gid"] == gid)
        self.assertFalse(mine["completed"])

        # Cudzi hrac ju obnovit nemoze.
        outsider = self._make_players(1)[0]
        self.assertIsNone(run(history.reopen_game(gid, outsider)))

        # Clen ju obnovi -> ended_at sa zmaze a hra je zas medzi nedohratymi.
        info = run(history.reopen_game(gid, ids[0]))
        self.assertIsNotNone(info)
        self.assertEqual(len(info["seats"]), 4)
        self.assertTrue(any(g["gid"] == gid for g in run(history.get_unfinished_games())))
        # A teda uz nie je v historii (zobrazuju sa iba ukoncene hry).
        self.assertFalse(any(g["gid"] == gid for g in run(history.get_player_history(ids[0]))))

    def test_standings_from_db(self):
        ids = self._make_players()
        gid = str(uuid.uuid4())
        run(history.record_game_started(gid, "Test", ids))
        run(history.record_completed_rounds(gid, make_core()))

        standings, guesses = run(history.get_standings(gid))
        # 1 seria, 1 kolo. Body podla sedadiel zo stubu [12, 0, 10, 11],
        # tipy zo stubu {0: 2, 1: 1, 2: 0, 3: 1}.
        self.assertEqual(standings, [[[12, 0, 10, 11]]])
        self.assertEqual(guesses, [[[2, 1, 0, 1]]])

    def test_player_history(self):
        ids = self._make_players()
        gid = str(uuid.uuid4())
        run(history.record_game_started(gid, "Test", ids))
        run(history.record_completed_rounds(gid, make_core()))

        rows = run(history.get_player_history(ids[0]))
        self.assertTrue(any(g["gid"] == gid for g in rows))
        mine = next(g for g in rows if g["gid"] == gid)
        self.assertEqual(mine["my_points"], 12)
        self.assertEqual(len(mine["players"]), 4)

    def test_totp_secret_stored_encrypted_not_plaintext(self):
        username = "dave_" + uuid.uuid4().hex[:6]
        data = run(auth.register_account(username))

        async def _raw_secret():
            async with async_session() as session:
                return await session.scalar(
                    select(Player.totp_secret).where(Player.username == username)
                )

        stored = run(_raw_secret())
        self.assertNotEqual(stored, data["secret"])
        self.assertEqual(crypto.decrypt(stored), data["secret"])

    def test_auth_token_stored_hashed_not_plaintext(self):
        username = "erin_" + uuid.uuid4().hex[:6]
        data = run(auth.register_account(username))
        ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))

        async def _raw_token():
            async with async_session() as session:
                return await session.scalar(
                    select(Player.auth_token).where(Player.username == username)
                )

        stored = run(_raw_token())
        self.assertNotEqual(stored, ident["token"])
        self.assertEqual(stored, crypto.hash_token(ident["token"]))


class CryptoCase(unittest.TestCase):
    def test_encrypt_decrypt_roundtrip(self):
        secret = "JBSWY3DPEHPK3PXP"
        ciphertext = crypto.encrypt(secret)
        self.assertNotEqual(ciphertext, secret)
        self.assertEqual(crypto.decrypt(ciphertext), secret)

    def test_hash_token_is_deterministic_and_distinct(self):
        self.assertEqual(crypto.hash_token("abc"), crypto.hash_token("abc"))
        self.assertNotEqual(crypto.hash_token("abc"), crypto.hash_token("abd"))

    def test_missing_key_raises(self):
        saved = os.environ.pop("ENCRYPTION_KEY")
        try:
            with self.assertRaises(RuntimeError):
                crypto.encrypt("x")
        finally:
            os.environ["ENCRYPTION_KEY"] = saved


def tearDownModule():
    from db.db import engine

    run(engine.dispose())
    try:
        os.remove(_DB_FILE)
    except OSError:
        pass


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