"""
Coverage tests for app_factory.py targeting ~37 missing statements.

Focuses on:
- register_error_handlers: 404/500 JSON vs HTML branching, CSRF handler,
  news API exception handler
- create_app: secret key file read/write branches, news scheduler
  enabled/disabled paths
- apply_middleware: teardown cleanup_db_session, WebSocket handler
- create_database: deprecated no-op
- register_blueprints: CSRF exemption path
"""

import pytest
from unittest.mock import MagicMock, patch
from flask import Flask

MODULE = "local_deep_research.web.app_factory"
_QUEUE_PROCESSOR_MOD = (
    "local_deep_research.web.queue.processor_v2.queue_processor"
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _minimal_app():
    """Minimal Flask app suitable for registering error handlers."""
    app = Flask(__name__)
    app.config["TESTING"] = True
    app.config["WTF_CSRF_ENABLED"] = False
    app.config["SECRET_KEY"] = "test-secret"
    return app


# ---------------------------------------------------------------------------
# 1. register_error_handlers
# ---------------------------------------------------------------------------


class TestRegisterErrorHandlers:
    """Tests for the register_error_handlers function."""

    @pytest.fixture
    def app_with_handlers(self):
        from local_deep_research.web.app_factory import register_error_handlers

        app = _minimal_app()
        register_error_handlers(app)
        return app

    @pytest.fixture
    def client(self, app_with_handlers):
        app_with_handlers.config["PROPAGATE_EXCEPTIONS"] = False

        # Add a route that raises to test 500 handler
        @app_with_handlers.route("/boom")
        def boom():
            raise RuntimeError("intentional error")

        # Add a plain 404 trigger for non-API path
        return app_with_handlers.test_client()

    def test_404_api_path_returns_json(self, client):
        """404 on /api/ path returns JSON body."""
        response = client.get("/api/does-not-exist")
        assert response.status_code == 404
        data = response.get_json()
        assert data is not None
        assert "error" in data

    def test_404_non_api_path_returns_text(self, client):
        """404 on a non-API path returns plain text, not JSON."""
        response = client.get("/totally-missing")
        assert response.status_code == 404
        # Should NOT be JSON
        assert response.content_type.startswith("text/")

    def test_500_api_path_returns_json(self, client):
        """500 on /api/ path returns JSON body."""
        # Patch the boom route under /api/
        app = client.application

        @app.route("/api/error-test")
        def api_error():
            raise RuntimeError("api boom")

        response = client.get("/api/error-test")
        assert response.status_code == 500
        data = response.get_json()
        assert data is not None
        assert "error" in data

    def test_500_non_api_path_returns_text(self, client):
        """500 on a non-API path returns plain text."""
        response = client.get("/boom")
        assert response.status_code == 500
        assert response.content_type.startswith("text/")

    def test_csrf_error_handler_registered(self, app_with_handlers):
        """CSRFError handler is registered when flask_wtf is available."""
        try:
            from flask_wtf.csrf import CSRFError

            # handler should be in the error_handler_spec
            spec = app_with_handlers.error_handler_spec
            # Flatten all registered exception-class keys from inner handler maps
            all_exc_keys = []
            for blueprint_handlers in spec.values():
                for code_or_exc, handler_map in blueprint_handlers.items():
                    # handler_map maps exception class -> handler function
                    if isinstance(handler_map, dict):
                        all_exc_keys.extend(handler_map.keys())
            # CSRFError should appear somewhere in registered handlers
            has_csrf = any(
                (isinstance(k, type) and issubclass(k, CSRFError))
                or k == CSRFError
                for k in all_exc_keys
            )
            assert has_csrf
        except ImportError:
            pytest.skip("flask_wtf not available")

    def test_csrf_error_handler_returns_400(self):
        """CSRF error handler returns 400 with JSON error message."""
        from local_deep_research.web.app_factory import register_error_handlers

        try:
            from flask_wtf.csrf import CSRFProtect  # noqa: F401
        except ImportError:
            pytest.skip("flask_wtf not available")

        app = _minimal_app()
        app.config["WTF_CSRF_ENABLED"] = True
        app.config["SECRET_KEY"] = "test-secret-key-for-csrf"
        CSRFProtect(app)
        register_error_handlers(app)

        # Add a POST route that requires CSRF
        @app.route("/form-post", methods=["POST"])
        def form_post():
            return "ok"

        client = app.test_client()
        # Posting without a CSRF token on a CSRF-protected app triggers CSRFError
        app.config["WTF_CSRF_ENABLED"] = True
        response = client.post("/form-post", data={})
        assert response.status_code == 400
        data = response.get_json()
        assert "error" in data

    def test_csrf_error_http_public_ip_path(self):
        """CSRF handler with HTTP + public IP shows detailed help message."""
        from local_deep_research.web.app_factory import register_error_handlers

        try:
            from flask_wtf.csrf import CSRFError, CSRFProtect  # noqa: F401
        except ImportError:
            pytest.skip("flask_wtf not available")

        app = _minimal_app()
        app.config["WTF_CSRF_ENABLED"] = True
        app.config["SECRET_KEY"] = "test-csrf-public-ip"
        CSRFProtect(app)
        register_error_handlers(app)

        @app.route("/csrf-test", methods=["POST"])
        def csrf_test():
            return "ok"

        app.test_client()
        # Simulate request from a "public" remote address by overriding environ
        with app.test_request_context(
            "/csrf-test",
            method="POST",
            environ_base={"REMOTE_ADDR": "8.8.8.8"},
            headers={"X-Forwarded-For": "8.8.8.8"},
        ):
            # Manually invoke the error handler logic

            err = CSRFError()
            err.description = "The CSRF token is missing."

            # Locate and call the handler directly
            handler = app.error_handler_spec[None][None].get(CSRFError)
            if handler is None:
                pytest.skip("CSRFError handler not found")
            resp = handler(err)
            # Response should be 400 with JSON containing "error"
            assert resp is not None


class TestNewsApiExceptionHandler:
    """Tests for the NewsAPIException error handler."""

    def test_news_api_exception_handler_registered_when_module_available(self):
        """NewsAPIException handler is registered when news module is available."""
        from local_deep_research.web.app_factory import register_error_handlers

        app = _minimal_app()
        register_error_handlers(app)

        try:
            from local_deep_research.news.exceptions import NewsAPIException

            spec = app.error_handler_spec
            all_exc_keys = []
            for blueprint_handlers in spec.values():
                for code_or_exc, handler_map in blueprint_handlers.items():
                    # handler_map maps exception class -> handler function
                    if isinstance(handler_map, dict):
                        all_exc_keys.extend(handler_map.keys())
            has_news = any(
                (isinstance(k, type) and issubclass(k, NewsAPIException))
                or k == NewsAPIException
                for k in all_exc_keys
            )
            assert has_news
        except ImportError:
            pytest.skip("news module not available")

    def test_news_api_exception_handler_returns_json(self):
        """NewsAPIException is converted to a JSON response with correct status."""
        from local_deep_research.web.app_factory import register_error_handlers

        try:
            from local_deep_research.news.exceptions import NewsAPIException
        except ImportError:
            pytest.skip("news module not available")

        app = _minimal_app()
        register_error_handlers(app)

        @app.route("/news-error")
        def news_error():
            raise NewsAPIException(
                message="feed unavailable",
                error_code="FEED_ERROR",
                status_code=503,
            )

        app.config["PROPAGATE_EXCEPTIONS"] = False
        client = app.test_client()
        response = client.get("/news-error")
        assert response.status_code == 503
        data = response.get_json()
        assert data is not None


# ---------------------------------------------------------------------------
# 2. create_database (deprecated)
# ---------------------------------------------------------------------------


class TestCreateDatabase:
    """Tests for the deprecated create_database function."""

    def test_create_database_is_noop(self):
        """create_database does nothing (deprecated)."""
        from local_deep_research.web.app_factory import create_database

        app = _minimal_app()
        # Should return None and not raise
        result = create_database(app)
        assert result is None

    def test_create_database_accepts_any_app(self):
        """create_database accepts any app argument without error."""
        from local_deep_research.web.app_factory import create_database

        create_database(None)  # Even None is acceptable
        create_database(Flask(__name__))


# ---------------------------------------------------------------------------
# 3. create_app: secret key branching
# ---------------------------------------------------------------------------


class TestCreateAppSecretKeyBranches:
    """Tests for the secret key file read/write branches in create_app."""

    def test_secret_key_read_from_existing_file(self, tmp_path):
        """When secret key file exists, it is read and used."""
        secret_file = tmp_path / ".secret_key"
        secret_file.write_text("my-persisted-key")

        with (
            patch(MODULE + ".SocketIOService"),
            patch(
                "local_deep_research.config.paths.get_data_directory",
                return_value=tmp_path,
            ),
            patch(_QUEUE_PROCESSOR_MOD),
            patch("atexit.register"),
        ):
            from local_deep_research.web.app_factory import create_app

            app, _ = create_app()
            assert app.config["SECRET_KEY"] == "my-persisted-key"

    def test_secret_key_generated_when_file_missing(self, tmp_path):
        """When no secret key file exists, a new key is generated and saved."""
        key_dir = tmp_path / "data"
        key_dir.mkdir()

        with (
            patch(MODULE + ".SocketIOService"),
            patch(
                "local_deep_research.config.paths.get_data_directory",
                return_value=key_dir,
            ),
            patch(_QUEUE_PROCESSOR_MOD),
            patch("atexit.register"),
        ):
            from local_deep_research.web.app_factory import create_app

            app, _ = create_app()
            # A hex key should have been generated
            assert (
                len(app.config["SECRET_KEY"]) == 64
            )  # 32 bytes -> 64 hex chars
            # The key file should have been written
            assert (key_dir / ".secret_key").exists()

    def test_secret_key_fallback_when_file_read_fails(self, tmp_path):
        """When secret key file exists but cannot be read, a fresh key is used."""
        secret_file = tmp_path / ".secret_key"
        secret_file.write_text("original-key")

        _real_open = open

        def _open_raises_for_secret_key(path, *args, **kwargs):
            """Raise OSError only for the .secret_key file; delegate otherwise."""
            if str(path).endswith(".secret_key"):
                raise OSError("permission denied")
            return _real_open(path, *args, **kwargs)

        with (
            patch(MODULE + ".SocketIOService"),
            patch(
                "local_deep_research.config.paths.get_data_directory",
                return_value=tmp_path,
            ),
            patch(_QUEUE_PROCESSOR_MOD),
            patch("atexit.register"),
            patch("builtins.open", side_effect=_open_raises_for_secret_key),
        ):
            from local_deep_research.web.app_factory import create_app

            app, _ = create_app()
            # Should still have a secret key (fallback generated)
            assert app.config["SECRET_KEY"] is not None
            assert len(app.config["SECRET_KEY"]) > 0


# ---------------------------------------------------------------------------
# 4. create_app: news scheduler branching
# ---------------------------------------------------------------------------


class TestCreateAppNewsScheduler:
    """Tests for the news scheduler enabled/disabled branches."""

    def test_news_scheduler_disabled_sets_none(self):
        """When news scheduler is disabled, app.news_scheduler is None."""
        with (
            patch(MODULE + ".SocketIOService"),
            patch(_QUEUE_PROCESSOR_MOD),
            patch("atexit.register"),
            patch(
                "local_deep_research.settings.env_registry.get_env_setting",
                return_value=False,
            ),
        ):
            from local_deep_research.web.app_factory import create_app

            app, _ = create_app()
            assert app.news_scheduler is None

    def test_news_scheduler_exception_sets_none(self):
        """When scheduler init throws, app.news_scheduler is None."""
        with (
            patch(MODULE + ".SocketIOService"),
            patch(_QUEUE_PROCESSOR_MOD),
            patch("atexit.register"),
            patch(
                "local_deep_research.settings.env_registry.get_env_setting",
                return_value=True,
            ),
            patch(
                "local_deep_research.news.subscription_manager.scheduler.get_news_scheduler",
                side_effect=RuntimeError("scheduler boom"),
            ),
        ):
            from local_deep_research.web.app_factory import create_app

            app, _ = create_app()
            assert app.news_scheduler is None


# ---------------------------------------------------------------------------
# 5. apply_middleware: teardown and WebSocket handler
# ---------------------------------------------------------------------------


class TestApplyMiddlewareTeardown:
    """Tests for cleanup_db_session teardown and WebSocket before_request."""

    @pytest.fixture
    def app_with_middleware(self):
        """Create a full app and return it for request-context testing."""
        with (
            patch(MODULE + ".SocketIOService"),
            patch(_QUEUE_PROCESSOR_MOD),
            patch("atexit.register"),
        ):
            from local_deep_research.web.app_factory import create_app

            app, _ = create_app()
            app.config["TESTING"] = True
            app.config["WTF_CSRF_ENABLED"] = False
            return app

    def test_teardown_runs_without_db_session(self, app_with_middleware):
        """Teardown context function runs cleanly when no db_session in g."""
        with app_with_middleware.test_request_context("/"):
            # Push and pop app context; teardown should not raise
            with app_with_middleware.app_context():
                pass  # teardown fires on exit

    def test_teardown_closes_session_on_exception(self, app_with_middleware):
        """Teardown rolls back and closes a session stored in g."""
        from flask import g

        mock_session = MagicMock()

        with app_with_middleware.test_request_context("/"):
            with app_with_middleware.app_context():
                g.db_session = mock_session
            # After app context exits, teardown ran; verify session was closed

        mock_session.rollback.assert_called_once()
        mock_session.close.assert_called_once()

    def test_websocket_path_returns_none_when_no_socket(
        self, app_with_middleware
    ):
        """Request to /socket.io without werkzeug.socket returns None (continue)."""
        client = app_with_middleware.test_client()
        # The before_request handler for /socket.io returns early without error
        # A GET to /socket.io/... may 404 but should not 500
        response = client.get("/socket.io/test")
        # Either 404 or any non-500 is acceptable
        assert response.status_code != 500


# ---------------------------------------------------------------------------
# 6. register_blueprints: CSRF exemption branch
# ---------------------------------------------------------------------------


class TestRegisterBlueprintsCsrfExemption:
    """Tests for the CSRF exemption block in register_blueprints."""

    def test_csrf_exemption_applied_to_api_v1(self):
        """api_v1 blueprint is exempted from CSRF when extension is present."""
        with (
            patch(MODULE + ".SocketIOService"),
            patch(_QUEUE_PROCESSOR_MOD),
            patch("atexit.register"),
        ):
            from local_deep_research.web.app_factory import create_app

            app, _ = create_app()

            if "csrf" not in app.extensions:
                pytest.skip("CSRF extension not registered")

            csrf = app.extensions["csrf"]
            api_v1_bp = app.blueprints.get("api_v1")
            if api_v1_bp is None:
                pytest.skip("api_v1 blueprint not registered")

            # The exemption list should include the api_v1 blueprint
            assert api_v1_bp in csrf._exempt_blueprints

    def test_csrf_extension_absent_skips_exemption_gracefully(self):
        """When csrf extension is absent, CSRF exemption block is skipped."""
        from local_deep_research.web.app_factory import register_blueprints

        app = _minimal_app()
        # Remove csrf from extensions (if present)
        app.extensions.pop("csrf", None)

        # register_blueprints should not raise even without csrf
        # (Requires minimal blueprint setup; we just verify no AttributeError)
        try:
            register_blueprints(app)
        except Exception as exc:
            # Blueprint import errors are acceptable; csrf-related ones are not
            if "csrf" in str(exc).lower():
                pytest.fail(f"Unexpected csrf-related error: {exc}")


# ---------------------------------------------------------------------------
# 7. apply_middleware: register_blueprints static/favicon routes
# ---------------------------------------------------------------------------


class TestStaticAndFaviconRoutes:
    """Tests for static file and favicon routes registered in register_blueprints."""

    @pytest.fixture
    def app(self):
        with (
            patch(MODULE + ".SocketIOService"),
            patch(_QUEUE_PROCESSOR_MOD),
            patch("atexit.register"),
        ):
            from local_deep_research.web.app_factory import create_app

            app, _ = create_app()
            app.config["TESTING"] = True
            app.config["WTF_CSRF_ENABLED"] = False
            return app

    def test_favicon_route_registered(self, app):
        """favicon.ico route is registered."""
        rules = [rule.rule for rule in app.url_map.iter_rules()]
        assert "/favicon.ico" in rules

    def test_static_path_route_registered(self, app):
        """Static file path route /static/<path:path> is registered."""
        rules = [rule.rule for rule in app.url_map.iter_rules()]
        assert any("/static/" in r for r in rules)

    def test_static_route_returns_404_for_missing_file(self, app):
        """Static route returns 404 when file does not exist."""
        client = app.test_client()
        response = client.get("/static/definitely-not-there.js")
        assert response.status_code == 404
