#!/usr/bin/env python3
"""Stáhne veřejné stránky CK Fischer, uloží hotely, termíny letů a historii cen."""

from __future__ import annotations

import html as htmlmod
import json
import re
import ssl
import time
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse

from db import connect, init_db
from notify import send_events

ROOT = Path(__file__).resolve().parent
CONFIG_PATH = ROOT / "config.json"
SNAPSHOT_PATH = ROOT / "data" / "snapshot.json"

CTX = ssl.create_default_context()

DEST_AIRPORTS = {
    "Rhodos": ("Rhodos Diagoras", "RHO"),
    "Kréta": ("Heraklion", "HER"),
    "Peloponés": ("Araxos", "GPA"),
}


def now_iso() -> str:
    return datetime.now(timezone.utc).astimezone().replace(microsecond=0).isoformat()


def load_config() -> dict:
    return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))


def public_filters(cfg: dict) -> dict:
    skip = {"email", "access_token", "user_agent"}
    return {k: v for k, v in cfg.items() if k not in skip}


def fetch(url: str, user_agent: str) -> str:
    req = urllib.request.Request(
        url,
        headers={
            "User-Agent": user_agent.encode("ascii", "ignore").decode("ascii") or "VacationOverview/1.0",
            "Accept-Language": "cs-CZ,cs;q=0.9,en;q=0.8",
        },
    )
    with urllib.request.urlopen(req, context=CTX, timeout=60) as resp:
        return resp.read().decode("utf-8", "replace")


def fetch_json(url: str, user_agent: str) -> Any:
    req = urllib.request.Request(
        url,
        headers={
            "User-Agent": user_agent.encode("ascii", "ignore").decode("ascii") or "VacationOverview/1.0",
            "Accept": "application/json",
            "Accept-Language": "cs-CZ,cs;q=0.9",
        },
    )
    with urllib.request.urlopen(req, context=CTX, timeout=60) as resp:
        return json.loads(resp.read().decode("utf-8", "replace"))


def component_json(html: str, name: str) -> Any | None:
    m = re.search(
        rf'<div data-component-name="{re.escape(name)}">\s*<script type="application/json">(.*?)</script>',
        html,
        re.S,
    )
    if not m:
        return None
    return json.loads(m.group(1))


