"""
Comprehensive branch-coverage tests for history_routes.py.

Covers all endpoints and major branches:
- history_page: renders template
- get_history: success, duration recalculation, recalc failure, limit/offset clamping, exception
- get_research_status: not found, active snapshot, completed with progress_log,
  completed with invalid progress_log, non-completed
- get_research_details: not found, active snapshot with log merging, completed,
  non-completed, db exception
- get_report: not found, report_data None, success with metadata, storage exception
- get_markdown: not found, content None, success, storage exception
- get_research_logs: not found, success with log formatting (missing fields)
- get_log_count: success
"""

from unittest.mock import MagicMock, patch

import pytest
from flask import Flask

MODULE = "local_deep_research.web.routes.history_routes"
AUTH_DB_MANAGER = "local_deep_research.web.auth.decorators.db_manager"


# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------


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

    from local_deep_research.web.auth.routes import auth_bp
    from local_deep_research.web.routes.history_routes import history_bp

    app.register_blueprint(auth_bp)
    app.register_blueprint(history_bp)
    return app


# ---------------------------------------------------------------------------
# Shared mock helpers
# ---------------------------------------------------------------------------


def _mock_db_manager():
    """Return a MagicMock satisfying login_required db_manager checks."""
    m = MagicMock()
    m.is_user_connected.return_value = True
    return m


def _make_db_ctx(mock_session):
    """Context manager that yields mock_session on __enter__."""
    ctx = MagicMock()
    ctx.__enter__ = MagicMock(return_value=mock_session)
    ctx.__exit__ = MagicMock(return_value=None)
    return ctx


def _make_db_ctx_raising(exc):
    """Context manager that raises exc on __enter__."""
    ctx = MagicMock()
    ctx.__enter__ = MagicMock(side_effect=exc)
    ctx.__exit__ = MagicMock(return_value=None)
    return ctx


def _build_join_chain(results):
    """Build chained SQLAlchemy mock for the outerjoin query in get_history."""
    q = MagicMock()
    (
        q.outerjoin.return_value.group_by.return_value.order_by.return_value.limit.return_value.offset.return_value.all
    ).return_value = results
    return q


def _build_filter_chain(result):
    """Build chained SQLAlchemy mock for filter_by().first()."""
    q = MagicMock()
    q.filter_by.return_value.first.return_value = result
    return q


def _make_research(**overrides):
    """Create a mock ResearchHistory with sensible defaults."""
    r = MagicMock()
    defaults = {
        "id": "test-id",
        "title": "Test Research",
        "query": "test query",
        "mode": "quick",
        "status": "completed",
        "created_at": "2024-01-01T10:00:00",
        "completed_at": "2024-01-01T10:05:00",
        "duration_seconds": 300,
        "research_meta": None,
        "progress_log": "[]",
        "report_path": None,
    }
    defaults.update(overrides)
    for k, v in defaults.items():
        setattr(r, k, v)
    return r


def _authed_get(client, path, **kwargs):
    """Issue an authenticated GET with session pre-populated."""
    with client.session_transaction() as sess:
        sess["username"] = "testuser"
    return client.get(path, **kwargs)


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


@pytest.fixture()
def app():
    return _create_test_app()


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


# ---------------------------------------------------------------------------
# history_page
# ---------------------------------------------------------------------------


class TestHistoryPage:
    @patch(f"{MODULE}.render_template_with_defaults")
    def test_renders_history_template(self, mock_render, client):
        """history_page renders the history.html template."""
        mock_render.return_value = "<html>history</html>"

        with patch(AUTH_DB_MANAGER, _mock_db_manager()):
            resp = _authed_get(client, "/history/")

        assert resp.status_code == 200
        mock_render.assert_called_once_with("pages/history.html")


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


