"""Background inbound MFT polling scheduler (SP16).

Turns Cyclone from a manual upload tool into a live clearinghouse:
a long-running asyncio task that periodically polls the Gainwell MFT
inbound path, downloads each new file, and runs it through the
appropriate parser. The operator no longer has to watch for inbound
files and POST them to ``/api/parse-999`` etc. by hand.

Design constraints
------------------

* **Idempotent.** A re-tick (or a process restart) must not re-parse
  the same inbound file. We persist a ``processed_inbound_files`` row
  per file and skip ones we've already seen.

* **Crash-safe.** If the parser raises or the DB write fails, the
  scheduler logs the error, records an ``error`` row, and moves on.
  The next tick continues from the next file.

* **Bounded blast radius.** A bad file must not stop the scheduler.
  Each file is wrapped in try/except so a 999 parser crash doesn't
  prevent us from processing the next inbound 835.

* **Operator-controlled.** The scheduler is OFF by default; the
  operator must explicitly start it (``POST /api/admin/scheduler/start``
  or ``CYCLONE_SCHEDULER_AUTOSTART=true``). When it's running, status
  is exposed via ``GET /api/admin/scheduler/status``.

* **No threading.** We use ``asyncio.create_task`` + ``asyncio.sleep``
  rather than APScheduler or threading because the rest of the
  codebase is asyncio-native (FastAPI). The whole polling loop runs
  in the FastAPI event loop on the main thread.

Compliance: SP16 is operational metadata only. Inbound file
processing is NOT part of the HIPAA audit chain (SP11) — an SFTP
outage shouldn't pollute the audit log with parser errors.
"""
from __future__ import annotations

import asyncio
import json
import logging
import os
import traceback
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Optional

from sqlalchemy.exc import IntegrityError

from cyclone import db
from cyclone.store import store as cycl_store
from cyclone.audit_log import AuditEvent, append_event
from cyclone.clearhouse import InboundFile, SftpClient
from cyclone.db import ProcessedInboundFile
from cyclone.edi.filenames import parse_inbound_filename
from cyclone.handlers import handle_277ca as _handle_277ca
from cyclone.handlers import handle_835 as _handle_835
from cyclone.handlers import handle_999 as _handle_999
from cyclone.handlers import handle_ta1 as _handle_ta1
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.providers import SftpBlock

log = logging.getLogger(__name__)


# Status values for ProcessedInboundFile.status.
STATUS_OK = "ok"
STATUS_ERROR = "error"
STATUS_SKIPPED = "skipped"
STATUS_PENDING = "pending"


# How long reconfigure_scheduler waits for the in-flight tick to
# drain before cancelling the old task. Matches Scheduler.stop().
_DRAIN_TIMEOUT_SECONDS = 30


# File types we know how to route. The HCPF set is broader (270/271/
# 276/277/278/820/834/ENCR) but Cyclone's parser only covers the
# four below. Files with unknown types are recorded as ``skipped``
# so the operator can see them in the audit table.
ROUTED_FILE_TYPES = frozenset({"999", "835", "277", "277CA", "TA1"})


@dataclass
class TickResult:
    """Outcome of a single scheduler tick (one poll cycle)."""

    started_at: datetime
    finished_at: Optional[datetime] = None
    files_seen: int = 0
    files_processed: int = 0
    files_skipped: int = 0
    files_errored: int = 0
    errors: list[str] = field(default_factory=list)
    # SP27 Task 9: ``sftp_failed`` is the discriminator that separates
    # SFTP-side failures (the listing step) from per-file processing
    # errors. ``tick()`` keys off this flag — NOT ``result.errors`` —
    # so a single malformed 999 cannot flip the operator's destructive
    # pill. Set in ``_tick_impl``'s listing-error branches only.
    sftp_failed: bool = False

    def as_dict(self) -> dict[str, Any]:
        return {
            "started_at": self.started_at.isoformat(),
            "finished_at": (
                self.finished_at.isoformat() if self.finished_at else None
            ),
            "files_seen": self.files_seen,
            "files_processed": self.files_processed,
            "files_skipped": self.files_skipped,
            "files_errored": self.files_errored,
            "errors": list(self.errors),
            "sftp_failed": self.sftp_failed,
        }


