from __future__ import annotations

from datetime import datetime, timedelta, timezone
import hashlib
import sqlite3
import unittest
from unittest import mock

from bank_importer import auth
from bank_importer.db import connect, migrate, utc_now


class AuthTestCase(unittest.TestCase):
    def setUp(self) -> None:
        self.connection = connect(":memory:")
        self.addCleanup(self.connection.close)
        migrate(self.connection)
        now = utc_now()
        with self.connection:
            self.connection.execute(
                "INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)",
                (now, now),
            )
        self.company_id = int(self.connection.execute("SELECT id FROM companies").fetchone()["id"])

    def create_company_user(self, username: str = "cashier-a", password: str = "Init1234") -> int:
        return auth.create_user(
            self.connection, username, password, "company", company_id=self.company_id
        )

    def create_admin(self, username: str = "group-admin", password: str = "Admin1234") -> int:
        return auth.create_user(self.connection, username, password, "admin")


class PasswordHashTests(unittest.TestCase):
    def test_hash_format_and_verify_roundtrip(self) -> None:
        stored = auth.hash_password("Secret123")
        scheme, iterations, salt_hex, hash_hex = stored.split("$")
        self.assertEqual("pbkdf2_sha256", scheme)
        self.assertEqual(str(auth.PBKDF2_ITERATIONS), iterations)
        self.assertEqual(32, len(salt_hex))
        self.assertEqual(64, len(hash_hex))
        self.assertTrue(auth.verify_password("Secret123", stored))

    def test_wrong_password_is_rejected(self) -> None:
        stored = auth.hash_password("Secret123")
        self.assertFalse(auth.verify_password("Secret124", stored))

    def test_same_password_gets_different_salts(self) -> None:
        self.assertNotEqual(auth.hash_password("Secret123"), auth.hash_password("Secret123"))

    def test_malformed_stored_hash_is_rejected(self) -> None:
        for stored in ("", "plain", "pbkdf2_sha256$x$y$z", "bcrypt$1$aa$bb"):
            with self.subTest(stored=stored):
                self.assertFalse(auth.verify_password("Secret123", stored))


class PasswordPolicyTests(unittest.TestCase):
    def test_policy_rejects_short_username_like_and_single_charset(self) -> None:
        self.assertIsNotNone(auth.validate_password_policy("Ab1", "cashier"))
        self.assertIsNotNone(auth.validate_password_policy("Cashier99", "cashier99"))
        self.assertIsNotNone(auth.validate_password_policy("abcdefgh", "cashier"))
        self.assertIsNotNone(auth.validate_password_policy("12345678", "cashier"))
        self.assertIsNone(auth.validate_password_policy("GoodPass1", "cashier"))

    def test_initial_password_generator_guarantees_charset(self) -> None:
        for _ in range(50):
            password = auth.generate_initial_password()
            self.assertEqual(12, len(password))
            self.assertTrue(any(char.isupper() for char in password))
            self.assertTrue(any(char.islower() for char in password))
            self.assertTrue(any(char.isdigit() for char in password))
        self.assertIsNone(auth.validate_password_policy(auth.generate_initial_password(), "x"))

    def test_initial_password_exclude_never_equals_username(self) -> None:
        for _ in range(50):
            password = auth.generate_initial_password(exclude="Cashier99")
            self.assertNotEqual(password.lower(), "cashier99")


class CreateUserTests(AuthTestCase):
    def test_company_role_requires_company(self) -> None:
        with self.assertRaises(ValueError):
            auth.create_user(self.connection, "cashier-x", "Init1234", "company")

    def test_company_must_exist(self) -> None:
        with self.assertRaises(ValueError):
            auth.create_user(self.connection, "cashier-x", "Init1234", "company", company_id=999)

    def test_admin_cannot_bind_company(self) -> None:
        with self.assertRaises(ValueError):
            auth.create_user(
                self.connection, "admin-x", "Init1234", "admin", company_id=self.company_id
            )

    def test_invalid_role_rejected(self) -> None:
        with self.assertRaises(ValueError):
            auth.create_user(self.connection, "nobody", "Init1234", "superuser")

    def test_duplicate_username_rejected(self) -> None:
        self.create_company_user()
        with self.assertRaises(ValueError):
            self.create_company_user()

    def test_new_company_user_must_change_password_by_default(self) -> None:
        user_id = self.create_company_user()
        user = self.connection.execute(
            "SELECT must_change_password, status FROM users WHERE id = ?", (user_id,)
        ).fetchone()
        self.assertEqual(1, user["must_change_password"])
        self.assertEqual("active", user["status"])