class TestGetHistorySuccess:
    def test_returns_history_items_with_duration(self, client):
        """get_history returns items when duration_seconds is already set."""
        research = _make_research(duration_seconds=300)
        mock_session = MagicMock()
        mock_session.query.return_value = _build_join_chain([(research, 2)])

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.filter_research_metadata", return_value={}),
        ):
            resp = _authed_get(client, "/history/api")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert len(data["items"]) == 1
        assert data["items"][0]["duration_seconds"] == 300
        assert data["items"][0]["document_count"] == 2

    def test_duration_recalculation_when_none(self, client):
        """When duration_seconds is None but both timestamps exist, recalculate it."""
        research = _make_research(
            duration_seconds=None,
            created_at="2024-01-01T10:00:00",
            completed_at="2024-01-01T10:05:00",
        )
        mock_session = MagicMock()
        mock_session.query.return_value = _build_join_chain([(research, 0)])

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.filter_research_metadata", return_value={}),
        ):
            resp = _authed_get(client, "/history/api")

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

    def test_duration_recalculation_failure_leaves_none(self, client):
        """When timestamp parsing fails, duration_seconds remains None."""
        research = _make_research(
            duration_seconds=None,
            created_at="not-a-date",
            completed_at="also-not-a-date",
        )
        mock_session = MagicMock()
        mock_session.query.return_value = _build_join_chain([(research, 0)])

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.filter_research_metadata", return_value={}),
        ):
            resp = _authed_get(client, "/history/api")

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

    def test_limit_clamped_to_max_500(self, client):
        """limit > 500 is clamped to 500."""
        mock_session = MagicMock()
        mock_session.query.return_value = _build_join_chain([])

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
        ):
            resp = _authed_get(client, "/history/api?limit=9999&offset=-5")

        assert resp.status_code == 200
        # Verify the chain was called with clamped values (limit=500, offset=0)
        chain = mock_session.query.return_value.outerjoin.return_value
        chain.group_by.return_value.order_by.return_value.limit.assert_called_with(
            500
        )
        chain.group_by.return_value.order_by.return_value.limit.return_value.offset.assert_called_with(
            0
        )

    def test_limit_clamped_to_min_1(self, client):
        """limit < 1 is clamped to 1."""
        mock_session = MagicMock()
        mock_session.query.return_value = _build_join_chain([])

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
        ):
            resp = _authed_get(client, "/history/api?limit=0")

        assert resp.status_code == 200
        chain = mock_session.query.return_value.outerjoin.return_value
        chain.group_by.return_value.order_by.return_value.limit.assert_called_with(
            1
        )

    def test_exception_returns_error_json(self, client):
        """When the DB raises, get_history returns status=error."""
        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx_raising(RuntimeError("db down")),
            ),
        ):
            resp = _authed_get(client, "/history/api")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "error"
        assert data["items"] == []
        assert "message" in data


# ---------------------------------------------------------------------------
# get_research_status
# ---------------------------------------------------------------------------


class TestGetResearchStatusNotFound:
    def test_missing_research_returns_404(self, client):
        """When research record does not exist, return 404."""
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(None)

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
        ):
            resp = _authed_get(client, "/history/status/nonexistent")

        assert resp.status_code == 404
        data = resp.get_json()
        assert data["status"] == "error"
        assert "not found" in data["message"].lower()


class TestGetResearchStatusActiveSnapshot:
    def test_active_research_uses_snapshot_progress_and_log(self, client):
        """When a snapshot exists, progress and log come from it."""
        research = _make_research(id="active-1", status="in_progress")
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        snapshot = {"progress": 42, "log": [{"time": "10:00", "msg": "step"}]}

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(
                f"{MODULE}.get_active_research_snapshot", return_value=snapshot
            ),
        ):
            resp = _authed_get(client, "/history/status/active-1")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["progress"] == 42
        assert data["log"] == snapshot["log"]


