"""HTTP endpoints for the Journal feature.

Includes the conversational journal endpoints (config / today / day / days /
trigger-prep / moments) plus the ambient-context surface lifted from the
old Briefing routes (RSS feeds, weather, news, RSS reactions, article-discuss).
The ambient endpoints read locations + temp_unit + topic preferences from the
``journal_config`` user setting.
"""
from __future__ import annotations

import asyncio
import datetime
import json
import logging
from zoneinfo import ZoneInfo

from quart import Blueprint, jsonify, request
from sqlalchemy import select

from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.models import Conversation, Message, async_session
from fabledassistant.services import weather as weather_svc
from fabledassistant.services.journal_prep import ensure_daily_prep_message
from fabledassistant.services.journal_scheduler import (
    DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG,
    update_user_schedule,
)
from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.moments import delete_moment, update_moment
from fabledassistant.services.settings import get_setting, set_setting

logger = logging.getLogger(__name__)

journal_bp = Blueprint("journal", __name__, url_prefix="/api/journal")


def _resolve_tz(tz_str: str) -> ZoneInfo:
    try:
        return ZoneInfo(tz_str)
    except Exception:
        return ZoneInfo("UTC")


def _today_in_tz(tz_str: str, *, day_rollover_hour: int) -> datetime.date:
    tz = _resolve_tz(tz_str)
    now = datetime.datetime.now(tz)
    if now.hour < day_rollover_hour:
        return (now - datetime.timedelta(days=1)).date()
    return now.date()


async def _user_timezone(user_id: int) -> str:
    return await get_setting(user_id, "user_timezone", "UTC") or "UTC"


async def _resolve_config(user_id: int) -> dict:
    raw = await get_setting(user_id, "journal_config", "")
    config: dict = {}
    if raw:
        try:
            parsed = json.loads(raw) if isinstance(raw, str) else raw
            if isinstance(parsed, dict):
                config = parsed
        except Exception:
            logger.warning("Invalid journal_config for user %d", user_id)
    return {**DEFAULT_JOURNAL_CONFIG, **config}


def _valid_location_keys(cfg: dict) -> set[str]:
    """Keys in ``cfg.locations`` that have a usable lat/lon. Anything else
    (orphaned cache rows, locations the user typed but didn't geocode) is
    excluded so it can't render as a fake site in the UI."""
    locations = cfg.get("locations") or {}
    return {
        key for key, loc in locations.items()
        if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
    }


@journal_bp.get("/config")
@login_required
async def get_config():
    user_id = get_current_user_id()
    return jsonify(await _resolve_config(user_id))


@journal_bp.put("/config")
@login_required
async def put_config():
    user_id = get_current_user_id()
    body = await request.get_json()
    if not isinstance(body, dict):
        return jsonify({"error": "config must be an object"}), 400
    await set_setting(user_id, "journal_config", json.dumps(body))
    await update_user_schedule(user_id)

    # Trigger a background weather refresh for any newly-saved location with
    # valid lat/lon. Without this, the cache row for the location doesn't
    # exist (or stays stale) until the user clicks the manual refresh button,
    # so the journal weather panel renders empty for newly-entered sites.
    valid_locs = [
        (key, loc)
        for key, loc in (body.get("locations") or {}).items()
        if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
    ]
    if valid_locs:
        asyncio.create_task(_refresh_locations_in_background(user_id, valid_locs))

    return jsonify({"ok": True})


async def _refresh_locations_in_background(
    user_id: int, locations: list[tuple[str, dict]]
) -> None:
    for key, loc in locations:
        try:
            await weather_svc.refresh_location_cache(
                user_id=user_id,
                location_key=key,
                location_label=loc.get("label", key),
                lat=loc["lat"],
                lon=loc["lon"],
            )
        except Exception:
            logger.warning(
                "Post-save weather refresh failed for user %d / %s",
                user_id, key, exc_info=True,
            )


@journal_bp.get("/today")
@login_required
async def get_today():
    user_id = get_current_user_id()
    config = await _resolve_config(user_id)
    tz_str = await _user_timezone(user_id)
    today = _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))

    await ensure_daily_prep_message(
        user_id=user_id, day_date=today, user_timezone=tz_str
    )
    return await _day_payload(user_id=user_id, day_date=today)


@journal_bp.get("/day/<iso_date>")
@login_required
async def get_day(iso_date: str):
    user_id = get_current_user_id()
    try:
        day = datetime.date.fromisoformat(iso_date)
    except ValueError:
        return jsonify({"error": "invalid date"}), 400
    return await _day_payload(user_id=user_id, day_date=day)


@journal_bp.get("/days")
@login_required
async def list_days():
    user_id = get_current_user_id()
    async with async_session() as session:
        stmt = (
            select(Conversation.day_date)
            .where(
                Conversation.user_id == user_id,
                Conversation.conversation_type == "journal",
                Conversation.day_date.is_not(None),
            )
            .order_by(Conversation.day_date.desc())
        )
        rows = (await session.execute(stmt)).scalars().all()
    return jsonify({"days": [d.isoformat() for d in rows]})


