from datetime import datetime, timedelta

from flask import Flask
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from werkzeug.security import generate_password_hash

import auth
import services.permission_service as permission_service_module
from database.permission_models import UserPermission
from database.user_models import LoginHistory, User
from services.auth_identity_service import (
    build_auth_identity_runtime_readback,
    required_roles_for_request,
    resolve_auth_identity_mode,
    resolve_client_ip,
)
from services.user_service import UserService
from services.permission_service import PermissionService


@pytest.fixture
def identity_store():
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    User.__table__.create(engine)
    LoginHistory.__table__.create(engine)
    UserPermission.__table__.create(engine)
    return sessionmaker(bind=engine)


@pytest.fixture
def identity_user(identity_store):
    db_session = identity_store()
    now = datetime.now()
    user = User(
        username="operator-admin",
        password_hash=generate_password_hash("DatabasePass9!", method="pbkdf2:sha256"),
        role="admin",
        display_name="Operator Admin",
        is_active=True,
        password_changed_at=now,
        created_at=now,
        updated_at=now,
    )
    db_session.add(user)
    db_session.commit()
    user_id = user.id
    db_session.close()
    return user_id


@pytest.fixture
def identity_app(monkeypatch, identity_store, identity_user):
    monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "hybrid")
    monkeypatch.setenv("MOMO_TRUSTED_PROXY_CIDRS", "127.0.0.1/32,::1/128")
    monkeypatch.setattr(auth, "LOGIN_PASSWORD", "LegacyPass9!")
    monkeypatch.setattr(auth, "get_identity_session", identity_store)

    app = Flask(__name__, template_folder=str(auth.os.path.join(auth.os.path.dirname(__file__), "..", "templates")))
    app.config.update(
        TESTING=True,
        SECRET_KEY="identity-test-secret",
        SESSION_COOKIE_SECURE=False,
    )
    app.jinja_env.globals["csrf_token"] = lambda: "test-csrf"
    app.add_url_rule(
        "/",
        endpoint="dashboard.index",
        view_func=lambda: "dashboard",
    )

    @app.route("/protected")
    @auth.login_required
    def protected():
        return "protected"

    auth.init_auth_routes(app)
    return app


def test_proxy_headers_are_ignored_from_untrusted_peer():
    assert resolve_client_ip(
        "203.0.113.9",
        "198.51.100.7",
        trusted_proxy_cidrs="127.0.0.1/32",
    ) == "203.0.113.9"
    assert resolve_client_ip(
        "127.0.0.1",
        "198.51.100.7, 127.0.0.1",
        trusted_proxy_cidrs="127.0.0.1/32",
    ) == "198.51.100.7"


def test_auth_mode_and_role_matrix_fail_closed():
    assert resolve_auth_identity_mode("auto") == "auto"
    with pytest.raises(ValueError):
        resolve_auth_identity_mode("invalid")
    assert required_roles_for_request("GET", "vendor.list", "vendor") == (
        "admin",
        "manager",
        "user",
    )
    assert required_roles_for_request("POST", "vendor.update", "vendor") == (
        "admin",
        "manager",
    )
    assert required_roles_for_request("GET", "cicd.status", "cicd") == ("admin",)


def test_lockout_is_durable_across_database_sessions(identity_store):
    for _ in range(5):
        db_session = identity_store()
        assert UserService(db_session).record_login(
            None,
            "operator-admin",
            "198.51.100.20",
            "pytest",
            LoginHistory.STATUS_FAILED,
            "invalid_credentials",
        ) is not None
        db_session.close()

    db_session = identity_store()
    service = UserService(db_session)
    locked, remaining, count = service.get_lockout_status(
        "operator-admin",
        "198.51.100.20",
        max_attempts=5,
        reset_seconds=1800,
        lockout_seconds=300,
    )
    assert locked is True
    assert remaining > 0
    assert count == 5

    service.record_login(
        None,
        "operator-admin",
        "198.51.100.20",
        "pytest",
        LoginHistory.STATUS_SUCCESS,
        "auth_source=database",
    )
    locked, remaining, count = service.get_lockout_status(
        "operator-admin",
        "198.51.100.20",
    )
    assert (locked, remaining, count) == (False, 0, 0)
    db_session.close()


