"""
Tech-stack outage watcher ("outage-slack-ping")

Polls the public status feeds of our core SaaS vendors and posts to Slack when
an incident *opens* and again when it *resolves* — so the channel is a clean
outage timeline, not a firehose. Between those two edges it stays quiet.

Vendors watched (see PROVIDERS below):
  Slack, Zoom, Splunk, NetSuite, Google Workspace, SAP Concur.

How it stays quiet
  Each run diffs the set of currently-active incidents against `state.json`
  (the set we last saw). New keys -> "outage" post. Keys that vanished ->
  "resolved" post. Nothing changed -> no Slack, no state write. In CI the
  workflow only commits state.json back when it actually changed, so the git
  history doubles as an outage log.

Severity
  Default is major-only (real disruptions/outages). Set INCLUDE_MINOR=true to
  also alert on minor degradations / notices.

Config (env / .env):
  SLACK_WEBHOOK_URL   Slack incoming webhook. Unset -> logs only (dry run).
  INCLUDE_MINOR       "true" to also alert on minor incidents (default major-only).
  STATE_PATH          Override path to state.json (default: alongside this file).
  ONLY_PROVIDERS      Comma-list to limit which vendors run (e.g. "Slack,Zoom").

Flags:
  --no-slack   Never post to Slack (still diffs + writes state). Local testing.
  --dry-run    Don't post AND don't write state. Pure read-only preview.
  --summary    Print every currently-active incident (all severities) and exit.

Exit code is non-zero only on a hard failure (e.g. every provider errored), not
for a normal outage — an outage is expected signal, not a script failure.
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import sys
from dataclasses import dataclass, field

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

try:
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:
    pass

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("outage_slack_ping")


# --------------------------------------------------------------------------- #
# Config
# --------------------------------------------------------------------------- #

SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "").strip()
INCLUDE_MINOR = os.environ.get("INCLUDE_MINOR", "").strip().lower() in ("1", "true", "yes")
_STATE_DEFAULT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json")
STATE_PATH = os.environ.get("STATE_PATH", "").strip() or _STATE_DEFAULT
ONLY_PROVIDERS = {
    p.strip().lower() for p in os.environ.get("ONLY_PROVIDERS", "").split(",") if p.strip()
}

HTTP_TIMEOUT = 20
# Browser-ish UA — a couple of these status hosts 403 an empty/default UA.
USER_AGENT = "hillspire-outage-slack-ping/1.0 (+systems-engineering)"

MAJOR = "major"   # real disruption / outage
MINOR = "minor"   # degradation, partial, notice, maintenance


@dataclass
class Incident:
    """A vendor incident, normalized across every provider's schema."""
    provider: str            # "Slack"
    key: str                 # stable unique id, e.g. "Slack:abc123"
    title: str               # human summary
    severity: str            # MAJOR | MINOR
    services: list[str] = field(default_factory=list)  # affected components
    status: str = ""         # raw vendor status, for display (e.g. "investigating")
    url: str = ""            # link to the incident / status page

    @property
    def services_str(self) -> str:
        return ", ".join(self.services) if self.services else "—"


def _alertable(inc: Incident) -> bool:
    return inc.severity == MAJOR or INCLUDE_MINOR


# --------------------------------------------------------------------------- #
# Providers
# --------------------------------------------------------------------------- #
#
# Each provider is a function (session) -> list[Incident] returning only the
# vendor's *currently active* incidents. A provider that raises is logged and
# treated as "no data from this vendor this run" (it does NOT clear that
# vendor's known incidents from state — see reconcile()).

STATUSPAGE_ALERT_IMPACTS = {"major", "critical"}   # + "minor" when INCLUDE_MINOR


def _session() -> requests.Session:
    s = requests.Session()
    s.headers.update({"Accept": "application/json", "User-Agent": USER_AGENT})
    # 16 public status feeds per run, several fronted by CDNs that 503 or
    # rate-limit under load. Without backoff a single blip drops that vendor for
    # the run, which reconcile() then has to paper over by holding its state.
    retry = Retry(
        total=3,
        backoff_factor=1,                       # 0s, 2s, 4s
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=frozenset({"GET", "HEAD"}),
        raise_on_status=False,
    )
    s.mount("https://", HTTPAdapter(max_retries=retry))
    return s


