"""Daily prep generator for the Journal.

Runs once per day per user (scheduled, or lazy on first journal-open of a
new day). Two phases:

1. Gather structured data (tasks/events/weather/projects/recent moments/
   open threads) — deterministic, no LLM call.
2. Hand the structured data to the LLM and ask it for a direct, informative
   conversational opener — flowing prose, briefing-style. Result is persisted
   as the first *assistant* message in today's journal Conversation, so it
   renders with the standard Illuminated Transcript bubble styling alongside
   the rest of the conversation.

The structured data is preserved on ``Message.msg_metadata.sections`` for
provenance and future tooling.

Message shape:
    role: 'assistant'
    content: <prose opener>
    msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } }
"""
from __future__ import annotations

import datetime
import logging
import re
from zoneinfo import ZoneInfo

from sqlalchemy import select

from fabledassistant.config import Config
from fabledassistant.models import Conversation, Message, async_session
from fabledassistant.services.events import list_events
from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import list_projects
from fabledassistant.services.settings import get_setting
from fabledassistant.services.weather import get_cached_weather_rows

logger = logging.getLogger(__name__)


# How many days out from today an event needs to be before the prep treats
# it as too far-future to surface. Catches recurring-event canonical rows
# whose RRULE expansion missed (an `rrulestr` failure falls back to the
# canonical event in `list_events`, which leaks far-future occurrences
# into today's prep).
_EVENT_PROXIMITY_DAYS = 7


def _task_to_prep_dict(task, today: datetime.date) -> dict:
    """Render a Note row as a prep-payload task entry, tagging overdue
    staleness when relevant so the prompt can frame it correctly."""
    d = {
        "id": task.id,
        "title": task.title,
        "status": task.status,
        "priority": task.priority,
        "due_date": task.due_date.isoformat() if task.due_date else None,
    }
    if task.due_date and task.due_date < today:
        d["days_overdue"] = (today - task.due_date).days
    return d


def _filter_proximate_events(
    events: list[dict], *, day_date: datetime.date, user_tz: ZoneInfo
) -> list[dict]:
    """Drop events whose start_dt is more than ``_EVENT_PROXIMITY_DAYS``
    away from ``day_date`` in the user's local timezone.

    Belt-and-suspenders against `list_events` returning a canonical
    far-future event (e.g. when RRULE expansion fails and the loop falls
    back to the original event row, regardless of date). The user
    observed "Birthday — 2026-09-29 (FREQ=YEARLY)" surfacing in every
    daily prep 5 months out; this filter keeps the prep proximate.
    """
    proximate: list[dict] = []
    for e in events:
        raw = e.get("start_dt") or ""
        try:
            start_dt = datetime.datetime.fromisoformat(
                raw.replace("Z", "+00:00") if isinstance(raw, str) else ""
            )
            local_date = start_dt.astimezone(user_tz).date()
            delta = abs((local_date - day_date).days)
        except (ValueError, TypeError, AttributeError):
            # Unparseable date — keep the event rather than suppress real data.
            proximate.append(e)
            continue
        if delta <= _EVENT_PROXIMITY_DAYS:
            proximate.append(e)
        else:
            logger.info(
                "daily_prep: dropping non-proximate event %r start_local=%s "
                "(%d days from day %s)",
                e.get("title"), local_date.isoformat(), delta, day_date.isoformat(),
            )
    return proximate