def strip_html(text: str | None) -> str:
    if not text:
        return ""
    text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
    text = re.sub(r"</p>", "\n", text, flags=re.I)
    text = re.sub(r"</li>", "\n", text, flags=re.I)
    text = re.sub(r"<li[^>]*>", "• ", text, flags=re.I)
    text = re.sub(r"<[^>]+>", "", text)
    text = htmlmod.unescape(text)
    text = text.replace("\xa0", " ").replace("\r", "")
    text = re.sub(r"[ \t]+", " ", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()


def km_value(raw: str | None) -> float | None:
    if not raw:
        return None
    m = re.search(r"([\d]+(?:[.,]\d+)?)\s*km", raw.replace(" ", ""), re.I)
    if m:
        return float(m.group(1).replace(",", "."))
    if re.search(r"^\s*0\s*m", raw, re.I):
        return 0.0
    return None


def section_map(detail_descriptions: list[dict]) -> dict[str, str]:
    out: dict[str, str] = {}
    for item in detail_descriptions or []:
        title = (item.get("title") or "").strip()
        if not title:
            continue
        out[title] = strip_html(item.get("description"))
    return out


def pick_section(sections: dict[str, str], *needles: str) -> str:
    for key, val in sections.items():
        kl = key.lower()
        if any(n.lower() in kl for n in needles) and val:
            return val
    return ""


def infer_meals_type(text: str) -> str:
    t = text.lower()
    if "ultra all inclusive" in t or "uall" in t:
        return "Ultra all inclusive"
    if "all inclusive" in t or "allinclusive" in t:
        return "All inclusive"
    if "polopenze" in t:
        return "Polopenze"
    return ""


def infer_airport(destination: str, sections: dict[str, str], hint: str, iata: str) -> tuple[str, str]:
    if hint:
        return hint, iata
    mapped = DEST_AIRPORTS.get(destination)
    if mapped:
        return mapped[0], mapped[1]
    blob = " ".join(sections.values())
    m = re.search(r"letišt[eě]\s+([A-ZÁÉÍÓÚÝČĎĚŇŘŠŤŽ][A-Za-zÁÉÍÓÚÝČĎĚŇŘŠŤŽ\-]+)", blob, re.I)
    if m:
        return m.group(1).strip(), iata
    return destination, iata


def parse_hotel(html: str, cfg_item: dict) -> dict:
    data = component_json(html, "abnbHotelDetail")
    if not data:
        raise ValueError("Na stránce chybí JSON hotelu (abnbHotelDetail).")

    info = data["hotelDetailInfo"]
    hotel = data["dataNew"]["hotel"]
    loc = hotel["hotelLocation"]
    sdo = loc["hotelSdo"]
    sections = section_map(hotel["description"]["detailDescriptions"])
    distances = {d["name"]: d["value"] for d in (info.get("distances") or [])}

    images = []
    for img in info.get("images") or []:
        url = img.get("medium") or img.get("large") or img.get("small")
        if url:
            images.append({"url": url, "alt": img.get("description") or info["name"]})

    meals = pick_section(sections, "Strava", "Stravování")
    notes = pick_section(sections, "Poznámka")
    specs = pick_section(sections, "Zvláštnosti")
    conditions_parts = [p for p in (specs, notes) if p]
    summary = strip_html(hotel["description"].get("mainDescription")) or pick_section(
        sections, "Informace o hotelu", "Poloha"
    )

    dest_name = sdo["destination"]["name"]
    airport_name, airport_iata = infer_airport(
        dest_name, sections, cfg_item.get("airport_hint", ""), cfg_item.get("airport_iata", "")
    )
    airport_distance = distances.get("Vzdálenost od nejbližšího letiště") or ""
    if not airport_distance:
        loc_blob = pick_section(sections, "Vzdálenost", "Poloha")
        m = re.search(r"letišt[eě][^:]*:\s*([^\n•]+)", loc_blob, re.I)
        if m:
            airport_distance = m.group(1).strip()

    meals_type = infer_meals_type(meals) or infer_meals_type(summary) or infer_meals_type(
        " ".join(b.get("text", "") for b in (info.get("mainProperties") or []))
    )

    website = pick_section(sections, "Web")
    website = website.split("\n")[0].strip("• ").strip()

    return {
        "id": info["identifier"],
        "slug": urlparse(info["url"]).path.rstrip("/").split("/")[-1],
        "name": info["name"].strip(),
        "url": info["url"],
        "official_stars": info.get("star"),
        "destination": dest_name,
        "destination_id": sdo["destination"]["id"],
        "area": sdo["area"]["name"],
        "country": sdo["country"]["name"],
        "lat": info["hotelPin"]["gps"]["latitude"],
        "lng": info["hotelPin"]["gps"]["longitude"],
        "airport_name": airport_name,
        "airport_iata": airport_iata,
        "airport_distance": airport_distance,
        "airport_distance_km": km_value(airport_distance),
        "beach_distance": distances.get("Vzdálenost k pláži") or "",
        "center_distance": distances.get("Centrum města") or "",
        "distances": distances,
        "benefits": [b["text"] for b in (info.get("mainProperties") or [])],
        "summary": summary,
        "meals": meals,
        "meals_type": meals_type,
        "conditions": "\n\n".join(conditions_parts),
        "sections": sections,
        "image_url": images[0]["url"] if images else "",
        "images": images[:12],
        "website": website,
        "giata": hotel["identifiers"].get("giata"),
        "data_source": hotel["identifiers"].get("dataSource"),
        "provider_id": hotel["identifiers"].get("bedBankID"),
        "filter_base": data.get("filterParametersBase"),
        "tour_tip": info.get("tourTip"),
    }


def parse_flights(html: str, source_url: str, cfg: dict) -> list[dict]:
    data = component_json(html, "appTourList")
    if not data:
        return []
    tours = (data.get("tourListResult") or {}).get("tours") or []
    out = []
    date_from = cfg["date_from"]
    date_to = cfg["date_to"]
    wanted_airport = cfg["departure_airport"].lower()
    wanted_id = cfg["departure_airport_id"]

    for tour in tours:
        loc = tour.get("location") or {}
        dep = (tour.get("departureLocation") or "").strip()
        dep_date = (tour.get("departureDate") or "")[:10]
        nights = (tour.get("nightsCount") or {}).get("from")
        filt = tour.get("searchFilter") or ""
        qs = parse_qs(filt)
        airport_id = int(qs.get("TO", [0])[0] or 0)
        ret = (qs.get("RD") or [""])[0][:10]
        if not ret and dep_date and nights:
            # fallback not computed; keep empty
            ret = ""

        if airport_id != wanted_id and dep.lower() != wanted_airport:
            continue
        if not dep_date or dep_date < date_from or dep_date > date_to:
            continue
        if loc.get("country") and loc.get("country") != "Řecko":
            continue

        out.append(
            {
                "destination_id": int(qs.get("D", [0])[0] or 0) or None,
                "destination": loc.get("destination"),
                "country": loc.get("country") or "Řecko",
                "departure_airport": dep or cfg["departure_airport"],
                "departure_airport_id": airport_id or wanted_id,
                "departure_date": dep_date,
                "return_date": ret,
                "nights": nights,
                "price_from": (tour.get("adultPriceFrom") or {}).get("amount"),
                "search_filter": filt,
                "source_url": source_url,
            }
        )
    return out


def _dpr(hotel: dict) -> str:
    qs = parse_qs(hotel.get("filter_base") or "")
    return (qs.get("DPR") or [""])[0]


def _is_pardubice(tour: dict, airport_id: int) -> bool:
    try:
        airport = tour["flight"]["departure"]["segments"][0]["airport"]
    except (KeyError, IndexError, TypeError):
        return False
    if airport.get("fromId") == airport_id:
        return True
    name = (airport.get("from") or "").lower()
    return name == "pardubice" or airport.get("fromIATA") == "PED"


def _is_all_inclusive(meal: str) -> bool:
    t = (meal or "").lower()
    return "all inclusive" in t or t in {"ai", "uai"}


def search_hotel_offers(hotel: dict, cfg: dict) -> list[dict]:
    """Termíny konkrétního hotelu z veřejného search API Fischeru (cena celkem za 2 osoby)."""
    date_from = datetime.strptime(cfg["date_from"], "%Y-%m-%d").date()
    date_to = datetime.strptime(cfg["date_to"], "%Y-%m-%d").date()
    airport_id = cfg["departure_airport_id"]
    nights_options = cfg.get("nights") or [7]
    delay = cfg.get("term_delay_sec", 0.12)
    found: dict[tuple, dict] = {}

    day = date_from
    while day <= date_to:
        for nights in nights_options:
            ret = day + timedelta(days=nights)
            params = {
                "DS": hotel["data_source"],
                "GIATA": hotel["giata"],
                "HID": hotel["id"],
                "D": hotel["destination_id"],
                "TT": 1,
                "TO": airport_id,
                "DD": day.isoformat(),
                "RD": ret.isoformat(),
                "DF": f"{day.isoformat()}|{ret.isoformat()}",
                "ERM": 0,
                "AC1": cfg.get("adults", 2),
                "KC1": 0,
                "IC1": 0,
                "PID": hotel.get("provider_id") or "",
                "DI": "AI",
                "NN": nights,
                "sortby": "Price",
                "sortorder": "0",
            }
            dpr = _dpr(hotel)
            if dpr:
                params["DPR"] = dpr
            url = "https://www.fischer.cz/api/searchapi/getsearchresult?" + urlencode(params)
            try:
                data = fetch_json(url, cfg["user_agent"])
            except Exception as exc:  # noqa: BLE001
                print(f"    termín {day} / {nights}n: {exc}")
                time.sleep(delay)
                continue
            for item in data.get("tours") or []:
                tour = item.get("tour") or {}
                if not _is_pardubice(tour, airport_id):
                    continue
                rooms = tour.get("rooms") or [{}]
                meal = (rooms[0] or {}).get("meal") or ""
                if not _is_all_inclusive(meal):
                    continue
                dep = (tour.get("date") or {}).get("from") or day.isoformat()
                back = (tour.get("date") or {}).get("to") or ret.isoformat()
                if dep < cfg["date_from"] or dep > cfg["date_to"]:
                    continue
                if back > cfg["date_to"]:
                    continue
                price = (tour.get("price") or {}).get("total")
                if price is None:
                    continue
                try:
                    origin = tour["flight"]["departure"]["segments"][0]["airport"]["from"]
                except (KeyError, IndexError, TypeError):
                    origin = cfg["departure_airport"]
                room = (rooms[0] or {}).get("name") or ""
                key = (dep, back, tour.get("nightsCount") or nights, meal, room)
                offer = {
                    "departure_date": dep,
                    "return_date": back,
                    "nights": tour.get("nightsCount") or nights,
                    "days": tour.get("daysCount") or (nights + 1),
                    "departure_airport": origin,
                    "arrival_airport": hotel.get("airport_name"),
                    "meal": meal,
                    "room": room,
                    "price_total": price,
                    "price_per_person": (tour.get("price") or {}).get("adultPrice"),
                    "booking_url": "https://www.fischer.cz" + (item.get("detailUrl") or hotel["url"]),
                    "raw": {
                        "date": tour.get("date"),
                        "price": tour.get("price"),
                        "flight": tour.get("flight"),
                        "room": room,
                        "meal": meal,
                    },
                }
                prev = found.get(key)
                if prev is None or price < prev["price_total"]:
                    found[key] = offer
            time.sleep(delay)
        day += timedelta(days=1)
    return sorted(found.values(), key=lambda o: (o["price_total"], o["departure_date"]))


def upsert_hotel(conn, hotel: dict, updated_at: str) -> None:
    conn.execute(
        """
        INSERT INTO hotels (
            id, slug, name, url, official_stars, destination, destination_id, area, country,
            lat, lng, airport_name, airport_iata, airport_distance, airport_distance_km,
            beach_distance, center_distance, distances_json, benefits_json, summary, meals,
            meals_type, conditions, image_url, images_json, website, giata, data_source,
            provider_id, extra_json, updated_at
        ) VALUES (
            :id, :slug, :name, :url, :official_stars, :destination, :destination_id, :area, :country,
            :lat, :lng, :airport_name, :airport_iata, :airport_distance, :airport_distance_km,
            :beach_distance, :center_distance, :distances_json, :benefits_json, :summary, :meals,
            :meals_type, :conditions, :image_url, :images_json, :website, :giata, :data_source,
            :provider_id, :extra_json, :updated_at
        )
        ON CONFLICT(id) DO UPDATE SET
            slug=excluded.slug, name=excluded.name, url=excluded.url,
            official_stars=excluded.official_stars, destination=excluded.destination,
            destination_id=excluded.destination_id, area=excluded.area, country=excluded.country,
            lat=excluded.lat, lng=excluded.lng, airport_name=excluded.airport_name,
            airport_iata=excluded.airport_iata, airport_distance=excluded.airport_distance,
            airport_distance_km=excluded.airport_distance_km, beach_distance=excluded.beach_distance,
            center_distance=excluded.center_distance, distances_json=excluded.distances_json,
            benefits_json=excluded.benefits_json, summary=excluded.summary, meals=excluded.meals,
            meals_type=excluded.meals_type, conditions=excluded.conditions, image_url=excluded.image_url,
            images_json=excluded.images_json, website=excluded.website, giata=excluded.giata,
            data_source=excluded.data_source, provider_id=excluded.provider_id,
            extra_json=excluded.extra_json, updated_at=excluded.updated_at
        """,
        {
            **hotel,
            "distances_json": json.dumps(hotel["distances"], ensure_ascii=False),
            "benefits_json": json.dumps(hotel["benefits"], ensure_ascii=False),
            "images_json": json.dumps(hotel["images"], ensure_ascii=False),
            "extra_json": json.dumps(
                {
                    "sections": hotel["sections"],
                    "filter_base": hotel.get("filter_base"),
                    "tour_tip": hotel.get("tour_tip"),
                },
                ensure_ascii=False,
            ),
            "updated_at": updated_at,
        },
    )


def upsert_flight(conn, flight: dict, scrape_id: int, scraped_at: str) -> None:
    conn.execute(
        """
        INSERT INTO flights (
            destination_id, destination, country, departure_airport, departure_airport_id,
            departure_date, return_date, nights, price_from, currency, search_filter,
            source_url, scrape_id, scraped_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'CZK', ?, ?, ?, ?)
        ON CONFLICT(destination_id, departure_airport_id, departure_date, return_date, nights)
        DO UPDATE SET
            price_from=excluded.price_from, search_filter=excluded.search_filter,
            source_url=excluded.source_url, scrape_id=excluded.scrape_id, scraped_at=excluded.scraped_at
        """,
        (
            flight["destination_id"],
            flight["destination"],
            flight["country"],
            flight["departure_airport"],
            flight["departure_airport_id"],
            flight["departure_date"],
            flight["return_date"],
            flight["nights"],
            flight["price_from"],
            flight["search_filter"],
            flight["source_url"],
            scrape_id,
            scraped_at,
        ),
    )


def make_offer_key(offer: dict) -> str:
    return "|".join(
        [
            "hotel",
            offer["departure_date"],
            offer["return_date"],
            str(offer["nights"]),
            offer["meal"],
            offer["room"],
        ]
    )


def record_hotel_offer(conn, hotel_id: int, offer: dict, scrape_id: int, scraped_at: str) -> str:
    offer_key = make_offer_key(offer)
    conn.execute(
        """
        INSERT INTO offers (
            hotel_id, offer_key, departure_date, return_date, nights, days, departure_airport,
            arrival_airport, meal, room, price_total, price_per_person, currency, scope,
            booking_url, raw_json, scrape_id, scraped_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CZK', 'hotel', ?, ?, ?, ?)
        ON CONFLICT(hotel_id, offer_key) DO UPDATE SET
            days=excluded.days, price_total=excluded.price_total,
            price_per_person=excluded.price_per_person, booking_url=excluded.booking_url,
            scrape_id=excluded.scrape_id, scraped_at=excluded.scraped_at, raw_json=excluded.raw_json
        """,
        (
            hotel_id,
            offer_key,
            offer["departure_date"],
            offer["return_date"],
            offer["nights"],
            offer.get("days"),
            offer["departure_airport"],
            offer["arrival_airport"],
            offer["meal"],
            offer["room"],
            offer["price_total"],
            offer["price_per_person"],
            offer["booking_url"],
            json.dumps(offer.get("raw"), ensure_ascii=False),
            scrape_id,
            scraped_at,
        ),
    )
    hist_key = f"{hotel_id}:{offer_key}"
    last = conn.execute(
        "SELECT price FROM price_history WHERE offer_key=? ORDER BY id DESC LIMIT 1",
        (hist_key,),
    ).fetchone()
    if last is None or float(last["price"]) != float(offer["price_total"]):
        conn.execute(
            """
            INSERT INTO price_history (
                hotel_id, offer_key, departure_date, return_date, nights, departure_airport,
                meal, room, price, currency, scope, scrape_id, scraped_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'CZK', 'hotel', ?, ?)
            """,
            (
                hotel_id,
                hist_key,
                offer["departure_date"],
                offer["return_date"],
                offer["nights"],
                offer["departure_airport"],
                offer["meal"],
                offer["room"],
                offer["price_total"],
                scrape_id,
                scraped_at,
            ),
        )
    return offer_key


def build_snapshot(conn) -> dict:
    hotels = [dict(r) for r in conn.execute("SELECT * FROM hotels ORDER BY destination, name")]
    flights = [dict(r) for r in conn.execute("SELECT * FROM flights ORDER BY departure_date, destination")]
    offers = [dict(r) for r in conn.execute("SELECT * FROM offers ORDER BY departure_date")]
    history = [
        dict(r)
        for r in conn.execute(
            "SELECT * FROM price_history ORDER BY scraped_at DESC, id DESC LIMIT 2000"
        )
    ]
    last = conn.execute("SELECT * FROM scrapes ORDER BY id DESC LIMIT 1").fetchone()

    for h in hotels:
        h["distances"] = json.loads(h["distances_json"] or "{}")
        h["benefits"] = json.loads(h["benefits_json"] or "[]")
        h["images"] = json.loads(h["images_json"] or "[]")
        extra = json.loads(h["extra_json"] or "{}")
        h["sections"] = extra.get("sections") or {}
        h.pop("distances_json", None)
        h.pop("benefits_json", None)
        h.pop("images_json", None)
        h.pop("extra_json", None)
        h["flights"] = [
            f
            for f in flights
            if f["destination_id"] == h["destination_id"]
            or (f["destination"] and f["destination"].split(" ")[0] in (h["destination"] or ""))
        ]
        h["offers"] = [o for o in offers if o["hotel_id"] == h["id"] and o.get("scope") == "hotel"]
        h["history"] = [x for x in history if x["hotel_id"] == h["id"] and x.get("scope") == "hotel"][:40]

    return {
        "generated_at": now_iso(),
        "filters": public_filters(json.loads(CONFIG_PATH.read_text(encoding="utf-8"))),
        "last_scrape": dict(last) if last else None,
        "hotels": hotels,
        "flights": flights,
    }


def run() -> None:
    cfg = load_config()
    conn = init_db()
    started = now_iso()
    cur = conn.execute(
        "INSERT INTO scrapes (started_at, notes) VALUES (?, ?)",
        (started, "hotely + konkrétní termíny z Pardubic (all inclusive, 2 osoby)"),
    )
    scrape_id = cur.lastrowid
    hotels_ok = 0
    offers_ok = 0
    notes = []
    events: list[dict] = []

    for item in cfg["hotels"]:
        url = item["url"]
        print(f"Hotel: {url}", flush=True)
        try:
            html = fetch(url, cfg["user_agent"])
            hotel = parse_hotel(html, item)
            upsert_hotel(conn, hotel, started)
            hotels_ok += 1
            print(f"  → {hotel['name']} ({hotel['destination']}, letiště {hotel['airport_distance']})")

            previous = {
                row["offer_key"]: dict(row)
                for row in conn.execute(
                    "SELECT offer_key, price_total FROM offers WHERE hotel_id=? AND scope='hotel'",
                    (hotel["id"],),
                )
            }
            offers = search_hotel_offers(hotel, cfg)
            seen_keys = set()
            for offer in offers:
                key = make_offer_key(offer)
                seen_keys.add(key)
                old = previous.get(key)
                if old is None:
                    events.append({"type": "new", "hotel": hotel["name"], "offer": offer})
                elif old["price_total"] is not None and float(old["price_total"]) != float(offer["price_total"]):
                    events.append(
                        {
                            "type": "price",
                            "hotel": hotel["name"],
                            "offer": offer,
                            "old_price": old["price_total"],
                        }
                    )
                record_hotel_offer(conn, hotel["id"], offer, scrape_id, started)
                offers_ok += 1
            if seen_keys:
                conn.execute(
                    "DELETE FROM offers WHERE hotel_id=? AND scope='hotel' AND offer_key NOT IN ({})".format(
                        ",".join("?" * len(seen_keys))
                    ),
                    (hotel["id"], *seen_keys),
                )
            else:
                conn.execute("DELETE FROM offers WHERE hotel_id=? AND scope='hotel'", (hotel["id"],))
            if offers:
                cheapest = offers[0]
                print(
                    f"  → {len(offers)} termín(ů) z Pardubic, od {cheapest['price_total']:.0f} Kč "
                    f"({cheapest['departure_date']} → {cheapest['return_date']}, "
                    f"{cheapest['nights']} nocí / {cheapest['days']} dní)"
                )
            else:
                print("  → v okně není all inclusive termín z Pardubic")
            conn.commit()
        except Exception as exc:  # noqa: BLE001
            notes.append(f"{url}: {exc}")
            print(f"  CHYBA: {exc}")
        time.sleep(cfg.get("request_delay_sec", 0.5))

    conn.execute(
        "UPDATE scrapes SET finished_at=?, hotels_ok=?, flights_ok=?, notes=? WHERE id=?",
        (now_iso(), hotels_ok, offers_ok, "; ".join(notes), scrape_id),
    )
    conn.commit()

    snapshot = build_snapshot(conn)
    SNAPSHOT_PATH.parent.mkdir(parents=True, exist_ok=True)
    SNAPSHOT_PATH.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
    conn.close()

    try:
        send_events(cfg, events)
    except Exception as exc:  # noqa: BLE001
        print(f"E-mail se nepodařilo odeslat: {exc}")
        notes.append(f"email: {exc}")

    print(f"Hotovo: {hotels_ok} hotelů, {offers_ok} termínů, {len(events)} změn → {SNAPSHOT_PATH}")


if __name__ == "__main__":
    run()
