"""
Tests for research_service functions.

Tests cover:
- Citation formatter retrieval
- Report export functions
- Research strategy management
- Research process management
- Cleanup functions
"""

import hashlib
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch

import pytest


class TestGetCitationFormatter:
    """Tests for get_citation_formatter function."""

    @patch("local_deep_research.config.search_config.get_setting_from_snapshot")
    def test_get_citation_formatter_number_hyperlinks(self, mock_get_setting):
        """Returns formatter with NUMBER_HYPERLINKS mode."""
        from local_deep_research.web.services.research_service import (
            get_citation_formatter,
        )
        from local_deep_research.text_optimization import CitationMode

        mock_get_setting.return_value = "number_hyperlinks"

        formatter = get_citation_formatter()

        assert formatter.mode == CitationMode.NUMBER_HYPERLINKS

    @patch("local_deep_research.config.search_config.get_setting_from_snapshot")
    def test_get_citation_formatter_domain_hyperlinks(self, mock_get_setting):
        """Returns formatter with DOMAIN_HYPERLINKS mode."""
        from local_deep_research.web.services.research_service import (
            get_citation_formatter,
        )
        from local_deep_research.text_optimization import CitationMode

        mock_get_setting.return_value = "domain_hyperlinks"

        formatter = get_citation_formatter()

        assert formatter.mode == CitationMode.DOMAIN_HYPERLINKS

    @patch("local_deep_research.config.search_config.get_setting_from_snapshot")
    def test_get_citation_formatter_no_hyperlinks(self, mock_get_setting):
        """Returns formatter with NO_HYPERLINKS mode."""
        from local_deep_research.web.services.research_service import (
            get_citation_formatter,
        )
        from local_deep_research.text_optimization import CitationMode

        mock_get_setting.return_value = "no_hyperlinks"

        formatter = get_citation_formatter()

        assert formatter.mode == CitationMode.NO_HYPERLINKS

    @patch("local_deep_research.config.search_config.get_setting_from_snapshot")
    def test_get_citation_formatter_default(self, mock_get_setting):
        """Returns formatter with default mode when unknown format."""
        from local_deep_research.web.services.research_service import (
            get_citation_formatter,
        )
        from local_deep_research.text_optimization import CitationMode

        mock_get_setting.return_value = "unknown_format"

        formatter = get_citation_formatter()

        assert formatter.mode == CitationMode.NUMBER_HYPERLINKS


class TestExportReportToMemory:
    """Tests for export_report_to_memory function."""

    def test_export_latex_format(self):
        """export_report_to_memory generates LaTeX content."""
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        markdown_content = "# Test Report\n\nThis is test content."

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "latex", title="Test Report"
        )

        assert filename.endswith(".tex")
        assert mimetype == "text/plain"
        assert isinstance(content, bytes)

    def test_export_ris_format(self):
        """export_report_to_memory generates RIS content."""
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        markdown_content = "# Test Report\n\nThis is test content."

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "ris", title="Test Report"
        )

        assert filename.endswith(".ris")
        assert mimetype == "text/plain"
        assert isinstance(content, bytes)

    def test_export_unsupported_format_raises(self):
        """export_report_to_memory raises for unsupported format."""
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        markdown_content = "# Test Report"

        try:
            export_report_to_memory(markdown_content, "unsupported")
            assert False, "Should have raised ValueError"
        except ValueError as e:
            assert "Unsupported export format" in str(e)

    def test_export_quarto_format(self):
        """export_report_to_memory generates Quarto zip content."""
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        markdown_content = "# Test Report\n\nThis is test content."

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "quarto", title="Test Report"
        )

        assert filename.endswith(".zip")
        assert mimetype == "application/zip"
        assert isinstance(content, bytes)

    def test_export_pdf_format(self):
        """export_report_to_memory generates PDF content."""
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        markdown_content = "# Test Report\n\nThis is test content."

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "pdf", title="Test Report"
        )

        assert filename.endswith(".pdf")
        assert mimetype == "application/pdf"
        # PDF files start with %PDF
        assert content.startswith(b"%PDF")


