"""Tests for the message-delivery package."""

from __future__ import annotations

import subprocess
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from message_delivery import (
    DeliveryError,
    DeliveryOptions,
    DeliveryPipeline,
    DeliveryResult,
    EmailError,
    EmailSender,
    GitError,
    GitOperations,
    NoteSaver,
    SaveError,
)
from session_store import SessionMessage

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


def make_message(
    content: str = "Some message content here",
    role: str = "assistant",
    tool: str = "devin",
    session_id: str = "sess-123",
    created_at: datetime | None = None,
) -> SessionMessage:
    if created_at is None:
        created_at = datetime(2026, 7, 14, 10, 30, 0, tzinfo=UTC)
    return SessionMessage(
        tool=tool,
        session_id=session_id,
        node_id="node-1",
        parent_node_id=None,
        message_id="msg-1",
        role=role,
        content=content,
        created_at=created_at,
    )


# ---------------------------------------------------------------------------
# DeliveryOptions / DeliveryResult
# ---------------------------------------------------------------------------


class TestDeliveryOptions:
    def test_defaults(self) -> None:
        opts = DeliveryOptions()
        assert opts.subject is None
        assert opts.filename is None
        assert opts.no_push is False
        assert opts.no_email is False
        assert opts.no_commit is False
        assert opts.dry_run is False
        assert opts.to_email is None

    def test_field_values(self) -> None:
        opts = DeliveryOptions(
            subject="Hello",
            filename="custom.md",
            no_push=True,
            no_email=True,
            no_commit=True,
            dry_run=True,
            to_email="user@example.com",
        )
        assert opts.subject == "Hello"
        assert opts.filename == "custom.md"
        assert opts.no_push is True
        assert opts.no_email is True
        assert opts.no_commit is True
        assert opts.dry_run is True
        assert opts.to_email == "user@example.com"


class TestDeliveryResult:
    def test_defaults(self) -> None:
        result = DeliveryResult(note_path=Path("/tmp/note.md"))
        assert result.note_path == Path("/tmp/note.md")
        assert result.committed is False
        assert result.emailed is False
        assert result.pushed is False

    def test_field_values(self) -> None:
        result = DeliveryResult(
            note_path=Path("/tmp/note.md"),
            committed=True,
            emailed=True,
            pushed=True,
        )
        assert result.committed is True
        assert result.emailed is True
        assert result.pushed is True


# ---------------------------------------------------------------------------
# NoteSaver
# ---------------------------------------------------------------------------


class TestBuildMarkdown:
    def test_format(self) -> None:
        saver = NoteSaver()
        msg = make_message(content="Hello world")
        md = saver.build_markdown(msg, "My Session Title")

        assert md.startswith("# My Session Title\n")
        assert "> **Tool:** devin" in md
        assert "> **Session:** sess-123" in md
        assert "> **Role:** assistant" in md
        assert "> **Created:** 2026-07-14T10:30:00Z" in md
        assert "\n---\n" in md
        assert md.endswith("Hello world")

    def test_content_after_rule(self) -> None:
        saver = NoteSaver()
        msg = make_message(content="Body text")
        md = saver.build_markdown(msg, "Title")
        # The content should appear after the horizontal rule
        parts = md.split("---\n", 1)
        assert len(parts) == 2
        assert parts[1].strip() == "Body text"