class AuthenticateTests(AuthTestCase):
    def test_success_returns_user_and_records_attempt(self) -> None:
        self.create_company_user()
        user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "127.0.0.1")
        self.assertIsNotNone(user)
        self.assertIsNone(reason)
        attempt = self.connection.execute("SELECT success FROM login_attempts").fetchone()
        self.assertEqual(1, attempt["success"])
        entry = self.connection.execute(
            "SELECT action FROM audit_log WHERE action = 'login_success'"
        ).fetchone()
        self.assertIsNotNone(entry)

    def test_bad_credentials_do_not_leak_which_part_failed(self) -> None:
        self.create_company_user()
        for username, password in (("cashier-a", "Wrong999"), ("no-such-user", "Init1234")):
            with self.subTest(username=username):
                user, reason = auth.authenticate(self.connection, username, password, "127.0.0.1")
                self.assertIsNone(user)
                self.assertEqual("bad_credentials", reason)

    def test_disabled_user_gets_disabled_reason(self) -> None:
        user_id = self.create_company_user()
        with self.connection:
            self.connection.execute(
                "UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?",
                (utc_now(), user_id),
            )
        user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "127.0.0.1")
        self.assertIsNone(user)
        self.assertEqual("disabled", reason)

    def test_five_failures_within_window_trigger_rate_limit(self) -> None:
        self.create_company_user()
        for _ in range(auth.RATE_LIMIT_MAX_FAILURES):
            user, reason = auth.authenticate(self.connection, "cashier-a", "Wrong999", "10.0.0.1")
            self.assertEqual("bad_credentials", reason)
        user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.1")
        self.assertIsNone(user)
        self.assertEqual("rate_limited", reason)
        # A different IP is not limited.
        user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.2")
        self.assertIsNotNone(user)

    def test_failures_outside_window_do_not_count(self) -> None:
        self.create_company_user()
        old = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
        with self.connection:
            for _ in range(auth.RATE_LIMIT_MAX_FAILURES):
                self.connection.execute(
                    "INSERT INTO login_attempts (username, ip, success, created_at) VALUES (?, ?, 0, ?)",
                    ("cashier-a", "10.0.0.9", old),
                )
        user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.9")
        self.assertIsNotNone(user)
        self.assertIsNone(reason)

    def test_env_flag_parses_truthy_and_falsy_values(self) -> None:
        cases = {
            "1": True,
            "true": True,
            "YES": True,
            " on ": True,
            "": False,
            "0": False,
            "false": False,
            "off": False,
            None: False,
        }
        for value, expected in cases.items():
            with self.subTest(value=value):
                self.assertEqual(expected, auth._env_flag(value))

    def test_rate_limit_enabled_by_default(self) -> None:
        self.assertFalse(auth.RATE_LIMIT_DISABLED)

    def test_env_switch_disables_rate_limit_but_not_credential_checks(self) -> None:
        self.create_company_user()
        with mock.patch.object(auth, "RATE_LIMIT_DISABLED", True):
            for _ in range(auth.RATE_LIMIT_MAX_FAILURES + 2):
                user, reason = auth.authenticate(
                    self.connection, "cashier-a", "Wrong999", "10.0.0.1"
                )
                self.assertIsNone(user)
                self.assertEqual("bad_credentials", reason)
            user, reason = auth.authenticate(
                self.connection, "cashier-a", "Init1234", "10.0.0.1"
            )
            self.assertIsNotNone(user)
            self.assertIsNone(reason)


class SessionTests(AuthTestCase):
    def test_create_and_resolve_roundtrip(self) -> None:
        user_id = self.create_company_user()
        token = auth.create_session(self.connection, user_id)
        user = auth.resolve_session(self.connection, token)
        self.assertIsNotNone(user)
        self.assertEqual(user_id, user["id"])
        # Only the digest is stored, never the raw token.
        row = self.connection.execute("SELECT token_hash FROM sessions").fetchone()
        self.assertEqual(hashlib.sha256(token.encode()).hexdigest(), row["token_hash"])
        self.assertNotIn(token, row["token_hash"])

    def test_expired_session_is_rejected(self) -> None:
        user_id = self.create_company_user()
        token = auth.create_session(self.connection, user_id)
        past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
        with self.connection:
            self.connection.execute(
                "UPDATE sessions SET expires_at = ? WHERE token_hash = ?",
                (past, hashlib.sha256(token.encode()).hexdigest()),
            )
        self.assertIsNone(auth.resolve_session(self.connection, token))

    def test_revoked_session_is_rejected(self) -> None:
        user_id = self.create_company_user()
        token = auth.create_session(self.connection, user_id)
        auth.revoke_session(self.connection, token)
        self.assertIsNone(auth.resolve_session(self.connection, token))

    def test_revoke_user_sessions_kills_all_sessions(self) -> None:
        user_id = self.create_company_user()
        first = auth.create_session(self.connection, user_id)
        second = auth.create_session(self.connection, user_id)
        auth.revoke_user_sessions(self.connection, user_id)
        self.assertIsNone(auth.resolve_session(self.connection, first))
        self.assertIsNone(auth.resolve_session(self.connection, second))

    def test_disabled_user_session_is_rejected(self) -> None:
        user_id = self.create_company_user()
        token = auth.create_session(self.connection, user_id)
        with self.connection:
            self.connection.execute(
                "UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?",
                (utc_now(), user_id),
            )
        self.assertIsNone(auth.resolve_session(self.connection, token))


class ChangePasswordTests(AuthTestCase):
    def test_change_password_clears_flag_and_updates_hash(self) -> None:
        user_id = self.create_company_user()
        error = auth.change_password(self.connection, user_id, "Init1234", "NewPass99")
        self.assertIsNone(error)
        user = self.connection.execute(
            "SELECT password_hash, must_change_password FROM users WHERE id = ?", (user_id,)
        ).fetchone()
        self.assertEqual(0, user["must_change_password"])
        self.assertTrue(auth.verify_password("NewPass99", user["password_hash"]))
        self.assertFalse(auth.verify_password("Init1234", user["password_hash"]))
        entry = self.connection.execute(
            "SELECT detail FROM audit_log WHERE action = 'password_change'"
        ).fetchone()
        self.assertIsNotNone(entry)
        self.assertNotIn("NewPass99", entry["detail"] or "")

    def test_wrong_old_password_rejected(self) -> None:
        user_id = self.create_company_user()
        error = auth.change_password(self.connection, user_id, "Wrong999", "NewPass99")
        self.assertIsNotNone(error)
        user = self.connection.execute(
            "SELECT must_change_password FROM users WHERE id = ?", (user_id,)
        ).fetchone()
        self.assertEqual(1, user["must_change_password"])

    def test_policy_violation_rejected(self) -> None:
        user_id = self.create_company_user()
        error = auth.change_password(self.connection, user_id, "Init1234", "short")
        self.assertIsNotNone(error)


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