class TestSaveResearchStrategy:
    """Tests for save_research_strategy function."""

    @patch(
        "local_deep_research.web.services.research_service.get_user_db_session"
    )
    def test_save_research_strategy_creates_new(self, mock_get_session):
        """save_research_strategy creates new strategy record."""
        from local_deep_research.web.services.research_service import (
            save_research_strategy,
        )

        mock_session = MagicMock()
        mock_query = Mock()
        mock_query.filter_by.return_value.first.return_value = None
        mock_session.query.return_value = mock_query
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        save_research_strategy(123, "standard", username="testuser")

        mock_session.add.assert_called_once()
        mock_session.commit.assert_called_once()

    @patch(
        "local_deep_research.web.services.research_service.get_user_db_session"
    )
    def test_save_research_strategy_updates_existing(self, mock_get_session):
        """save_research_strategy updates existing strategy."""
        from local_deep_research.web.services.research_service import (
            save_research_strategy,
        )

        mock_strategy = Mock()
        mock_strategy.strategy_name = "old_strategy"

        mock_session = MagicMock()
        mock_query = Mock()
        mock_query.filter_by.return_value.first.return_value = mock_strategy
        mock_session.query.return_value = mock_query
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        save_research_strategy(123, "new_strategy", username="testuser")

        assert mock_strategy.strategy_name == "new_strategy"
        mock_session.commit.assert_called_once()

    def test_username_is_required_keyword_only(self):
        """Locks the security contract from #4526: ``username`` must be
        supplied, and only as a keyword. This guards the mutation the
        route-level autospec test cannot catch — re-adding a
        ``username=None`` default, which would silently reopen the
        ``get_user_db_session(None)`` implicit-fallback foot-gun. No DB
        mock: the TypeError is raised at call-binding time, before the
        body runs."""
        from local_deep_research.web.services.research_service import (
            save_research_strategy,
        )

        # Omitting username entirely -> missing required keyword-only arg.
        with pytest.raises(TypeError, match="username"):
            save_research_strategy(123, "standard")

        # Passing it positionally must also fail (keyword-only barrier),
        # so a refactor can't silently turn it into a positional arg.
        with pytest.raises(TypeError):
            save_research_strategy(123, "standard", "testuser")


class TestGetResearchStrategy:
    """Tests for get_research_strategy function."""

    @patch(
        "local_deep_research.web.services.research_service.get_user_db_session"
    )
    def test_get_research_strategy_found(self, mock_get_session):
        """get_research_strategy returns strategy name when found."""
        from local_deep_research.web.services.research_service import (
            get_research_strategy,
        )

        mock_strategy = Mock()
        mock_strategy.strategy_name = "standard"

        mock_session = MagicMock()
        mock_query = Mock()
        mock_query.filter_by.return_value.first.return_value = mock_strategy
        mock_session.query.return_value = mock_query
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        result = get_research_strategy(123, username="testuser")

        assert result == "standard"

    @patch(
        "local_deep_research.web.services.research_service.get_user_db_session"
    )
    def test_get_research_strategy_not_found(self, mock_get_session):
        """get_research_strategy returns None when not found."""
        from local_deep_research.web.services.research_service import (
            get_research_strategy,
        )

        mock_session = MagicMock()
        mock_query = Mock()
        mock_query.filter_by.return_value.first.return_value = None
        mock_session.query.return_value = mock_query
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        result = get_research_strategy(123, username="testuser")

        assert result is None

    def test_username_is_required_keyword_only(self):
        """Locks the security contract from #4526: ``username`` must be
        supplied, and only as a keyword. Without this, re-adding a
        ``username=None`` default would pass every other strategy test
        (they all pass ``username=`` explicitly) while silently
        reopening the implicit Flask-session fallback path inside
        ``get_user_db_session``. No DB mock: the TypeError is raised at
        call-binding time, before the body runs."""
        from local_deep_research.web.services.research_service import (
            get_research_strategy,
        )

        # Omitting username entirely -> missing required keyword-only arg.
        with pytest.raises(TypeError, match="username"):
            get_research_strategy(123)

        # Passing it positionally must also fail (keyword-only barrier).
        with pytest.raises(TypeError):
            get_research_strategy(123, "testuser")