async def gather_daily_sections(
    *,
    user_id: int,
    day_date: datetime.date,
    user_timezone: str,
) -> dict:
    """Gather all daily-prep sections and return them as a dict.

    Pure data fetching — no LLM call. Each section degrades to an empty
    list/dict on failure so the caller always gets a complete shape.

    Tasks are returned in three explicit buckets so the prompt can frame
    overdue items correctly (instead of calling them "due today" — the
    pre-2026-04-29 behavior, before this rewrite).
    """
    sections: dict = {}

    next_day = day_date + datetime.timedelta(days=1)
    upcoming_end = day_date + datetime.timedelta(days=8)
    try:
        due_today_rows, _ = await list_notes(
            user_id=user_id, is_task=True, status=["todo", "in_progress"],
            due_after=day_date, due_before=next_day,
            limit=20, sort="due_date", order="asc",
        )
        upcoming_rows, _ = await list_notes(
            user_id=user_id, is_task=True, status=["todo", "in_progress"],
            due_after=next_day, due_before=upcoming_end,
            limit=20, sort="due_date", order="asc",
        )
        overdue_rows, _ = await list_notes(
            user_id=user_id, is_task=True, status=["todo", "in_progress"],
            due_before=day_date,
            limit=20, sort="due_date", order="asc",
        )
        sections["tasks_due_today"] = [_task_to_prep_dict(t, day_date) for t in due_today_rows]
        sections["tasks_upcoming"] = [_task_to_prep_dict(t, day_date) for t in upcoming_rows]
        sections["tasks_overdue"] = [_task_to_prep_dict(t, day_date) for t in overdue_rows]
        # Backwards-compat alias for any consumers still reading sections["tasks"].
        # The combined view is more useful than the prior overdue-only behavior.
        sections["tasks"] = (
            sections["tasks_due_today"]
            + sections["tasks_upcoming"]
            + sections["tasks_overdue"]
        )
    except Exception:
        logger.exception("daily_prep tasks section failed for user %d", user_id)
        sections["tasks_due_today"] = []
        sections["tasks_upcoming"] = []
        sections["tasks_overdue"] = []
        sections["tasks"] = []

    try:
        try:
            user_tz = ZoneInfo(user_timezone)
        except Exception:
            logger.warning("daily_prep: invalid user_timezone %r — defaulting to UTC", user_timezone)
            user_tz = ZoneInfo("UTC")
        # Build the local-day window in the user's TZ, then convert to UTC
        # for the DB / RRULE expansion. A naive datetime here previously
        # caused rrule.between() to throw, falling back to the canonical
        # event row regardless of date — the source of stale recurring
        # events polluting every daily prep.
        day_start_local = datetime.datetime.combine(day_date, datetime.time.min, tzinfo=user_tz)
        day_end_local = datetime.datetime.combine(day_date, datetime.time.max, tzinfo=user_tz)
        day_start = day_start_local.astimezone(datetime.timezone.utc)
        day_end = day_end_local.astimezone(datetime.timezone.utc)
        all_events = await list_events(
            user_id=user_id,
            date_from=day_start,
            date_to=day_end,
        )
        sections["events"] = _filter_proximate_events(
            all_events, day_date=day_date, user_tz=user_tz,
        )
    except Exception:
        logger.exception("daily_prep events section failed for user %d", user_id)
        sections["events"] = []

    try:
        # Lazy import: journal_scheduler imports this module for prep generation,
        # so a top-level import would cycle.
        from fabledassistant.services.journal_scheduler import get_journal_config
        cfg = await get_journal_config(user_id)
        valid_weather_keys = {
            key for key, loc in (cfg.get("locations") or {}).items()
            if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
        }
        weather_rows = await get_cached_weather_rows(user_id, valid_weather_keys)
        sections["weather"] = [w.to_dict() for w in weather_rows]
    except Exception:
        logger.exception("daily_prep weather section failed for user %d", user_id)
        sections["weather"] = []

    try:
        projects = await list_projects(user_id=user_id, status="active")
        sections["projects"] = [
            {
                "id": p.id,
                "title": p.title,
                "auto_summary": p.auto_summary,
            }
            for p in projects[:5]
        ]
    except Exception:
        logger.exception("daily_prep projects section failed for user %d", user_id)
        sections["projects"] = []

    try:
        sections["recent_moments"] = await search_journal(
            user_id=user_id,
            date_from=day_date - datetime.timedelta(days=3),
            date_to=day_date - datetime.timedelta(days=1),
            limit=10,
        )
    except Exception:
        logger.exception("daily_prep recent_moments section failed for user %d", user_id)
        sections["recent_moments"] = []

    try:
        sections["open_threads"] = await _open_threads(user_id=user_id, day_date=day_date)
    except Exception:
        logger.exception("daily_prep open_threads section failed for user %d", user_id)
        sections["open_threads"] = []

    return sections


async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
    """Heuristic: moments from the last 7 days that look unresolved.

    Treated as 'unresolved' when they have no linked tasks/notes and aren't
    pinned. Starting heuristic — refine empirically.
    """
    candidates = await search_journal(
        user_id=user_id,
        date_from=day_date - datetime.timedelta(days=7),
        date_to=day_date - datetime.timedelta(days=1),
        limit=50,
    )
    return [
        m for m in candidates
        if not m.get("task_ids")
        and not m.get("note_ids")
        and not m.get("pinned")
    ]


