"""Watch-mode loop orchestrator (pure — no I/O).

Two entry points (added in later tasks):

* `run_one_cycle` — per-RSID work (fetch → snapshot → diff). Pure-ish; depends
  on the injected `WatchContext` collaborators.
* `run_watch_loop` — the driver. Iterates RSIDs each cycle, calls the gating
  helpers, and sleeps via the injected sleeper. Polls `StopToken` between
  RSIDs and inside the sleep so SIGINT feels responsive.

All real I/O (network, filesystem, signal handlers, wall-clock sleep) lives
in `cli/commands/watch.py`. This module is fully unit-testable with fakes.
"""

from __future__ import annotations

import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Literal, Protocol

from aa_auto_sdr.snapshot.git import GitOpResult, generate_commit_message, git_commit_snapshot
from aa_auto_sdr.snapshot.models import DiffReport

logger = logging.getLogger(__name__)

# --- Stop token ------------------------------------------------------------


class StopToken:
    """A thread-safe stop signal. Set from a signal handler; polled by the loop."""

    def __init__(self) -> None:
        self._event = threading.Event()

    def set(self) -> None:
        self._event.set()

    def is_set(self) -> bool:
        return self._event.is_set()


# --- Collaborator protocols ------------------------------------------------


class Fetcher(Protocol):
    def fetch_snapshot(self, rsid: str) -> Any: ...


class SnapshotStore(Protocol):
    def latest(self, rsid: str) -> dict[str, Any] | None: ...

    def save(self, rsid: str, doc: Any) -> tuple[Path, dict[str, Any]]:
        """Persist the document and return (path, envelope_dict).

        The envelope is the canonical shape compare() consumes; returning it
        alongside the path lets the orchestrator avoid a re-read of the file
        it just wrote. The real adapter (cli/commands/watch.py) calls
        save_snapshot + load_snapshot to populate both."""
        ...


class Clock(Protocol):
    def utcnow(self) -> datetime: ...


class Sleeper(Protocol):
    def sleep(self, seconds: float) -> None: ...


class Emitter(Protocol):
    def emit(self, payload: dict[str, Any]) -> None: ...


class NotionPublisher(Protocol):
    """Publishes an SDR snapshot to Notion. Called by the watch loop after a
    baseline or real-change cycle when ``--format notion`` is active.

    Implementations must not raise; they should log and swallow errors so that
    a transient Notion failure never kills the watch loop.
    """

    def publish(self, *, snapshot_path: Path, rsid: str) -> None: ...


# --- Context ---------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class WatchContext:
    """Injected collaborators + per-loop options. Built once per `--watch` invocation."""

    fetcher: Fetcher
    snapshot_store: SnapshotStore
    clock: Clock
    sleeper: Sleeper
    emitter: Emitter
    ignore_fields: frozenset[str] = field(default_factory=frozenset)
    extended_fields: bool = False
    # v1.15.0 — git integration. snapshot_dir is needed because git_commit_snapshot
    # operates on a directory path; we cannot derive it from snapshot_store alone.
    git_commit: bool = False
    git_push: bool = False
    git_message: str | None = None
    snapshot_dir: Path | None = None
    # v1.20.0 — optional Notion publisher. When set and --format notion is active,
    # the loop publishes the snapshot after baseline and real-change cycles.
    notion_publisher: NotionPublisher | None = None


# --- Cycle result ----------------------------------------------------------


CycleKind = Literal["baseline", "diffed", "fetch_error"]


@dataclass(frozen=True, slots=True)
class CycleResult:
    """Outcome of running one RSID through one cycle. Tagged union over `kind`."""

    kind: CycleKind
    rsid: str
    started_at: datetime
    ended_at: datetime
    snapshot_path: Path | None = None
    diff: DiffReport | None = None
    error: BaseException | None = None
    # v1.15.0 — git operation outcome; None when ctx.git_commit is False
    # or when this cycle didn't get to git (e.g. fetch_error).
    git_op: GitOpResult | None = None

    @classmethod
    def baseline(
        cls,
        *,
        rsid: str,
        snapshot_path: Path,
        started_at: datetime,
        ended_at: datetime,
    ) -> CycleResult:
        return cls(
            kind="baseline",
            rsid=rsid,
            started_at=started_at,
            ended_at=ended_at,
            snapshot_path=snapshot_path,
        )

    @classmethod
    def diffed(
        cls,
        *,
        rsid: str,
        snapshot_path: Path,
        diff: DiffReport,
        started_at: datetime,
        ended_at: datetime,
    ) -> CycleResult:
        return cls(
            kind="diffed",
            rsid=rsid,
            started_at=started_at,
            ended_at=ended_at,
            snapshot_path=snapshot_path,
            diff=diff,
        )

    @classmethod
    def fetch_error(
        cls,
        *,
        rsid: str,
        error: BaseException,
        started_at: datetime,
        ended_at: datetime,
    ) -> CycleResult:
        return cls(
            kind="fetch_error",
            rsid=rsid,
            started_at=started_at,
            ended_at=ended_at,
            error=error,
        )


