"""Extra coverage tests for research_routes.py targeting remaining uncovered branches.

Targets routes/branches NOT covered by test_research_routes_deep_coverage.py:
- get_research_logs: success path with log entries
- get_research_report: success, not found, content None, DB exception
- export_research_report: unsupported format, not found, empty content, success, export error
- get_research_status: error classification (token_limit, llm_error, ollama_error,
  connection, unknown, solution metadata), with milestone log, no error metadata
- save_raw_config: invalid TOML, blocked keys, success, empty raw_config, write exception
- upload_pdf: success, validation failure, no files, extraction failure, multiple files,
  file count exceeded, exception during processing
- get_queue_position: success, exception
- get_queue_status: success, exception
- get_history: success with duration + doc count + title, no completedAt
- start_research: missing query, missing model, OPENAI_ENDPOINT validation,
  non-JSON body (require_json_body), settings snapshot failure
- page renders: progress, details, results, history, settings
- open_file_location: always 403
- get_upload_limits: returns config
- get_research_details: success, not found
"""

import io
import uuid
from contextlib import contextmanager
from datetime import datetime
from unittest.mock import MagicMock, patch

import pytest
from flask import Flask

MODULE = "local_deep_research.web.routes.research_routes"
ASSEMBLY_MOD = "local_deep_research.web.services.report_assembly_service"
# Lazy imports inside function bodies must be patched at source
STORAGE_MOD = "local_deep_research.storage"
EXPORTERS_MOD = "local_deep_research.exporters"
FILE_WRITE_MOD = "local_deep_research.security.file_write_verifier"
QUEUE_MOD = "local_deep_research.web.queue"


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


def _uid():
    return uuid.uuid4().hex[:8]


def _make_research(
    id="res-1",
    query="test query",
    mode="quick",
    status="completed",
    created_at="2025-01-01T00:00:00+00:00",
    completed_at="2025-01-01T01:00:00+00:00",
    progress=100,
    report_path="/tmp/report.md",
    research_meta=None,
    progress_log=None,
    title=None,
):
    r = MagicMock()
    r.id = id
    r.query = query
    r.mode = mode
    r.status = status
    r.created_at = created_at
    r.completed_at = completed_at
    r.progress = progress
    r.report_path = report_path
    r.research_meta = research_meta if research_meta is not None else {}
    r.progress_log = progress_log
    r.title = title
    r.chat_session_id = None
    return r


def _make_log_entry(id=1, message="Step done", level="INFO"):
    entry = MagicMock()
    entry.id = id
    entry.message = message
    entry.timestamp = datetime(2025, 1, 1, 12, 0, 0)
    entry.level = level
    return entry


def _make_milestone(message="Phase complete"):
    entry = MagicMock()
    entry.id = 1
    entry.message = message
    entry.timestamp = datetime(2025, 1, 1, 12, 0, 0)
    entry.level = "MILESTONE"
    return entry


def _mock_db_session():
    return MagicMock()


@contextmanager
def _ctx(session):
    yield session


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


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

    from local_deep_research.web.routes.research_routes import research_bp

    flask_app.register_blueprint(research_bp)

    with patch("local_deep_research.web.auth.decorators.db_manager") as mock_db:
        mock_db.is_user_connected.return_value = True
        yield flask_app


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


@pytest.fixture(autouse=True)
def _inject_session(app):
    @app.before_request
    def _set_sess():
        from flask import session

        session["username"] = "testuser"
        session["session_id"] = "sid-1"


# ---------------------------------------------------------------------------
# get_research_logs — success path
# ---------------------------------------------------------------------------


