"""Tests for M4-T03 + M4-T07: scripts/admin_cli.py — CLI escape hatch.

Covers:
- reset-password: new password verifies, old password fails.
- unlock: clears specified/all throttle rows; prints count; idempotent.
- disable-totp: clears TOTP state + recovery codes without requiring any credential.
- reissue-totp: writes new secret; prints otpauth URI; secret changes.
- list-admin: prints user table without crashing.
- Non-existent user → friendly error + SystemExit with non-zero code.
- Data-safety: CLI must never reference user-data tables in delete/drop.

DB isolation: each test gets a fresh temporary SQLite database via
``cli_db`` fixture (mirrors the pattern in test_login_throttle.py).
"""

from __future__ import annotations

import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path

import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker

from app.db import reset_db_caches
from app.config import get_settings
from app.models.auth import AuthUser, RecoveryCode
from app.models.auth_throttle import LoginThrottle
from app.services.auth import hash_password, verify_password
from scripts.admin_cli import (
    build_parser,
    cmd_disable_totp,
    cmd_list_admin,
    cmd_reset_password,
    cmd_reissue_totp,
    cmd_unlock,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


def _make_app_alembic_config(database_url: str) -> Config:
    config = Config("alembic_app.ini")
    config.set_main_option("sqlalchemy.url", database_url)
    return config


@pytest.fixture()
def cli_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
    """Fresh migrated SQLite DB wired as the active app DB for the CLI.

    Monkeypatches APP_DATABASE_URL so that ``get_session_local()`` inside
    admin_cli reaches this temp file, not the real data store.
    """
    db_path = tmp_path / "cli_test.db"
    db_url = f"sqlite:///{db_path}"

    monkeypatch.setenv("APP_DATABASE_URL", db_url)
    monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
    monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "original-password")
    monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
    monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "false")
    get_settings.cache_clear()
    reset_db_caches()

    command.upgrade(_make_app_alembic_config(db_url), "head")
    reset_db_caches()

    # Bootstrap the admin user manually so the test controls the initial hash.
    engine = create_engine(db_url, connect_args={"check_same_thread": False})
    SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
    with SessionLocal() as session:
        # The Alembic upgrade does NOT create a user (that is app startup logic).
        # Insert the initial admin user here for tests that need one.
        existing = session.scalar(select(AuthUser).limit(1))
        if existing is None:
            user = AuthUser(
                username="admin",
                password_hash=hash_password("original-password"),
                is_active=True,
                force_password_change=False,
                created_at=datetime.now(UTC),
            )
            session.add(user)
            session.commit()
    engine.dispose()
    reset_db_caches()

    yield db_url

    get_settings.cache_clear()
    reset_db_caches()


@pytest.fixture()
def cli_session(cli_db: str) -> Session:
    """SQLAlchemy session bound to the CLI test DB."""
    engine = create_engine(cli_db, connect_args={"check_same_thread": False})
    SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
    session = SessionLocal()
    try:
        yield session
    finally:
        session.close()
        engine.dispose()


def _add_throttle_rows(session: Session, rows: list[tuple[str, str]]) -> None:
    """Helper: insert (scope, key) throttle rows into the test DB."""
    now = datetime.now(UTC)
    for scope, key in rows:
        session.add(
            LoginThrottle(
                scope=scope,
                key=key,
                failures=5,
                first_failed_at=now,
                last_failed_at=now,
                next_allowed_at=None,
            )
        )
    session.commit()


def _throttle_count(session: Session) -> int:
    return session.query(LoginThrottle).count()


def _enable_totp_for_user(session: Session, username: str, secret: str = "JBSWY3DPEHPK3PXP") -> None:
    """Helper: set TOTP enabled + secret for a user and add some recovery codes."""
    user = session.scalar(select(AuthUser).where(AuthUser.username == username))
    assert user is not None, f"User '{username}' not found in _enable_totp_for_user"
    user.totp_secret = secret
    user.totp_enabled = True
    # Add two fake recovery codes so we can verify they get deleted.
    session.add(RecoveryCode(user_id=user.id, code_hash=hash_password("aaaa-bbbb")))
    session.add(RecoveryCode(user_id=user.id, code_hash=hash_password("cccc-dddd")))
    session.commit()
    session.expire_all()


