"""
Tests verifying that ``@login_required`` rejects unauthenticated requests
correctly across the URL shapes the app actually exposes.

Guards two things:

* Page routes (e.g. ``/news/``, ``/news/subscriptions``) redirect
  unauthenticated callers to the login page.
* API routes — including nested blueprints like ``/news/api/...`` —
  return a JSON ``401`` instead of an HTML redirect. This is the
  regression case that motivated extending ``_is_api_path`` to match
  ``/api/`` anywhere in the path, not only as a top-level prefix.

The real news/research blueprints pull in heavy optional dependencies
(langchain, pdfplumber, etc.) and are exercised elsewhere. Here we only
care about the auth decorator's behavior at each URL shape, so we
register synthetic routes whose URL paths and decorator stacks mirror
production.
"""

from unittest.mock import patch

import pytest
from flask import Blueprint, Flask, jsonify

from local_deep_research.web.auth.decorators import login_required


@pytest.fixture
def app():
    app = Flask(__name__)
    app.config["SECRET_KEY"] = "test-secret-key"
    app.config["WTF_CSRF_ENABLED"] = False
    app.config["TESTING"] = True

    # Stub auth blueprint so url_for("auth.login") works for redirects.
    auth = Blueprint("auth", __name__)

    @auth.route("/login")
    def login():
        return "Login Page"

    app.register_blueprint(auth, url_prefix="/auth")

    # Mirror production: news_bp at /news with a nested news_api_bp at /api,
    # so the API surfaces at /news/api/... — same nesting that broke
    # JSON-vs-HTML detection before _is_api_path() used a substring match.
    news_bp = Blueprint("news", __name__)
    news_api_bp = Blueprint("news_api", __name__, url_prefix="/api")

    @news_bp.route("/")
    @login_required
    def news_page():
        return "News"

    @news_bp.route("/subscriptions")
    @login_required
    def subscriptions_page():
        return "Subscriptions"

    @news_bp.route("/subscriptions/new")
    @login_required
    def new_subscription_page():
        return "New"

    @news_bp.route("/subscriptions/<sid>/edit")
    @login_required
    def edit_subscription_page(sid):
        return f"Edit {sid}"

    @news_bp.route("/health")
    def news_health():
        return jsonify({"status": "ok"})

    @news_api_bp.route("/categories", methods=["GET"])
    @login_required
    def get_categories():
        return jsonify({"categories": []})

    @news_api_bp.route("/subscribe", methods=["POST"])
    @login_required
    def subscribe():
        return jsonify({"ok": True})

    news_bp.register_blueprint(news_api_bp)
    app.register_blueprint(news_bp, url_prefix="/news")

    # research_bp registered at root, /api/config/limits is a top-level API.
    research_bp = Blueprint("research", __name__)

    @research_bp.route("/api/config/limits", methods=["GET"])
    @login_required
    def get_upload_limits():
        return jsonify({"limit": 0})

    app.register_blueprint(research_bp)

    return app


@pytest.fixture
def client(app):
    return app.test_client()


@pytest.fixture(autouse=True)
def patch_db_manager():
    """Stub the auth db_manager so we don't touch a real database.

    Default is_user_connected=False so the unauthenticated-rejection
    tests don't need to opt in. Tests that want to exercise the
    authenticated 200 path use the `authenticated_client` fixture
    below, which flips this to True and seeds the session.
    """
    with patch("local_deep_research.web.auth.decorators.db_manager") as mock_dm:
        mock_dm.is_user_connected.return_value = False
        yield mock_dm


@pytest.fixture
def authenticated_client(client, patch_db_manager):
    """Test client that passes both auth checks in `login_required`:
    a `username` in the session AND `db_manager.is_user_connected` True.

    Without this, every test in the file is one-sided (rejection-only)
    and a regression that breaks the *allow* path of the decorator —
    e.g. the post-rejection branch swallowing valid requests — would
    not be caught by any existing test.
    """
    patch_db_manager.is_user_connected.return_value = True
    with client.session_transaction() as sess:
        sess["username"] = "test-user"
    return client


class TestNewsPageRoutesRequireAuth:
    """The four news page routes added in PR #3129 must redirect to login
    when the caller has no session."""

    @pytest.mark.parametrize(
        "path",
        [
            "/news/",
            "/news/subscriptions",
            "/news/subscriptions/new",
            "/news/subscriptions/abc-123/edit",
        ],
    )
    def test_unauthenticated_page_redirects_to_login(self, client, path):
        response = client.get(path)
        assert response.status_code == 302
        assert "/auth/login" in response.location


class TestNewsApiRoutesRequireAuth:
    """/news/api/categories must return JSON 401, not an HTML redirect.
    This is the regression case for nested-blueprint API path detection."""

    def test_unauthenticated_categories_returns_json_401(self, client):
        response = client.get("/news/api/categories")
        assert response.status_code == 401
        assert response.is_json
        assert response.json["error"] == "Authentication required"
        # Must not be an HTML redirect to /auth/login.
        location = response.headers.get("Location") or ""
        assert "/auth/login" not in location


