import importlib
import sys
from types import SimpleNamespace

import bcrypt
import pytest
from fastapi import HTTPException
from starlette.status import HTTP_403_FORBIDDEN

from lightrag.api.passwords import BCRYPT_PASSWORD_PREFIX, hash_password
from lightrag.tools.hash_password import main as hash_password_main
from lightrag.utils import logger as lightrag_logger


def import_real_api_module(module_name: str):
    sys.modules.pop(module_name, None)

    package_name, _, child_name = module_name.rpartition(".")
    package = sys.modules.get(package_name)
    if package is not None and hasattr(package, child_name):
        delattr(package, child_name)

    return importlib.import_module(module_name)


@pytest.fixture
def auth_module(monkeypatch):
    config = import_real_api_module("lightrag.api.config")

    mock_global_args = SimpleNamespace(
        token_secret="test-jwt-secret",
        jwt_algorithm="HS256",
        token_expire_hours=48,
        guest_token_expire_hours=24,
        auth_accounts="admin:admin_pass",
    )

    monkeypatch.setattr(config, "global_args", mock_global_args)

    module = import_real_api_module("lightrag.api.auth")
    module = importlib.reload(module)
    yield module
    sys.modules.pop("lightrag.api.auth", None)


def build_bcrypt_value(password: str) -> str:
    hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
    return f"{BCRYPT_PASSWORD_PREFIX}{hashed}"


def test_verify_plaintext_password(auth_module):
    handler = auth_module.AuthHandler()
    handler.accounts = {"admin": "admin_pass"}

    assert handler.verify_password("admin", "admin_pass")
    assert not handler.verify_password("admin", "wrong_pass")


def test_verify_prefixed_bcrypt_password(auth_module):
    handler = auth_module.AuthHandler()
    handler.accounts = {"user": build_bcrypt_value("user_pass")}

    assert handler.verify_password("user", "user_pass")
    assert not handler.verify_password("user", "wrong_pass")


def test_plaintext_password_with_bcrypt_prefix_stays_plaintext(auth_module):
    handler = auth_module.AuthHandler()
    handler.accounts = {"user": "$2b$not-a-real-hash"}

    assert handler.verify_password("user", "$2b$not-a-real-hash")
    assert not handler.verify_password("user", "anything-else")


def test_invalid_auth_accounts_raises(monkeypatch):
    config = import_real_api_module("lightrag.api.config")

    mock_global_args = SimpleNamespace(
        token_secret="test-jwt-secret",
        jwt_algorithm="HS256",
        token_expire_hours=48,
        guest_token_expire_hours=24,
        auth_accounts="admin",
    )

    monkeypatch.setattr(config, "global_args", mock_global_args)

    with pytest.raises(ValueError, match="AUTH_ACCOUNTS must use"):
        import_real_api_module("lightrag.api.auth")

    sys.modules.pop("lightrag.api.auth", None)


def test_initialize_config_rejects_default_token_secret_with_auth_accounts():
    config = import_real_api_module("lightrag.api.config")

    insecure_args = SimpleNamespace(
        auth_accounts="admin:admin_pass",
        token_secret=config.DEFAULT_TOKEN_SECRET,
    )

    with pytest.raises(ValueError, match="TOKEN_SECRET must be explicitly set"):
        config.initialize_config(insecure_args, force=True)


def test_initialize_config_allows_custom_token_secret_with_auth_accounts():
    config = import_real_api_module("lightrag.api.config")

    secure_args = SimpleNamespace(
        auth_accounts="admin:admin_pass",
        token_secret="custom-jwt-secret",
    )

    initialized = config.initialize_config(secure_args, force=True)

    assert initialized is secure_args


def test_guest_tokens_fall_back_to_default_secret_when_token_secret_missing(
    monkeypatch,
):
    config = import_real_api_module("lightrag.api.config")

    mock_global_args = SimpleNamespace(
        token_secret=None,
        jwt_algorithm="HS256",
        token_expire_hours=48,
        guest_token_expire_hours=24,
        auth_accounts="",
    )

    monkeypatch.setattr(config, "global_args", mock_global_args)
    warning_messages = []

    def capture_warning(message):
        warning_messages.append(message)

    monkeypatch.setattr(lightrag_logger, "warning", capture_warning)

    module = import_real_api_module("lightrag.api.auth")
    module = importlib.reload(module)
    handler = module.AuthHandler()

    token = handler.create_token("guest", role="guest")
    token_info = handler.validate_token(token)

    assert handler.secret == config.DEFAULT_TOKEN_SECRET
    assert token_info["username"] == "guest"
    assert token_info["role"] == "guest"
    assert any(
        "Falling back to the default guest-mode JWT secret" in msg
        for msg in warning_messages
    )

    sys.modules.pop("lightrag.api.auth", None)