class TestGetResearchLogs:
    def test_logs_success_returns_entries(self, client):
        """Returns log entries with correct fields."""
        research = _make_research()
        log1 = _make_log_entry(id=1, message="Starting", level="INFO")
        log2 = _make_log_entry(id=2, message="Done", level="MILESTONE")

        ms = _mock_db_session()
        # first query().filter_by().first() -> research exists
        # second query().filter_by().order_by().all() -> log entries
        query_mock = MagicMock()
        ms.query.return_value = query_mock

        # Chain for research lookup
        filter_by_mock = MagicMock()
        query_mock.filter_by.return_value = filter_by_mock
        filter_by_mock.first.return_value = research
        filter_by_mock.order_by.return_value.all.return_value = [log1, log2]

        with patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)):
            resp = client.get("/api/research/res-1/logs")

        assert resp.status_code == 200
        data = resp.get_json()
        assert len(data) == 2
        assert data[0]["id"] == 1
        assert data[0]["message"] == "Starting"
        assert data[0]["log_type"] == "INFO"
        assert data[1]["id"] == 2


# ---------------------------------------------------------------------------
# get_research_report
# ---------------------------------------------------------------------------


class TestGetResearchReport:
    def test_report_success(self, client):
        """Returns assembled report content + metadata + backwards-compat fields."""
        # Post-#4371 (#3665): /api/report reads `sources` from the
        # research_resources table via get_research_source_links_batch (shape
        # {"url", "title"}), not the dead research_meta["all_links_of_system"]
        # key the chat-mode-v2 save path stopped writing. `findings` still
        # comes from research_meta.
        meta = {"findings": ["f1"]}
        research = _make_research(
            research_meta=meta, title="My Report", report_path="/tmp/r.md"
        )
        ms = _mock_db_session()
        ms.query.return_value.filter_by.return_value.first.return_value = (
            research
        )

        with (
            patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)),
            patch(
                f"{ASSEMBLY_MOD}.assemble_full_report",
                return_value="# Report content",
            ),
            patch(
                f"{ASSEMBLY_MOD}.get_research_source_links_batch",
                return_value={"res-1": [{"url": "http://a.com", "title": "A"}]},
            ),
        ):
            resp = client.get("/api/report/res-1")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["content"] == "# Report content"
        assert data["summary"] == "# Report content"
        assert data["sources"] == [{"url": "http://a.com", "title": "A"}]
        assert data["findings"] == ["f1"]
        assert data["metadata"]["title"] == "My Report"
        assert data["metadata"]["query"] == "test query"

    def test_report_not_found(self, client):
        ms = _mock_db_session()
        ms.query.return_value.filter_by.return_value.first.return_value = None

        with patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)):
            resp = client.get("/api/report/no-such")

        assert resp.status_code == 404

    def test_report_content_none(self, client):
        """Only None from assemble_full_report triggers 404; empty
        string is now a valid empty-but-found report."""
        research = _make_research()
        ms = _mock_db_session()
        ms.query.return_value.filter_by.return_value.first.return_value = (
            research
        )

        with (
            patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)),
            patch(
                f"{ASSEMBLY_MOD}.assemble_full_report",
                return_value=None,
            ),
        ):
            resp = client.get("/api/report/res-1")

        assert resp.status_code == 404

    def test_report_db_exception(self, client):
        with patch(
            f"{MODULE}.get_user_db_session", side_effect=Exception("db err")
        ):
            resp = client.get("/api/report/res-1")

        assert resp.status_code == 500


# ---------------------------------------------------------------------------
# export_research_report
# ---------------------------------------------------------------------------