class TestGenerateReportPath:
    """Tests for _generate_report_path function."""

    @patch("local_deep_research.web.services.research_service.OUTPUT_DIR")
    def test_generate_report_path_creates_unique_path(self, mock_output_dir):
        """_generate_report_path creates unique path from query."""
        from local_deep_research.web.services.research_service import (
            _generate_report_path,
        )

        mock_output_dir.__truediv__ = lambda self, x: Path(f"/test/output/{x}")

        query = "test research query"

        result = _generate_report_path(query)

        # Path should contain hash of query
        query_hash = hashlib.md5(  # DevSkim: ignore DS126858
            query.encode("utf-8"), usedforsecurity=False
        ).hexdigest()[:10]
        assert query_hash in str(result)
        assert "research_report" in str(result)


class TestStartResearchProcess:
    """Tests for start_research_process function."""

    @patch(
        "local_deep_research.web.services.research_service.thread_with_app_context"
    )
    @patch("local_deep_research.web.services.research_service.thread_context")
    @patch("local_deep_research.web.routes.globals.check_and_start_research")
    def test_start_research_process_creates_thread(
        self, mock_check_start, mock_thread_context, mock_thread_with_context
    ):
        """start_research_process creates a thread and atomically registers it."""
        from local_deep_research.web.services.research_service import (
            start_research_process,
        )

        mock_callback = Mock()
        mock_thread_with_context.return_value = mock_callback
        mock_thread_context.return_value = {}
        mock_check_start.return_value = True  # dedup passes

        with patch(
            "local_deep_research.web.services.research_service.threading.Thread"
        ) as mock_thread_class:
            mock_thread = Mock()
            mock_thread_class.return_value = mock_thread

            start_research_process(
                research_id=123,
                query="test query",
                mode="quick",
                run_research_callback=mock_callback,
            )

            # The dedup helper owns starting the thread — not the caller.
            mock_thread.start.assert_not_called()
            mock_check_start.assert_called_once()
            call_args = mock_check_start.call_args
            assert call_args[0][0] == 123
            assert call_args[0][1]["status"] == "in_progress"
            assert call_args[0][1]["thread"] is mock_thread

    @patch(
        "local_deep_research.web.services.research_service.thread_with_app_context"
    )
    @patch("local_deep_research.web.services.research_service.thread_context")
    @patch("local_deep_research.web.routes.globals.check_and_start_research")
    def test_start_research_process_stores_settings(
        self, mock_check_start, mock_thread_context, mock_thread_with_context
    ):
        """start_research_process stores settings in active_research."""
        from local_deep_research.web.services.research_service import (
            start_research_process,
        )

        mock_callback = Mock()
        mock_thread_with_context.return_value = mock_callback
        mock_thread_context.return_value = {}
        mock_check_start.return_value = True

        with patch(
            "local_deep_research.web.services.research_service.threading.Thread"
        ) as mock_thread_class:
            mock_thread = Mock()
            mock_thread_class.return_value = mock_thread

            start_research_process(
                research_id=123,
                query="test query",
                mode="detailed",
                run_research_callback=mock_callback,
                model="gpt-4",
                search_engine="google",
            )

            call_args = mock_check_start.call_args
            settings = call_args[0][1]["settings"]
            assert settings["model"] == "gpt-4"
            assert settings["search_engine"] == "google"

    @patch(
        "local_deep_research.web.services.research_service.thread_with_app_context"
    )
    @patch("local_deep_research.web.services.research_service.thread_context")
    @patch("local_deep_research.web.routes.globals.check_and_start_research")
    def test_start_research_process_raises_on_duplicate(
        self, mock_check_start, mock_thread_context, mock_thread_with_context
    ):
        """If a live thread already exists for research_id, raises
        DuplicateResearchError without spawning a second thread."""
        import pytest
        from local_deep_research.exceptions import DuplicateResearchError
        from local_deep_research.web.services.research_service import (
            start_research_process,
        )

        mock_callback = Mock()
        mock_thread_with_context.return_value = mock_callback
        mock_thread_context.return_value = {}
        mock_check_start.return_value = False  # dedup refuses

        with patch(
            "local_deep_research.web.services.research_service.threading.Thread"
        ) as mock_thread_class:
            mock_thread = Mock()
            mock_thread_class.return_value = mock_thread

            with pytest.raises(DuplicateResearchError):
                start_research_process(
                    research_id=123,
                    query="test query",
                    mode="quick",
                    run_research_callback=mock_callback,
                )

            # Thread prepared but never started — check_and_start_research
            # owns the .start() call and refused to make it.
            mock_thread.start.assert_not_called()