def _statuspage_incidents(session: requests.Session, base: str) -> list[dict]:
    """Return a Statuspage's unresolved incidents.

    Prefers `/incidents/unresolved.json`, falling back to `/summary.json`
    (whose `incidents` field is also unresolved-only). Some vendors — e.g.
    OpenAI — front Statuspage with an app that only serves `summary.json`.
    """
    for path in ("/api/v2/incidents/unresolved.json", "/api/v2/summary.json"):
        try:
            r = session.get(base + path, timeout=HTTP_TIMEOUT)
            r.raise_for_status()
            data = r.json()
        except (requests.RequestException, ValueError):
            continue
        if isinstance(data, dict) and "incidents" in data:
            return data["incidents"]
    raise RuntimeError(f"no usable Statuspage feed at {base}")


def fetch_statuspage(session: requests.Session, provider: str, base: str) -> list[Incident]:
    """Any Atlassian Statuspage vendor (Zoom, Splunk, NetSuite, GitHub, Palo Alto, …)."""
    raw = _statuspage_incidents(session, base)
    out: list[Incident] = []
    for inc in raw:
        name = (inc.get("name") or "").strip()
        # NetSuite publishes a synthetic uptime record as a fake "incident".
        if name.startswith("_system_metadata"):
            continue
        impact = (inc.get("impact") or "none").lower()
        if impact == "none":
            continue
        severity = MAJOR if impact in ("major", "critical") else MINOR
        components = [c.get("name") for c in inc.get("components", []) if c.get("name")]
        out.append(Incident(
            provider=provider,
            key=f"{provider}:{inc.get('id')}",
            title=name or f"{provider} incident",
            severity=severity,
            services=components,
            status=(inc.get("status") or "").replace("_", " "),
            url=inc.get("shortlink") or base,
        ))
    return out


def fetch_slack(session: requests.Session) -> list[Incident]:
    """Slack's own status API (not Statuspage)."""
    r = session.get("https://status.slack.com/api/v2.0.0/current", timeout=HTTP_TIMEOUT)
    r.raise_for_status()
    data = r.json()
    out: list[Incident] = []
    for inc in data.get("active_incidents", []):
        itype = (inc.get("type") or "").lower()          # "incident" | "outage" | "notice"
        # Slack's severe types are "outage"/"incident"; "notice"/"maintenance" are minor.
        severity = MAJOR if itype in ("outage", "incident") else MINOR
        raw_services = inc.get("services") or []
        services = [s.get("name") if isinstance(s, dict) else str(s) for s in raw_services]
        out.append(Incident(
            provider="Slack",
            key=f"Slack:{inc.get('id')}",
            title=inc.get("title") or "Slack incident",
            severity=severity,
            services=[s for s in services if s],
            status=(inc.get("status") or itype),
            url=inc.get("url") or "https://status.slack.com",
        ))
    return out


def fetch_google(session: requests.Session) -> list[Incident]:
    """Google Workspace status dashboard incidents.json."""
    r = session.get(
        "https://www.google.com/appsstatus/dashboard/incidents.json", timeout=HTTP_TIMEOUT
    )
    r.raise_for_status()
    out: list[Incident] = []
    for inc in r.json():
        # An incident with an `end` timestamp is over. Active ones have none.
        if inc.get("end"):
            continue
        recent = inc.get("most_recent_update") or {}
        status = (recent.get("status") or "").upper()
        if status == "AVAILABLE":     # resolved/mitigated even if `end` not yet stamped
            continue
        severity = MAJOR if status == "SERVICE_DISRUPTION" else MINOR
        products = [p.get("title") for p in inc.get("affected_products", []) if p.get("title")]
        if not products and inc.get("service_name"):
            products = [inc["service_name"]]
        uri = inc.get("uri") or ""
        url = ("https://www.google.com/appsstatus/dashboard/" + uri) if uri \
            else "https://www.google.com/appsstatus/dashboard/"
        out.append(Incident(
            provider="Google Workspace",
            key=f"Google:{inc.get('id')}",
            title=inc.get("external_desc") or "Google Workspace incident",
            severity=severity,
            services=products,
            status=status.replace("_", " ").title(),
            url=url,
        ))
    return out