class TestExportResearchReport:
    def test_unsupported_format(self, client):
        mock_registry = MagicMock()
        mock_registry.is_format_supported.return_value = False
        mock_registry.get_available_formats.return_value = ["pdf", "latex"]

        with patch(f"{EXPORTERS_MOD}.ExporterRegistry", mock_registry):
            resp = client.post("/api/v1/research/res-1/export/docx")

        assert resp.status_code == 400
        data = resp.get_json()
        assert "pdf" in data["error"]

    def test_research_not_found(self, client):
        mock_registry = MagicMock()
        mock_registry.is_format_supported.return_value = True

        ms = _mock_db_session()
        ms.query.return_value.filter_by.return_value.first.return_value = None

        with (
            patch(f"{EXPORTERS_MOD}.ExporterRegistry", mock_registry),
            patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)),
        ):
            resp = client.post("/api/v1/research/res-1/export/pdf")

        assert resp.status_code == 404

    def test_report_content_none(self, client):
        """Export returns 404 only when assemble_full_report returns
        None. Empty string is a valid empty-but-found report."""
        mock_registry = MagicMock()
        mock_registry.is_format_supported.return_value = True

        research = _make_research(research_meta={})
        ms = _mock_db_session()
        ms.query.return_value.filter_by.return_value.first.return_value = (
            research
        )

        with (
            patch(f"{EXPORTERS_MOD}.ExporterRegistry", mock_registry),
            patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)),
            patch(
                f"{ASSEMBLY_MOD}.assemble_full_report",
                return_value=None,
            ),
        ):
            resp = client.post("/api/v1/research/res-1/export/pdf")

        assert resp.status_code == 404

    def test_export_success(self, client):
        mock_registry = MagicMock()
        mock_registry.is_format_supported.return_value = True

        research = _make_research(research_meta={}, title="Test")
        ms = _mock_db_session()
        ms.query.return_value.filter_by.return_value.first.return_value = (
            research
        )

        with (
            patch(f"{EXPORTERS_MOD}.ExporterRegistry", mock_registry),
            patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)),
            patch(
                f"{ASSEMBLY_MOD}.assemble_full_report",
                return_value="# Report",
            ),
            patch(
                f"{MODULE}.export_report_to_memory",
                return_value=(b"PDF-CONTENT", "report.pdf", "application/pdf"),
            ),
        ):
            resp = client.post("/api/v1/research/res-1/export/pdf")

        assert resp.status_code == 200
        assert resp.content_type == "application/pdf"

    def test_export_function_raises(self, client):
        mock_registry = MagicMock()
        mock_registry.is_format_supported.return_value = True

        research = _make_research(research_meta={}, title="Test")
        ms = _mock_db_session()
        ms.query.return_value.filter_by.return_value.first.return_value = (
            research
        )

        mock_storage = MagicMock()
        mock_storage.get_report.return_value = "# Report"

        with (
            patch(f"{EXPORTERS_MOD}.ExporterRegistry", mock_registry),
            patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)),
            patch(
                f"{STORAGE_MOD}.get_report_storage", return_value=mock_storage
            ),
            patch(
                f"{MODULE}.export_report_to_memory",
                side_effect=RuntimeError("render fail"),
            ),
        ):
            resp = client.post("/api/v1/research/res-1/export/pdf")

        assert resp.status_code == 500


# ---------------------------------------------------------------------------
# get_research_status — error classification branches
# ---------------------------------------------------------------------------


class TestGetResearchStatusErrorClassification:
    def _status_response(self, client, error_msg, extra_meta=None):
        """Helper: return /api/research/<id>/status with given error in metadata."""
        meta = {"error": error_msg}
        if extra_meta:
            meta.update(extra_meta)
        research = _make_research(status="failed", research_meta=meta)
        ms = _mock_db_session()
        ms.query.return_value.filter_by.return_value.first.return_value = (
            research
        )

        # milestone query returns None
        milestone_query = MagicMock()
        ms.query.side_effect = [
            # First call: ResearchHistory query
            ms.query.return_value,
            # Second call: ResearchLog milestone query
            milestone_query,
        ]
        milestone_query.filter_by.return_value.order_by.return_value.first.return_value = None

        with patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)):
            return client.get("/api/research/res-1/status")

    def test_token_limit_error(self, client):
        resp = self._status_response(client, "Hit token limit during synthesis")
        data = resp.get_json()
        assert data["metadata"]["error_info"]["type"] == "token_limit"

    def test_context_length_error(self, client):
        resp = self._status_response(
            client, "Exceeded context length for model"
        )
        data = resp.get_json()
        assert data["metadata"]["error_info"]["type"] == "token_limit"

    def test_llm_error(self, client):
        resp = self._status_response(
            client, "Final answer synthesis failed: LLM Error"
        )
        data = resp.get_json()
        assert data["metadata"]["error_info"]["type"] == "llm_error"

    def test_ollama_error(self, client):
        resp = self._status_response(
            client, "Ollama service not responding on port 11434"
        )
        data = resp.get_json()
        assert data["metadata"]["error_info"]["type"] == "ollama_error"

    def test_connection_error(self, client):
        resp = self._status_response(
            client, "Connection refused by remote host"
        )
        data = resp.get_json()
        assert data["metadata"]["error_info"]["type"] == "connection"

    def test_unknown_error_with_solution(self, client):
        resp = self._status_response(
            client,
            "Something weird happened",
            extra_meta={"solution": "Restart the service"},
        )
        data = resp.get_json()
        error_info = data["metadata"]["error_info"]
        assert error_info["type"] == "unknown"
        assert error_info["suggestion"] == "Restart the service"

    def test_generic_unknown_error(self, client):
        resp = self._status_response(client, "Completely unexpected failure")
        data = resp.get_json()
        error_info = data["metadata"]["error_info"]
        assert error_info["type"] == "unknown"
        assert "try again" in error_info["suggestion"].lower()