def _render_task_line(t: dict, *, include_due: bool, include_overdue: bool) -> str:
    line = f"  - {t.get('title', '?')}"
    if include_overdue and t.get("days_overdue"):
        n = t["days_overdue"]
        unit = "day" if n == 1 else "days"
        line += f" (was due {t['due_date']}, {n} {unit} overdue)"
    elif include_due and t.get("due_date"):
        line += f" (due {t['due_date']})"
    if t.get("priority") and t["priority"] not in (None, "none"):
        line += f" [{t['priority']} priority]"
    if t.get("status") == "in_progress":
        line += " [in progress]"
    return line


def _render_sections_for_prompt(sections: dict) -> str:
    """Render the gathered sections as a structured plain-text block for the LLM."""
    lines: list[str] = []

    due_today = sections.get("tasks_due_today") or []
    upcoming = sections.get("tasks_upcoming") or []
    overdue = sections.get("tasks_overdue") or []
    if due_today:
        lines.append("TASKS DUE TODAY:")
        for t in due_today[:8]:
            lines.append(_render_task_line(t, include_due=False, include_overdue=False))
        lines.append("")
    if upcoming:
        lines.append("UPCOMING TASKS (next 7 days):")
        for t in upcoming[:8]:
            lines.append(_render_task_line(t, include_due=True, include_overdue=False))
        lines.append("")
    if overdue:
        lines.append("OVERDUE TASKS (past their due date, still open — backlog, not today's work):")
        for t in overdue[:8]:
            lines.append(_render_task_line(t, include_due=False, include_overdue=True))
        lines.append("")

    events = sections.get("events") or []
    if events:
        lines.append("CALENDAR EVENTS TODAY:")
        for e in events[:8]:
            title = e.get("title", "Untitled")
            when = e.get("start_dt", "?")
            location = e.get("location") or ""
            line = f"  - {title} at {when}"
            if location:
                line += f" ({location})"
            lines.append(line)
        lines.append("")

    weather = sections.get("weather") or []
    if weather:
        lines.append("WEATHER:")
        for w in weather:
            label = w.get("location_label") or w.get("location_key") or "Location"
            forecast_json = w.get("forecast_json") or {}
            daily = forecast_json.get("daily") or {}
            today_max = (daily.get("temperature_2m_max") or [None])[0]
            today_min = (daily.get("temperature_2m_min") or [None])[0]
            precip = (daily.get("precipitation_probability_max") or [None])[0]
            bits = [label]
            if today_max is not None and today_min is not None:
                bits.append(f"high {today_max}° / low {today_min}°")
            if precip is not None:
                bits.append(f"{precip}% chance of precipitation")
            lines.append("  - " + ", ".join(bits))
        lines.append("")
    else:
        # Explicit absent-marker. A silently-omitted weather block leaves a
        # small model an unanchored void it tends to fill with plausible
        # fabricated weather (observed: invented "68°F, 15% rain" with an
        # empty weather section). A concrete "none" line + directive holds
        # far better than relying on a negative system-prompt rule alone.
        lines.append(
            "WEATHER: none available — no weather, temperature, or precipitation "
            "data exists for today. Do NOT mention weather in any form."
        )
        lines.append("")

    projects = sections.get("projects") or []
    if projects:
        lines.append("ACTIVE PROJECTS:")
        for p in projects[:5]:
            line = f"  - {p.get('title', '?')}"
            if p.get("auto_summary"):
                summary = p["auto_summary"][:160]
                line += f" — {summary}"
            lines.append(line)
        lines.append("")

    recent_moments = sections.get("recent_moments") or []
    if recent_moments:
        lines.append("RECENT JOURNAL MOMENTS (last few days):")
        for m in recent_moments[:8]:
            day = m.get("day_date", "?")
            content = (m.get("content") or "").strip()
            lines.append(f"  - [{day}] {content}")
        lines.append("")

    open_threads = sections.get("open_threads") or []
    if open_threads:
        lines.append("OPEN THREADS (mentioned recently but not resolved):")
        for m in open_threads[:5]:
            day = m.get("day_date", "?")
            content = (m.get("content") or "").strip()
            lines.append(f"  - [{day}] {content}")
        lines.append("")

    # The weather-none marker is always emitted, so `lines` is never empty;
    # a quiet day is one with no *substantive* sections beyond that marker.
    substantive = [ln for ln in lines if ln and not ln.startswith("WEATHER: none")]
    if not substantive:
        return (
            "(No tasks, events, or notable data for today — a quiet day. "
            "Do not invent weather or any other details.)"
        )
    return "\n".join(lines).rstrip()