@dataclass
class SchedulerStatus:
    """Snapshot of the scheduler's runtime state.

    SP27 Task 9 added the four SFTP-error fields
    (``consecutive_failures``, ``last_error``, ``last_error_at``,
    ``last_sftp_attempt_at``) so the operator's UI can tell "all
    quiet on the MFT front" from "we've been unable to reach the
    MFT server for 3 hours". The previous 06/25 incident went
    unnoticed because the status dict had no signal for a hung
    SFTP poll.
    """

    running: bool
    poll_interval_seconds: int
    sftp_block_name: str
    last_poll_at: Optional[datetime]
    poll_count: int
    total_processed: int
    total_skipped: int
    total_errored: int
    last_tick: Optional[TickResult] = None
    # SP27 Task 9: SFTP-error surfacing. The UI flips to a destructive
    # pill when ``consecutive_failures >= 3`` (``CYCLONE_SCHEDULER_FAIL_THRESHOLD``
    # in the operator pill config; not enforced server-side).
    consecutive_failures: int = 0
    last_error: Optional[str] = None
    last_error_at: Optional[datetime] = None
    last_sftp_attempt_at: Optional[datetime] = None

    def as_dict(self) -> dict[str, Any]:
        return {
            "running": self.running,
            "poll_interval_seconds": self.poll_interval_seconds,
            "sftp_block_name": self.sftp_block_name,
            "last_poll_at": (
                self.last_poll_at.isoformat() if self.last_poll_at else None
            ),
            "poll_count": self.poll_count,
            "total_processed": self.total_processed,
            "total_skipped": self.total_skipped,
            "total_errored": self.total_errored,
            "last_tick": self.last_tick.as_dict() if self.last_tick else None,
            "consecutive_failures": self.consecutive_failures,
            "last_error": self.last_error,
            "last_error_at": (
                self.last_error_at.isoformat() if self.last_error_at else None
            ),
            "last_sftp_attempt_at": (
                self.last_sftp_attempt_at.isoformat()
                if self.last_sftp_attempt_at else None
            ),
        }


# ---------------------------------------------------------------------------
# Per-file-type handlers. Each returns (parser_name, claim_count) and
# persists its own DB rows. The scheduler records the outcome.
# ---------------------------------------------------------------------------
#
# 999 (Task 2), TA1 (Task 3), 277CA (Task 4), and 835 (Task 5) now
# live in ``cyclone.handlers``. The HANDLERS dict literal below
# references the alias imports so the wiring stays identical.


# The 277CA handler has moved to ``cyclone.handlers.handle_277ca``
# (SP27 Task 4); the inline def was deleted. The HANDLERS dict literal
# below still references ``_handle_277ca`` because the import-alias
# line at the top of this module binds that name to the new function.
# Only the 835 handler stays inline (Task 5).


# Map file_type → handler. Mirrors ROUTED_FILE_TYPES.
HANDLERS: dict[str, Callable[[str, str], tuple[str, int]]] = {
    "999": _handle_999,
    "835": _handle_835,
    "277": _handle_277ca,   # filename uses 277; parser is the same
    "277CA": _handle_277ca,
    "TA1": _handle_ta1,
}


# ---------------------------------------------------------------------------
# Light copies of helpers the API endpoints use, so the scheduler can
# Run without depending on the FastAPI module.
#
# Note: ``_277ca_synthetic_source_batch_id`` lived here as a
# scheduler-local duplicate of
# ``cyclone.handlers._ack_id.two77ca_synthetic_source_batch_id`` until
# SP27 Task 4 landed; the inline def has been deleted because
# ``handle_277ca`` now imports the canonical copy. The historical
# helper was the only remaining inline def in this section — the
# surviving inline handler is ``_handle_835`` (lifts in Task 5).


# ---------------------------------------------------------------------------
# Scheduler
# ---------------------------------------------------------------------------