# ---------------------------------------------------------------------------
# save_raw_config
# ---------------------------------------------------------------------------


class TestSaveRawConfig:
    def test_invalid_toml(self, client):
        resp = client.post(
            "/api/save_raw_config",
            json={"raw_config": "this is [not valid toml"},
            content_type="application/json",
        )
        assert resp.status_code == 400
        assert resp.get_json()["success"] is False
        assert "TOML" in resp.get_json()["error"]

    def test_blocked_keys(self, client):
        toml_str = '[custom]\nmodule_path = "evil.module"\n'
        resp = client.post(
            "/api/save_raw_config",
            json={"raw_config": toml_str},
            content_type="application/json",
        )
        assert resp.status_code == 403
        data = resp.get_json()
        assert data["success"] is False
        assert "blocked_keys" in data

    def test_success(self, client):
        toml_str = '[search]\ntool = "searxng"\n'

        with (
            patch(f"{MODULE}.get_config_directory", return_value=MagicMock()),
            patch(f"{FILE_WRITE_MOD}.write_file_verified"),
        ):
            resp = client.post(
                "/api/save_raw_config",
                json={"raw_config": toml_str},
                content_type="application/json",
            )

        assert resp.status_code == 200
        assert resp.get_json()["success"] is True


# ---------------------------------------------------------------------------
# upload_pdf
# ---------------------------------------------------------------------------


class TestUploadPdf:
    def test_success(self, client):
        """Valid PDF → extracted text returned."""
        mock_service = MagicMock()
        mock_service.extract_text_and_metadata.return_value = {
            "success": True,
            "filename": "test.pdf",
            "text": "Hello world",
            "size": 1024,
            "pages": 1,
        }

        mock_validator = MagicMock()
        mock_validator.MAX_FILE_SIZE = 50 * 1024 * 1024
        mock_validator.MAX_FILES_PER_REQUEST = 100
        mock_validator.validate_file_count.return_value = (True, None)
        mock_validator.validate_upload.return_value = (True, None)

        data = {"files": (io.BytesIO(b"%PDF-1.4 test"), "test.pdf")}

        with (
            patch(
                f"{MODULE}.get_pdf_extraction_service",
                return_value=mock_service,
            ),
            patch(f"{MODULE}.FileUploadValidator", mock_validator),
        ):
            resp = client.post(
                "/api/upload/pdf",
                data=data,
                content_type="multipart/form-data",
            )

        assert resp.status_code == 200
        body = resp.get_json()
        assert body["status"] == "success"
        assert body["processed_files"] == 1
        assert body["extracted_texts"][0]["text"] == "Hello world"

    def test_validation_failure(self, client):
        """File that fails validation → error in results list."""
        mock_service = MagicMock()

        mock_validator = MagicMock()
        mock_validator.MAX_FILE_SIZE = 50 * 1024 * 1024
        mock_validator.MAX_FILES_PER_REQUEST = 100
        mock_validator.validate_file_count.return_value = (True, None)
        mock_validator.validate_upload.return_value = (
            False,
            "File type not allowed",
        )

        data = {"files": (io.BytesIO(b"not-a-pdf"), "evil.exe")}

        with (
            patch(
                f"{MODULE}.get_pdf_extraction_service",
                return_value=mock_service,
            ),
            patch(f"{MODULE}.FileUploadValidator", mock_validator),
        ):
            resp = client.post(
                "/api/upload/pdf",
                data=data,
                content_type="multipart/form-data",
            )

        # 0 files processed -> 400
        assert resp.status_code == 400
        body = resp.get_json()
        assert body["status"] == "error"
        assert len(body["errors"]) >= 1

    def test_no_files_key(self, client):
        """No 'files' key in request → 400."""
        resp = client.post(
            "/api/upload/pdf",
            data={"other": (io.BytesIO(b"data"), "other.txt")},
            content_type="multipart/form-data",
        )

        assert resp.status_code == 400
        assert "No files" in resp.get_json()["error"]