class TestCleanupResearchResources:
    """Tests for cleanup_research_resources function."""

    @patch("local_deep_research.settings.env_registry.is_test_mode")
    @patch("local_deep_research.web.queue.processor_v2.queue_processor")
    @patch("local_deep_research.web.routes.globals.cleanup_research")
    @patch("local_deep_research.web.services.socket_service.SocketIOService")
    def test_cleanup_calls_cleanup_research(
        self,
        mock_socket,
        mock_cleanup,
        mock_queue,
        mock_test_mode,
    ):
        """cleanup_research_resources calls cleanup_research to remove from dicts."""
        from local_deep_research.web.services.research_service import (
            cleanup_research_resources,
        )

        mock_test_mode.return_value = False

        cleanup_research_resources(123, username="testuser")

        mock_cleanup.assert_called_once_with(123)

    @patch("local_deep_research.settings.env_registry.is_test_mode")
    @patch("local_deep_research.web.queue.processor_v2.queue_processor")
    @patch("local_deep_research.web.routes.globals.cleanup_research")
    @patch("local_deep_research.web.services.socket_service.SocketIOService")
    def test_cleanup_notifies_queue_processor(
        self,
        mock_socket,
        mock_cleanup,
        mock_queue,
        mock_test_mode,
    ):
        """cleanup_research_resources notifies queue processor."""
        from local_deep_research.web.services.research_service import (
            cleanup_research_resources,
        )

        mock_test_mode.return_value = False

        cleanup_research_resources(123, username="testuser")

        mock_queue.notify_research_completed.assert_called_once_with(
            "testuser", 123, user_password=None
        )


class TestCancelResearch:
    """Tests for cancel_research function."""

    @patch(
        "local_deep_research.web.routes.globals.is_research_active",
        return_value=True,
    )
    @patch("local_deep_research.web.routes.globals.set_termination_flag")
    @patch(
        "local_deep_research.web.services.research_service.handle_termination"
    )
    def test_cancel_research_sets_termination_flag(
        self, mock_handle_termination, mock_set_flag, mock_is_active
    ):
        """cancel_research sets termination flag."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        result = cancel_research(123, username="testuser")

        assert result is True
        mock_set_flag.assert_called_once_with(123)
        mock_handle_termination.assert_called_once_with(123, "testuser")

    @patch(
        "local_deep_research.web.routes.globals.is_research_active",
        return_value=False,
    )
    @patch("local_deep_research.web.routes.globals.set_termination_flag")
    @patch(
        "local_deep_research.web.services.research_service.get_user_db_session"
    )
    def test_cancel_research_updates_db_for_inactive(
        self, mock_get_session, mock_set_flag, mock_is_active
    ):
        """cancel_research updates database for inactive research."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        mock_research = Mock()
        mock_research.status = "in_progress"

        mock_session = MagicMock()
        mock_query = Mock()
        mock_query.filter_by.return_value.first.return_value = mock_research
        mock_session.query.return_value = mock_query
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        result = cancel_research(123, username="testuser")

        assert result is True
        assert mock_research.status == "suspended"

    @patch(
        "local_deep_research.web.routes.globals.is_research_active",
        return_value=False,
    )
    @patch("local_deep_research.web.routes.globals.set_termination_flag")
    @patch(
        "local_deep_research.web.services.research_service.get_user_db_session"
    )
    def test_cancel_research_already_completed(
        self, mock_get_session, mock_set_flag, mock_is_active
    ):
        """cancel_research returns True for already completed research."""
        from local_deep_research.web.services.research_service import (
            cancel_research,
        )

        mock_research = Mock()
        mock_research.status = "completed"

        mock_session = MagicMock()
        mock_query = Mock()
        mock_query.filter_by.return_value.first.return_value = mock_research
        mock_session.query.return_value = mock_query
        mock_session.__enter__ = Mock(return_value=mock_session)
        mock_session.__exit__ = Mock(return_value=False)
        mock_get_session.return_value = mock_session

        result = cancel_research(123, username="testuser")

        assert result is True


