"""§12: the branch-hygiene scheduler.

The structural commitment Slice 8 owes. Closes the loop on §11.5's
branch lifecycle (open → closed at 30d → deleted at 90d) and on §10.7's
post-merge deletion timer for per-RFC PR branches.

The sweep rides next to `DigestScheduler` per the §19.1 brief — same
`start` / `stop` / `run_tick` shape, same hourly cadence by default,
same test seam pattern. The cadence is configurable via
`HYGIENE_TICK_SECONDS` for tests and dev.

Per §15.9 and the §19.1 brief, hygiene actions fire as "the app" —
`actor_user_id = NULL` and `on_behalf_of = bot_login`. The action
kinds (`close_idle_branch`, `delete_stale_branch`,
`delete_post_merge_branch`) are intentionally outside
`notify._AUTO_WATCH_ACTIONS` and `notify._ROUTING`, so no notifications
fan out for the hygiene gestures. The branches being touched are stale
by definition; the affected population would be churn-grade noise per
§15.4.

The per-user message-cursor preservation contract per §11.5: this
module never touches `branch_chat_messages` or `branch_chat_seen`.
Chat history survives the branch's deletion in Gitea because those
tables are app-canonical, not cached.
"""
from __future__ import annotations

import asyncio
import logging
import os
from datetime import datetime, timedelta, timezone

from . import db, projects as projects_mod
from .bot import Bot
from .config import Config

log = logging.getLogger(__name__)


# Window sizes per §11.5 / §12 / §10.7. The 30/90 numbers are the
# canonical spec values; they are exposed as env vars so the integration
# tests can compress windows to small fractions of a second without
# touching production code.
def _close_after_days() -> int:
    return int(os.environ.get("HYGIENE_CLOSE_AFTER_DAYS", "30"))


def _delete_after_days() -> int:
    return int(os.environ.get("HYGIENE_DELETE_AFTER_DAYS", "90"))


# ---------------------------------------------------------------------------
# Scheduler shell — mirrors DigestScheduler
# ---------------------------------------------------------------------------


class HygieneScheduler:
    """Periodic task wrapper that drives `run_tick()`.

    Same lifecycle contract as DigestScheduler so the operator's mental
    model stays "the app has three scheduled jobs, all the same shape"
    (reconciler, digest, hygiene)."""

    def __init__(self, *, config: Config, bot: Bot, tick_seconds: int | None = None):
        self._config = config
        self._bot = bot
        self._tick = tick_seconds or int(os.environ.get("HYGIENE_TICK_SECONDS", "3600"))
        self._task: asyncio.Task | None = None
        self._stop = asyncio.Event()

    def start(self) -> None:
        if self._task is None:
            self._task = asyncio.create_task(self._loop())

    async def stop(self) -> None:
        self._stop.set()
        if self._task is not None:
            await self._task

    async def _loop(self) -> None:
        await self._safe_tick()
        while not self._stop.is_set():
            try:
                await asyncio.wait_for(self._stop.wait(), timeout=self._tick)
            except asyncio.TimeoutError:
                pass
            if self._stop.is_set():
                break
            await self._safe_tick()

    async def _safe_tick(self) -> None:
        try:
            await run_tick(config=self._config, bot=self._bot)
        except Exception:
            log.exception("hygiene tick failed")


# ---------------------------------------------------------------------------
# The tick itself
# ---------------------------------------------------------------------------