def _load_api_key_only_modules(monkeypatch):
    """Import auth + utils_api in the API-key-only profile.

    API-key-only = an API key is set but AUTH_ACCOUNTS is empty, so
    ``auth_configured`` is False and AuthHandler falls back to the public
    DEFAULT_TOKEN_SECRET. Returns (config, auth, utils_api).
    """
    config = import_real_api_module("lightrag.api.config")

    mock_global_args = SimpleNamespace(
        token_secret=None,  # -> AuthHandler falls back to DEFAULT_TOKEN_SECRET
        jwt_algorithm="HS256",
        token_expire_hours=48,
        guest_token_expire_hours=24,
        auth_accounts="",  # no password accounts -> auth_configured is False
        whitelist_paths="/health",  # consumed by utils_api at import time
        token_auto_renew=False,
    )
    monkeypatch.setattr(config, "global_args", mock_global_args)

    auth = import_real_api_module("lightrag.api.auth")
    auth = importlib.reload(auth)
    utils_api = import_real_api_module("lightrag.api.utils_api")
    return config, auth, utils_api


async def test_combined_auth_rejects_guest_token_in_api_key_mode(monkeypatch):
    """A forged/obtained guest token must not bypass the X-API-Key check.

    Regression for GHSA-f4vv-55c2-5789 / GHSA-xr5c-v5r6-c9f9: in the
    API-key-only profile an anonymous caller can mint a guest JWT with the
    public default secret (or fetch one from /auth-status) and previously
    short-circuited authorization before the API key was ever checked.
    """
    config, auth, utils_api = _load_api_key_only_modules(monkeypatch)
    try:
        assert utils_api.auth_configured is False
        assert auth.auth_handler.secret == config.DEFAULT_TOKEN_SECRET

        forged_guest = auth.auth_handler.create_token(username="guest", role="guest")

        api_key = "operator-secret-key"
        dependency = utils_api.get_combined_auth_dependency(api_key=api_key)

        request = SimpleNamespace(url=SimpleNamespace(path="/documents"), scope={})
        response = SimpleNamespace(headers={})

        # Forged guest token, no API key -> rejected (was HTTP 200 before the fix).
        with pytest.raises(HTTPException) as rejected:
            await dependency(
                request=request,
                response=response,
                token=forged_guest,
                api_key_header_value=None,
            )
        assert rejected.value.status_code == HTTP_403_FORBIDDEN

        # Guest token together with the real API key -> allowed (the WebUI sends
        # both Authorization and X-API-Key headers).
        result = await dependency(
            request=request,
            response=response,
            token=forged_guest,
            api_key_header_value=api_key,
        )
        assert result is None
    finally:
        sys.modules.pop("lightrag.api.utils_api", None)
        sys.modules.pop("lightrag.api.auth", None)


async def test_combined_auth_allows_guest_token_when_fully_open(monkeypatch):
    """Fully-open mode (no API key, no AUTH_ACCOUNTS) keeps accepting guest tokens.

    Guards against over-fixing: the bypass fix must not break zero-config guest
    access, which is the intended convenience mode.
    """
    _config, auth, utils_api = _load_api_key_only_modules(monkeypatch)
    try:
        guest_token = auth.auth_handler.create_token(username="guest", role="guest")

        dependency = utils_api.get_combined_auth_dependency(api_key=None)
        request = SimpleNamespace(url=SimpleNamespace(path="/documents"), scope={})
        response = SimpleNamespace(headers={})

        result = await dependency(
            request=request,
            response=response,
            token=guest_token,
            api_key_header_value=None,
        )
        assert result is None
    finally:
        sys.modules.pop("lightrag.api.utils_api", None)
        sys.modules.pop("lightrag.api.auth", None)


def test_hash_password_returns_prefixed_value(auth_module):
    hashed = hash_password("new_password")

    assert hashed.startswith(BCRYPT_PASSWORD_PREFIX)
    raw_hash = hashed[len(BCRYPT_PASSWORD_PREFIX) :]
    assert bcrypt.checkpw("new_password".encode("utf-8"), raw_hash.encode("utf-8"))


def test_hash_password_cli_outputs_auth_accounts_entry(capsys):
    exit_code = hash_password_main(["--username", "admin", "secret"])

    assert exit_code == 0
    output = capsys.readouterr().out.strip()
    username, hashed = output.split(":", 1)
    assert username == "admin"
    assert hashed.startswith(BCRYPT_PASSWORD_PREFIX)
    raw_hash = hashed[len(BCRYPT_PASSWORD_PREFIX) :]
    assert bcrypt.checkpw("secret".encode("utf-8"), raw_hash.encode("utf-8"))