class TestGenerateFilename:
    def setup_method(self) -> None:
        self.saver = NoteSaver()

    def test_heading(self) -> None:
        msg = make_message("# Implementing the auth module\n\nBody")
        assert self.saver.generate_filename(msg) == "implementing-the-auth-module.md"

    def test_multiple_spaces(self) -> None:
        msg = make_message("Hello   world   foo")
        assert self.saver.generate_filename(msg) == "hello-world-foo.md"

    def test_special_chars(self) -> None:
        msg = make_message("Hello, World! How are you?")
        assert self.saver.generate_filename(msg) == "hello-world-how-are-you.md"

    def test_leading_hash_strip(self) -> None:
        msg = make_message("### Deep heading")
        assert self.saver.generate_filename(msg) == "deep-heading.md"

    def test_empty_content(self) -> None:
        msg = make_message("")
        assert self.saver.generate_filename(msg) == ".md"

    def test_long_line_truncation(self) -> None:
        long_line = "word " * 50  # 250 chars
        msg = make_message(long_line)
        name = self.saver.generate_filename(msg)
        # Filename should be at most 60 chars + ".md"
        stem = name[:-3]
        assert len(stem) <= 60

    def test_first_non_empty_line(self) -> None:
        msg = make_message("\n\n  \n# Real Title\nbody")
        assert self.saver.generate_filename(msg) == "real-title.md"

    def test_uppercase(self) -> None:
        msg = make_message("HELLO WORLD")
        assert self.saver.generate_filename(msg) == "hello-world.md"

    def test_consecutive_hyphens_collapse(self) -> None:
        msg = make_message("foo---bar  baz")
        assert self.saver.generate_filename(msg) == "foo-bar-baz.md"


class TestNoteSaverSave:
    def test_writes_to_docs_notes(self, tmp_path: Path) -> None:
        saver = NoteSaver()
        msg = make_message(content="Hello")
        path = saver.save(msg, "Title", tmp_path, filename="custom.md")
        assert path == tmp_path / "docs" / "notes" / "custom.md"
        assert path.exists()
        assert "# Title" in path.read_text()

    def test_creates_dir(self, tmp_path: Path) -> None:
        saver = NoteSaver()
        msg = make_message(content="Hello")
        assert not (tmp_path / "docs" / "notes").exists()
        path = saver.save(msg, "Title", tmp_path, filename="x.md")
        assert (tmp_path / "docs" / "notes").is_dir()
        assert path.exists()

    def test_auto_filename(self, tmp_path: Path) -> None:
        saver = NoteSaver()
        msg = make_message("# My Heading\nbody")
        path = saver.save(msg, "Title", tmp_path)
        assert path.name == "my-heading.md"
        assert path.exists()

    def test_raises_save_error(self, tmp_path: Path) -> None:
        saver = NoteSaver()
        msg = make_message(content="Hello")
        with patch("pathlib.Path.mkdir", side_effect=OSError("boom")), pytest.raises(SaveError):
            saver.save(msg, "Title", tmp_path, filename="x.md")


# ---------------------------------------------------------------------------
# GitOperations
# ---------------------------------------------------------------------------


def _init_git_repo(path: Path) -> None:
    """Create a real temporary git repo with an initial commit."""
    subprocess.run(["git", "init"], cwd=path, capture_output=True, check=True)
    subprocess.run(
        ["git", "config", "user.email", "test@example.com"],
        cwd=path,
        capture_output=True,
        check=True,
    )
    subprocess.run(
        ["git", "config", "user.name", "Test User"],
        cwd=path,
        capture_output=True,
        check=True,
    )
    (path / "README.md").write_text("init\n")
    subprocess.run(["git", "add", "."], cwd=path, capture_output=True, check=True)
    subprocess.run(["git", "commit", "-m", "init"], cwd=path, capture_output=True, check=True)


class TestFindProjectRoot:
    def test_finds_toplevel(self, tmp_path: Path) -> None:
        _init_git_repo(tmp_path)
        git_ops = GitOperations()
        root = git_ops.find_project_root(start=tmp_path)
        assert Path(root) == tmp_path.resolve() or Path(root) == tmp_path

    def test_caches_result(self, tmp_path: Path) -> None:
        _init_git_repo(tmp_path)
        git_ops = GitOperations()
        git_ops.find_project_root(start=tmp_path)
        assert git_ops._project_root_cache is not None

    def test_not_a_repo(self, tmp_path: Path) -> None:
        git_ops = GitOperations()
        with pytest.raises(GitError):
            git_ops.find_project_root(start=tmp_path)


