"""Tests for journal closeout extraction helpers."""

import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest


def _msg(role: str, content: str, kind: str | None = None):
    """Build a Message-like stand-in for the filter helpers."""
    metadata = {"kind": kind} if kind else None
    return SimpleNamespace(role=role, content=content, msg_metadata=metadata)


def _fake_conv(conv_id: int = 1):
    return SimpleNamespace(id=conv_id)


def _patch_db(messages, conv=None):
    """Build a context manager that fakes async_session() and the two
    select() calls (conversation lookup + messages query)."""
    session = AsyncMock()
    session.execute = AsyncMock()
    conv_result = MagicMock()
    conv_result.scalar_one_or_none = MagicMock(return_value=conv)
    msg_result = MagicMock()
    scalars = MagicMock()
    scalars.all = MagicMock(return_value=list(messages))
    msg_result.scalars = MagicMock(return_value=scalars)
    session.execute.side_effect = [conv_result, msg_result]

    session_ctx = MagicMock()
    session_ctx.__aenter__ = AsyncMock(return_value=session)
    session_ctx.__aexit__ = AsyncMock(return_value=None)
    return session_ctx


def test_filter_excludes_daily_prep_messages():
    from fabledassistant.services.journal_closeout import _filter_messages

    msgs = [
        _msg("assistant", "Good morning — here is today's plan…", kind="daily_prep"),
        _msg("user", "I want to focus on the auth refactor today."),
        _msg("assistant", "Got it. I'll keep tool calls quiet."),
    ]
    kept = _filter_messages(msgs)
    contents = [m.content for m in kept]
    assert "Good morning — here is today's plan…" not in contents
    assert "I want to focus on the auth refactor today." in contents
    assert "Got it. I'll keep tool calls quiet." in contents


def test_build_transcript_labels_roles_and_caps_content():
    from fabledassistant.services.journal_closeout import _build_transcript

    msgs = [
        _msg("user", "hello"),
        _msg("assistant", "hi"),
        _msg("user", "x" * 600),
    ]
    out = _build_transcript(msgs)
    lines = out.splitlines()
    assert lines[0] == "USER: hello"
    assert lines[1] == "ASSISTANT: hi"
    # Third line content truncated to 500 chars
    assert lines[2].startswith("USER: ")
    assert len(lines[2]) == len("USER: ") + 500


def test_build_transcript_keeps_only_last_20_messages():
    from fabledassistant.services.journal_closeout import _build_transcript

    msgs = [_msg("user", f"msg-{i}") for i in range(30)]
    out = _build_transcript(msgs)
    lines = out.splitlines()
    assert len(lines) == 20
    # Newest 20 means msg-10 through msg-29
    assert lines[0] == "USER: msg-10"
    assert lines[-1] == "USER: msg-29"


def test_system_prompt_lists_structured_fields_to_exclude():
    """The prompt must explicitly tell the LLM not to restate structured
    fields, so the freeform learned_summary stays a narrow lane."""
    from fabledassistant.services.journal_closeout import SYSTEM_PROMPT

    text = SYSTEM_PROMPT.lower()
    for field in ("name", "job title", "industry", "expertise", "response style", "tone", "interests"):
        assert field in text, f"system prompt should mention '{field}'"
    assert "(nothing to note)" in SYSTEM_PROMPT


@pytest.mark.asyncio
async def test_run_for_user_happy_path_appends_bullets():
    from fabledassistant.services import journal_closeout

    yesterday = datetime.date(2026, 5, 11)
    msgs = [
        _msg("assistant", "Good morning — here's today.", kind="daily_prep"),
        _msg("user", "Skip the news section — I never read it."),
        _msg("assistant", "Noted. Will drop it."),
    ]

    with (
        patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())),
        patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")),
        patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="- User skips news section")),
        patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
    ):
        await journal_closeout.run_for_user(user_id=42, yesterday=yesterday)

    mock_append.assert_awaited_once_with(42, "- User skips news section")


@pytest.mark.asyncio
async def test_run_for_user_skips_when_no_conversation():
    from fabledassistant.services import journal_closeout

    with (
        patch.object(journal_closeout, "async_session", return_value=_patch_db([], conv=None)),
        patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
        patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
    ):
        await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))

    mock_append.assert_not_awaited()
    mock_llm.assert_not_awaited()


@pytest.mark.asyncio
async def test_run_for_user_skips_when_only_prep_message_after_filter():
    """If only the daily_prep message exists, the in-Python filter pass
    leaves zero messages and we short-circuit before the LLM."""
    from fabledassistant.services import journal_closeout

    msgs_post_sql = [
        _msg("assistant", "Daily prep block.", kind="daily_prep"),
    ]
    with (
        patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs_post_sql, conv=_fake_conv())),
        patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
        patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
    ):
        await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))

    mock_append.assert_not_awaited()
    mock_llm.assert_not_awaited()


@pytest.mark.asyncio
async def test_run_for_user_respects_nothing_to_note_sentinel():
    from fabledassistant.services import journal_closeout

    msgs = [_msg("user", "hi"), _msg("assistant", "hi back")]
    with (
        patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())),
        patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")),
        patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="(nothing to note)")),
        patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
    ):
        await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))

    mock_append.assert_not_awaited()


@pytest.mark.asyncio
async def test_update_user_schedule_registers_closeout_when_enabled(monkeypatch):
    from fabledassistant.services import journal_scheduler as sched

    fake_scheduler = MagicMock()
    fake_scheduler.get_job = MagicMock(return_value=None)
    monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
    monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
        "prep_enabled": False,           # isolate the closeout job
        "closeout_enabled": True,
        "day_rollover_hour": 4,
    }))
    monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))

    await sched.update_user_schedule(user_id=7)

    added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
    assert "journal_closeout_7" in added


@pytest.mark.asyncio
async def test_update_user_schedule_does_not_register_closeout_when_disabled(monkeypatch):
    from fabledassistant.services import journal_scheduler as sched

    fake_scheduler = MagicMock()
    fake_scheduler.get_job = MagicMock(return_value=None)
    monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
    monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
        "prep_enabled": False,
        "closeout_enabled": False,
        "day_rollover_hour": 4,
    }))
    monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))

    await sched.update_user_schedule(user_id=7)

    added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
    assert "journal_closeout_7" not in added


@pytest.mark.asyncio
async def test_closeout_catchup_skips_when_already_have_entry(monkeypatch):
    from fabledassistant.services import journal_scheduler as sched

    monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
    monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
        "closeout_enabled": True,
        "day_rollover_hour": 0,  # slot has always passed
    }))
    yesterday = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)).date()
    fake_profile = SimpleNamespace(observations_raw=[{"date": yesterday.isoformat(), "bullets": "..."}])

    from fabledassistant.services import user_profile as up
    monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))

    run_mock = AsyncMock()
    from fabledassistant.services import journal_closeout as jc
    monkeypatch.setattr(jc, "run_for_user", run_mock)

    await sched._closeout_catchup(user_id=7)
    run_mock.assert_not_awaited()


@pytest.mark.asyncio
async def test_closeout_catchup_runs_when_no_entry_yet(monkeypatch):
    from fabledassistant.services import journal_scheduler as sched

    monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
    monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
        "closeout_enabled": True,
        "day_rollover_hour": 0,
    }))
    fake_profile = SimpleNamespace(observations_raw=[])

    from fabledassistant.services import user_profile as up
    monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))

    run_mock = AsyncMock()
    from fabledassistant.services import journal_closeout as jc
    monkeypatch.setattr(jc, "run_for_user", run_mock)

    await sched._closeout_catchup(user_id=7)
    run_mock.assert_awaited_once()