def fetch_concur(session: requests.Session) -> list[Incident]:
    """SAP Concur 'Concur Open' hidden JSON API. Only P1 incidents are published."""
    r = session.get("https://open.concur.com/api/open/incidents", timeout=HTTP_TIMEOUT)
    r.raise_for_status()
    out: list[Incident] = []
    for inc in r.json().get("incidents", []):
        status = (inc.get("status") or "").upper()
        # RESOLVED / NORMAL are done; active incidents sit in INVESTIGATION.
        if status != "INVESTIGATION":
            continue
        if inc.get("end_epoch"):      # belt-and-suspenders: an ended incident isn't active
            continue
        sev_raw = (inc.get("severity") or "").lower()
        severity = MAJOR if sev_raw in ("disruption", "degradation") else MINOR
        services = inc.get("affected_services") or []
        # Best available human title: first line of the newest English message.
        title = _concur_title(inc) or (f"{', '.join(services)} disruption" if services
                                        else "Concur incident")
        out.append(Incident(
            provider="Concur",
            key=f"Concur:{inc.get('id')}",
            title=title,
            severity=severity,
            services=services,
            status=(sev_raw or "investigation").title(),
            url="https://open.concur.com/",
        ))
    return out


def _concur_title(inc: dict) -> str:
    msgs = inc.get("messages") or []
    if not msgs:
        return ""
    latest = max(msgs, key=lambda m: m.get("created_epoch") or 0)
    body = latest.get("body") or {}
    text = (body.get("en") if isinstance(body, dict) else str(body)) or ""
    first = text.strip().splitlines()[0] if text.strip() else ""
    return (first[:200] + "…") if len(first) > 200 else first


def fetch_incidentio(session: requests.Session, provider: str, base: str) -> list[Incident]:
    """incident.io status pages (OpenAI). `api/v1/summary` -> `ongoing_incidents[]`.

    OpenAI migrated off Atlassian Statuspage to incident.io (page rebuilt
    2026-07-09). `summary.json` still answers 200, but no longer carries an
    `incidents` key — so the Statuspage fetcher fell through to "no usable feed"
    and ChatGPT quietly went unmonitored. `poll_all` logs that as a warning and
    carries on, which is right for one flaky vendor but means a *permanent* feed
    break reads as normal operation.

    incident.io exposes no per-incident impact level, so — same call as
    StatusCast/Sophos — every ongoing incident counts as major.
    `scheduled_maintenances` / `in_progress_maintenances` are ignored.
    """
    r = session.get(f"{base}/api/v1/summary", timeout=HTTP_TIMEOUT)
    r.raise_for_status()
    data = r.json()
    if not isinstance(data, dict) or "ongoing_incidents" not in data:
        # Fail loudly rather than reporting "all clear" from a payload we don't
        # recognize — that silence is exactly how this vendor rotted before.
        raise RuntimeError(f"no ongoing_incidents in incident.io summary at {base}")
    out: list[Incident] = []
    for inc in data["ongoing_incidents"] or []:
        components = [c.get("name") for c in (inc.get("affected_components") or [])
                      if isinstance(c, dict) and c.get("name")]
        out.append(Incident(
            provider=provider,
            key=f"{provider}:{inc.get('id')}",
            title=inc.get("name") or f"{provider} incident",
            severity=MAJOR,
            services=components,
            status=(inc.get("status") or "").replace("_", " "),
            url=inc.get("url") or f"{base}/",
        ))
    return out


def fetch_statuscast(session: requests.Session, provider: str, base: str) -> list[Incident]:
    """StatusCast vendors (Sophos). `summary.json` -> UnresolvedIncidents[].

    StatusCast doesn't expose a clean per-incident impact level, and it files
    maintenance separately under UpcomingIncidents. So we alert on every real
    unresolved incident (treated as major) and skip ScheduledMaintenance.
    """
    r = session.get(f"{base}/summary.json", timeout=HTTP_TIMEOUT)
    r.raise_for_status()
    data = r.json()
    out: list[Incident] = []
    for inc in data.get("UnresolvedIncidents", []):
        if (inc.get("IncidentType") or "").lower() == "scheduledmaintenance":
            continue
        components = [c.get("ComponentName") for c in inc.get("AffectedComponents", [])
                     if c.get("ComponentName")]
        out.append(Incident(
            provider=provider,
            key=f"{provider}:{inc.get('Id')}",
            title=inc.get("Title") or f"{provider} incident",
            severity=MAJOR,
            services=components,
            status=inc.get("Status") or "",
            url=inc.get("ShortUrl") or base,
        ))
    return out