async def run_tick(*, config: Config, bot: Bot, now: datetime | None = None) -> dict[str, int]:
    """One pass over the §12 + §10.7 surfaces.

    Returns counters for observability and tests. Idempotent — a second
    tick within the same window is a no-op because the state-flip is
    monotonic (open → closed → deleted).

    Tests pass an explicit `now` to control the time horizon; production
    uses `datetime.now(timezone.utc)`.
    """
    if now is None:
        now = datetime.now(timezone.utc)

    closed_after = timedelta(days=_close_after_days())
    deleted_after = timedelta(days=_delete_after_days())

    close_cutoff = (now - closed_after).strftime("%Y-%m-%d %H:%M:%S")
    delete_cutoff = (now - deleted_after).strftime("%Y-%m-%d %H:%M:%S")

    counters = {
        "closed_idle": 0,
        "closed_post_merge": 0,
        "deleted_stale": 0,
        "deleted_post_merge": 0,
    }

    # Order matters: deletes fire BEFORE closes so a branch that
    # crosses both boundaries in the same sweep (a long-merged PR
    # whose branch is still open in the cache, the cache-bootstrap
    # case the brief calls out) goes straight to 'deleted' rather
    # than spending one tick at 'closed' with a fresh closed_at that
    # would delay the delete by another 90 days. Real-time sweeps see
    # the two windows 60 days apart, so this is only load-bearing for
    # cache-bootstrap and clock-jump cases — but those are exactly the
    # cases this slice hardens against.

    # ---- 90-day delete: §10.7 fast-path for merged-PR branches that
    # never got flipped to 'closed' (cache-bootstrap from history the
    # bot did not author, or a process restart that skipped enough
    # ticks for both boundaries to land in one sweep). ----
    post_merge_delete = db.conn().execute(
        f"""
        SELECT DISTINCT b.rfc_slug, b.branch_name
          FROM cached_branches b
          JOIN cached_prs p
            ON p.rfc_slug = b.rfc_slug
           AND p.head_branch = b.branch_name
         WHERE b.state IN ('open', 'closed')
           AND b.pinned = 0
           AND p.state = 'merged'
           AND COALESCE(p.merged_at, '') != ''
           AND p.merged_at <= ?
        """,
        (delete_cutoff,),
    ).fetchall()
    for r in post_merge_delete:
        ok = await _delete_branch_via_bot(
            config=config, bot=bot,
            slug=r["rfc_slug"], branch=r["branch_name"],
            action_kind="delete_post_merge_branch",
            reason="90d post-merge",
        )
        if ok:
            counters["deleted_post_merge"] += 1

    # ---- 90-day delete: idle branches that closed long enough ago ----
    stale_rows = db.conn().execute(
        f"""
        SELECT b.rfc_slug, b.branch_name, b.closed_at, b.last_commit_at
          FROM cached_branches b
         WHERE b.state = 'closed'
           AND b.pinned = 0
           AND b.branch_name != 'main'
           AND COALESCE(b.closed_at, b.last_commit_at, b.created_at) <= ?
        """,
        (delete_cutoff,),
    ).fetchall()
    for r in stale_rows:
        ok = await _delete_branch_via_bot(
            config=config, bot=bot,
            slug=r["rfc_slug"], branch=r["branch_name"],
            action_kind="delete_stale_branch",
            reason="90d closed",
        )
        if ok:
            counters["deleted_stale"] += 1

    # ---- 30-day close: idle open branches ----
    #
    # §11.5: a branch with no associated PR auto-closes at 30 days from
    # last commit. The query joins against cached_prs to exclude
    # branches that have any open PR (those stay open) or any merged PR
    # (those are handled by the post-merge timer below). Pinned branches
    # (§12) skip the close.
    idle_rows = db.conn().execute(
        f"""
        SELECT b.rfc_slug, b.branch_name
          FROM cached_branches b
         WHERE b.state = 'open'
           AND b.pinned = 0
           AND b.branch_name != 'main'
           AND COALESCE(b.last_commit_at, b.created_at) <= ?
           AND NOT EXISTS (
                 SELECT 1 FROM cached_prs p
                  WHERE p.rfc_slug = b.rfc_slug
                    AND p.head_branch = b.branch_name
                    AND p.state IN ('open', 'merged')
               )
        """,
        (close_cutoff,),
    ).fetchall()
    for r in idle_rows:
        _close_branch(r["rfc_slug"], r["branch_name"], config.gitea_bot_user, reason="30d idle")
        counters["closed_idle"] += 1

    # ---- 30-day "settle": post-merge branches still flagged open ----
    #
    # §10.7: after merge, the branch enters a closed state per §12. In
    # practice the cached_branches row may still read state='open'
    # immediately after the merge (the meta-repo refresh doesn't flip
    # it). At the 30-day mark the hygiene sweep formalizes the closure
    # so the rest of the app reads "this branch is sealed." Pinned
    # branches retain open state.
    post_merge_close = db.conn().execute(
        f"""
        SELECT DISTINCT b.rfc_slug, b.branch_name
          FROM cached_branches b
          JOIN cached_prs p
            ON p.rfc_slug = b.rfc_slug
           AND p.head_branch = b.branch_name
         WHERE b.state = 'open'
           AND b.pinned = 0
           AND p.state = 'merged'
           AND COALESCE(p.merged_at, '') != ''
           AND p.merged_at <= ?
        """,
        (close_cutoff,),
    ).fetchall()
    for r in post_merge_close:
        _close_branch(r["rfc_slug"], r["branch_name"], config.gitea_bot_user, reason="30d post-merge")
        counters["closed_post_merge"] += 1

    return counters


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