_PREP_SYSTEM_PROMPT = (
    "You are briefing the user on their day. Direct and informative — tell them what's "
    "actually on their plate so they can step into the day with a clear picture.\n\n"
    "Rules:\n"
    "- LEAD with the practical data: tasks due today, calendar events, weather.\n"
    "- Be specific and concrete. Use real task titles, event times, temperatures, "
    "precipitation chances. Don't paraphrase data into vague summaries.\n"
    "- Write in flowing sentences — no markdown, no bullet points, no headers — but "
    "keep the prose factual and useful, not sentimental.\n"
    "- 4 to 7 sentences total. Tight. No padding, no flowery openings, no \"Good morning\" "
    "greetings unless the actual content warrants two clauses' worth.\n"
    "- TASK BUCKETS — three sections may appear: TASKS DUE TODAY, UPCOMING TASKS, "
    "OVERDUE TASKS. Lead with TASKS DUE TODAY when present. Do NOT call overdue items "
    "\"due today\" — they aren't. When OVERDUE TASKS appears, state the overdue "
    "duration exactly as given (e.g. \"3 days overdue\") and frame it as backlog to "
    "revisit, not as today's work. Do NOT echo the parenthetical section labels "
    "verbatim. If the only data is overdue, lead with it but frame it as a backlog "
    "reminder.\n"
    "- If RECENT JOURNAL MOMENTS or OPEN THREADS are present, mention one or two BRIEFLY "
    "at the end as context — not as the lead. Skip them if nothing notable.\n"
    "- Close with one short invitation to journal: \"What's on your mind?\", "
    "\"Anything to set down?\", \"How's the morning shaping up?\" — pick one, keep it under 8 words.\n"
    "- Use ONLY the data below. A category marked 'none' (or absent) genuinely has no "
    "data — do not invent it and do not mention it. In particular, NEVER state weather, "
    "temperature, or precipitation unless an explicit WEATHER section with numbers "
    "appears below.\n"
    "- Voice is competent assistant briefing the user. Not a friend writing a letter."
)


def _fallback_prep_text(day_date: datetime.date) -> str:
    """If the LLM call fails, return a minimal greeting so the user still sees something."""
    weekday = day_date.strftime("%A")
    return f"{weekday}, {day_date.isoformat()}. What's on your mind?"


# Strong, low-false-positive weather signals. Deliberately NOT bare words like
# "rain"/"sunny"/"weather" (those legitimately appear in task/event titles —
# "buy rain boots"). Targets the concrete phrasings small models actually
# emit when fabricating ("partly cloudy with a high of 68°F and a 15% chance
# of rain"): temperature glyphs, "high/low of N", "chance of <precip>",
# "(partly|mostly) (cloudy|sunny)", "overcast", "precipitation", "forecast".
_WEATHER_SIGNAL_RE = re.compile(
    r"""
      \d{1,3}\s?°
    | \b\d{1,3}\s?°?\s?(?:degrees|fahrenheit|celsius)\b
    | \b(?:high|low)\s+of\s+\d
    | \bchance\s+of\s+(?:rain|showers?|precipitation|snow|sleet|storms?|thunder)
    | \b(?:partly|mostly)\s+(?:cloudy|sunny)\b
    | \bovercast\b
    | \bprecipitation\b
    | \bforecast\b
    """,
    re.IGNORECASE | re.VERBOSE,
)


def _prose_fabricated_weather(prose: str, sections: dict) -> bool:
    """True when the prose talks weather but no weather data was gathered.

    The deterministic backstop for the system-prompt rule: an 8–14B model
    still invents weather on quiet days even when told not to. If the
    WEATHER section is genuinely empty and the prose trips a strong weather
    signal, that text is fabricated.
    """
    if sections.get("weather"):
        return False
    return bool(_WEATHER_SIGNAL_RE.search(prose or ""))