class Scheduler:
    """Background polling loop for inbound MFT files.

    Lifecycle:
        sched = Scheduler(sftp_block, poll_interval_seconds=60)
        await sched.start()       # begin polling
        # ... later ...
        await sched.stop()        # finish current tick, then exit
        status = sched.status()   # snapshot

    The scheduler is a single asyncio task. ``tick()`` does one full
    poll cycle and is exposed for tests + the ``/api/admin/scheduler/tick``
    endpoint so the operator can force a poll without waiting.

    Threading: NOT thread-safe. All access (start/stop/tick/status)
    must happen on the same event loop. The FastAPI app satisfies
    this trivially because endpoints run on the loop.
    """

    def __init__(
        self,
        sftp_block: SftpBlock,
        *,
        poll_interval_seconds: int = 60,
        sftp_block_name: str = "default",
        sftp_client_factory: Optional[Callable[[SftpBlock], Any]] = None,
    ) -> None:
        self._sftp_block = sftp_block
        self._poll_interval = poll_interval_seconds
        self._sftp_block_name = sftp_block_name
        # Factory indirection lets tests substitute a fake client
        # without monkey-patching the module-level SftpClient.
        self._sftp_client_factory = sftp_client_factory or SftpClient

        self._task: Optional[asyncio.Task[None]] = None
        self._stop_event = asyncio.Event()
        self._last_poll_at: Optional[datetime] = None
        self._poll_count = 0
        self._total_processed = 0
        self._total_skipped = 0
        self._total_errored = 0
        self._last_tick: Optional[TickResult] = None
        # Coalesce overlapping ticks (a slow MFT server shouldn't let
        # ticks stack up; the next tick fires only after the previous
        # one finishes).
        self._tick_in_progress = False
        # SP27 Task 9: SFTP-error surfacing. ``_consecutive_failures``
        # counts back-to-back tick failures (cleared on the next
        # successful tick). The other three fields are the most
        # recent failure for the operator's pill — preserved across
        # successes so the audit trail doesn't disappear the moment
        # the MFT server recovers.
        self._consecutive_failures = 0
        self._last_error: Optional[str] = None
        self._last_error_at: Optional[datetime] = None
        self._last_sftp_attempt_at: Optional[datetime] = None

    def _record_sftp_outcome(
        self, *, success: bool, error: Optional[str] = None,
    ) -> None:
        """Update the SFTP-error state after a tick.

        ``success=True`` resets the consecutive-failure counter; the
        last-error fields are preserved as an audit trail (the
        operator's UI can still surface "last failure: 2 hours ago"
        after a recovery).

        ``success=False`` bumps the counter and records the error
        message + timestamp. ``last_sftp_attempt_at`` is bumped in
        both cases so the operator can tell when the scheduler last
        *tried* to reach the MFT (not just when it last failed).
        """
        now = datetime.now(timezone.utc)
        self._last_sftp_attempt_at = now
        if success:
            self._consecutive_failures = 0
            return
        self._consecutive_failures += 1
        self._last_error = error
        self._last_error_at = now

    # ---- Public API -------------------------------------------------------

    async def start(self) -> None:
        """Begin polling. Idempotent."""
        if self._task is not None and not self._task.done():
            log.info("Scheduler already running; start() is a no-op")
            return
        self._stop_event.clear()
        self._task = asyncio.create_task(self._run(), name="mft-scheduler")
        log.info(
            "Scheduler started",
            extra={
                "poll_interval_s": self._poll_interval,
                "sftp_block": self._sftp_block_name,
            },
        )

    async def stop(self) -> None:
        """Stop polling. Waits for the current tick to finish."""
        if self._task is None or self._task.done():
            return
        self._stop_event.set()
        try:
            await asyncio.wait_for(self._task, timeout=30)
        except asyncio.TimeoutError:
            log.warning("Scheduler did not stop within 30s; cancelling")
            self._task.cancel()
            try:
                await self._task
            except (asyncio.CancelledError, Exception):  # noqa: BLE001
                pass
        self._task = None
        log.info("Scheduler stopped")

    def status(self) -> SchedulerStatus:
        """Return a snapshot of the scheduler's state."""
        return SchedulerStatus(
            running=self.is_running(),
            poll_interval_seconds=self._poll_interval,
            sftp_block_name=self._sftp_block_name,
            last_poll_at=self._last_poll_at,
            poll_count=self._poll_count,
            total_processed=self._total_processed,
            total_skipped=self._total_skipped,
            total_errored=self._total_errored,
            last_tick=self._last_tick,
            consecutive_failures=self._consecutive_failures,
            last_error=self._last_error,
            last_error_at=self._last_error_at,
            last_sftp_attempt_at=self._last_sftp_attempt_at,
        )

    def is_running(self) -> bool:
        return self._task is not None and not self._task.done()

    async def tick(self) -> TickResult:
        """Run a single poll cycle and return the outcome.

        Concurrent ticks are coalesced: if a tick is already in
        progress, the second caller waits for it. This protects the
        SFTP server from a stampede when the operator hits
        ``/api/admin/scheduler/tick`` while a scheduled tick is
        already running.

        SP27 Task 9: the tick outcome is also recorded on the
        scheduler's SFTP-error state via
        :meth:`_record_sftp_outcome`. The discriminator is
        ``TickResult.sftp_failed`` (set by ``_tick_impl``'s
        listing-error branches only), not ``TickResult.errors``
        which is also populated by per-file processing errors.
        """
        while self._tick_in_progress:
            await asyncio.sleep(0.05)
        self._tick_in_progress = True
        try:
            result = await self._tick_impl()
            self._last_tick = result
            self._last_poll_at = result.finished_at or result.started_at
            self._poll_count += 1
            self._total_processed += result.files_processed
            self._total_skipped += result.files_skipped
            self._total_errored += result.files_errored
            # SFTP-error surfacing: discriminator is ``result.sftp_failed``,
            # NOT ``result.errors``. ``sftp_failed`` is set by
            # ``_tick_impl``'s listing-error branches (timeout, connect
            # refused). Per-file processing errors append to
            # ``result.errors`` but do NOT set ``sftp_failed`` — a
            # single malformed 999 in a 5-file batch must not flip
            # the operator's pill to destructive.
            if result.sftp_failed:
                self._record_sftp_outcome(
                    success=False,
                    error="; ".join(result.errors),
                )
            else:
                self._record_sftp_outcome(success=True)
            return result
        finally:
            self._tick_in_progress = False

    async def process_inbound_files(
        self, files: list[InboundFile],
    ) -> TickResult:
        """Run the per-file processing pipeline over a pre-fetched list.

        Unlike :meth:`tick`, this does **not** call SFTP — the caller
        is expected to have already downloaded (or arranged the local
        copy of) each ``InboundFile.local_path``. Used by the
        ``/api/admin/scheduler/pull-inbound`` admin endpoint and the
        ``cyclone pull-inbound`` CLI command to process a date-filtered
        subset of the inbound MFT path without paying the cost of a
        full poll.

        Honors ``_stop_event`` between files. Concurrent calls are
        coalesced the same way :meth:`tick` does, so a slow handler
        can't be stampeded by a parallel admin invocation.

        SP27 Task 9: deliberately does NOT update the SFTP-error
        state (no listing, no SFTP). The consecutive-failure counter
        is the signal for scheduled SFTP polling; operator-initiated
        batches shouldn't be able to flip it.
        """
        while self._tick_in_progress:
            await asyncio.sleep(0.05)
        self._tick_in_progress = True
        try:
            started = datetime.now(timezone.utc)
            result = TickResult(started_at=started, files_seen=len(files))
            for f in files:
                if self._stop_event.is_set():
                    break
                await self._handle_one(f, result)
            result.finished_at = datetime.now(timezone.utc)
            self._last_tick = result
            self._last_poll_at = result.finished_at
            self._poll_count += 1
            self._total_processed += result.files_processed
            self._total_skipped += result.files_skipped
            self._total_errored += result.files_errored
            return result
        finally:
            self._tick_in_progress = False

    # ---- Internals --------------------------------------------------------

    async def _run(self) -> None:
        """Main loop. Runs until ``stop()`` is called."""
        # Stagger the first tick so we don't hammer the MFT server on
        # startup if multiple operators restart Cyclone in lockstep.
        await asyncio.sleep(1)
        while not self._stop_event.is_set():
            try:
                await self.tick()
            except Exception as exc:  # noqa: BLE001
                # tick() should never raise — it catches per-file
                # exceptions. This is the safety net for SFTP outages
                # or DB connectivity issues.
                log.exception("Scheduler tick raised", extra={"error": str(exc)})
            try:
                await asyncio.wait_for(
                    self._stop_event.wait(),
                    timeout=self._poll_interval,
                )
            except asyncio.TimeoutError:
                pass  # poll interval elapsed; loop again

    async def _tick_impl(self) -> TickResult:
        """One poll cycle: list → filter already-processed → route each."""
        started = datetime.now(timezone.utc)
        result = TickResult(started_at=started)

        # SP27 Task 8: use the async-wrapped SFTP client so a hung
        # ``listdir_attr`` (the 06/25 silent-hang failure mode) is
        # bounded by ``CYCLONE_SFTP_OP_TIMEOUT_SECONDS`` instead of
        # waiting on paramiko forever. ``asyncio.TimeoutError`` is
        # handled explicitly so the tick surfaces a clear error in
        # ``Scheduler.status()`` (Task 9) rather than a generic
        # ``Exception`` catch-all.
        client = self._sftp_client_factory(self._sftp_block)
        try:
            files = await client.async_list_inbound()
        except asyncio.TimeoutError:
            log.exception("SFTP list_inbound timed out")
            result.errors.append("list_inbound: timeout")
            result.sftp_failed = True
            result.finished_at = datetime.now(timezone.utc)
            return result
        except Exception as exc:  # noqa: BLE001
            log.exception("SFTP list_inbound failed")
            result.errors.append(f"list_inbound: {exc}")
            result.sftp_failed = True
            result.finished_at = datetime.now(timezone.utc)
            return result

        result.files_seen = len(files)
        for f in files:
            if self._stop_event.is_set():
                break
            await self._handle_one(f, result)

        result.finished_at = datetime.now(timezone.utc)
        return result

    async def _handle_one(self, f: InboundFile, result: TickResult) -> None:
        """Process one inbound file: skip-if-seen, classify, parse, record."""
        if await self._already_processed(f.name):
            return

        try:
            inbound = parse_inbound_filename(f.name)
            file_type = inbound.file_type
        except ValueError:
            file_type = None

        if file_type not in HANDLERS:
            await self._record(
                name=f.name, size=f.size, modified_at=f.modified_at,
                file_type=file_type, parser_used=None, claim_count=0,
                status=STATUS_SKIPPED,
                error_message=(
                    f"file_type {file_type!r} not in {sorted(HANDLERS)}"
                    if file_type else "filename does not match HCPF inbound format"
                ),
            )
            result.files_skipped += 1
            return

        try:
            _path, parser_used, claim_count = await asyncio.to_thread(
                self._download_and_parse, f, file_type,
            )
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to process inbound file", extra={"input_filename": f.name})
            await self._record(
                name=f.name, size=f.size, modified_at=f.modified_at,
                file_type=file_type, parser_used=None, claim_count=0,
                status=STATUS_ERROR,
                error_message=(
                    f"{type(exc).__name__}: {exc}\n"
                    f"{traceback.format_exc()[-500:]}"
                ),
            )
            result.files_errored += 1
            result.errors.append(f"{f.name}: {exc}")
            return

        await self._record(
            name=f.name, size=f.size, modified_at=f.modified_at,
            file_type=file_type, parser_used=parser_used, claim_count=claim_count,
            status=STATUS_OK, error_message=None,
        )
        result.files_processed += 1
        log.info(
            "Processed inbound file",
            extra={
                "input_filename": f.name,
                "parser": parser_used,
                "claims": claim_count,
            },
        )

    async def _already_processed(self, name: str) -> bool:
        with db.SessionLocal()() as session:
            row = (
                session.query(ProcessedInboundFile)
                .filter_by(sftp_block_name=self._sftp_block_name, name=name)
                .filter(ProcessedInboundFile.status != STATUS_PENDING)
                .first()
            )
            return row is not None

    async def _record(
        self,
        *,
        name: str,
        size: int,
        modified_at: datetime,
        file_type: Optional[str],
        parser_used: Optional[str],
        claim_count: int,
        status: str,
        error_message: Optional[str],
    ) -> None:
        """Persist a processed_inbound_files row. Idempotent."""
        with db.SessionLocal()() as session:
            row = ProcessedInboundFile(
                sftp_block_name=self._sftp_block_name,
                name=name,
                size=size,
                modified_at=modified_at,
                file_type=file_type,
                processed_at=datetime.now(timezone.utc),
                parser_used=parser_used,
                claim_count=claim_count,
                status=status,
                error_message=error_message,
            )
            session.add(row)
            try:
                session.commit()
            except IntegrityError:
                # A concurrent scheduler (or a retry after a partial
                # failure) already recorded this file. That's fine —
                # the latest row wins; we just skip the dup.
                session.rollback()

    def _download_and_parse(
        self, f: InboundFile, file_type: str,
    ) -> tuple[Path, str, int]:
        """Run the right handler on one inbound file. Returns (path, parser, count).

        Both stub and real modes read from ``f.local_path`` — the
        inbound file is already on disk:
          * Stub mode: ``_list_inbound_stub`` points ``local_path`` at
            the operator-dropped staging file.
          * Real mode: ``_list_inbound_paramiko`` downloads each
            ``listdir_attr`` entry into the local cache as part of the
            listing pass. Re-reading from the MFT would require
            ``SftpClient.read_file`` with a full remote path, which the
            scheduler was passing just ``f.name`` for (i.e. a bare
            filename at the SFTP root, not the inbound dir). Use the
            cached bytes instead.
        """
        content = f.local_path.read_bytes()
        text = content.decode("utf-8")
        handler = HANDLERS[file_type]
        parser_used, claim_count = handler(text, f.name)
        return f.local_path, parser_used, claim_count