def _close_branch(slug: str, branch: str, bot_login: str, *, reason: str) -> None:
    """Flip the cached_branches row to state='closed' and write the
    audit row. No Gitea call — the branch stays present in Gitea until
    the 90-day mark. The `On-behalf-of` shape mirrors the bot's
    `delete_branch` system path: actor_user_id=NULL, on_behalf_of=bot."""
    db.conn().execute(
        """
        UPDATE cached_branches
           SET state = 'closed',
               closed_at = COALESCE(closed_at, datetime('now'))
         WHERE rfc_slug = ? AND branch_name = ? AND state = 'open'
        """,
        (slug, branch),
    )
    db.conn().execute(
        """
        INSERT INTO actions
            (actor_user_id, on_behalf_of, action_kind, rfc_slug, branch_name, details)
        VALUES (NULL, ?, 'close_idle_branch', ?, ?, ?)
        """,
        (bot_login, slug, branch, _json_details({"reason": reason})),
    )


async def _delete_branch_via_bot(
    *,
    config: Config,
    bot: Bot,
    slug: str,
    branch: str,
    action_kind: str,
    reason: str,
) -> bool:
    """Call `bot.delete_branch` with the system actor. Resolves the
    `(org, repo)` pair from the slug: under the meta-only topology (§1)
    every meta-resident entry's edit branches and graduation branches
    live on the meta repo; a legacy per-RFC repo (a `repo:` that survives
    from before the fold-back, §13.6) is named by `cached_rfcs.repo`.

    Returns True on a clean delete; False if the rfc row is missing
    (we leave the branch row in place — a subsequent reconciler sweep
    will reconcile or the operator can intervene)."""
    rfc = db.conn().execute(
        "SELECT state, repo, collection_id FROM cached_rfcs WHERE slug = ?", (slug,)
    ).fetchone()
    if rfc is None:
        log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
        return False
    if not rfc["repo"]:
        # §22/G-15: the edit/graduation branch lives on the entry's COLLECTION's
        # project content_repo, not the deployment default.
        owner, repo, _ = projects_mod.entry_location(config, rfc["collection_id"], slug)
        if not repo:
            log.warning(
                "hygiene: no content_repo resolved; skipping branch delete for %s/%s",
                slug, branch,
            )
            return False
    elif "/" in rfc["repo"]:
        owner, repo = rfc["repo"].split("/", 1)
    else:
        log.warning("hygiene: cannot resolve repo for %s state=%s repo=%r",
                    slug, rfc["state"], rfc["repo"])
        return False
    try:
        await bot.delete_branch(
            None,
            owner=owner,
            repo=repo,
            branch=branch,
            slug=slug,
            action_kind=action_kind,
            reason=reason,
            bot_login=config.gitea_bot_user,
        )
    except Exception:
        log.exception("hygiene: bot.delete_branch failed for %s/%s", slug, branch)
        return False
    db.conn().execute(
        """
        UPDATE cached_branches
           SET state = 'deleted'
         WHERE rfc_slug = ? AND branch_name = ?
        """,
        (slug, branch),
    )
    return True


def _json_details(payload: dict) -> str:
    import json
    return json.dumps(payload)