def _recovery_code_count(session: Session, username: str) -> int:
    user = session.scalar(select(AuthUser).where(AuthUser.username == username))
    assert user is not None
    return session.query(RecoveryCode).filter(RecoveryCode.user_id == user.id).count()


# ---------------------------------------------------------------------------
# reset-password tests
# ---------------------------------------------------------------------------


class TestResetPassword:
    """reset-password sub-command."""

    def test_new_password_verifies(self, cli_db: str, cli_session: Session):
        """After reset, new password passes verify_password; old one fails."""
        parser = build_parser()
        args = parser.parse_args(["reset-password", "admin", "--password", "new-secret-123"])
        cmd_reset_password(args)

        # Re-query from the session (committed by CLI, so flush needed).
        cli_session.expire_all()
        user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user is not None
        assert verify_password("new-secret-123", user.password_hash), (
            "New password should verify against stored hash"
        )
        assert not verify_password("original-password", user.password_hash), (
            "Old password must no longer verify"
        )

    def test_nonexistent_user_exits_nonzero(self, cli_db: str):
        """reset-password for an unknown username must SystemExit with code != 0."""
        parser = build_parser()
        args = parser.parse_args(
            ["reset-password", "ghost-user", "--password", "doesnt-matter"]
        )
        with pytest.raises(SystemExit) as exc_info:
            cmd_reset_password(args)
        assert exc_info.value.code != 0

    def test_empty_password_exits_nonzero(self, cli_db: str):
        """Passing an empty --password string must fail with non-zero exit."""
        parser = build_parser()
        args = parser.parse_args(["reset-password", "admin", "--password", ""])
        with pytest.raises(SystemExit) as exc_info:
            cmd_reset_password(args)
        assert exc_info.value.code != 0

    def test_multiple_resets_are_idempotent(self, cli_db: str, cli_session: Session):
        """Calling reset-password multiple times leaves the last password active."""
        parser = build_parser()

        args1 = parser.parse_args(["reset-password", "admin", "--password", "first-pass"])
        cmd_reset_password(args1)

        args2 = parser.parse_args(["reset-password", "admin", "--password", "second-pass"])
        cmd_reset_password(args2)

        cli_session.expire_all()
        user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user is not None
        assert verify_password("second-pass", user.password_hash)
        assert not verify_password("first-pass", user.password_hash)


# ---------------------------------------------------------------------------
# unlock tests
# ---------------------------------------------------------------------------