# ---------------------------------------------------------------------------
# get_queue_position — success
# ---------------------------------------------------------------------------


class TestGetQueuePosition:
    def test_success(self, client):
        with patch(f"{QUEUE_MOD}.QueueManager") as mock_qm:
            mock_qm.get_queue_position.return_value = 3
            resp = client.get("/api/queue/res-1/position")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert data["position"] == 3

    def test_exception(self, client):
        with patch(
            f"{QUEUE_MOD}.QueueManager",
            side_effect=RuntimeError("queue error"),
        ):
            resp = client.get("/api/queue/res-1/position")

        assert resp.status_code == 500


# ---------------------------------------------------------------------------
# get_queue_status
# ---------------------------------------------------------------------------


class TestGetQueueStatus:
    def test_success(self, client):
        with patch(f"{QUEUE_MOD}.QueueManager") as mock_qm:
            mock_qm.get_user_queue.return_value = [
                {"id": "r1", "position": 1},
                {"id": "r2", "position": 2},
            ]
            resp = client.get("/api/queue/status")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert data["total"] == 2

    def test_exception(self, client):
        with patch(
            f"{QUEUE_MOD}.QueueManager",
            side_effect=RuntimeError("queue error"),
        ):
            resp = client.get("/api/queue/status")

        assert resp.status_code == 500


# ---------------------------------------------------------------------------
# get_research_status — additional branches
# ---------------------------------------------------------------------------


class TestGetResearchStatusAdditional:
    def test_status_with_milestone_log(self, client):
        """Status with a milestone log → includes log_entry field."""
        research = _make_research(status="in_progress", research_meta={})
        milestone = _make_milestone("Analyzing sources")

        ms = _mock_db_session()
        research_query = MagicMock()
        milestone_query = MagicMock()
        ms.query.side_effect = [research_query, milestone_query]
        research_query.filter_by.return_value.first.return_value = research
        milestone_query.filter_by.return_value.order_by.return_value.first.return_value = milestone

        with patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)):
            resp = client.get("/api/research/res-1/status")

        assert resp.status_code == 200
        data = resp.get_json()
        assert "log_entry" in data
        assert data["log_entry"]["message"] == "Analyzing sources"
        assert data["log_entry"]["type"] == "MILESTONE"

    def test_status_no_error_metadata(self, client):
        """Status without error in metadata → no error_info field."""
        research = _make_research(
            status="in_progress",
            research_meta={"some_key": "val"},
        )
        ms = _mock_db_session()
        research_query = MagicMock()
        milestone_query = MagicMock()
        ms.query.side_effect = [research_query, milestone_query]
        research_query.filter_by.return_value.first.return_value = research
        milestone_query.filter_by.return_value.order_by.return_value.first.return_value = None

        with patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)):
            resp = client.get("/api/research/res-1/status")

        assert resp.status_code == 200
        data = resp.get_json()
        assert "error_info" not in data["metadata"]

    def test_status_not_found(self, client):
        ms = _mock_db_session()
        ms.query.return_value.filter_by.return_value.first.return_value = None

        with patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)):
            resp = client.get("/api/research/res-1/status")

        assert resp.status_code == 404

    def test_status_db_exception(self, client):
        with patch(
            f"{MODULE}.get_user_db_session", side_effect=Exception("db")
        ):
            resp = client.get("/api/research/res-1/status")

        assert resp.status_code == 500