# Registry: display name -> zero-arg-ish fetcher (bound to a session at call time).
PROVIDERS: dict[str, "callable"] = {
    "Slack": fetch_slack,
    "Zoom": lambda s: fetch_statuspage(s, "Zoom", "https://status.zoom.us"),
    "Splunk": lambda s: fetch_statuspage(s, "Splunk", "https://status.splunkcloud.com"),
    "NetSuite": lambda s: fetch_statuspage(s, "NetSuite", "https://status.netsuite.com"),
    "Google Workspace": fetch_google,
    "Concur": fetch_concur,
    "GitHub": lambda s: fetch_statuspage(s, "GitHub", "https://www.githubstatus.com"),
    # Added from the CIO's full list (all Atlassian Statuspage unless noted):
    "DocuSign": lambda s: fetch_statuspage(s, "DocuSign", "https://status.docusign.com"),
    "Perimeter 81": lambda s: fetch_statuspage(s, "Perimeter 81", "https://status.perimeter81.com"),
    "Cisco Meraki": lambda s: fetch_statuspage(s, "Cisco Meraki", "https://status.meraki.com"),
    "Palo Alto Networks": lambda s: fetch_statuspage(s, "Palo Alto Networks", "https://status.paloaltonetworks.com"),
    "Smartsheet": lambda s: fetch_statuspage(s, "Smartsheet", "https://status.smartsheet.com"),
    "Claude": lambda s: fetch_statuspage(s, "Claude", "https://status.claude.com"),
    "ChatGPT": lambda s: fetch_incidentio(s, "ChatGPT", "https://status.openai.com"),  # incident.io, not Statuspage
    "ShareFile": lambda s: fetch_statuspage(s, "ShareFile", "https://status.sharefile.com"),
    "Sophos": lambda s: fetch_statuscast(s, "Sophos", "https://sophoscentral.status.page"),
}
# Note: Okta has no public status feed — status.okta.com sits behind a Salesforce
# login (even its RSS 401s), so it can't be polled like the others. See README.


@dataclass
class Poll:
    active: dict[str, Incident] = field(default_factory=dict)   # key -> Incident
    ok_providers: set[str] = field(default_factory=set)         # fetched cleanly
    failed_providers: dict[str, str] = field(default_factory=dict)  # provider -> error


def poll_all() -> Poll:
    session = _session()
    poll = Poll()
    for name, fetcher in PROVIDERS.items():
        if ONLY_PROVIDERS and name.lower() not in ONLY_PROVIDERS:
            continue
        try:
            incidents = fetcher(session)
        except Exception as e:  # noqa: BLE001 — one flaky vendor must not sink the run
            poll.failed_providers[name] = str(e)
            log.warning("  ! %-18s fetch failed: %s", name, e)
            continue
        poll.ok_providers.add(name)
        alertable = [i for i in incidents if _alertable(i)]
        for inc in alertable:
            poll.active[inc.key] = inc
        log.info("  ✓ %-18s %d active (%d alertable)", name, len(incidents), len(alertable))
    return poll


# --------------------------------------------------------------------------- #
# State
# --------------------------------------------------------------------------- #
#
# state.json: { "version": 1, "incidents": { key: {provider,title,severity,...} } }
# Only keys from providers that fetched OK this run are eligible to be "resolved"
# — if a vendor's feed is down we hold its last-known incidents rather than
# false-alarm a resolution.

STATE_VERSION = 1


def load_state() -> dict[str, dict]:
    try:
        with open(STATE_PATH, encoding="utf-8") as f:
            data = json.load(f)
        return data.get("incidents", {}) if isinstance(data, dict) else {}
    except FileNotFoundError:
        return {}
    except (json.JSONDecodeError, OSError) as e:
        log.warning("Couldn't read state (%s) — treating as empty.", e)
        return {}


def save_state(incidents: dict[str, dict]) -> None:
    tmp = STATE_PATH + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump({"version": STATE_VERSION, "incidents": incidents}, f,
                  indent=2, sort_keys=True)
        f.write("\n")
    os.replace(tmp, STATE_PATH)