class TestHandleTermination:
    """Tests for handle_termination function."""

    @patch("local_deep_research.web.queue.processor_v2.queue_processor")
    @patch(
        "local_deep_research.web.services.research_service.cleanup_research_resources"
    )
    def test_handle_termination_queues_update(self, mock_cleanup, mock_queue):
        """handle_termination queues suspension update."""
        from local_deep_research.web.services.research_service import (
            handle_termination,
        )

        handle_termination(123, username="testuser")

        mock_queue.queue_error_update.assert_called_once()
        call_kwargs = mock_queue.queue_error_update.call_args[1]
        assert call_kwargs["status"] == "suspended"
        assert call_kwargs["research_id"] == 123

    @patch("local_deep_research.web.queue.processor_v2.queue_processor")
    @patch(
        "local_deep_research.web.services.research_service.cleanup_research_resources"
    )
    def test_handle_termination_calls_cleanup(self, mock_cleanup, mock_queue):
        """handle_termination calls cleanup function."""
        from local_deep_research.web.services.research_service import (
            handle_termination,
        )

        handle_termination(123, username="testuser")

        # Termination must report SUSPENDED to cleanup so the final socket
        # message is not a spurious "completed".
        mock_cleanup.assert_called_once_with(
            123, "testuser", final_status="suspended"
        )


class TestExportQuartoFormat:
    """Tests for quarto export format."""

    def test_export_quarto_creates_zip(self):
        """export_report_to_memory creates zip for quarto format."""
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        markdown_content = "# Test Report\n\nThis is test content."

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "quarto", title="Test Report"
        )

        assert filename.endswith(".zip")
        assert mimetype == "application/zip"
        assert isinstance(content, bytes)
        # Verify it's a valid zip file by checking magic bytes
        assert content[:2] == b"PK"


class TestExportLatexFormat:
    """Tests for latex export format."""

    def test_export_latex_format_via_memory(self):
        """export_report_to_memory handles latex format."""
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        markdown_content = "# Test Report\n\nThis is test content."

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "latex", title="Test Report"
        )

        assert filename.endswith(".tex")
        assert mimetype == "text/plain"  # LaTeX uses text/plain mimetype
        assert isinstance(content, bytes)


class TestGenerateReportPathUniqueHash:
    """Tests for _generate_report_path unique hash generation."""

    @patch("local_deep_research.web.services.research_service.OUTPUT_DIR")
    def test_different_queries_different_paths(self, mock_output_dir):
        """Different queries should generate different paths."""
        from local_deep_research.web.services.research_service import (
            _generate_report_path,
        )

        mock_output_dir.__truediv__ = lambda self, x: Path(f"/test/output/{x}")

        path1 = _generate_report_path("query one")
        path2 = _generate_report_path("query two")

        # Paths should be different
        assert str(path1) != str(path2)

    @patch("local_deep_research.web.services.research_service.OUTPUT_DIR")
    def test_same_query_same_path(self, mock_output_dir):
        """Same query should generate same path."""
        from local_deep_research.web.services.research_service import (
            _generate_report_path,
        )

        mock_output_dir.__truediv__ = lambda self, x: Path(f"/test/output/{x}")

        path1 = _generate_report_path("test query")
        path2 = _generate_report_path("test query")

        # Paths should be the same
        assert str(path1) == str(path2)