# ---------------------------------------------------------------------------
# Module-level singleton — only one scheduler per process.
# ---------------------------------------------------------------------------


_scheduler: Optional[Scheduler] = None


def configure_scheduler(
    sftp_block: SftpBlock,
    *,
    poll_interval_seconds: int = 60,
    sftp_block_name: str = "default",
    force: bool = False,
) -> Scheduler:
    """Create the module-level scheduler singleton (or return the existing one).

    Called from the FastAPI lifespan handler. Tests pre-configure the
    scheduler before the TestClient opens the lifespan; in that case
    we leave the existing singleton alone (``force=False``). Pass
    ``force=True`` to replace unconditionally.
    """
    global _scheduler
    if _scheduler is not None and not force:
        return _scheduler
    poll = int(
        os.environ.get("CYCLONE_SCHEDULER_POLL_SECONDS", poll_interval_seconds),
    )
    _scheduler = Scheduler(
        sftp_block,
        poll_interval_seconds=poll,
        sftp_block_name=sftp_block_name,
    )
    return _scheduler


def get_scheduler() -> Scheduler:
    """Return the module-level scheduler.

    Raises:
        RuntimeError: if ``configure_scheduler`` hasn't been called.
    """
    if _scheduler is None:
        raise RuntimeError(
            "scheduler not configured; call configure_scheduler() first",
        )
    return _scheduler