def test_database_identity_login_writes_audit_and_versioned_session(
    identity_app,
    identity_store,
    identity_user,
):
    client = identity_app.test_client()
    response = client.post(
        "/login",
        data={"username": "operator-admin", "password": "DatabasePass9!"},
    )
    assert response.status_code == 302
    assert response.headers["Location"].endswith("/")
    with client.session_transaction() as current_session:
        assert current_session["user_id"] == identity_user
        assert current_session["role"] == "admin"
        assert current_session["auth_source"] == "database"
        assert current_session["identity_version"]

    db_session = identity_store()
    event = db_session.query(LoginHistory).order_by(LoginHistory.id.desc()).first()
    assert event.status == LoginHistory.STATUS_SUCCESS
    assert event.failure_reason == "auth_source=database"
    db_session.close()


def test_identity_change_revokes_existing_session(
    identity_app,
    identity_store,
    identity_user,
):
    client = identity_app.test_client()
    assert client.post(
        "/login",
        data={"username": "operator-admin", "password": "DatabasePass9!"},
    ).status_code == 302

    db_session = identity_store()
    user = db_session.query(User).filter(User.id == identity_user).one()
    user.updated_at = datetime.now() + timedelta(seconds=1)
    db_session.commit()
    db_session.close()

    response = client.get("/protected")
    assert response.status_code == 302
    assert response.headers["Location"].endswith("/login")
    with client.session_transaction() as current_session:
        assert "logged_in" not in current_session

    db_session = identity_store()
    statuses = [row.status for row in db_session.query(LoginHistory).all()]
    assert LoginHistory.STATUS_REVOKED in statuses
    db_session.close()


def test_legacy_hybrid_login_audits_real_peer_not_spoofed_header(
    identity_app,
    identity_store,
):
    client = identity_app.test_client()
    response = client.post(
        "/login",
        data={"username": "", "password": "LegacyPass9!"},
        headers={"X-Forwarded-For": "198.51.100.88"},
        environ_base={"REMOTE_ADDR": "203.0.113.44"},
    )
    assert response.status_code == 302
    with client.session_transaction() as current_session:
        assert current_session["auth_source"] == "legacy_shared_password"
        assert current_session["client_ip"] == "203.0.113.44"

    db_session = identity_store()
    event = db_session.query(LoginHistory).order_by(LoginHistory.id.desc()).first()
    assert event.ip_address == "203.0.113.44"
    db_session.close()


def test_database_mode_refuses_legacy_authority(
    monkeypatch,
    identity_app,
):
    monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "database")
    client = identity_app.test_client()
    response = client.post(
        "/login",
        data={"username": "", "password": "LegacyPass9!"},
    )
    assert response.status_code == 401
    with client.session_transaction() as current_session:
        assert "logged_in" not in current_session


def test_auto_mode_retires_legacy_session_after_durable_admin_canary(
    monkeypatch,
    identity_app,
    identity_store,
    identity_user,
):
    monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "auto")
    client = identity_app.test_client()
    assert client.post(
        "/login",
        data={"username": "", "password": "LegacyPass9!"},
    ).status_code == 302

    db_session = identity_store()
    service = UserService(db_session)
    for _ in range(2):
        assert service.record_login(
            identity_user,
            "operator-admin",
            "127.0.0.1",
            "pytest",
            LoginHistory.STATUS_SUCCESS,
            "auth_source=database",
        ) is not None
    assert service.get_auth_auto_cutover_state()["ready"] is True
    db_session.close()

    response = client.get("/protected")
    assert response.status_code == 302
    assert response.headers["Location"].endswith("/login")
    with client.session_transaction() as current_session:
        assert "logged_in" not in current_session