def _inc_record(inc: Incident) -> dict:
    return {
        "provider": inc.provider, "title": inc.title, "severity": inc.severity,
        "services": inc.services, "status": inc.status, "url": inc.url,
    }


@dataclass
class Diff:
    new: list[Incident] = field(default_factory=list)
    resolved: list[dict] = field(default_factory=list)     # stored records
    next_state: dict[str, dict] = field(default_factory=dict)


def reconcile(prev: dict[str, dict], poll: Poll) -> Diff:
    diff = Diff()
    # New: active now, not in previous state.
    for key, inc in poll.active.items():
        if key not in prev:
            diff.new.append(inc)
    # Resolved: was in state, now gone — but only trust vendors that fetched OK.
    for key, rec in prev.items():
        if key in poll.active:
            continue
        provider = rec.get("provider", "")
        if provider in poll.ok_providers or (not ONLY_PROVIDERS and provider not in PROVIDERS):
            diff.resolved.append(rec)
        # else: vendor feed failed this run — keep the incident, don't resolve it.
    # Next state = everything active now, plus held-over incidents from failed vendors.
    diff.next_state = {k: _inc_record(v) for k, v in poll.active.items()}
    for key, rec in prev.items():
        if key not in poll.active and rec.get("provider") not in poll.ok_providers:
            if ONLY_PROVIDERS and rec.get("provider", "").lower() not in ONLY_PROVIDERS:
                continue  # provider excluded this run — drop it, it'll be re-added later
            diff.next_state[key] = rec
    return diff


# --------------------------------------------------------------------------- #
# Slack (Block Kit)
# --------------------------------------------------------------------------- #

def _sev_badge(sev: str) -> str:
    return ":red_circle: *Major outage*" if sev == MAJOR else ":large_orange_circle: *Degradation*"


def blocks_for_new(inc: Incident) -> tuple[str, list[dict]]:
    fallback = f":rotating_light: {inc.provider} outage — {inc.title}"
    fields = [
        {"type": "mrkdwn", "text": f"*Vendor*\n{inc.provider}"},
        {"type": "mrkdwn", "text": f"*Severity*\n{_sev_badge(inc.severity)}"},
        {"type": "mrkdwn", "text": f"*Affected*\n{inc.services_str}"},
    ]
    if inc.status:
        fields.append({"type": "mrkdwn", "text": f"*Status*\n{inc.status}"})
    blocks = [
        {"type": "header",
         "text": {"type": "plain_text", "text": f"🚨 {inc.provider} — service issue", "emoji": True}},
        {"type": "section", "text": {"type": "mrkdwn", "text": f"*{inc.title}*"}},
        {"type": "section", "fields": fields},
    ]
    if inc.url:
        blocks.append({"type": "actions", "elements": [
            {"type": "button", "text": {"type": "plain_text", "text": "View status page", "emoji": True},
             "url": inc.url}]})
    blocks.append({"type": "context", "elements": [
        {"type": "mrkdwn", "text": f"{inc.provider} status feed · key `{inc.key}`"}]})
    return fallback, blocks


def blocks_for_resolved(rec: dict) -> tuple[str, list[dict]]:
    provider = rec.get("provider", "Vendor")
    title = rec.get("title", "incident")
    fallback = f":large_green_circle: {provider} recovered — {title}"
    blocks = [
        {"type": "section", "text": {"type": "mrkdwn",
         "text": f":large_green_circle: *{provider} recovered* — resolved on the vendor's status page."}},
        {"type": "section", "text": {"type": "mrkdwn", "text": f"~{title}~"}},
        {"type": "context", "elements": [
            {"type": "mrkdwn",
             "text": f"Affected: {', '.join(rec.get('services') or []) or '—'} · {provider} status feed"}]},
    ]
    return fallback, blocks


def send_test_post() -> None:
    """Fire a sample outage + resolved pair so you can see the format live.

    Clearly marked as a drill so nobody mistakes it for a real outage. Doesn't
    poll anything or touch state.
    """
    sample = Incident(
        provider="Zoom", key="Zoom:TEST", severity=MAJOR,
        title="Users unable to join or start meetings",
        services=["Zoom Meetings", "Zoom Phone", "Zoom Webinars"],
        status="investigating",
        url="https://status.zoom.us",
    )
    text, blocks = blocks_for_new(sample)
    blocks.insert(0, {"type": "context", "elements": [
        {"type": "mrkdwn", "text": ":test_tube: *Test notification* — this is a drill, no real outage."}]})
    log.info("TEST     %s", text)
    post_to_slack(text, blocks)

    rtext, rblocks = blocks_for_resolved(_inc_record(sample))
    rblocks.insert(0, {"type": "context", "elements": [
        {"type": "mrkdwn", "text": ":test_tube: *Test notification* — this is a drill, no real outage."}]})
    log.info("TEST     %s", rtext)
    post_to_slack(rtext, rblocks)