class TestUnlock:
    """unlock sub-command."""

    def test_unlock_all_clears_all_rows(self, cli_db: str, cli_session: Session):
        """unlock --all should delete every row in auth_login_throttle."""
        _add_throttle_rows(
            cli_session,
            [("ip", "1.2.3.4"), ("ip", "5.6.7.8"), ("user", "admin")],
        )
        assert _throttle_count(cli_session) == 3

        parser = build_parser()
        args = parser.parse_args(["unlock", "--all"])
        cmd_unlock(args)

        cli_session.expire_all()
        assert _throttle_count(cli_session) == 0

    def test_unlock_ip_clears_only_target_ip(self, cli_db: str, cli_session: Session):
        """unlock --ip should remove only the matching scope='ip' row."""
        _add_throttle_rows(
            cli_session,
            [("ip", "10.0.0.1"), ("ip", "10.0.0.2"), ("user", "admin")],
        )

        parser = build_parser()
        args = parser.parse_args(["unlock", "--ip", "10.0.0.1"])
        cmd_unlock(args)

        cli_session.expire_all()
        assert _throttle_count(cli_session) == 2
        remaining = cli_session.scalars(select(LoginThrottle)).all()
        keys = [(r.scope, r.key) for r in remaining]
        assert ("ip", "10.0.0.1") not in keys
        assert ("ip", "10.0.0.2") in keys
        assert ("user", "admin") in keys

    def test_unlock_username_clears_only_target_user(self, cli_db: str, cli_session: Session):
        """unlock --username should remove only the matching scope='user' row."""
        _add_throttle_rows(
            cli_session,
            [("ip", "1.2.3.4"), ("user", "admin"), ("user", "other")],
        )

        parser = build_parser()
        args = parser.parse_args(["unlock", "--username", "admin"])
        cmd_unlock(args)

        cli_session.expire_all()
        assert _throttle_count(cli_session) == 2
        remaining = cli_session.scalars(select(LoginThrottle)).all()
        keys = [(r.scope, r.key) for r in remaining]
        assert ("user", "admin") not in keys
        assert ("ip", "1.2.3.4") in keys
        assert ("user", "other") in keys

    def test_unlock_all_idempotent(self, cli_db: str, cli_session: Session):
        """Calling unlock --all when no rows exist must not raise."""
        assert _throttle_count(cli_session) == 0

        parser = build_parser()
        args = parser.parse_args(["unlock", "--all"])
        cmd_unlock(args)  # must not raise

        cli_session.expire_all()
        assert _throttle_count(cli_session) == 0

    def test_unlock_ip_idempotent_when_absent(self, cli_db: str, cli_session: Session):
        """Calling unlock --ip for a non-existent IP is a no-op (0 rows deleted)."""
        parser = build_parser()
        args = parser.parse_args(["unlock", "--ip", "99.99.99.99"])
        cmd_unlock(args)  # must not raise; 0 rows deleted is fine

    def test_unlock_username_idempotent_when_absent(self, cli_db: str, cli_session: Session):
        """Calling unlock --username for a non-existent username is a no-op."""
        parser = build_parser()
        args = parser.parse_args(["unlock", "--username", "nobody"])
        cmd_unlock(args)  # must not raise

    def test_unlock_all_prints_row_count(self, cli_db: str, cli_session: Session, capsys):
        """unlock --all must print the number of rows deleted."""
        _add_throttle_rows(cli_session, [("ip", "1.2.3.4"), ("user", "admin")])

        parser = build_parser()
        args = parser.parse_args(["unlock", "--all"])
        cmd_unlock(args)

        captured = capsys.readouterr()
        assert "2" in captured.out

    def test_unlock_does_not_touch_auth_users(self, cli_db: str, cli_session: Session):
        """unlock must not delete or alter auth_users rows."""
        _add_throttle_rows(cli_session, [("ip", "1.2.3.4")])
        user_count_before = cli_session.query(AuthUser).count()

        parser = build_parser()
        args = parser.parse_args(["unlock", "--all"])
        cmd_unlock(args)

        cli_session.expire_all()
        assert cli_session.query(AuthUser).count() == user_count_before


# ---------------------------------------------------------------------------
# list-admin tests
# ---------------------------------------------------------------------------


class TestListAdmin:
    """list-admin sub-command."""

    def test_list_admin_prints_header_and_user(self, cli_db: str, capsys):
        """list-admin must print the admin user row."""
        parser = build_parser()
        args = parser.parse_args(["list-admin"])
        cmd_list_admin(args)

        captured = capsys.readouterr()
        assert "admin" in captured.out

    def test_list_admin_no_users(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys
    ):
        """list-admin on an empty user table should say 'No users found.'"""
        db_path = tmp_path / "empty_test.db"
        db_url = f"sqlite:///{db_path}"

        monkeypatch.setenv("APP_DATABASE_URL", db_url)
        monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
        monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "x")
        monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
        get_settings.cache_clear()
        reset_db_caches()
        command.upgrade(_make_app_alembic_config(db_url), "head")
        reset_db_caches()

        # Do NOT bootstrap a user — table is empty.
        parser = build_parser()
        args = parser.parse_args(["list-admin"])
        cmd_list_admin(args)

        captured = capsys.readouterr()
        assert "No users found" in captured.out

        get_settings.cache_clear()
        reset_db_caches()