def test_unversioned_cookie_is_rejected_outside_test_mode(monkeypatch, identity_store):
    monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "hybrid")
    monkeypatch.setattr(auth, "get_identity_session", identity_store)
    app = Flask(__name__)
    app.config.update(SECRET_KEY="production-shape-secret", TESTING=False)

    @app.route("/login", endpoint="login")
    def login_stub():
        return "login"

    @app.route("/protected-production-shape")
    @auth.login_required
    def protected_production_shape():
        return "protected"

    client = app.test_client()
    with client.session_transaction() as current_session:
        current_session["logged_in"] = True
    response = client.get("/protected-production-shape")
    assert response.status_code == 302
    assert response.headers["Location"].endswith("/login")
    with client.session_transaction() as current_session:
        assert "logged_in" not in current_session


def test_runtime_readback_separates_canary_from_closed(identity_store, identity_user):
    db_session = identity_store()
    report = build_auth_identity_runtime_readback(db_session, mode="hybrid")
    assert report["status"] == "canary_ready"
    assert report["canary_ready"] is True
    assert report["runtime_closed"] is False
    assert report["inventory"]["active_admin_count"] == 1
    assert report["checks"]["legacy_shared_password_retired"] is False

    closed = build_auth_identity_runtime_readback(db_session, mode="database")
    assert closed["status"] == "completed"
    assert closed["runtime_closed"] is True
    assert closed["checks"]["legacy_shared_password_retired"] is True
    db_session.close()


def test_auto_runtime_readback_closes_after_admin_success_threshold(
    identity_store,
    identity_user,
):
    db_session = identity_store()
    initial = build_auth_identity_runtime_readback(db_session, mode="auto")
    assert initial["configured_auth_identity_mode"] == "auto"
    assert initial["auth_identity_mode"] == "hybrid"
    assert initial["runtime_closed"] is False

    service = UserService(db_session)
    for _ in range(2):
        service.record_login(
            identity_user,
            "operator-admin",
            "127.0.0.1",
            "pytest",
            LoginHistory.STATUS_SUCCESS,
            "auth_source=database",
        )
    promoted = build_auth_identity_runtime_readback(db_session, mode="auto")
    assert promoted["configured_auth_identity_mode"] == "auto"
    assert promoted["auth_identity_mode"] == "database"
    assert promoted["auto_cutover"]["manual_review_required"] is False
    assert promoted["auto_cutover"]["database_admin_successes"] == 2
    assert promoted["runtime_closed"] is True
    db_session.close()


def test_identity_mutations_write_no_secret_audits(
    identity_store,
    identity_user,
):
    audit_context = {
        "actor_id": identity_user,
        "ip_address": "198.51.100.40",
        "user_agent": "pytest-identity-audit",
    }
    db_session = identity_store()
    service = UserService(db_session)
    user, error = service.create_user(
        "audit-target",
        "CreatePass9!",
        role="admin",
        created_by=identity_user,
        audit_context=audit_context,
    )
    assert error is None

    success, error = service.update_user(
        user.id,
        audit_context=audit_context,
        role="user",
        display_name="Audit Target",
    )
    assert (success, error) == (True, None)
    success, error = service.change_password(
        user.id,
        "CreatePass9!",
        "ChangedPass8!",
        audit_context=audit_context,
    )
    assert (success, error) == (True, None)
    success, error = service.reset_password(
        user.id,
        "ResetPass7!",
        admin_id=identity_user,
        audit_context=audit_context,
    )
    assert (success, error) == (True, None)
    success, error = service.delete_user(user.id, audit_context=audit_context)
    assert (success, error) == (True, None)

    events = db_session.query(LoginHistory).filter(
        LoginHistory.user_id == user.id,
    ).order_by(LoginHistory.id).all()
    assert [event.status for event in events] == [
        LoginHistory.STATUS_USER_CREATED,
        LoginHistory.STATUS_IDENTITY_CHANGED,
        LoginHistory.STATUS_PASSWORD_CHANGED,
        LoginHistory.STATUS_PASSWORD_CHANGED,
        LoginHistory.STATUS_USER_DISABLED,
    ]
    serialized = " ".join(
        f"{event.failure_reason or ''} {event.user_agent or ''}"
        for event in events
    )
    assert "CreatePass9!" not in serialized
    assert "ChangedPass8!" not in serialized
    assert "ResetPass7!" not in serialized
    assert all(event.ip_address == "198.51.100.40" for event in events)
    assert service.get_identity_inventory()["identity_mutation_events"] == 5
    db_session.close()