# --- compare() import + run_one_cycle ------------------------------------

from aa_auto_sdr.snapshot.comparator import compare  # noqa: E402 — import-after-types


def run_one_cycle(*, rsid: str, ctx: WatchContext) -> CycleResult:
    """Run a single watch cycle for one RSID.

    Steps:
      1. Read the prior snapshot envelope dict from the store (None on first cycle).
      2. Fetch the current state via the injected fetcher.
      3. Save the current snapshot. `save()` returns `(path, envelope_dict)` — the
         envelope is the canonical shape compare() expects, so we get it back
         in-memory without a re-read.
      4. If prior was None, return a `baseline` result (no diff).
      5. Otherwise, diff prior_envelope vs current_envelope and return a `diffed`
         result. Both sides are dicts — matches the shape used by --diff and
         v1.13 trending.

    Any non-control-flow exception from ANY step — reading the prior snapshot,
    fetching, saving, diffing — returns a `fetch_error` result so a long-running
    watch survives transient store failures (disk full, one corrupt snapshot on
    disk), not just API failures. KeyboardInterrupt and SystemExit propagate so
    signal handlers can drive a clean shutdown.
    """
    started_at = ctx.clock.utcnow()

    try:
        prev = ctx.snapshot_store.latest(rsid)
        current_doc = ctx.fetcher.fetch_snapshot(rsid)
        snapshot_path, current_envelope = ctx.snapshot_store.save(rsid, current_doc)
        if prev is None:
            return CycleResult.baseline(
                rsid=rsid,
                snapshot_path=snapshot_path,
                started_at=started_at,
                ended_at=ctx.clock.utcnow(),
            )
        diff = compare(
            a=prev,
            b=current_envelope,
            ignore_fields=ctx.ignore_fields,
            extended_fields=ctx.extended_fields,
        )
        return CycleResult.diffed(
            rsid=rsid,
            snapshot_path=snapshot_path,
            diff=diff,
            started_at=started_at,
            ended_at=ctx.clock.utcnow(),
        )
    except KeyboardInterrupt, SystemExit:
        raise
    except Exception as e:  # cycle errors are non-fatal to the loop
        return CycleResult.fetch_error(
            rsid=rsid,
            error=e,
            started_at=started_at,
            ended_at=ctx.clock.utcnow(),
        )


# --- emit gating + payload -------------------------------------------------

from aa_auto_sdr.core.logging import redact_text  # noqa: E402 — scrubs error strings before emit
from aa_auto_sdr.output.watch_event import WATCH_EVENT_SCHEMA  # noqa: E402


def _total_changes(diff: DiffReport) -> int:
    """Sum added + removed + modified across all component types."""
    return sum(len(c.added) + len(c.removed) + len(c.modified) for c in diff.components)


def _should_emit(result: CycleResult, *, threshold: int) -> bool:
    """Decide whether this cycle's result should be emitted as an NDJSON event.

    Rules:
      * baseline / fetch_error → always emit.
      * diffed → emit when total_changes >= threshold. threshold=0 means emit
                 every cycle including zero-change (heartbeat).
    """
    if result.kind in ("baseline", "fetch_error"):
        return True
    assert result.diff is not None  # type guard — kind == "diffed"
    if threshold == 0:
        return True
    return _total_changes(result.diff) >= threshold