class TestStartResearchProcessWithOptions:
    """Tests for start_research_process with various options."""

    @patch(
        "local_deep_research.web.services.research_service.thread_with_app_context"
    )
    @patch("local_deep_research.web.services.research_service.thread_context")
    @patch("local_deep_research.web.routes.globals.check_and_start_research")
    def test_start_research_with_local_collections(
        self, mock_check_start, mock_thread_context, mock_thread_with_context
    ):
        """start_research_process handles local_collections option."""
        from local_deep_research.web.services.research_service import (
            start_research_process,
        )

        mock_callback = Mock()
        mock_thread_with_context.return_value = mock_callback
        mock_thread_context.return_value = {}
        mock_check_start.return_value = True

        with patch(
            "local_deep_research.web.services.research_service.threading.Thread"
        ) as mock_thread_class:
            mock_thread = Mock()
            mock_thread_class.return_value = mock_thread

            start_research_process(
                research_id=123,
                query="test query",
                mode="detailed",
                run_research_callback=mock_callback,
                local_collections=["collection1", "collection2"],
            )

            call_args = mock_check_start.call_args
            settings = call_args[0][1]["settings"]
            assert settings["local_collections"] == [
                "collection1",
                "collection2",
            ]

    @patch(
        "local_deep_research.web.services.research_service.thread_with_app_context"
    )
    @patch("local_deep_research.web.services.research_service.thread_context")
    @patch("local_deep_research.web.routes.globals.check_and_start_research")
    def test_start_research_stores_knowledge_graph_option(
        self, mock_check_start, mock_thread_context, mock_thread_with_context
    ):
        """start_research_process stores knowledge_graph option."""
        from local_deep_research.web.services.research_service import (
            start_research_process,
        )

        mock_callback = Mock()
        mock_thread_with_context.return_value = mock_callback
        mock_thread_context.return_value = {}
        mock_check_start.return_value = True

        with patch(
            "local_deep_research.web.services.research_service.threading.Thread"
        ) as mock_thread_class:
            mock_thread = Mock()
            mock_thread_class.return_value = mock_thread

            start_research_process(
                research_id=456,
                query="test query",
                mode="quick",
                run_research_callback=mock_callback,
                enable_knowledge_graph=True,
            )

            call_args = mock_check_start.call_args
            settings = call_args[0][1]["settings"]
            assert settings["enable_knowledge_graph"] is True


class TestResearchServiceExportFormats:
    """Tests for export format handling."""

    def test_export_unsupported_format_returns_error(self):
        """export_report_to_memory raises for unsupported format."""
        import pytest
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        markdown_content = "# Test Report\n\nThis is test content."

        # Test unsupported format raises ValueError
        with pytest.raises(ValueError) as exc_info:
            export_report_to_memory(
                markdown_content, "unsupported_format", title="Test"
            )

        assert "Unsupported export format" in str(exc_info.value)


class TestTitlePrepending:
    """Test title prepending behavior for different export formats.

    The export_report_to_memory function should prepend a markdown title
    for formats that render markdown (PDF, ODT), but NOT for formats
    where it would corrupt the output (RIS, LaTeX, Quarto).
    """

    def test_pdf_gets_title_prepended(self):
        """PDF format should get markdown title prepended."""
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        # Content without a heading
        markdown_content = "This is content without a title heading."
        title = "My Report Title"

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "pdf", title=title
        )

        # PDF should be valid and have the title (we can't easily inspect content,
        # but we verify the export succeeded and returned valid PDF)
        assert content.startswith(b"%PDF")
        assert filename.endswith(".pdf")

    def test_odt_gets_title_prepended(self):
        """ODT format should get markdown title prepended."""
        # Check if pandoc is available
        try:
            import pypandoc

            pypandoc.get_pandoc_version()
        except (ImportError, OSError):
            import pytest

            pytest.skip("Pandoc is not installed (required for ODT export)")

        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        # Content without a heading
        markdown_content = "This is content without a title heading."
        title = "My Report Title"

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "odt", title=title
        )

        # ODT should be valid ZIP and have the title
        assert content[:2] == b"PK"
        assert filename.endswith(".odt")

    def test_ris_does_not_get_title_prepended(self):
        """RIS format should NOT get title prepended (would corrupt output).

        RIS is a bibliographic format with strict structure. Prepending
        a markdown title would corrupt the output.
        """
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        # Content with sources
        markdown_content = """# Research Report

Some content here.

## Sources

[1] First Source
URL: https://example.com/1
"""
        title = "My Report Title"

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "ris", title=title
        )

        # Decode the RIS content and verify it doesn't start with markdown title
        ris_text = content.decode("utf-8")

        # RIS should NOT have "# My Report Title" prepended
        assert not ris_text.startswith("# My Report Title")
        assert filename.endswith(".ris")

    def test_latex_does_not_get_title_prepended(self):
        """LaTeX format should NOT get title prepended.

        LaTeX has its own document structure with \\documentclass, etc.
        Prepending a markdown title would not be appropriate.
        """
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        markdown_content = "Some content here."
        title = "My Report Title"

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "latex", title=title
        )

        # The function should NOT prepend "# My Report Title" to the content
        # before passing to the LaTeX exporter
        # We verify the export succeeded with proper LaTeX output
        assert filename.endswith(".tex")
        assert isinstance(content, bytes)
        # Verify the content is valid (can be decoded)
        assert content.decode("utf-8")

    def test_quarto_does_not_get_title_prepended(self):
        """Quarto format should NOT get title prepended.

        Quarto uses YAML front matter for titles. Prepending a markdown
        title would duplicate the title.
        """
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        markdown_content = "Some content here."
        title = "My Report Title"

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "quarto", title=title
        )

        # Quarto produces a ZIP file
        assert content[:2] == b"PK"
        assert filename.endswith(".zip")

    def test_title_not_duplicated_if_already_present(self):
        """If content already starts with title heading, don't duplicate."""
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        # Content that already has the title as heading
        title = "My Report Title"
        markdown_content = f"# {title}\n\nThis content already has the title."

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "pdf", title=title
        )

        # Should succeed without duplicating
        assert content.startswith(b"%PDF")

    def test_title_not_prepended_if_content_starts_with_heading(self):
        """If content starts with any heading, don't prepend title."""
        from local_deep_research.web.services.research_service import (
            export_report_to_memory,
        )

        # Content that starts with a different heading
        markdown_content = "# Different Heading\n\nSome content here."
        title = "My Report Title"

        content, filename, mimetype = export_report_to_memory(
            markdown_content, "pdf", title=title
        )

        # Should succeed - PDF formats should not prepend if content starts with #
        assert content.startswith(b"%PDF")