@journal_bp.post("/curator/run/<int:conv_id>")
@login_required
async def trigger_curator_run(conv_id: int):
    """Manually run the journal curator over a conversation.

    The curator reads recent messages and fires tool calls (record_moment,
    update_task, etc.) the chat model can't (chat models have tools=[]).
    Returns a summary of what was captured.

    See services/curator.py for the architectural background.
    """
    user_id = get_current_user_id()

    # Confirm the conversation belongs to this user (curator runs against
    # arbitrary conv_ids would otherwise leak data across tenants).
    from sqlalchemy import select as _select
    from fabledassistant.models import async_session as _async_session
    from fabledassistant.models.conversation import Conversation as _Conversation
    async with _async_session() as _sess:
        _res = await _sess.execute(
            _select(_Conversation).where(
                _Conversation.id == conv_id,
                _Conversation.user_id == user_id,
            )
        )
        if _res.scalar_one_or_none() is None:
            return jsonify({"error": "Conversation not found"}), 404

    from fabledassistant.services.curator import run_curator_for_conversation
    result = await run_curator_for_conversation(conv_id)

    # Stamp last_curator_run_at on success so the scheduler doesn't
    # immediately re-process the same conversation on its next sweep.
    # Errored runs intentionally leave the timestamp alone so the
    # scheduler retries them. Persist the curator's summary too when
    # non-empty (Phase 3 feedback loop) — empty summary keeps the
    # existing one rather than clobbering useful context.
    if not result.error:
        import datetime as _dt
        from sqlalchemy import update as _update
        _values: dict = {"last_curator_run_at": _dt.datetime.now(_dt.timezone.utc)}
        if result.summary:
            _values["curator_summary"] = result.summary.strip()[:240]
        async with _async_session() as _sess:
            await _sess.execute(
                _update(_Conversation).where(_Conversation.id == conv_id).values(**_values)
            )
            await _sess.commit()

    return jsonify(result.to_dict())


@journal_bp.post("/trigger-prep")
@login_required
async def trigger_prep():
    user_id = get_current_user_id()
    body = await request.get_json(silent=True) or {}
    iso_date = body.get("date")
    config = await _resolve_config(user_id)
    tz_str = await _user_timezone(user_id)
    day = (
        datetime.date.fromisoformat(iso_date)
        if iso_date
        else _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
    )
    msg = await ensure_daily_prep_message(
        user_id=user_id, day_date=day, user_timezone=tz_str, force=True
    )
    return jsonify({"ok": True, "message_id": msg.id})


@journal_bp.get("/moments")
@login_required
async def list_moments():
    user_id = get_current_user_id()
    args = request.args
    df = args.get("date_from")
    dt = args.get("date_to")
    person_id = args.get("person_id", type=int)
    place_id = args.get("place_id", type=int)
    tag = args.get("tag")
    query = args.get("query")
    pinned_only = args.get("pinned_only", "false").lower() == "true"
    limit = args.get("limit", default=50, type=int)

    results = await search_journal(
        user_id=user_id,
        query=query,
        person_id=person_id,
        place_id=place_id,
        tag=tag,
        date_from=datetime.date.fromisoformat(df) if df else None,
        date_to=datetime.date.fromisoformat(dt) if dt else None,
        limit=limit,
    )
    if pinned_only:
        results = [r for r in results if r.get("pinned")]
    return jsonify({"moments": results})


@journal_bp.patch("/moments/<int:moment_id>")
@login_required
async def patch_moment(moment_id: int):
    user_id = get_current_user_id()
    body = await request.get_json()
    if not isinstance(body, dict):
        return jsonify({"error": "body must be an object"}), 400
    moment = await update_moment(
        user_id=user_id,
        moment_id=moment_id,
        content=body.get("content"),
        tags=body.get("tags"),
        pinned=body.get("pinned"),
        person_ids=body.get("person_ids"),
        place_ids=body.get("place_ids"),
        task_ids=body.get("task_ids"),
        note_ids=body.get("note_ids"),
    )
    if moment is None:
        return jsonify({"error": "not found"}), 404
    return jsonify(moment.to_dict())


@journal_bp.delete("/moments/<int:moment_id>")
@login_required
async def remove_moment(moment_id: int):
    user_id = get_current_user_id()
    deleted = await delete_moment(user_id=user_id, moment_id=moment_id)
    if not deleted:
        return jsonify({"error": "not found"}), 404
    return jsonify({"ok": True})


# ───────────────────────────────────────────────────────────────────────────────
# Ambient endpoints (lifted from the old briefing surface).
# ───────────────────────────────────────────────────────────────────────────────


async def _journal_temp_unit(user_id: int) -> str:
    cfg = await _resolve_config(user_id)
    unit = cfg.get("temp_unit", "C")
    return unit if unit in ("C", "F") else "C"


# ── Weather ───────────────────────────────────────────────────────────────────


_STALE_THRESHOLD_SECONDS = 4 * 3600  # 4 hours — start refreshing well before the 7-day forecast window slides past today