def _should_publish(result: CycleResult, *, threshold: int) -> bool:
    """Decide whether to publish this cycle's snapshot to Notion.

    Rules:
      * baseline → always publish (establishes the initial page).
      * fetch_error → never publish (no snapshot available).
      * diffed → publish when total_changes >= max(threshold, 1).
                 Zero-change cycles are never published regardless of threshold
                 because publishing an unchanged SDR is noise.
    """
    if result.kind == "baseline":
        return True
    if result.kind == "fetch_error":
        return False
    assert result.diff is not None  # type guard — kind == "diffed"
    return _total_changes(result.diff) >= max(threshold, 1)


def _iso_z(ts: datetime) -> str:
    """Format a UTC datetime as Z-suffixed ISO-8601 (matches snapshot envelope).

    Defensive: assert the datetime is tz-aware. A naive datetime would silently
    emit a non-Z-suffixed string and break downstream consumers; we want a loud
    failure instead.
    """
    assert ts.tzinfo is not None, "_iso_z requires a tz-aware datetime"
    return ts.isoformat().replace("+00:00", "Z")


def _event_name(kind: CycleKind) -> str:
    return {"baseline": "baseline", "diffed": "change", "fetch_error": "error"}[kind]


def _diff_summary(diff: DiffReport) -> dict[str, Any]:
    """Aggregate counts (added/removed/modified/unchanged) + per-component-type breakdown."""
    added = removed = modified = unchanged = 0
    by_type: dict[str, dict[str, int]] = {}
    for c in diff.components:
        a, r, m = len(c.added), len(c.removed), len(c.modified)
        added += a
        removed += r
        modified += m
        unchanged += c.unchanged_count
        by_type[c.component_type] = {"added": a, "removed": r, "modified": m}
    return {
        "added": added,
        "removed": removed,
        "modified": modified,
        "unchanged": unchanged,
        "by_type": by_type,
    }


def _event_payload(result: CycleResult, *, cycle_n: int) -> dict[str, Any]:
    """Build the NDJSON event payload for a CycleResult.

    Timestamps use Z-suffix. The `error` field is passed through
    `core.logging.redact_text` so tokens / org IDs in API errors don't leak.

    v1.15.0: when `result.git_op` carries a completed commit, attach a `git`
    block to baseline/change events so consumers see the commit_sha in the
    structured stream. Gated on `committed=True` (NOT `ok=True`) so a commit
    followed by a push failure still surfaces the sha — the follow-up error
    event (emitted by `_emit_cycle`) carries the push failure detail.
    """
    base: dict[str, Any] = {
        "schema": WATCH_EVENT_SCHEMA,
        "event": _event_name(result.kind),
        "cycle": cycle_n,
        "rsid": result.rsid,
        "started_at": _iso_z(result.started_at),
        "ended_at": _iso_z(result.ended_at),
    }
    if result.kind == "baseline":
        # POSIX form keeps the NDJSON event contract platform-agnostic — Windows'
        # str(Path) emits backslashes, which downstream consumers should not have
        # to handle.
        base["snapshot_path"] = result.snapshot_path.as_posix() if result.snapshot_path else ""
    elif result.kind == "fetch_error":
        err = result.error
        base["error_type"] = type(err).__name__ if err is not None else "Unknown"
        base["error"] = redact_text(str(err)) if err is not None else ""
    else:
        # diffed
        assert result.diff is not None
        base["snapshot_path"] = result.snapshot_path.as_posix() if result.snapshot_path else ""
        base["summary"] = _diff_summary(result.diff)

    # v1.15.0 — git operation block on snapshot events.
    if result.kind in ("baseline", "diffed") and result.git_op is not None and result.git_op.committed:
        base["git"] = {
            "committed": True,
            "commit_sha": result.git_op.commit_sha,
            "pushed": bool(result.git_op.pushed),
        }

    return base


def _emit_cycle(ctx: WatchContext, result: CycleResult, *, cycle_n: int) -> None:
    """Emit the per-cycle event(s). Most cycles emit one event; when git_op
    failed, we emit the snapshot event first then a follow-up error event."""
    ctx.emitter.emit(_event_payload(result, cycle_n=cycle_n))
    if result.git_op is not None and not result.git_op.ok:
        ctx.emitter.emit(
            {
                "schema": WATCH_EVENT_SCHEMA,
                "event": "error",
                "cycle": cycle_n,
                "rsid": result.rsid,
                "started_at": _iso_z(result.started_at),
                "ended_at": _iso_z(result.ended_at),
                "error_type": result.git_op.error_kind or "GitError",
                "error": redact_text(result.git_op.error_message or ""),
            },
        )