# ---------------------------------------------------------------------------
# save_raw_config — additional
# ---------------------------------------------------------------------------


class TestSaveRawConfigAdditional:
    def test_empty_raw_config(self, client):
        """Empty raw_config → 400."""
        resp = client.post(
            "/api/save_raw_config",
            json={"raw_config": ""},
            content_type="application/json",
        )
        assert resp.status_code == 400

    def test_write_exception(self, client):
        """Write failure → 500."""
        toml_str = '[search]\ntool = "searxng"\n'

        with (
            patch(f"{MODULE}.get_config_directory", return_value=MagicMock()),
            patch(
                f"{FILE_WRITE_MOD}.write_file_verified",
                side_effect=OSError("disk full"),
            ),
        ):
            resp = client.post(
                "/api/save_raw_config",
                json={"raw_config": toml_str},
                content_type="application/json",
            )

        assert resp.status_code == 500

    def test_nested_blocked_keys(self, client):
        """Nested blocked key in TOML → 403."""
        toml_str = '[outer]\n[outer.inner]\nclass_name = "evil"\n'
        resp = client.post(
            "/api/save_raw_config",
            json={"raw_config": toml_str},
            content_type="application/json",
        )
        assert resp.status_code == 403

    def test_missing_body(self, client):
        """Non-JSON request → handled by require_json_body decorator."""
        resp = client.post(
            "/api/save_raw_config",
            data="not json",
            content_type="text/plain",
        )
        assert resp.status_code == 400


# ---------------------------------------------------------------------------
# upload_pdf — additional
# ---------------------------------------------------------------------------


class TestUploadPdfAdditional:
    def test_extraction_failure(self, client):
        """PDF extraction returns success=False → error recorded."""
        mock_service = MagicMock()
        mock_service.extract_text_and_metadata.return_value = {
            "success": False,
            "error": "Encrypted PDF",
            "filename": "locked.pdf",
        }

        mock_validator = MagicMock()
        mock_validator.MAX_FILE_SIZE = 50 * 1024 * 1024
        mock_validator.MAX_FILES_PER_REQUEST = 100
        mock_validator.validate_file_count.return_value = (True, None)
        mock_validator.validate_upload.return_value = (True, None)

        data = {"files": (io.BytesIO(b"%PDF-1.4"), "locked.pdf")}

        with (
            patch(
                f"{MODULE}.get_pdf_extraction_service",
                return_value=mock_service,
            ),
            patch(f"{MODULE}.FileUploadValidator", mock_validator),
        ):
            resp = client.post(
                "/api/upload/pdf",
                data=data,
                content_type="multipart/form-data",
            )

        assert resp.status_code == 400
        body = resp.get_json()
        assert "Encrypted PDF" in body["errors"][0]

    def test_file_count_exceeded(self, client):
        """Too many files → 400."""
        mock_validator = MagicMock()
        mock_validator.MAX_FILE_SIZE = 50 * 1024 * 1024
        mock_validator.MAX_FILES_PER_REQUEST = 100
        mock_validator.validate_file_count.return_value = (
            False,
            "Too many files",
        )

        data = {"files": (io.BytesIO(b"data"), "file.pdf")}

        with patch(f"{MODULE}.FileUploadValidator", mock_validator):
            resp = client.post(
                "/api/upload/pdf",
                data=data,
                content_type="multipart/form-data",
            )

        assert resp.status_code == 400

    def test_empty_filename(self, client):
        """File with empty filename → 400."""
        data = {"files": (io.BytesIO(b""), "")}

        resp = client.post(
            "/api/upload/pdf",
            data=data,
            content_type="multipart/form-data",
        )

        assert resp.status_code == 400

    def test_exception_during_processing(self, client):
        """Exception during PDF processing → error recorded, continues."""
        mock_service = MagicMock()
        mock_service.extract_text_and_metadata.side_effect = RuntimeError(
            "crash"
        )

        mock_validator = MagicMock()
        mock_validator.MAX_FILE_SIZE = 50 * 1024 * 1024
        mock_validator.MAX_FILES_PER_REQUEST = 100
        mock_validator.validate_file_count.return_value = (True, None)
        mock_validator.validate_upload.return_value = (True, None)

        data = {"files": (io.BytesIO(b"%PDF-1.4"), "test.pdf")}

        with (
            patch(
                f"{MODULE}.get_pdf_extraction_service",
                return_value=mock_service,
            ),
            patch(f"{MODULE}.FileUploadValidator", mock_validator),
        ):
            resp = client.post(
                "/api/upload/pdf",
                data=data,
                content_type="multipart/form-data",
            )

        assert resp.status_code == 400
        body = resp.get_json()
        assert len(body["errors"]) >= 1