def reset_scheduler_for_tests() -> None:
    """Clear the module-level scheduler. Test-only."""
    global _scheduler
    _scheduler = None


async def reconfigure_scheduler(
    sftp_block: SftpBlock,
    *,
    poll_interval_seconds: int = 60,
    sftp_block_name: str = "default",
) -> Scheduler:
    """Replace the singleton scheduler; restart if it was running. SP25.

    Called from ``PATCH /api/clearhouse`` so the next scheduler tick
    uses the new SftpBlock without a process restart.

    Lifecycle:
        * If the previous scheduler is running, ``stop()`` it (waits
          up to ``_DRAIN_TIMEOUT_SECONDS`` for the current tick to
          finish — matches the existing ``Scheduler.stop()`` timeout).
        * Replace the module-level ``_scheduler`` singleton with a
          fresh ``Scheduler`` against the new block.
        * If the previous one was running, ``start()`` the new one.

    Threading: same constraint as ``configure_scheduler`` — must be
    called from the FastAPI event loop, not a worker thread.
    """
    global _scheduler
    was_running = False
    if _scheduler is not None:
        was_running = _scheduler.is_running()
        if was_running:
            # Drain the in-flight tick. Scheduler.stop() already
            # applies the _DRAIN_TIMEOUT_SECONDS timeout internally;
            # we just await it.
            await _scheduler.stop()
    poll = int(
        os.environ.get("CYCLONE_SCHEDULER_POLL_SECONDS", poll_interval_seconds),
    )
    _scheduler = Scheduler(
        sftp_block,
        poll_interval_seconds=poll,
        sftp_block_name=sftp_block_name,
    )
    if was_running:
        await _scheduler.start()
    log.info(
        "Scheduler reconfigured",
        extra={
            "was_running": was_running,
            "sftp_block": sftp_block_name,
            "host": sftp_block.host,
            "stub": sftp_block.stub,
        },
    )
    return _scheduler