"""Pin/unpin and auto-pin operations on NoteVersion.

The autosave-rolling system in `services/note_versions.py` continues to
own the existing rolling-cap behavior; this module adds the manual-pin
API and the daily auto-pin scan.

Tiers (pin_kind values):
  None     → rolling autosave; capped at MAX_VERSIONS, FIFO.
  "auto"   → system-declared via the stability scan; capped at MAX_AUTO_PINS, FIFO.
  "manual" → user-declared; unlimited, never pruned.

Design: docs/superpowers/specs/2026-05-13-note-version-pinning-design.md
"""
from __future__ import annotations

import datetime
import logging
from datetime import timezone

from sqlalchemy import select, text

from scribe.models import async_session
from scribe.models.note_version import NoteVersion

logger = logging.getLogger(__name__)

MAX_AUTO_PINS = 25
AUTO_PIN_STABILITY_DAYS = 2
PIN_LABEL_MAX_LEN = 500


async def pin_version(
    user_id: int, note_id: int, version_id: int, *, label: str | None,
) -> NoteVersion | None:
    """Mark a version as manually pinned. Returns the updated row or None
    if not found (or wrong user/note scope).

    Acceptable on already-pinned rows (manual or auto) — promotes to
    manual and updates the label. Labels are capped at PIN_LABEL_MAX_LEN
    chars; longer values raise ValueError.
    """
    if label is not None and len(label) > PIN_LABEL_MAX_LEN:
        raise ValueError(
            f"pin_label too long ({len(label)} > {PIN_LABEL_MAX_LEN} chars)"
        )
    async with async_session() as session:
        version = (
            await session.execute(
                select(NoteVersion).where(
                    NoteVersion.id == version_id,
                    NoteVersion.note_id == note_id,
                    NoteVersion.user_id == user_id,
                )
            )
        ).scalars().first()
        if version is None:
            return None
        version.pin_kind = "manual"
        version.pin_label = label
        await session.commit()
        await session.refresh(version)
        return version


async def unpin_version(
    user_id: int, note_id: int, version_id: int,
) -> NoteVersion | None:
    """Clear pin_kind and pin_label, downgrading the row to rolling.

    Does NOT delete the row. If the row is older than the rolling cap
    depth, the next create_version call will prune it via the rolling
    FIFO. Returns the updated row or None if not found.
    """
    async with async_session() as session:
        version = (
            await session.execute(
                select(NoteVersion).where(
                    NoteVersion.id == version_id,
                    NoteVersion.note_id == note_id,
                    NoteVersion.user_id == user_id,
                )
            )
        ).scalars().first()
        if version is None:
            return None
        version.pin_kind = None
        version.pin_label = None
        await session.commit()
        await session.refresh(version)
        return version


def _format_auto_pin_label(
    start: datetime.datetime, end: datetime.datetime | None,
) -> str:
    """Auto-generated label describing the stability window.

    end=None → version is the latest with no successor; render as
    "stable since {start_iso}". Otherwise render as
    "stable {start_iso} → {end_iso}".
    """
    s = start.date().isoformat()
    if end is None:
        return f"stable since {s}"
    return f"stable {s} → {end.date().isoformat()}"


def _promote_stable_versions_for_note(versions_chrono: list) -> list:
    """Mutate the input list: set pin_kind='auto' + auto-label on any
    unpinned version whose gap to its successor (or to now, for the
    latest) is >= AUTO_PIN_STABILITY_DAYS.

    Returns the list of versions that were newly pinned (caller uses
    this for logging / counts).
    """
    now = datetime.datetime.now(timezone.utc)
    newly_pinned: list = []
    for i, v in enumerate(versions_chrono):
        if v.pin_kind is not None:
            continue
        if i + 1 < len(versions_chrono):
            next_ts = versions_chrono[i + 1].created_at
            is_latest = False
        else:
            next_ts = now
            is_latest = True
        v_ts = v.created_at
        if v_ts.tzinfo is None:
            v_ts = v_ts.replace(tzinfo=timezone.utc)
        if next_ts.tzinfo is None:
            next_ts = next_ts.replace(tzinfo=timezone.utc)
        gap_days = (next_ts - v_ts).total_seconds() / 86400
        if gap_days >= AUTO_PIN_STABILITY_DAYS:
            v.pin_kind = "auto"
            v.pin_label = _format_auto_pin_label(
                v_ts, None if is_latest else next_ts,
            )
            newly_pinned.append(v)
    return newly_pinned


async def _list_user_note_ids_with_versions(user_id: int) -> list[int]:
    """Return note_ids belonging to user_id that have at least one row in
    note_versions. Notes with no version history are ignored."""
    async with async_session() as session:
        rows = (
            await session.execute(
                text(
                    """
                    SELECT DISTINCT note_id FROM note_versions
                    WHERE user_id = :user_id
                    """
                ).bindparams(user_id=user_id)
            )
        ).all()
    return [r[0] for r in rows]


async def _scan_one_note(user_id: int, note_id: int) -> int:
    """Run the promote+prune flow for one note. Returns count of newly-
    pinned versions."""
    async with async_session() as session:
        versions = (
            await session.execute(
                select(NoteVersion)
                .where(
                    NoteVersion.note_id == note_id,
                    NoteVersion.user_id == user_id,
                )
                .order_by(NoteVersion.created_at.asc())
            )
        ).scalars().all()
        if not versions:
            return 0
        newly_pinned = _promote_stable_versions_for_note(list(versions))
        if newly_pinned:
            await session.commit()
    if newly_pinned:
        await prune_auto_pins(user_id=user_id, note_id=note_id)
    return len(newly_pinned)


async def scan_user_for_auto_pins(user_id: int) -> int:
    """Run the scan across every versioned note for one user. Returns
    total newly-pinned-this-pass."""
    total = 0
    note_ids = await _list_user_note_ids_with_versions(user_id)
    for note_id in note_ids:
        try:
            total += await _scan_one_note(user_id, note_id)
        except Exception:
            logger.exception(
                "auto-pin scan failed for user=%d note=%d", user_id, note_id,
            )
    return total


async def scan_all_users_for_auto_pins() -> dict[int, int]:
    """Top-level scan entrypoint. Iterates over all users and runs the
    per-user scan. Returns {user_id: newly_pinned_count}. Per-user errors
    are caught and logged so one user's failure doesn't stop the scan."""
    from scribe.models import User

    async with async_session() as session:
        users = (await session.execute(select(User.id))).scalars().all()

    out: dict[int, int] = {}
    for uid in users:
        try:
            out[uid] = await scan_user_for_auto_pins(uid)
        except Exception:
            logger.exception("auto-pin scan failed for user=%d", uid)
            out[uid] = 0
    return out


async def prune_auto_pins(user_id: int, note_id: int) -> None:
    """FIFO-prune the auto-pinned bucket for one note past MAX_AUTO_PINS.

    Manual pins and rolling rows are untouched. Called by the scan job
    after each note's auto-pin promotions finish.
    """
    async with async_session() as session:
        await session.execute(
            text(
                """
                DELETE FROM note_versions
                WHERE id IN (
                    SELECT id FROM note_versions
                    WHERE note_id = :note_id
                      AND user_id = :user_id
                      AND pin_kind = 'auto'
                    ORDER BY created_at DESC
                    OFFSET :max_auto_pins
                )
                """
            ).bindparams(
                note_id=note_id,
                user_id=user_id,
                max_auto_pins=MAX_AUTO_PINS,
            )
        )
        await session.commit()