def _maybe_commit(ctx: WatchContext, result: CycleResult, *, cycle_n: int) -> CycleResult:
    """If --git-commit is set and the cycle has a snapshot to commit, run
    git_commit_snapshot and return a new CycleResult with git_op populated.
    Returns the original result unchanged otherwise.

    Called AFTER _should_emit returns True so threshold-suppressed cycles
    don't generate commits the user can't see in the event stream.

    `cycle_n` is threaded into the auto-generated commit footer as
    `(watch cycle <n>)`; a user-supplied `ctx.git_message` is taken verbatim
    (no footer appended) so external automation can pin exact wording.
    """
    if not ctx.git_commit or ctx.snapshot_dir is None:
        return result
    if result.kind not in ("baseline", "diffed"):
        return result
    from dataclasses import replace

    summary = _diff_summary(result.diff) if result.diff is not None else None
    message = ctx.git_message or generate_commit_message(
        rsid=result.rsid,
        captured_at=_iso_z(result.started_at),
        change_summary=summary,
        watch_cycle=cycle_n,
    )
    git_op = git_commit_snapshot(
        ctx.snapshot_dir,
        rsid=result.rsid,
        message=message,
        push=ctx.git_push,
    )
    return replace(result, git_op=git_op)


# --- loop driver -----------------------------------------------------------

from datetime import timedelta  # noqa: E402

from aa_auto_sdr.core.exit_codes import ExitCode  # noqa: E402


def _interruptible_sleep(
    stop: StopToken,
    *,
    until: datetime,
    sleeper: Sleeper,
    clock: Clock,
    poll_seconds: float = 0.25,
) -> None:
    """Sleep until `until` or until `stop` is set, whichever comes first.

    Polls `stop.is_set()` every `poll_seconds` so SIGINT is honored within a
    quarter-second by default.
    """
    while not stop.is_set():
        now = clock.utcnow()
        if now >= until:
            return
        remaining = (until - now).total_seconds()
        slice_ = min(poll_seconds, remaining)
        if slice_ <= 0:
            return
        sleeper.sleep(slice_)


def run_watch_loop(
    *,
    ctx: WatchContext,
    rsids: list[str] | tuple[str, ...],
    interval: timedelta,
    threshold: int,
    stop: StopToken,
    max_cycles: int | None = None,
) -> tuple[ExitCode, int]:
    """Drive the watch loop.

    First cycle runs immediately (no leading sleep). Subsequent cycles sleep
    until `cycle_start + interval`. Returns when:
      * `stop` is set (SIGINT/SIGTERM), or
      * `max_cycles` cycles have completed.

    Per-cycle errors (`fetch_error` results) emit an `error` event and the
    loop continues. SIGINT received between RSIDs aborts the current cycle.

    Returns `(ExitCode.OK, cycles_completed)`. `cycles_completed` is the number
    of cycles that fully ran (including any cycle that exited mid-iteration due
    to stop).
    """
    cycle_n = 0
    while not stop.is_set():
        cycle_started = ctx.clock.utcnow()
        for rsid in rsids:
            if stop.is_set():
                break
            result = run_one_cycle(rsid=rsid, ctx=ctx)
            if _should_emit(result, threshold=threshold):
                result = _maybe_commit(ctx, result, cycle_n=cycle_n)
                _emit_cycle(ctx, result, cycle_n=cycle_n)
            if (
                ctx.notion_publisher is not None
                and result.snapshot_path is not None
                and _should_publish(result, threshold=threshold)
            ):
                try:
                    ctx.notion_publisher.publish(
                        snapshot_path=result.snapshot_path,
                        rsid=rsid,
                    )
                except Exception:
                    logger.warning(
                        "notion_watch_publish_failed rsid=%s",
                        rsid,
                        exc_info=True,
                    )
        cycle_n += 1
        if max_cycles is not None and cycle_n >= max_cycles:
            return ExitCode.OK, cycle_n
        if stop.is_set():
            break
        _interruptible_sleep(
            stop,
            until=cycle_started + interval,
            sleeper=ctx.sleeper,
            clock=ctx.clock,
        )
    return ExitCode.OK, cycle_n