class TestGetResearchStatusCompleted:
    def test_completed_no_snapshot_progress_100_and_parses_log(self, client):
        """Completed research without snapshot: progress=100, log parsed from DB."""
        from local_deep_research.constants import ResearchStatus

        research = _make_research(
            id="done-1",
            status=ResearchStatus.COMPLETED,
            progress_log='[{"time": "10:00", "message": "done"}]',
        )
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.get_active_research_snapshot", return_value=None),
        ):
            resp = _authed_get(client, "/history/status/done-1")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["progress"] == 100
        assert isinstance(data["log"], list)
        assert len(data["log"]) == 1

    def test_completed_invalid_progress_log_defaults_to_empty_list(
        self, client
    ):
        """Completed research with unparseable progress_log returns log=[]."""
        from local_deep_research.constants import ResearchStatus

        research = _make_research(
            id="done-bad",
            status=ResearchStatus.COMPLETED,
            progress_log="{bad json",
        )
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.get_active_research_snapshot", return_value=None),
        ):
            resp = _authed_get(client, "/history/status/done-bad")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["progress"] == 100
        assert data["log"] == []


class TestGetResearchStatusNonCompleted:
    def test_non_completed_non_active_progress_zero(self, client):
        """Non-active, non-completed research: progress=0, log parsed from DB."""
        research = _make_research(
            id="pending-1",
            status="in_progress",
            progress_log='[{"time": "10:00", "msg": "started"}]',
        )
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.get_active_research_snapshot", return_value=None),
        ):
            resp = _authed_get(client, "/history/status/pending-1")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["progress"] == 0
        assert isinstance(data["log"], list)

    def test_non_completed_invalid_progress_log_returns_empty_list(
        self, client
    ):
        """Non-active, non-completed research with bad progress_log: log=[]."""
        research = _make_research(
            id="bad-log",
            status="failed",
            progress_log="not valid json {{",
        )
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.get_active_research_snapshot", return_value=None),
        ):
            resp = _authed_get(client, "/history/status/bad-log")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["progress"] == 0
        assert data["log"] == []


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


class TestGetResearchDetailsNotFound:
    def test_missing_research_returns_404(self, client):
        """When research record does not exist, details returns 404."""
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(None)

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
        ):
            resp = _authed_get(client, "/history/details/missing-id")

        assert resp.status_code == 404
        data = resp.get_json()
        assert data["status"] == "error"
        assert "not found" in data["message"].lower()


class TestGetResearchDetailsActiveWithLogs:
    def test_active_research_merges_and_deduplicates_memory_logs(self, client):
        """Active research merges in-memory logs with DB logs, deduplicating by time."""
        research = _make_research(id="active-det", status="in_progress")
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        db_logs = [
            {"time": "10:00:00", "message": "step 1"},
            {"time": "10:01:00", "message": "step 2"},
        ]
        memory_logs = [
            {
                "time": "10:01:00",
                "message": "step 2",
            },  # duplicate — should be dropped
            {
                "time": "10:02:00",
                "message": "step 3",
            },  # unique — should be added
        ]
        snapshot = {"progress": 55, "log": memory_logs}

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.get_logs_for_research", return_value=db_logs),
            patch(f"{MODULE}.get_research_strategy", return_value="standard"),
            patch(
                f"{MODULE}.get_active_research_snapshot", return_value=snapshot
            ),
        ):
            resp = _authed_get(client, "/history/details/active-det")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["progress"] == 55
        # 2 db logs + 1 unique memory log = 3 total, sorted by time
        assert len(data["log"]) == 3
        times = [entry["time"] for entry in data["log"]]
        assert times == sorted(times)


class TestGetResearchDetailsCompleted:
    def test_completed_research_returns_progress_100(self, client):
        """Completed research without snapshot returns progress=100."""
        from local_deep_research.constants import ResearchStatus

        research = _make_research(
            id="done-det", status=ResearchStatus.COMPLETED
        )
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.get_logs_for_research", return_value=[]),
            patch(f"{MODULE}.get_research_strategy", return_value="smart"),
            patch(f"{MODULE}.get_active_research_snapshot", return_value=None),
        ):
            resp = _authed_get(client, "/history/details/done-det")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["progress"] == 100
        assert data["strategy"] == "smart"