async def _generate_prep_prose(
    *,
    sections: dict,
    day_date: datetime.date,
    user_id: int,
) -> str:
    """Ask the LLM for a direct conversational journal opener built from the sections."""
    from fabledassistant.services.llm import generate_completion

    model = (await get_setting(user_id, "default_model", "")) or Config.OLLAMA_MODEL
    if not model:
        logger.warning("No LLM model configured for daily prep — using fallback text")
        return _fallback_prep_text(day_date)

    rendered = _render_sections_for_prompt(sections)
    user_trigger = (
        f"Today is {day_date.strftime('%A, %B %-d, %Y')} ({day_date.isoformat()}).\n\n"
        f"Here is what I gathered for you:\n\n{rendered}\n\n"
        f"Write the opener for today's journal."
    )

    _WEATHER_CORRECTION = (
        "\n\nIMPORTANT: there is NO weather data for today. Do not mention "
        "weather, temperature, sky conditions, or precipitation in any form."
    )

    # Up to 2 attempts: if the first trips the fabricated-weather guard, retry
    # once with an explicit corrective appended to the user turn. A 14B model
    # almost always complies on the corrected pass; if it still doesn't we log
    # and accept (surgically excising a sentence risks breaking prose flow —
    # better a rare stray clause than mangled output, and the log lets us
    # measure whether a model bump is actually warranted).
    prose = ""
    for attempt in (1, 2):
        trigger = user_trigger
        if attempt == 2:
            trigger = user_trigger + _WEATHER_CORRECTION
        messages = [
            {"role": "system", "content": _PREP_SYSTEM_PROMPT},
            {"role": "user", "content": trigger},
        ]
        try:
            raw = await generate_completion(
                messages=messages,
                model=model,
                max_tokens=400,
            )
        except Exception:
            logger.exception("Daily prep prose generation failed for day %s", day_date)
            return _fallback_prep_text(day_date)

        prose = (raw or "").strip()
        if not prose:
            logger.warning("LLM returned empty prep prose for day %s — using fallback", day_date)
            return _fallback_prep_text(day_date)

        if not _prose_fabricated_weather(prose, sections):
            return prose

        if attempt == 1:
            logger.warning(
                "daily_prep: fabricated weather detected for day %s (no weather "
                "data gathered) — regenerating with corrective", day_date,
            )
        else:
            logger.error(
                "daily_prep: weather still fabricated after corrective retry "
                "for day %s — accepting prose as-is", day_date,
            )
    return prose


async def ensure_daily_prep_message(
    *,
    user_id: int,
    day_date: datetime.date,
    user_timezone: str,
    force: bool = False,
) -> Message:
    """Get or create today's journal Conversation, then ensure the prep message exists.

    The prep message is an *assistant* role message containing the prose opener,
    with the structured sections preserved on ``msg_metadata``. If a legacy
    system-role prep exists from an earlier version, it gets upgraded in place
    on the next call.

    With ``force=True`` the prose is regenerated even when a prep already exists.
    Used by the manual /api/journal/trigger-prep endpoint.
    """
    async with async_session() as session:
        result = await session.execute(
            select(Conversation).where(
                Conversation.user_id == user_id,
                Conversation.conversation_type == "journal",
                Conversation.day_date == day_date,
            )
        )
        conv = result.scalar_one_or_none()
        if conv is None:
            conv = Conversation(
                user_id=user_id,
                conversation_type="journal",
                day_date=day_date,
                title=day_date.isoformat(),
            )
            session.add(conv)
            await session.flush()

        # Find any existing prep (system or assistant role from any version).
        prep_stmt = select(Message).where(Message.conversation_id == conv.id)
        existing_prep = None
        for msg in (await session.execute(prep_stmt)).scalars():
            if msg.msg_metadata and msg.msg_metadata.get("kind") == "daily_prep":
                existing_prep = msg
                break

        # If we already have an assistant-role prep with prose content and the
        # caller didn't ask to force regeneration, we're done.
        if (
            existing_prep
            and not force
            and existing_prep.role == "assistant"
            and (existing_prep.content or "").strip()
        ):
            return existing_prep

        sections = await gather_daily_sections(
            user_id=user_id, day_date=day_date, user_timezone=user_timezone
        )
        prose = await _generate_prep_prose(
            sections=sections, day_date=day_date, user_id=user_id
        )
        new_metadata = {"kind": "daily_prep", "sections": sections}

        if existing_prep:
            # Upgrade in place: bump role, replace content + metadata.
            existing_prep.role = "assistant"
            existing_prep.content = prose
            existing_prep.msg_metadata = new_metadata
            await session.commit()
            return existing_prep

        prep_msg = Message(
            conversation_id=conv.id,
            role="assistant",
            content=prose,
            msg_metadata=new_metadata,
        )
        session.add(prep_msg)
        await session.commit()
        return prep_msg


# Backwards-compat alias — older imports may use the old name.
generate_daily_prep = gather_daily_sections