# ---------------------------------------------------------------------------
# get_research_details
# ---------------------------------------------------------------------------


class TestGetResearchDetails:
    def test_success(self, client):
        research = _make_research(research_meta={"key": "val"})
        ms = _mock_db_session()
        ms.query.return_value.filter.return_value.first.return_value = research

        with patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)):
            resp = client.get("/api/research/res-1")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["id"] == "res-1"
        assert data["query"] == "test query"

    def test_not_found(self, client):
        ms = _mock_db_session()
        ms.query.return_value.filter.return_value.first.return_value = None

        with patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)):
            resp = client.get("/api/research/no-such")

        assert resp.status_code == 404

    def test_db_exception(self, client):
        with patch(
            f"{MODULE}.get_user_db_session", side_effect=Exception("db")
        ):
            resp = client.get("/api/research/res-1")

        assert resp.status_code == 500


# ---------------------------------------------------------------------------
# get_history
# ---------------------------------------------------------------------------


class TestGetHistory:
    def test_success_with_title_and_duration(self, client):
        """History returns items with title, duration, doc_count."""
        research = _make_research(
            title="My Research",
            created_at="2025-01-01T00:00:00",
            completed_at="2025-01-01T01:00:00",
            research_meta={"submission": {"model": "gpt4"}},
        )
        ms = _mock_db_session()
        ms.query.return_value.order_by.return_value.limit.return_value.offset.return_value.all.return_value = [
            research
        ]
        # Document count query: now a single GROUP BY query rather than
        # an N+1 .filter_by(...).count() call. Mock the new chain shape.
        ms.query.return_value.filter.return_value.group_by.return_value.all.return_value = [
            (research.id, 5)
        ]

        with (
            patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)),
            patch(f"{MODULE}.calculate_duration", return_value=3600),
        ):
            resp = client.get("/api/history")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert len(data["items"]) == 1
        assert data["items"][0]["title"] == "My Research"
        assert data["items"][0]["duration_seconds"] == 3600
        assert data["items"][0]["document_count"] == 5

    def test_success_no_completed_at(self, client):
        """History item without completed_at → duration_seconds is None."""
        research = _make_research(completed_at=None, title=None)
        ms = _mock_db_session()
        ms.query.return_value.order_by.return_value.limit.return_value.offset.return_value.all.return_value = [
            research
        ]
        ms.query.return_value.filter.return_value.group_by.return_value.all.return_value = []

        with patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)):
            resp = client.get("/api/history")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["items"][0]["duration_seconds"] is None

    def test_db_exception(self, client):
        with patch(
            f"{MODULE}.get_user_db_session", side_effect=Exception("db")
        ):
            resp = client.get("/api/history")

        assert resp.status_code == 500


# ---------------------------------------------------------------------------
# Page renders
# ---------------------------------------------------------------------------