class TestGetResearchDetailsNonCompleted:
    def test_non_active_non_completed_returns_progress_zero(self, client):
        """Non-active, non-completed research details: progress=0."""
        research = _make_research(id="pend-det", status="in_progress")
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.get_logs_for_research", return_value=[]),
            patch(f"{MODULE}.get_research_strategy", return_value="standard"),
            patch(f"{MODULE}.get_active_research_snapshot", return_value=None),
        ):
            resp = _authed_get(client, "/history/details/pend-det")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["progress"] == 0


class TestGetResearchDetailsDbException:
    def test_database_exception_returns_500(self, client):
        """When the DB raises inside the with block, return 500."""
        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx_raising(RuntimeError("db down")),
            ),
        ):
            resp = _authed_get(client, "/history/details/some-id")

        assert resp.status_code == 500
        data = resp.get_json()
        assert data["status"] == "error"
        assert "database" in data["message"].lower()


# ---------------------------------------------------------------------------
# get_report
# ---------------------------------------------------------------------------


class TestGetReportNotFound:
    def test_missing_research_returns_404(self, client):
        """When research record is absent, get_report returns 404."""
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(None)

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value="testuser",
            ),
        ):
            resp = _authed_get(client, "/history/report/missing-id")

        assert resp.status_code == 404
        data = resp.get_json()
        assert data["status"] == "error"
        assert "not found" in data["message"].lower()


class TestGetReportDataNone:
    def test_storage_returns_none_gives_404(self, client):
        """When storage.get_report_with_metadata returns None, return 404."""
        research = _make_research(id="no-data")
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        mock_storage = MagicMock()
        mock_storage.get_report_with_metadata.return_value = None

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value="testuser",
            ),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
        ):
            resp = _authed_get(client, "/history/report/no-data")

        assert resp.status_code == 404
        data = resp.get_json()
        assert data["status"] == "error"
        assert "not found" in data["message"].lower()


class TestGetReportSuccess:
    def test_success_returns_content_and_metadata(self, client):
        """get_report with valid data returns status=success with merged metadata."""
        research = _make_research(id="ok-report")
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        mock_storage = MagicMock()
        mock_storage.get_report_with_metadata.return_value = {
            "content": "# Report",
            "metadata": {"source_count": 5},
        }

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value="testuser",
            ),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
        ):
            resp = _authed_get(client, "/history/report/ok-report")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert data["content"] == "# Report"
        # Merged metadata should include both db fields and stored metadata
        assert "query" in data["metadata"]
        assert data["metadata"]["source_count"] == 5


class TestGetReportStorageException:
    def test_storage_exception_returns_500(self, client):
        """When storage raises, get_report returns 500."""
        research = _make_research(id="err-report")
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        mock_storage = MagicMock()
        mock_storage.get_report_with_metadata.side_effect = RuntimeError(
            "io error"
        )

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value="testuser",
            ),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
        ):
            resp = _authed_get(client, "/history/report/err-report")

        assert resp.status_code == 500
        data = resp.get_json()
        assert data["status"] == "error"


# ---------------------------------------------------------------------------
# get_markdown
# ---------------------------------------------------------------------------


class TestGetMarkdownNotFound:
    def test_missing_research_returns_404(self, client):
        """When research record is absent, get_markdown returns 404."""
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(None)

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value="testuser",
            ),
        ):
            resp = _authed_get(client, "/history/markdown/missing-id")

        assert resp.status_code == 404
        data = resp.get_json()
        assert data["status"] == "error"


class TestGetMarkdownContentNone:
    def test_none_content_returns_404(self, client):
        """When storage.get_report returns None, return 404."""
        research = _make_research(id="none-md")
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        mock_storage = MagicMock()
        mock_storage.get_report.return_value = None

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value="testuser",
            ),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
        ):
            resp = _authed_get(client, "/history/markdown/none-md")

        assert resp.status_code == 404
        data = resp.get_json()
        assert data["status"] == "error"
        assert "not found" in data["message"].lower()

    def test_empty_string_content_returns_404(self, client):
        """When storage.get_report returns empty string, return 404."""
        research = _make_research(id="empty-md")
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

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

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value="testuser",
            ),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
        ):
            resp = _authed_get(client, "/history/markdown/empty-md")

        assert resp.status_code == 404
        data = resp.get_json()
        assert data["status"] == "error"