# ---------------------------------------------------------------------------
# disable-totp tests
# ---------------------------------------------------------------------------


class TestDisableTotp:
    """disable-totp sub-command (M4-T07)."""

    def test_disable_totp_clears_state(self, cli_db: str, cli_session: Session):
        """After disable-totp: totp_enabled=False, totp_secret=None, recovery codes gone."""
        _enable_totp_for_user(cli_session, "admin")

        # Verify preconditions.
        user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user is not None
        assert user.totp_enabled is True
        assert user.totp_secret is not None
        assert _recovery_code_count(cli_session, "admin") == 2

        # Run disable-totp — NO credential supplied (escape hatch semantics).
        parser = build_parser()
        args = parser.parse_args(["disable-totp", "admin"])
        cmd_disable_totp(args)

        # Verify state.
        cli_session.expire_all()
        user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user is not None
        assert user.totp_enabled is False
        assert user.totp_secret is None
        assert _recovery_code_count(cli_session, "admin") == 0

    def test_disable_totp_no_credential_required(self, cli_db: str, cli_session: Session):
        """disable-totp must succeed without any recovery code or password argument."""
        _enable_totp_for_user(cli_session, "admin")

        # The argparse spec for disable-totp takes only username — no credential flags.
        # Parsing must succeed with just the username.
        parser = build_parser()
        args = parser.parse_args(["disable-totp", "admin"])
        # No exception means no credential was needed.
        cmd_disable_totp(args)  # must not raise

    def test_disable_totp_already_disabled_is_noop(self, cli_db: str, cli_session: Session):
        """disable-totp on a user who never had TOTP is a harmless no-op."""
        user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user is not None
        assert user.totp_enabled is False

        parser = build_parser()
        args = parser.parse_args(["disable-totp", "admin"])
        cmd_disable_totp(args)  # must not raise

        cli_session.expire_all()
        user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user is not None
        assert user.totp_enabled is False
        assert user.totp_secret is None

    def test_disable_totp_nonexistent_user_exits_nonzero(self, cli_db: str):
        """disable-totp for an unknown user must SystemExit with code != 0."""
        parser = build_parser()
        args = parser.parse_args(["disable-totp", "ghost-user"])
        with pytest.raises(SystemExit) as exc_info:
            cmd_disable_totp(args)
        assert exc_info.value.code != 0

    def test_disable_totp_prints_confirmation(
        self, cli_db: str, cli_session: Session, capsys
    ):
        """disable-totp must print a confirmation message mentioning the user."""
        _enable_totp_for_user(cli_session, "admin")

        parser = build_parser()
        args = parser.parse_args(["disable-totp", "admin"])
        cmd_disable_totp(args)

        captured = capsys.readouterr()
        assert "admin" in captured.out

    def test_disable_totp_does_not_touch_password(
        self, cli_db: str, cli_session: Session
    ):
        """disable-totp must not alter the user's password_hash."""
        _enable_totp_for_user(cli_session, "admin")

        user_before = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user_before is not None
        pw_hash_before = user_before.password_hash

        parser = build_parser()
        args = parser.parse_args(["disable-totp", "admin"])
        cmd_disable_totp(args)

        cli_session.expire_all()
        user_after = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user_after is not None
        assert user_after.password_hash == pw_hash_before


# ---------------------------------------------------------------------------
# reissue-totp tests
# ---------------------------------------------------------------------------