class TestParseResearchMetadata:
    """Tests for _parse_research_metadata function."""

    def test_parse_dict_returns_copy(self):
        """_parse_research_metadata returns copy when given dict."""
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        original = {"key": "value", "number": 42}
        result = _parse_research_metadata(original)

        assert result == original
        # Should be a copy, not the same object
        assert result is not original

    def test_parse_dict_with_nested_data(self):
        """_parse_research_metadata handles nested dict data."""
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        original = {
            "iterations": 5,
            "metadata": {"model": "gpt-4", "search_engine": "google"},
            "sources": ["source1", "source2"],
        }
        result = _parse_research_metadata(original)

        assert result == original
        assert result["metadata"]["model"] == "gpt-4"

    def test_parse_valid_json_string(self):
        """_parse_research_metadata parses valid JSON string."""
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        json_str = '{"key": "value", "number": 42}'
        result = _parse_research_metadata(json_str)

        assert result == {"key": "value", "number": 42}

    def test_parse_complex_json_string(self):
        """_parse_research_metadata parses complex JSON string."""
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        json_str = '{"iterations": 5, "metadata": {"model": "gpt-4"}, "sources": ["a", "b"]}'
        result = _parse_research_metadata(json_str)

        assert result["iterations"] == 5
        assert result["metadata"]["model"] == "gpt-4"
        assert result["sources"] == ["a", "b"]

    def test_parse_invalid_json_string_returns_empty_dict(self):
        """_parse_research_metadata returns empty dict for invalid JSON."""
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        invalid_json = "not valid json {{"
        result = _parse_research_metadata(invalid_json)

        assert result == {}

    def test_parse_empty_json_string(self):
        """_parse_research_metadata handles empty JSON object string."""
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        result = _parse_research_metadata("{}")

        assert result == {}

    def test_parse_none_returns_empty_dict(self):
        """_parse_research_metadata returns empty dict for None."""
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        result = _parse_research_metadata(None)

        assert result == {}

    def test_parse_empty_string_returns_empty_dict(self):
        """_parse_research_metadata returns empty dict for empty string."""
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        result = _parse_research_metadata("")

        assert result == {}

    def test_parse_integer_returns_empty_dict(self):
        """_parse_research_metadata returns empty dict for integer."""
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        result = _parse_research_metadata(42)

        assert result == {}

    def test_parse_list_returns_empty_dict(self):
        """_parse_research_metadata returns empty dict for list."""
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        result = _parse_research_metadata([1, 2, 3])

        assert result == {}

    def test_parse_empty_dict_returns_empty_dict(self):
        """_parse_research_metadata handles empty dict."""
        from local_deep_research.web.services.research_service import (
            _parse_research_metadata,
        )

        result = _parse_research_metadata({})

        assert result == {}