class TestGitCommit:
    def test_commit_created(self, tmp_path: Path) -> None:
        _init_git_repo(tmp_path)
        git_ops = GitOperations()
        note = tmp_path / "note.md"
        note.write_text("content\n")
        result = git_ops.commit(note, "Add note")
        assert result is True

    def test_nothing_to_commit(self, tmp_path: Path) -> None:
        _init_git_repo(tmp_path)
        git_ops = GitOperations()
        note = tmp_path / "note.md"
        note.write_text("content\n")
        git_ops.commit(note, "Add note")  # first commit
        # Second commit with no changes
        result = git_ops.commit(note, "Add note again")
        assert result is False


# ---------------------------------------------------------------------------
# EmailSender
# ---------------------------------------------------------------------------


class TestEmailSenderIsAvailable:
    def test_returns_bool_true(self) -> None:
        sender = EmailSender()
        with patch("shutil.which", return_value="/usr/local/bin/send-emails"):
            assert sender.is_available() is True

    def test_returns_bool_false(self) -> None:
        sender = EmailSender()
        with patch("shutil.which", return_value=None):
            assert sender.is_available() is False


class TestEmailSenderSend:
    def test_calls_subprocess_with_correct_args(self, tmp_path: Path) -> None:
        sender = EmailSender()
        note = tmp_path / "note.md"
        note.write_text("content")
        mock_result = MagicMock()
        mock_result.returncode = 0
        mock_result.stderr = ""
        mock_result.stdout = ""
        with (
            patch("shutil.which", return_value="/usr/local/bin/send-emails"),
            patch("subprocess.run", return_value=mock_result) as mock_run,
        ):
            sender.send(note, "My Subject", to="user@example.com")

        mock_run.assert_called_once()
        cmd = mock_run.call_args[0][0]
        assert cmd[0] == "send-emails"
        assert "send" in cmd
        assert "--subject" in cmd
        assert "My Subject" in cmd
        assert "--body-file" in cmd
        assert str(note) in cmd
        assert "--render-html" in cmd
        assert "--to" in cmd
        assert "user@example.com" in cmd

    def test_send_without_to(self, tmp_path: Path) -> None:
        sender = EmailSender()
        note = tmp_path / "note.md"
        note.write_text("content")
        mock_result = MagicMock()
        mock_result.returncode = 0
        mock_result.stderr = ""
        mock_result.stdout = ""
        with (
            patch("shutil.which", return_value="/usr/local/bin/send-emails"),
            patch("subprocess.run", return_value=mock_result) as mock_run,
        ):
            sender.send(note, "Subject")
        cmd = mock_run.call_args[0][0]
        assert "--to" not in cmd

    def test_raises_when_not_available(self, tmp_path: Path) -> None:
        sender = EmailSender()
        note = tmp_path / "note.md"
        note.write_text("content")
        with patch("shutil.which", return_value=None), pytest.raises(EmailError, match="not found"):
            sender.send(note, "Subject")

    def test_raises_on_nonzero_exit(self, tmp_path: Path) -> None:
        sender = EmailSender()
        note = tmp_path / "note.md"
        note.write_text("content")
        mock_result = MagicMock()
        mock_result.returncode = 1
        mock_result.stderr = "some error"
        mock_result.stdout = ""
        with (
            patch("shutil.which", return_value="/usr/local/bin/send-emails"),
            patch("subprocess.run", return_value=mock_result),
            pytest.raises(EmailError, match="failed"),
        ):
            sender.send(note, "Subject")


# ---------------------------------------------------------------------------
# DeliveryPipeline
# ---------------------------------------------------------------------------