def test_identity_mutation_and_audit_roll_back_together(
    monkeypatch,
    identity_store,
    identity_user,
):
    db_session = identity_store()

    def fail_commit():
        raise RuntimeError("forced commit failure")

    monkeypatch.setattr(db_session, "commit", fail_commit)
    user, error = UserService(db_session).create_user(
        "rollback-target",
        "RollbackPass9!",
        role="admin",
        created_by=identity_user,
        audit_context={"actor_id": identity_user},
    )
    assert user is None
    assert "forced commit failure" in error
    db_session.close()

    verification_session = identity_store()
    assert verification_session.query(User).filter_by(username="rollback-target").count() == 0
    assert verification_session.query(LoginHistory).filter_by(
        username_attempted="rollback-target",
    ).count() == 0
    verification_session.close()


def test_permission_mutation_and_audit_share_one_transaction(
    monkeypatch,
    identity_store,
    identity_user,
):
    seed_session = identity_store()
    target = User(
        username="permission-target",
        password_hash=generate_password_hash("PermissionPass9!", method="pbkdf2:sha256"),
        role="user",
        display_name="Permission Target",
        is_active=True,
        password_changed_at=datetime.now(),
        created_at=datetime.now(),
    )
    seed_session.add(target)
    seed_session.commit()
    target_id = target.id
    seed_session.close()
    monkeypatch.setattr(permission_service_module, "get_session", identity_store)

    success, message = PermissionService.set_user_permissions(
        target_id,
        ["dashboard.view", "dashboard.view"],
        granted_by=identity_user,
        audit_context={
            "actor_id": identity_user,
            "ip_address": "198.51.100.41",
            "user_agent": "pytest-permission-audit",
        },
    )
    assert success is True
    assert "1 項權限" in message

    verification_session = identity_store()
    assert verification_session.query(UserPermission).filter_by(user_id=target_id).count() == 1
    event = verification_session.query(LoginHistory).filter_by(
        user_id=target_id,
        status=LoginHistory.STATUS_PERMISSION_CHANGED,
    ).one()
    assert event.failure_reason == f"actor_id={identity_user};change=replace;count=1"
    assert event.ip_address == "198.51.100.41"
    assert event.user_agent == "pytest-permission-audit"
    verification_session.close()


def test_permission_mutation_and_audit_roll_back_on_commit_failure(
    monkeypatch,
    identity_store,
    identity_user,
):
    seed_session = identity_store()
    target = User(
        username="permission-rollback",
        password_hash=generate_password_hash("PermissionPass9!", method="pbkdf2:sha256"),
        role="user",
        display_name="Permission Rollback",
        is_active=True,
        password_changed_at=datetime.now(),
        created_at=datetime.now(),
    )
    seed_session.add(target)
    seed_session.commit()
    target_id = target.id
    seed_session.close()

    failing_session = identity_store()

    def fail_commit():
        raise RuntimeError("forced permission commit failure")

    monkeypatch.setattr(failing_session, "commit", fail_commit)
    monkeypatch.setattr(permission_service_module, "get_session", lambda: failing_session)
    success, message = PermissionService.set_user_permissions(
        target_id,
        ["dashboard.view"],
        granted_by=identity_user,
        audit_context={"actor_id": identity_user},
    )
    assert success is False
    assert "forced permission commit failure" in message

    verification_session = identity_store()
    assert verification_session.query(UserPermission).filter_by(user_id=target_id).count() == 0
    assert verification_session.query(LoginHistory).filter_by(
        user_id=target_id,
        status=LoginHistory.STATUS_PERMISSION_CHANGED,
    ).count() == 0
    verification_session.close()