class TestPageRenders:
    def test_progress_page(self, client):
        with patch(f"{MODULE}.render_template_with_defaults", return_value=""):
            resp = client.get("/progress/res-1")
        assert resp.status_code == 200

    def test_details_page(self, client):
        with patch(f"{MODULE}.render_template_with_defaults", return_value=""):
            resp = client.get("/details/res-1")
        assert resp.status_code == 200

    def test_results_page(self, client):
        with patch(f"{MODULE}.render_template_with_defaults", return_value=""):
            resp = client.get("/results/res-1")
        assert resp.status_code == 200

    def test_history_page(self, client):
        with patch(f"{MODULE}.render_template_with_defaults", return_value=""):
            resp = client.get("/history")
        assert resp.status_code == 200

    def test_settings_page(self, client):
        with patch(f"{MODULE}.render_template_with_defaults", return_value=""):
            resp = client.get("/settings")
        assert resp.status_code == 200


# ---------------------------------------------------------------------------
# open_file_location — always 403
# ---------------------------------------------------------------------------


class TestOpenFileLocation:
    def test_always_403(self, client):
        resp = client.post("/open_file_location", json={})
        assert resp.status_code == 403
        assert "disabled" in resp.get_json()["message"].lower()


# ---------------------------------------------------------------------------
# get_upload_limits
# ---------------------------------------------------------------------------


class TestGetUploadLimits:
    def test_returns_limits(self, client):
        resp = client.get("/api/config/limits")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "max_file_size" in data
        assert "max_files" in data
        assert "allowed_mime_types" in data


# ---------------------------------------------------------------------------
# redirect_static
# ---------------------------------------------------------------------------


class TestRedirectStatic:
    def test_redirect(self, client):
        resp = client.get("/redirect-static/css/style.css")
        assert resp.status_code == 302


# ---------------------------------------------------------------------------
# start_research — validation branches
# ---------------------------------------------------------------------------


class TestStartResearchValidation:
    def test_missing_query(self, client):
        """No query → 400."""
        mock_sm = MagicMock()
        mock_sm.get_setting.return_value = "OLLAMA"

        ms = _mock_db_session()

        with (
            patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)),
            patch(
                "local_deep_research.settings.manager.SettingsManager",
                return_value=mock_sm,
            ),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "", "mode": "quick"},
                content_type="application/json",
            )

        assert resp.status_code == 400

    def test_missing_model(self, client):
        """No model configured → 400."""
        mock_sm = MagicMock()
        mock_sm.get_setting.return_value = None

        ms = _mock_db_session()

        with (
            patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)),
            patch(
                "local_deep_research.settings.manager.SettingsManager",
                return_value=mock_sm,
            ),
        ):
            resp = client.post(
                "/api/start_research",
                json={"query": "test", "mode": "quick"},
                content_type="application/json",
            )

        assert resp.status_code == 400

    def test_openai_endpoint_without_custom_url(self, client):
        """OPENAI_ENDPOINT provider without custom_endpoint → 400."""
        call_count = 0

        def get_setting_side_effect(key, default=None):
            nonlocal call_count
            call_count += 1
            if key == "llm.provider":
                return "OPENAI_ENDPOINT"
            if key == "llm.model":
                return "gpt-4"
            if key == "llm.openai_endpoint.url":
                return None
            return default

        mock_sm = MagicMock()
        mock_sm.get_setting.side_effect = get_setting_side_effect

        ms = _mock_db_session()

        with (
            patch(f"{MODULE}.get_user_db_session", return_value=_ctx(ms)),
            patch(
                "local_deep_research.settings.manager.SettingsManager",
                return_value=mock_sm,
            ),
        ):
            resp = client.post(
                "/api/start_research",
                json={
                    "query": "test",
                    "mode": "quick",
                    "model_provider": "OPENAI_ENDPOINT",
                    "model": "gpt-4",
                },
                content_type="application/json",
            )

        assert resp.status_code == 400
        assert "endpoint" in resp.get_json()["message"].lower()