class TestGetMarkdownSuccess:
    def test_success_returns_content(self, client):
        """get_markdown with valid content returns status=success."""
        research = _make_research(id="ok-md")
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        mock_storage = MagicMock()
        mock_storage.get_report.return_value = "# My Report\nContent here."

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value="testuser",
            ),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
        ):
            resp = _authed_get(client, "/history/markdown/ok-md")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert data["content"] == "# My Report\nContent here."


class TestGetMarkdownStorageException:
    def test_storage_exception_returns_500(self, client):
        """When storage.get_report raises, return 500."""
        research = _make_research(id="err-md")
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        mock_storage = MagicMock()
        mock_storage.get_report.side_effect = RuntimeError("disk failure")

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value="testuser",
            ),
            patch(
                "local_deep_research.storage.get_report_storage",
                return_value=mock_storage,
            ),
        ):
            resp = _authed_get(client, "/history/markdown/err-md")

        assert resp.status_code == 500
        data = resp.get_json()
        assert data["status"] == "error"


# ---------------------------------------------------------------------------
# get_research_logs
# ---------------------------------------------------------------------------


class TestGetResearchLogsNotFound:
    def test_missing_research_returns_404(self, client):
        """When research record is absent, get_research_logs returns 404."""
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(None)

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
        ):
            resp = _authed_get(client, "/history/logs/missing-id")

        assert resp.status_code == 404
        data = resp.get_json()
        assert data["status"] == "error"
        assert "not found" in data["message"].lower()


class TestGetResearchLogsSuccess:
    def test_logs_returned_with_all_fields(self, client):
        """Logs with all fields present are returned unchanged."""
        research = _make_research()
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        raw_logs = [
            {"time": "10:00:00", "message": "step 1", "type": "info"},
            {"time": "10:01:00", "message": "step 2", "type": "warning"},
        ]

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.get_logs_for_research", return_value=raw_logs),
        ):
            resp = _authed_get(client, "/history/logs/test-id")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert len(data["logs"]) == 2
        assert data["logs"][0]["message"] == "step 1"

    def test_logs_with_missing_fields_get_defaults(self, client):
        """Logs missing time/message/type receive default values."""
        research = _make_research()
        mock_session = MagicMock()
        mock_session.query.return_value = _build_filter_chain(research)

        raw_logs = [
            {"time": "10:00", "message": "ok", "type": "info"},
            {"extra": "custom_field"},  # missing time, message, type
        ]

        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(
                f"{MODULE}.get_user_db_session",
                return_value=_make_db_ctx(mock_session),
            ),
            patch(f"{MODULE}.get_logs_for_research", return_value=raw_logs),
        ):
            resp = _authed_get(client, "/history/logs/test-id")

        assert resp.status_code == 200
        data = resp.get_json()
        assert len(data["logs"]) == 2
        second = data["logs"][1]
        assert second["time"] == ""
        assert second["message"] == "No message"
        assert second["type"] == "info"
        assert second["extra"] == "custom_field"


# ---------------------------------------------------------------------------
# get_log_count
# ---------------------------------------------------------------------------


class TestGetLogCount:
    def test_returns_total_log_count(self, client):
        """get_log_count returns the total number of logs for the research."""
        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(f"{MODULE}.get_total_logs_for_research", return_value=17),
        ):
            resp = _authed_get(client, "/history/log_count/test-id")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "success"
        assert data["total_logs"] == 17

    def test_returns_zero_when_no_logs(self, client):
        """get_log_count returns 0 when there are no logs."""
        with (
            patch(AUTH_DB_MANAGER, _mock_db_manager()),
            patch(f"{MODULE}.get_total_logs_for_research", return_value=0),
        ):
            resp = _authed_get(client, "/history/log_count/empty-id")

        assert resp.status_code == 200
        data = resp.get_json()
        assert data["total_logs"] == 0