class TestReissueTotp:
    """reissue-totp sub-command (M4-T07)."""

    def test_reissue_totp_changes_secret(self, cli_db: str, cli_session: Session):
        """After reissue-totp, totp_secret must be a new non-empty value."""
        old_secret = "JBSWY3DPEHPK3PXP"
        _enable_totp_for_user(cli_session, "admin", secret=old_secret)

        parser = build_parser()
        args = parser.parse_args(["reissue-totp", "admin"])
        cmd_reissue_totp(args)

        cli_session.expire_all()
        user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user is not None
        assert user.totp_secret is not None
        assert user.totp_secret != old_secret, "Secret must be regenerated"

    def test_reissue_totp_prints_uri(self, cli_db: str, cli_session: Session, capsys):
        """reissue-totp must print an otpauth:// URI and the secret."""
        _enable_totp_for_user(cli_session, "admin")

        parser = build_parser()
        args = parser.parse_args(["reissue-totp", "admin"])
        cmd_reissue_totp(args)

        captured = capsys.readouterr()
        assert "otpauth://" in captured.out
        assert "Secret" in captured.out

    def test_reissue_totp_totp_enabled_unchanged(self, cli_db: str, cli_session: Session):
        """reissue-totp must leave totp_enabled in its current state (True stays True)."""
        _enable_totp_for_user(cli_session, "admin")

        user_before = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user_before is not None
        assert user_before.totp_enabled is True

        parser = build_parser()
        args = parser.parse_args(["reissue-totp", "admin"])
        cmd_reissue_totp(args)

        cli_session.expire_all()
        user_after = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user_after is not None
        assert user_after.totp_enabled is True, "totp_enabled must remain unchanged"

    def test_reissue_totp_disabled_user_stays_disabled(
        self, cli_db: str, cli_session: Session
    ):
        """reissue-totp on a user with totp_enabled=False leaves it False."""
        # Don't enable TOTP — user is in disabled state.
        parser = build_parser()
        args = parser.parse_args(["reissue-totp", "admin"])
        cmd_reissue_totp(args)

        cli_session.expire_all()
        user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
        assert user is not None
        assert user.totp_enabled is False, "totp_enabled must remain False"
        assert user.totp_secret is not None, "A new secret should have been written"

    def test_reissue_totp_nonexistent_user_exits_nonzero(self, cli_db: str):
        """reissue-totp for an unknown user must SystemExit with code != 0."""
        parser = build_parser()
        args = parser.parse_args(["reissue-totp", "ghost-user"])
        with pytest.raises(SystemExit) as exc_info:
            cmd_reissue_totp(args)
        assert exc_info.value.code != 0


# ---------------------------------------------------------------------------
# Argument-parser edge-cases
# ---------------------------------------------------------------------------


class TestParserEdgeCases:
    """Ensure argparse flags behave as expected."""

    def test_unlock_requires_flag(self):
        """unlock without any flag should exit with a parse error (non-zero)."""
        parser = build_parser()
        with pytest.raises(SystemExit) as exc_info:
            parser.parse_args(["unlock"])
        assert exc_info.value.code != 0

    def test_unlock_ip_and_all_mutually_exclusive(self):
        """--ip and --all together should produce a parse error."""
        parser = build_parser()
        with pytest.raises(SystemExit) as exc_info:
            parser.parse_args(["unlock", "--all", "--ip", "1.2.3.4"])
        assert exc_info.value.code != 0

    def test_unknown_command_exits_nonzero(self):
        """An unknown sub-command must exit non-zero."""
        parser = build_parser()
        with pytest.raises(SystemExit) as exc_info:
            parser.parse_args(["nonexistent-command"])
        assert exc_info.value.code != 0


# ---------------------------------------------------------------------------
# Module-level smoke: python -m scripts.admin_cli --help
# ---------------------------------------------------------------------------


def test_help_flag_exits_zero():
    """python -m scripts.admin_cli --help must exit 0."""
    result = subprocess.run(
        [sys.executable, "-m", "scripts.admin_cli", "--help"],
        capture_output=True,
        text=True,
    )
    assert result.returncode == 0
    assert "reset-password" in result.stdout
    assert "unlock" in result.stdout
    assert "disable-totp" in result.stdout
    assert "reissue-totp" in result.stdout
    assert "list-admin" in result.stdout