def post_to_slack(text: str, blocks: list[dict]) -> None:
    if not SLACK_WEBHOOK_URL:
        log.info("SLACK_WEBHOOK_URL not set — would have posted: %s", text)
        return
    # POST is retried here (unlike the feed GETs elsewhere) because the state
    # diff has already been computed: if this post is dropped the incident is
    # recorded as notified and never re-announced. A duplicate alert is a much
    # cheaper failure than a silent miss.
    s = requests.Session()
    retry = Retry(
        total=3,
        backoff_factor=1,                       # 0s, 2s, 4s
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=frozenset({"POST"}),
        raise_on_status=False,
    )
    s.mount("https://", HTTPAdapter(max_retries=retry))
    try:
        r = s.post(SLACK_WEBHOOK_URL, json={"text": text, "blocks": blocks},
                   timeout=HTTP_TIMEOUT)
        r.raise_for_status()
    except requests.RequestException as e:
        log.error("Failed to post to Slack (%s): %s", text, e)


# --------------------------------------------------------------------------- #
# Main
# --------------------------------------------------------------------------- #

def _print_summary(poll: Poll) -> None:
    if poll.failed_providers:
        for name, err in poll.failed_providers.items():
            log.info("FAILED  %-18s %s", name, err)
    if not poll.active:
        log.info("No active alertable incidents across %d vendor(s).", len(poll.ok_providers))
        return
    log.info("Currently active (alertable) incidents:")
    for inc in poll.active.values():
        log.info("  [%s] %-16s %s — %s", inc.severity.upper(), inc.provider,
                 inc.title, inc.services_str)


def main() -> int:
    ap = argparse.ArgumentParser(description="Poll SaaS status feeds and alert Slack on outages.")
    ap.add_argument("--no-slack", action="store_true", help="Diff + write state, but never post.")
    ap.add_argument("--dry-run", action="store_true", help="Read-only: no Slack, no state write.")
    ap.add_argument("--summary", action="store_true", help="Print active incidents and exit.")
    ap.add_argument("--test-post", action="store_true",
                    help="Post a sample outage + resolved alert (marked as a test) and exit.")
    args = ap.parse_args()

    if args.test_post:
        if not SLACK_WEBHOOK_URL:
            log.error("--test-post needs SLACK_WEBHOOK_URL set.")
            return 1
        send_test_post()
        return 0

    scope = f" (only: {', '.join(sorted(ONLY_PROVIDERS))})" if ONLY_PROVIDERS else ""
    log.info("Polling %d vendor status feed(s)%s [major-only=%s] ...",
             len(ONLY_PROVIDERS) if ONLY_PROVIDERS else len(PROVIDERS), scope, not INCLUDE_MINOR)
    poll = poll_all()

    # Hard failure only if we got nothing from anyone.
    if not poll.ok_providers:
        log.error("Every provider failed to fetch — aborting without touching state.")
        return 1

    if args.summary:
        _print_summary(poll)
        return 0

    prev = load_state()
    diff = reconcile(prev, poll)

    log.info("Diff: %d new, %d resolved (was tracking %d).",
             len(diff.new), len(diff.resolved), len(prev))

    post = not (args.no_slack or args.dry_run)
    for inc in diff.new:
        text, blocks = blocks_for_new(inc)
        log.info("NEW      %s", text)
        if post:
            post_to_slack(text, blocks)
    for rec in diff.resolved:
        text, blocks = blocks_for_resolved(rec)
        log.info("RESOLVED %s", text)
        if post:
            post_to_slack(text, blocks)

    if args.dry_run:
        log.info("--dry-run: state not written.")
    else:
        save_state(diff.next_state)

    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        sys.exit(130)
    except Exception:
        log.exception("Outage watcher run failed")
        sys.exit(1)