class TestResearchApiRoutesRequireAuth:
    """/api/config/limits added in PR #3129 must return JSON 401 for
    unauthenticated callers."""

    def test_unauthenticated_upload_limits_returns_json_401(self, client):
        response = client.get("/api/config/limits")
        assert response.status_code == 401
        assert response.is_json
        assert response.json["error"] == "Authentication required"


class TestNewsApiNonGetMethodsRequireAuth:
    """The decorator runs before the route, so it should reject POST/PUT/
    DELETE the same way as GET. Probe one non-GET API endpoint to lock
    that in."""

    def test_unauthenticated_post_returns_json_401(self, client):
        response = client.post("/news/api/subscribe", json={})
        assert response.status_code == 401
        assert response.is_json
        assert response.json["error"] == "Authentication required"


class TestNewsHealthRouteIsPublic:
    """/news/health is intentionally exempt from @login_required (per PR
    #3129). Lock that contract in."""

    def test_health_does_not_require_auth(self, client):
        response = client.get("/news/health")
        assert response.status_code == 200


class TestAuthenticatedRequestsArePassedThrough:
    """Happy-path coverage. Without these tests, the entire file only
    verifies that the decorator REJECTS — a regression where it stopped
    ALLOWING valid requests would slip through. Mirrors each rejection
    test class with a positive case at the same URL shape.
    """

    @pytest.mark.parametrize(
        "path",
        [
            "/news/",
            "/news/subscriptions",
            "/news/subscriptions/new",
            "/news/subscriptions/abc-123/edit",
        ],
    )
    def test_authenticated_page_returns_200(self, authenticated_client, path):
        response = authenticated_client.get(path)
        assert response.status_code == 200

    def test_authenticated_news_api_categories_returns_200(
        self, authenticated_client
    ):
        """Counterpart to test_unauthenticated_categories_returns_json_401."""
        response = authenticated_client.get("/news/api/categories")
        assert response.status_code == 200
        assert response.is_json
        assert response.json == {"categories": []}

    def test_authenticated_research_api_limits_returns_200(
        self, authenticated_client
    ):
        """Counterpart to test_unauthenticated_upload_limits_returns_json_401."""
        response = authenticated_client.get("/api/config/limits")
        assert response.status_code == 200
        assert response.is_json
        assert response.json == {"limit": 0}

    def test_authenticated_post_returns_200(self, authenticated_client):
        """Counterpart to test_unauthenticated_post_returns_json_401."""
        response = authenticated_client.post("/news/api/subscribe", json={})
        assert response.status_code == 200
        assert response.is_json
        assert response.json == {"ok": True}


class TestAuthenticatedButDisconnectedDb:
    """Second branch of the decorator: `username` is in session but
    `db_manager.is_user_connected` is False. This happens when the
    session outlives the encrypted-DB connection (server restart with
    encrypted databases enabled). API paths must get JSON 401 with
    a different error message ("Database connection required") and
    page paths must redirect with the session cleared.
    """

    @pytest.fixture
    def session_without_db(self, client, patch_db_manager):
        # is_user_connected stays False (autouse default), but seed the
        # session so we hit the second decorator branch, not the first.
        patch_db_manager.is_user_connected.return_value = False
        with client.session_transaction() as sess:
            sess["username"] = "test-user"
        return client

    def test_api_path_returns_json_401_db_required(self, session_without_db):
        response = session_without_db.get("/news/api/categories")
        assert response.status_code == 401
        assert response.is_json
        assert response.json["error"] == "Database connection required"

    def test_page_path_redirects_to_login(self, session_without_db):
        response = session_without_db.get("/news/subscriptions")
        assert response.status_code == 302
        assert "/auth/login" in response.location


class TestUnauthorized401ErrorHandler:
    """The 401 error handler in app_factory.register_error_handlers must
    use the same _is_api_path detection as the decorator, so that
    abort(401) on a nested API path returns JSON, not an HTML redirect."""

    def _build_app(self):
        from flask import Flask, abort

        from local_deep_research.web.app_factory import register_error_handlers

        app = Flask(__name__)
        app.config["SECRET_KEY"] = "test"

        # Stub auth.login so the redirect target resolves.
        auth = Blueprint("auth", __name__)

        @auth.route("/login")
        def login():
            return "Login"

        app.register_blueprint(auth, url_prefix="/auth")

        @app.route("/news/api/raises")
        def raises_nested_api():
            abort(401)

        @app.route("/dashboard/raises")
        def raises_page():
            abort(401)

        register_error_handlers(app)
        return app

    def test_abort_401_on_nested_api_returns_json(self):
        app = self._build_app()
        with app.test_client() as client:
            response = client.get("/news/api/raises")
            assert response.status_code == 401
            assert response.is_json
            assert response.json["error"] == "Authentication required"

    def test_abort_401_on_page_redirects(self):
        app = self._build_app()
        with app.test_client() as client:
            response = client.get("/dashboard/raises")
            assert response.status_code == 302
            assert "/auth/login" in response.location