def _is_stale(cache_row) -> bool:
    if cache_row is None or cache_row.fetched_at is None:
        return True
    age = (datetime.datetime.now(datetime.timezone.utc) - cache_row.fetched_at).total_seconds()
    return age > _STALE_THRESHOLD_SECONDS


async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> None:
    """Best-effort refresh of stale cache rows. Silently no-ops if the user's
    config has no usable lat/lon for a given location_key."""
    cfg = await _resolve_config(user_id)
    locations = cfg.get("locations") or {}
    for key in stale_keys:
        loc = locations.get(key)
        if not loc or not loc.get("lat") or not loc.get("lon"):
            continue
        try:
            await weather_svc.refresh_location_cache(
                user_id=user_id,
                location_key=key,
                location_label=loc.get("label", key),
                lat=loc["lat"],
                lon=loc["lon"],
            )
        except Exception:
            logger.warning("Background weather refresh failed for user %d / %s", user_id, key, exc_info=True)


@journal_bp.get("/weather")
@login_required
async def get_weather():
    user_id = get_current_user_id()
    cfg = await _resolve_config(user_id)
    valid_keys = _valid_location_keys(cfg)
    rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
    temp_unit = await _journal_temp_unit(user_id)

    # Kick off a best-effort background refresh for stale rows so the next page
    # load gets fresh data; we still serve whatever's currently cached now.
    stale_keys = {row.location_key for row in rows if _is_stale(row)}
    if stale_keys:
        asyncio.create_task(_refresh_stale_in_background(user_id, stale_keys))

    cards = [
        card for row in rows
        if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
    ]
    return jsonify({"locations": cards, "temp_unit": temp_unit})


@journal_bp.get("/weather/current")
@login_required
async def get_current_weather():
    """Live current temperature + conditions for the user's primary location."""
    user_id = get_current_user_id()
    cfg = await _resolve_config(user_id)
    temp_unit = await _journal_temp_unit(user_id)
    locations = cfg.get("locations") or {}
    loc = locations.get("home") or locations.get("work")
    if not loc or not loc.get("lat") or not loc.get("lon"):
        return jsonify({"error": "No location configured"}), 404

    current = await weather_svc.fetch_current_conditions(loc["lat"], loc["lon"])
    if current is None:
        return jsonify({"error": "Failed to fetch current conditions"}), 502

    temp = current["temperature"]
    if temp is not None and temp_unit == "F":
        temp = temp * 9 / 5 + 32
    current["temperature"] = round(temp) if temp is not None else None
    current["temp_unit"] = temp_unit
    current["location"] = loc.get("label") or "Home"
    return jsonify(current)


@journal_bp.post("/weather/refresh")
@login_required
async def refresh_weather():
    user_id = get_current_user_id()
    cfg = await _resolve_config(user_id)
    temp_unit = await _journal_temp_unit(user_id)
    for key, loc in (cfg.get("locations") or {}).items():
        if not loc.get("lat") or not loc.get("lon"):
            continue
        try:
            await weather_svc.refresh_location_cache(
                user_id=user_id,
                location_key=key,
                location_label=loc.get("label", key),
                lat=loc["lat"],
                lon=loc["lon"],
            )
        except Exception:
            logger.warning("Failed to refresh weather for %s", key, exc_info=True)
    valid_keys = _valid_location_keys(cfg)
    rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
    cards = [
        card for row in rows
        if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
    ]
    return jsonify({"locations": cards, "temp_unit": temp_unit})


@journal_bp.post("/weather/geocode")
@login_required
async def geocode_location():
    data = await request.get_json()
    query = (data.get("query") or "").strip()
    if not query:
        return jsonify({"error": "query required"}), 400
    try:
        lat, lon, label = await weather_svc.geocode(query)
        return jsonify({"lat": lat, "lon": lon, "label": label})
    except ValueError as e:
        return jsonify({"error": str(e)}), 404


async def _day_payload(*, user_id: int, day_date: datetime.date):
    async with async_session() as session:
        conv_stmt = select(Conversation).where(
            Conversation.user_id == user_id,
            Conversation.conversation_type == "journal",
            Conversation.day_date == day_date,
        )
        conv = (await session.execute(conv_stmt)).scalar_one_or_none()
        if conv is None:
            return jsonify({
                "day_date": day_date.isoformat(),
                "conversation": None,
                "messages": [],
            })

        msgs_stmt = (
            select(Message)
            .where(Message.conversation_id == conv.id)
            .order_by(Message.created_at)
        )
        messages = (await session.execute(msgs_stmt)).scalars().all()
        # conv.to_dict() recomputes message_count from the `messages`
        # relationship, which isn't eager-loaded here, so it would report 0.
        # We already have the real list — override with the known count, same
        # convention the chat-list path uses (services/chat.py).
        conv_dict = conv.to_dict()
        conv_dict["message_count"] = len(messages)
        return jsonify({
            "day_date": day_date.isoformat(),
            "conversation": conv_dict,
            "messages": [m.to_dict() for m in messages],
        })