class TestDeliveryPipeline:
    def _make_mocks(self) -> tuple[MagicMock, MagicMock, MagicMock]:
        saver = MagicMock()
        saver.save.return_value = Path("/fake/docs/notes/note.md")
        git = MagicMock()
        git.find_project_root.return_value = Path("/fake/project")
        git.commit.return_value = True
        emailer = MagicMock()
        return saver, git, emailer

    def test_full_pipeline(self) -> None:
        saver, git, emailer = self._make_mocks()
        pipeline = DeliveryPipeline(saver=saver, git=git, emailer=emailer)
        msg = make_message()
        opts = DeliveryOptions(subject="Test Subject")
        result = pipeline.deliver(msg, "Session Title", opts)

        assert result.note_path == Path("/fake/docs/notes/note.md")
        assert result.committed is True
        assert result.emailed is True
        assert result.pushed is True
        saver.save.assert_called_once()
        git.commit.assert_called_once()
        emailer.send.assert_called_once()
        git.push.assert_called_once()

    def test_dry_run_skips_commit_email_push(self) -> None:
        saver, git, emailer = self._make_mocks()
        pipeline = DeliveryPipeline(saver=saver, git=git, emailer=emailer)
        msg = make_message()
        opts = DeliveryOptions(dry_run=True)
        result = pipeline.deliver(msg, "Session Title", opts)

        assert result.committed is False
        assert result.emailed is False
        assert result.pushed is False
        saver.save.assert_called_once()
        git.commit.assert_not_called()
        emailer.send.assert_not_called()
        git.push.assert_not_called()

    def test_no_email_skips_email(self) -> None:
        saver, git, emailer = self._make_mocks()
        pipeline = DeliveryPipeline(saver=saver, git=git, emailer=emailer)
        msg = make_message()
        opts = DeliveryOptions(no_email=True)
        result = pipeline.deliver(msg, "Session Title", opts)

        assert result.committed is True
        assert result.emailed is False
        assert result.pushed is True
        git.commit.assert_called_once()
        emailer.send.assert_not_called()
        git.push.assert_called_once()

    def test_no_commit_skips_commit(self) -> None:
        saver, git, emailer = self._make_mocks()
        pipeline = DeliveryPipeline(saver=saver, git=git, emailer=emailer)
        msg = make_message()
        opts = DeliveryOptions(no_commit=True)
        result = pipeline.deliver(msg, "Session Title", opts)

        assert result.committed is False
        assert result.emailed is True
        assert result.pushed is True
        git.commit.assert_not_called()
        emailer.send.assert_called_once()
        git.push.assert_called_once()

    def test_no_push_skips_push(self) -> None:
        saver, git, emailer = self._make_mocks()
        pipeline = DeliveryPipeline(saver=saver, git=git, emailer=emailer)
        msg = make_message()
        opts = DeliveryOptions(no_push=True)
        result = pipeline.deliver(msg, "Session Title", opts)

        assert result.committed is True
        assert result.emailed is True
        assert result.pushed is False
        git.commit.assert_called_once()
        emailer.send.assert_called_once()
        git.push.assert_not_called()

    def test_propagates_git_error(self) -> None:
        saver, git, emailer = self._make_mocks()
        git.commit.side_effect = GitError("boom")
        pipeline = DeliveryPipeline(saver=saver, git=git, emailer=emailer)
        msg = make_message()
        opts = DeliveryOptions()
        with pytest.raises(GitError):
            pipeline.deliver(msg, "Session Title", opts)

    def test_propagates_email_error(self) -> None:
        saver, git, emailer = self._make_mocks()
        emailer.send.side_effect = EmailError("boom")
        pipeline = DeliveryPipeline(saver=saver, git=git, emailer=emailer)
        msg = make_message()
        opts = DeliveryOptions()
        with pytest.raises(EmailError):
            pipeline.deliver(msg, "Session Title", opts)

    def test_dry_run_uses_temp_dir(self) -> None:
        saver, git, emailer = self._make_mocks()
        pipeline = DeliveryPipeline(saver=saver, git=git, emailer=emailer)
        msg = make_message()
        opts = DeliveryOptions(dry_run=True)
        pipeline.deliver(msg, "Session Title", opts)
        # In dry_run, find_project_root should not be called
        git.find_project_root.assert_not_called()
        # The project_root passed to save should be a temp dir (not the git root)
        call_args = saver.save.call_args
        project_root_arg = call_args[0][2]
        assert "message-delivery-dry-run-" in str(project_root_arg)


# ---------------------------------------------------------------------------
# Error hierarchy
# ---------------------------------------------------------------------------


class TestErrorHierarchy:
    def test_all_subclass_delivery_error(self) -> None:
        assert issubclass(GitError, DeliveryError)
        assert issubclass(EmailError, DeliveryError)
        assert issubclass(SaveError, DeliveryError)
