#!/usr/bin/env python3
import io
import json
import locale
import logging
import os
import re
import shlex
import shutil
import stat
import sys
import tempfile
import xml.etree.ElementTree as ET
from contextlib import contextmanager
from datetime import date, datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from html.parser import HTMLParser
from http.client import HTTPResponse
from typing import (
    Dict,
    Final,
    IO,
    Iterator,
    List,
    Optional,
    Tuple,
    TypedDict,
    Union,
    overload,
)
from urllib.request import Request, urlopen
from urllib.parse import urlencode, urljoin

logger = logging.getLogger(__name__)


@contextmanager
def create_tempfile(*args, **kwargs) -> Iterator[tempfile._TemporaryFileWrapper]:
    tmp = tempfile.NamedTemporaryFile(*args, **kwargs)
    try:
        os.chmod(tmp.fileno(), stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH)
        yield tmp
    finally:
        try:
            tmp.close()
        except FileNotFoundError:
            pass


vtimezone_europe_berlin = [
    "BEGIN:VTIMEZONE\r\n",
    "TZID:Europe/Berlin\r\n",
    "BEGIN:STANDARD\r\n",
    "DTSTART:16011028T030000\r\n",
    "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\r\n",
    "TZOFFSETFROM:+0200\r\n",
    "TZOFFSETTO:+0100\r\n",
    "END:STANDARD\r\n",
    "BEGIN:DAYLIGHT\r\n",
    "DTSTART:16010325T020000\r\n",
    "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3\r\n",
    "TZOFFSETFROM:+0100\r\n",
    "TZOFFSETTO:+0200\r\n",
    "END:DAYLIGHT\r\n",
    "END:VTIMEZONE\r\n",
]


ics_date_format = "%Y%m%d"  # type: Final[str]
ics_datetime_format = ics_date_format + "T%H%M%S"  # type: Final[str]


def ics_escape(x: str) -> str:
    def repl(m: re.Match) -> str:
        c = m[0]
        if c == "\n":
            return "\\n"
        else:
            return "\\" + c

    return re.sub(r"[\\;,\n]", repl, x)


def write_ics_line(fp: IO[bytes], line: str) -> None:
    buf = line.encode("utf-8")
    assert buf.endswith(b"\r\n")
    while len(buf) > 75:
        # see if the line on its own is still valid utf-8
        n = 73
        while True:
            try:
                buf[:n].decode("utf-8")
            except UnicodeDecodeError:
                n -= 1
                continue
            break
        fp.write(buf[:n])
        fp.write(b"\r\n")
        buf = b" " + buf[n:]
    fp.write(buf)


class _WebsiteEvent(TypedDict, total=True):
    url: str
    title: str
    start: Union[date, datetime]
    end: Union[date, datetime]


class WebsiteEvent(_WebsiteEvent, total=False):
    description: str


def write_ics_line_from_event_field(
    fp: IO[bytes],
    property: str,
    ev: WebsiteEvent,
    field: str,
    *,
    required: bool = True,
) -> None:
    try:
        value = ev[field]  # type: ignore
    except KeyError:
        if not required:
            return
        raise
    if value is None:
        if not required:
            return
        raise ValueError
    write_ics_line(fp, "%s:%s\r\n" % (property, ics_escape(value)))


def write_ics_start_end_stamp(
    fp: IO[bytes],
    ev: WebsiteEvent,
    dtstamp: Optional[datetime] = None,
) -> None:
    for property, dt in [
        ("DTSTART", ev["start"]),
        ("DTEND", ev["end"]),
    ]:
        if isinstance(dt, datetime):
            if dt.tzinfo == timezone.utc:
                params = ""
                format = ics_datetime_format + "Z"
            else:
                # we expect our local timezone to be Europe/Berlin
                dt = dt.astimezone(tz=None)
                params = ";TZID=Europe/Berlin"
                format = ics_datetime_format
        else:
            assert isinstance(dt, date)
            params = ";VALUE=DATE"
            format = ics_date_format
        write_ics_line(
            fp, "%s%s:%s\r\n" % (property, params, ics_escape(dt.strftime(format)))
        )

    if dtstamp is None:
        dtstamp = datetime.now(tz=timezone.utc)
    else:
        # convert to UTC
        dtstamp = dtstamp.astimezone(tz=timezone.utc)
    write_ics_line(
        fp, "DTSTAMP:%sZ\r\n" % ics_escape(dtstamp.strftime(ics_datetime_format))
    )


def write_ics_from_event(
    fp: IO[bytes],
    ev: WebsiteEvent,
    location: str,
    geo: Optional[str] = None,
    dtstamp: Optional[datetime] = None,
) -> None:
    write_ics_line(fp, "BEGIN:VEVENT\r\n")
    write_ics_line_from_event_field(fp, "UID", ev, "url")
    write_ics_line_from_event_field(fp, "SUMMARY", ev, "title")
    write_ics_line_from_event_field(fp, "URL", ev, "url")
    write_ics_line(fp, "LOCATION:%s\r\n" % ics_escape(location))
    if geo is not None:
        write_ics_line(fp, "GEO:%s\r\n" % geo)
    write_ics_line_from_event_field(
        fp,
        "DESCRIPTION",
        ev,
        "description",
        required=False,
    )
    write_ics_start_end_stamp(fp, ev, dtstamp)
    write_ics_line(fp, "END:VEVENT\r\n")


def xml_get_text(elem: Optional[ET.Element]) -> str:
    assert elem is not None
    text = elem.text
    assert text is not None
    return text


def html_escape(x: str) -> str:
    def repl(m: re.Match) -> str:
        return {
            "&": "&amp;",
            "<": "&lt;",
            '"': "&dquot;",
        }[m[0]]

    return re.sub(r'[&<"]', repl, x)


def html_datetime(dt: datetime) -> str:
    iso = dt.isoformat(timespec="seconds")
    if iso.endswith("+00:00"):
        iso = iso[:-6] + "Z"
    return '<time datetime="%s">%s</time>' % (
        html_escape(iso),
        html_escape(dt.strftime("%Y-%m-%d %H:%M:%S %Z").rstrip()),
    )


# Bärenzwinger #################################################################


BZEvent = Dict[str, str]


@contextmanager
def fetch_bz(url: str, content_type: str) -> Iterator[HTTPResponse]:
    user_agent = (
        "User-Agent",
        "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0",
    )
    logger.debug(
        "$ curl -H %s %s",
        shlex.quote("%s: %s" % user_agent),
        shlex.quote(url),
    )
    with urlopen(Request(url, headers=dict([user_agent]))) as resp:
        assert resp.status == 200
        assert resp.getheader("content-type") == content_type, "expected %s, got %s" % (
            content_type,
            resp.getheader("content-type"),
        )
        yield resp


def strip_eol(line: str) -> str:
    for eol in ("\r\n", "\n"):
        if line.endswith(eol):
            return line[: -len(eol)]
    return line


def fetch_bz_ics_event(url: str, organizer: Optional[str]) -> BZEvent:
    with fetch_bz(url, "text/calendar; charset=utf-8") as resp:
        fp = io.TextIOWrapper(resp, encoding="utf-8", newline="\n")
        lines = map(strip_eol, iter(fp))
        for line in lines:
            if line == "BEGIN:VEVENT":
                break
        ics = {}  # type: BZEvent
        key = ""
        for line in lines:
            if line.startswith(" "):
                ics[key] = ics[key][:2] + line[1:] + "\r\n"
            elif line == "END:VEVENT":
                break
            else:
                key = line.split(":", 1)[0]
                assert key not in ics
                ics[key] = line + "\r\n"
                if key == "UID":
                    line += "@baerenzwinger.de"
        ics["UID"] = ics["UID"].rstrip() + "@baerenzwinger.de\r\n"
        if (
            "LOCATION" not in ics
            or ics["LOCATION"].rstrip() == "LOCATION:Studentenclub Bärenzwinger"
        ):
            ics["LOCATION"] = "LOCATION:%s\r\n" % ics_escape(
                "Studentenclub Bärenzwinger e.\u202FV.\nBrühlscher Garten 1\n01067 Dresden"
            )
            if "GEO" not in ics:
                ics["GEO"] = "GEO:51.0524493;13.7451854\r\n"
        if "ORGANIZER" not in ics and organizer is not None:
            ics["ORGANIZER"] = "ORGANIZER:%s\r\n" % ics_escape(organizer)
        assert (
            re.match(r"(^DTSTART:\d{8}|.*Z)\r\n$", ics["DTSTART"], re.DOTALL)
            is not None
        )
        assert re.match(r"(^DTEND:\d{8}|.*Z)\r\n$", ics["DTEND"], re.DOTALL) is not None
        # assert ics["DTSTAMP"].endswith("Z\r\n")
        try:
            del ics["TZID"]
        except KeyError:
            pass
        return ics


class BZEventOverviewHtmlParser(HTMLParser):
    @classmethod
    def get_event_urls(cls, html: IO[str]) -> Dict[str, Optional[str]]:
        p = cls()
        p.feed(html.read())
        return p.ics_urls

    def __init__(self) -> None:
        super().__init__()
        self.depth = 0
        self.expect_organizer = -1
        self.expect_organizer_text = False
        self.url = ""
        self.ics_urls = {}  # type: Dict[str, Optional[str]]

    def handle_starttag(self, tag, attrs):
        self.depth += 1
        if (
            self.expect_organizer >= 0
            and ("class", "evo_card_organizer_name_t") in attrs
        ):
            self.expect_organizer_text = True
            self.expect_organizer = -1
            return
        params = {"action": "eventon_ics_download"}
        for name, value in attrs:
            if name == "data-event_id":
                params["event_id"] = value
            elif name == "data-ri":
                assert value.endswith("r")
                value = value[:-1]
                params["ri"] = value
        if len(params) == 1:
            return
        self.url = "https://baerenzwinger.de/wp-admin/admin-ajax.php?" + urlencode(
            params
        )
        assert self.url not in self.ics_urls
        self.ics_urls[self.url] = None
        self.expect_organizer = self.depth

    def handle_endtag(self, tag):
        self.depth -= 1
        if self.depth < self.expect_organizer:
            self.expect_organizer = -1
        self.expect_organizer_text = False

    def handle_data(self, data):
        if self.expect_organizer_text:
            assert self.ics_urls[self.url] is None
            self.ics_urls[self.url] = data
            self.expect_organizer_text = False


def fetch_bz_events() -> List[Dict[str, str]]:
    with fetch_bz(
        "https://baerenzwinger.de/Veranstaltungen/",
        "text/html; charset=UTF-8",
    ) as resp:
        fp = io.TextIOWrapper(resp, encoding="utf-8")
        return [
            fetch_bz_ics_event(url, organizer)
            for url, organizer in BZEventOverviewHtmlParser.get_event_urls(fp).items()
            if not ignore_bzevent(url, organizer)
        ]


def write_bz_event(
    fp: IO[bytes],
    ev: BZEvent,
) -> None:
    properties = [
        ("UID", True),
        ("SUMMARY", True),
        ("URL", False),
        ("LOCATION", True),
        ("GEO", False),
        ("ORGANIZER", False),
        ("DESCRIPTION", False),
        ("DTSTART", True),
        ("DTEND", True),
        ("DTSTAMP", True),
    ]
    write_ics_line(fp, "BEGIN:VEVENT\r\n")
    for prop, required in properties:
        if required or prop in ev:
            write_ics_line(fp, ev[prop])
    for prop, line in ev.items():
        if not any(prop == p for p, _ in properties):
            write_ics_line(fp, line)
    write_ics_line(fp, "END:VEVENT\r\n")


def ignore_bzevent(url: str, organizer: Optional[str]) -> bool:
    if organizer is None:
        return False
    else:
        logger.debug("ignored %s because of %s" % (url, shlex.quote(organizer)))
        return True


@contextmanager
def write_bz(
    ics_name: str,
    html_name: str,
) -> Iterator[Tuple[tempfile._TemporaryFileWrapper, tempfile._TemporaryFileWrapper]]:
    events = fetch_bz_events()
    now = datetime.now(tz=timezone.utc)

    with create_tempfile(dir=os.path.curdir, mode="w+", encoding="utf-8") as html:
        with create_tempfile(dir=os.path.curdir, mode="wb") as ics:
            write_ics_line(ics, "BEGIN:VCALENDAR\r\n")
            write_ics_line(ics, "VERSION:2.0\r\n")
            write_ics_line(ics, "PRODID:-//TODO\r\n")
            for line in vtimezone_europe_berlin:
                write_ics_line(ics, line)
            for ev in events:
                write_bz_event(ics, ev)
            write_ics_line(ics, "END:VCALENDAR\r\n")
            ics.flush()

            html.write(
                """\
    <div>
      <h2>Bärenzwinger</h2>
      <p>Die letzte erfolgreiche Konvertierung fand %(now)s statt.</p>
      <p>Die Wordpress-Seite <a href="https://baerenzwinger.de/Veranstaltungen/">baerenzwinger.de</a> nutzt den Plugin <a href="https://wordpress.org/plugins/eventon-lite/">EventON</a>. Dieser stellt iCalendar-Dateien für einzelne Events bereit. Diese werden zu <a href="%(ics_name)s">%(ics_name)s</a> kombiniert und mit zusätzlichen Informationen auf der HTML-Seite gefiltert.</p>
    </div>
"""
                % {
                    "ics_name": html_escape(ics_name),
                    "now": html_datetime(now),
                }
            )
            html.flush()

            yield (ics, html)

            os.replace(ics.name, ics_name)
        os.replace(html.name, html_name)


# eXma #########################################################################


@contextmanager
def fetch_exma(url: str) -> Iterator[IO[str]]:
    logger.debug("$ curl %s", shlex.quote(url))
    with urlopen(url) as resp:
        assert resp.status == 200
        assert resp.getheader("content-type", "").endswith(
            "; charset=iso-8859-1"
        ), "expected %s, got %s" % (
            "charset=iso-8859-1",
            resp.getheader("content-type"),
        )
        fp = io.TextIOWrapper(resp, encoding="iso-8859-1")
        yield fp


class ExmaEventHtmlParser(HTMLParser):
    @classmethod
    def get_event(cls, html: IO[str], url: str) -> WebsiteEvent:
        p = cls()
        p.feed(html.read())
        msg = "title=%r, start_date=%r, start_time=%r, description=%r for event %s" % (
            p.title,
            p.start_date,
            p.start_time,
            p.description,
            url,
        )
        assert p.title is not None, msg
        assert p.start_time is not None, msg
        assert p.start_date is not None, msg
        start = datetime.combine(p.start_date, p.start_time)
        ev = WebsiteEvent(
            url=url,
            title=p.title,
            start=start,
            end=start + timedelta(hours=4),
        )
        if p.description:
            ev["description"] = p.description
        return ev

    def __init__(self):
        super().__init__()
        self.title = None
        self.description = ""
        self.start_date = None
        self.start_time = None
        self.hrefs = []
        self.depth = 0
        self.expect_title = False
        self.expect_start_time = -1
        self.expect_start_date = False
        self.expect_start_time_label = True
        self.expect_description = -1

    def handle_starttag(self, tag, attrs):
        self.depth += 1
        if self.expect_description >= 0:
            if tag == "br":
                self.description += "\n"
            elif tag == "a":
                href = ""
                for name, value in attrs:
                    if name == "href":
                        href = value
                        break
                self.hrefs.append(href)
        elif ("class", "event-info") in attrs:
            self.expect_description = self.depth
            self.hrefs = []
        elif ("class", "eventhead") in attrs:
            self.expect_title = True
            # because the title is followed by <br/> handle_endtag will
            # set self.expect_title to False
        elif ("class", "eventdate") in attrs:
            self.expect_start_date = True
        elif tag == "b":
            self.expect_start_time_label = True

    def handle_endtag(self, tag):
        self.depth -= 1
        self.expect_title = False
        self.expect_start_date = False
        self.expect_start_time_label = False
        if self.expect_description >= 0:
            if tag == "a":
                href = self.hrefs.pop()
                if href:
                    self.description += " <" + href + ">"
        if self.depth < self.expect_description:
            self.expect_description = -1
        if self.depth < self.expect_start_time:
            self.expect_start_time = -1

    def handle_data(self, data):
        if self.expect_description >= 0:
            self.description += data
        elif self.expect_title:
            self.title = data
            self.expect_title = False
        elif self.expect_start_date:
            m = re.search(r"(\d\d\.\d\d\.\d\d\d\d)$", data)
            if m is not None:
                self.start_date = datetime.strptime(m[1], "%d.%m.%Y").date()
                self.expect_start_date = False
        elif self.expect_start_time >= 0:
            m = re.search(r"\d\d:\d\d(?= Uhr)", data)
            if m is not None:
                self.start_time = datetime.strptime(m[0], "%H:%M").time()
                self.expect_start_time = False
        elif self.expect_start_time_label:
            # must come last because it does not only run once
            if data == "Beginn:":
                self.expect_start_time = self.depth - 1
                # we do not set self.expect_start_time_label = False because
                # if there is other data in the <b> we can unset it
            else:
                self.expect_start_time = -1
                self.expect_start_time_label = False


def fetch_exma_event(url: str) -> WebsiteEvent:
    with fetch_exma(url) as fp:
        return ExmaEventHtmlParser.get_event(fp, url)


class ExmaEventOverviewHtmlParser(HTMLParser):
    """This parser looks for the first <a> inside class="normal", similar to
    the CSS selectors `.normal a`."""

    @classmethod
    def get_event_urls(cls, html: IO[str], baseurl: str) -> List[str]:
        p = cls()
        p.feed(html.read())
        return [urljoin(baseurl, href) for href in p.hrefs]

    def __init__(self):
        super().__init__()
        self.hrefs = []
        self.depth = 0
        self.expect_a = -1

    def handle_starttag(self, tag, attrs):
        self.depth += 1
        if self.expect_a >= 0:
            if tag == "a":
                for attr, value in attrs:
                    if attr == "href":
                        self.hrefs.append(value)
                        self.expect_a = -1
                        break
        elif ("class", "normal") in attrs:
            self.expect_a = self.depth

    def handle_endtag(self, tag):
        self.depth -= 1
        if self.depth < self.expect_a:
            self.expect_a = -1


def normalize_exma_event_url(url: str) -> str:
    m = re.match(
        r"^(https://www\.exmatrikulationsamt\.de)/event_(\d+\.html)(?:\?.*)$",
        url,
    )
    if m is None:
        return url
    return "%s/events/%s" % (m[1], m[2])


def fetch_exma_event_overview(location: str) -> List[WebsiteEvent]:
    url = "https://www.exmatrikulationsamt.de/location/" + location
    with fetch_exma(url) as fp:
        return sorted(
            (
                fetch_exma_event(normalize_exma_event_url(ev_url))
                for ev_url in ExmaEventOverviewHtmlParser.get_event_urls(fp, url)
            ),
            key=lambda ev: ev["start"],
        )


@contextmanager
def write_exma(
    club: str,
    address: str,
    geo: str,
    ics_name: str,
    html_name: str,
    *,
    name: Optional[str] = None,
) -> Iterator[Tuple[tempfile._TemporaryFileWrapper, tempfile._TemporaryFileWrapper]]:
    if name is None:
        name = club
    events = fetch_exma_event_overview(club)
    now = datetime.now(tz=timezone.utc)

    with create_tempfile(dir=os.path.curdir, mode="w+", encoding="utf-8") as html:
        with create_tempfile(dir=os.path.curdir, mode="wb") as ics:
            write_ics_line(ics, "BEGIN:VCALENDAR\r\n")
            write_ics_line(ics, "VERSION:2.0\r\n")
            write_ics_line(ics, "PRODID:-//TODO\r\n")
            for line in vtimezone_europe_berlin:
                write_ics_line(ics, line)
            for ev in events:
                write_ics_from_event(
                    ics,
                    ev,
                    location=address,
                    geo=geo,
                    dtstamp=now,
                )
            write_ics_line(ics, "END:VCALENDAR\r\n")
            ics.flush()

            html.write(
                """\
    <div>
      <h2>%(name)s</h2>
      <p>Die letzte erfolgreiche Konvertierung fand %(now)s statt.</p>
      <p><a href="%(ics_name)s">%(ics_name)s</a> wird aus der <a href="%(url)s">eXma-Seite</a> generiert.</p>
    </div>
"""
                % {
                    "name": html_escape(name),
                    "ics_name": html_escape(ics_name),
                    "now": html_datetime(now),
                    "url": html_escape(
                        "https://www.exmatrikulationsamt.de/location/" + club
                    ),
                }
            )
            html.flush()

            yield (ics, html)

            os.replace(ics.name, ics_name)
        os.replace(html.name, html_name)


# Traumtänzer ##################################################################


class _TTEvent(WebsiteEvent, total=True):
    uid: str


class TTEvent(_TTEvent, total=False):
    location: str
    geo: str
    html_desc: str


@overload
def parse_mec_date(
    date_elem: Optional[ET.Element],
    hour_elem: Optional[ET.Element],
    dtstart: None = None,
) -> Union[date, datetime]:
    ...


@overload
def parse_mec_date(
    date_elem: Optional[ET.Element],
    hour_elem: Optional[ET.Element],
    dtstart: datetime,
) -> datetime:
    ...


# dtstart: date must come after dtstart: datetime, because datetime is a
# subclass of date
@overload
def parse_mec_date(
    date_elem: Optional[ET.Element],
    hour_elem: Optional[ET.Element],
    dtstart: date,
) -> date:
    ...


def parse_mec_date(date_elem, hour_elem, dtstart=None):
    d = datetime.strptime(xml_get_text(date_elem), "%Y-%m-%d").date()
    try:
        t_text = xml_get_text(hour_elem)
    except AssertionError:
        t_text = "Ganztägig"
    # if DTSTART is a DATE then DTEND must be a DATE too
    # datetime is a subclass of date, so we cannot use isinstance(dtstart, date)
    if t_text == "Ganztägig":
        assert dtstart is None or not isinstance(dtstart, datetime)
        return d
    elif isinstance(dtstart, datetime):
        return datetime.strptime(t_text, "%H:%M").replace(
            year=d.year,
            month=d.month,
            day=d.day,
            tzinfo=None if dtstart is None else dtstart.tzinfo,
        )
    else:
        return d


def strip_tt_description(x: str) -> str:
    # TODO so far I have only seen <p> and <br/> tags
    return x


def parse_tt_rss(xml: IO[bytes]) -> List[TTEvent]:
    events = []  # type: List[TTEvent]
    tree = ET.parse(xml)
    ns = {
        "content": "http://purl.org/rss/1.0/modules/content/",
        "mec": "http://webnus.net/rss/mec/",
    }
    for item in tree.iterfind("./channel/item", ns):
        url = xml_get_text(item.find("./link", ns))

        try:
            uid = xml_get_text(item.find("./guid", ns))
        except AssertionError:
            uid = url
        else:
            if not uid:
                uid = url

        title = xml_get_text(item.find("./title", ns))

        try:
            start = parsedate_to_datetime(
                xml_get_text(item.find("./pubDate", ns))
            )  # type: date
            assert isinstance(start, datetime)
            # parsedate_to_datetime sets tzinfo to None for UTC
            if start.tzinfo is None:
                start = start.replace(tzinfo=timezone.utc)
        except AssertionError:
            # All-day events seem to have an empty `<pubDate>`, first
            # encountered at 2023-12-05 for [Winterpause - Geschlossen](https://club-traumtaenzer.de/index.php/events/winterpause-geschlossen/?occurrence=2023-12-26).
            start = parse_mec_date(
                item.find("./mec:startDate", ns),
                item.find("./mec:startHour", ns),
            )

        try:
            # <mec:(start|end)(Date|Hour)> do not have timezone information,
            # try to re-use timezone from <pubDate>.
            end = parse_mec_date(
                item.find("./mec:endDate", ns),
                item.find("./mec:endHour", ns),
                start,
            )
        except Exception:
            logger.exception(
                "cannot get end of event %s, using end of day",
                shlex.quote(uid),
            )
            if isinstance(start, date):
                end = start
            else:
                end = (start + timedelta(days=1)).replace(hour=0, minute=0, second=0)

        ev = TTEvent(
            uid=uid,
            url=url,
            title=title,
            start=start,
            end=end,
        )

        try:
            desc = strip_tt_description(
                xml_get_text(item.find("./content:encoded", ns))
            )
            assert desc
            ev["description"] = desc
        except Exception:
            logger.exception(
                "cannot get plain-text description of event %s",
                shlex.quote(uid),
            )
        try:
            desc = xml_get_text(item.find("./description", ns))
            assert desc
            ev["html_desc"] = desc
        except Exception:
            logger.exception(
                "cannot get HTML description of event %s",
                shlex.quote(uid),
            )

        try:
            location = xml_get_text(item.find("./mec:location", ns))
            assert location not in (
                "Budapester Str. 24, 01069 Dresden",
                "Budapester Str. 24 a, 01069 Dresden",
            )
            ev["location"] = location
        except AssertionError:
            ev[
                "location"
            ] = "Club Traumtänzer e.\u202FV.\nBudapester Str. 24\n01069 Dresden"
            ev["geo"] = "51.04227;13.72110"

        events.append(ev)
    return events


def fetch_tt_calendar(start: date) -> List[TTEvent]:
    url = "https://club-traumtaenzer.de/index.php/events/feed"
    logger.debug("$ curl -L %s" % shlex.quote(url))
    with urlopen(url) as resp:
        assert resp.status == 200
        assert (
            resp.getheader("content-type", "").split(";", 1)[0] == "application/rss+xml"
        ), "expected %s, got %s" % (
            "application/rss+xml",
            resp.getheader("content-type"),
        )
        return parse_tt_rss(resp)


def write_ics_from_ttevent(
    fp: IO[bytes],
    ev: TTEvent,
    dtstamp: Optional[datetime] = None,
) -> None:
    write_ics_line(fp, "BEGIN:VEVENT\r\n")
    write_ics_line(fp, "%s:%s\r\n" % ("UID", ics_escape(ev["uid"])))
    write_ics_line_from_event_field(fp, "SUMMARY", ev, "title")
    write_ics_line_from_event_field(fp, "URL", ev, "url")
    write_ics_line_from_event_field(fp, "LOCATION", ev, "location", required=False)
    if "geo" in ev:
        write_ics_line(fp, "GEO:%s\r\n" % ev["geo"])
    write_ics_line_from_event_field(
        fp,
        "DESCRIPTION",
        ev,
        "description",
        required=False,
    )
    write_ics_line_from_event_field(
        fp,
        "X-ALT-DESC;FMTTYPE=text/html",
        ev,
        "html_desc",
        required=False,
    )
    write_ics_start_end_stamp(fp, ev, dtstamp)
    write_ics_line(fp, "END:VEVENT\r\n")


@contextmanager
def write_tt(
    ics_name: str,
    html_name: str,
) -> Iterator[Tuple[tempfile._TemporaryFileWrapper, tempfile._TemporaryFileWrapper]]:
    events = fetch_tt_calendar(date.today())
    now = datetime.now(tz=timezone.utc)

    with create_tempfile(dir=os.path.curdir, mode="w+", encoding="utf-8") as html:
        with create_tempfile(dir=os.path.curdir, mode="wb") as ics:
            write_ics_line(ics, "BEGIN:VCALENDAR\r\n")
            write_ics_line(ics, "VERSION:2.0\r\n")
            write_ics_line(ics, "PRODID:-//TODO\r\n")
            for line in vtimezone_europe_berlin:
                write_ics_line(ics, line)
            for ev in events:
                # TODO filter
                write_ics_from_ttevent(ics, ev, now)
            write_ics_line(ics, "END:VCALENDAR\r\n")
            ics.flush()

            html.write(
                """\
    <div>
      <h2>Traumtänzer</h2>
      <p>Die letzte erfolgreiche Konvertierung fand %(now)s statt.</p>
      <p>Die Wordpress-Seite <a href="https://club-traumtaenzer.de/index.php/veranstaltungen/">club-traumtaenzer.de</a> nutzt den Plugin <a href="https://webnus.net/modern-events-calendar/lite/">Modern Events Calendar Lite</a>. Der <a href="https://club-traumtaenzer.de/index.php/events/feed">RSS-Feed</a> wird zu <a href="%(ics_name)s">%(ics_name)s</a> konvertiert.</p>
    </div>
"""
                % {
                    "ics_name": html_escape(ics_name),
                    "now": html_datetime(now),
                }
            )
            html.flush()

            yield (ics, html)

            os.replace(ics.name, ics_name)
        os.replace(html.name, html_name)


# main #########################################################################


if __name__ == "__main__":
    logging.basicConfig(
        format="[%(asctime)s] %(levelname)-8s %(message)s",
        stream=sys.stderr,
        level=logging.DEBUG,
    )
    locale.setlocale(locale.LC_TIME, "de_DE.UTF-8")

    with create_tempfile(dir=os.path.curdir, mode="w", encoding="utf-8") as fp_index:
        fp_index.write(
            """\
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <meta charset="UTF-8" />
    <title>Studentenclub iCalendar Adapter</title>
  </head>
  <body>
    <h1>Studentenclub iCalendar Adapter</h1>
    <p>Einige Clubs stellen ihre Veranstaltungen nicht im iCalendar-Format (<a href="https://www.rfc-editor.org/rfc/rfc5545">RFC5545</a>) bereit. <a href="ics.py">ics.py</a> lädt sich die Veranstaltungen von den Websiten und erstellt daraus iCalendar-Feeds.</p>
"""
        )
        try:
            for club_func, ics_name, html_name in [
                (write_bz, "baerenzwinger.ics", "baerenzwinger.html"),
                (
                    lambda ics, html: write_exma(
                        "Borsi_34",
                        "Studentenclub Borsi 34 e.\u202FV.\nBorsbergstr. 34\n01309 Dresden",
                        "geo:51.0328076;13.7401300",
                        ics,
                        html,
                        name="Borsi 34",
                    ),
                    "borsi.ics",
                    "borsi.html",
                ),
                (
                    lambda ics, html: write_exma(
                        "Gutzkowclub",
                        "Gutzkowclub \u2013 GC e.\u202FV.\nGutzkowstr. 29-33\n01069 Dresden",
                        "geo:51.0328076;13.7401300",
                        ics,
                        html,
                    ),
                    "gutzkowclub.ics",
                    "gutzkowclub.html",
                ),
                (
                    lambda ics, html: write_exma(
                        "Club_Novitatis",
                        "Club Novitatis\nFritz-Löffler-Str. 12\n01069 Dresden",
                        "geo:51.0371949;13.7313460",
                        ics,
                        html,
                        name="Novitatis",
                    ),
                    "novitatis.ics",
                    "novitatis.html",
                ),
                (write_tt, "traumtaenzer.ics", "traumtaenzer.html"),
            ]:
                pos = fp_index.tell()
                try:
                    with club_func(ics_name, html_name) as (fp_ics, fp_new_html):
                        fp_new_html.seek(0)
                        shutil.copyfileobj(fp_new_html, fp_index)
                except Exception:
                    logger.exception("Cannot write %s", shlex.quote(ics_name))
                    # discard new HTML and copy old HTML
                    fp_index.seek(pos)
                    fp_index.truncate(pos)
                    try:
                        with open(html_name, "r", encoding="utf-8") as fp_old_html:
                            shutil.copyfileobj(fp_old_html, fp_index)
                    except FileNotFoundError:
                        pass
                else:
                    logger.info("wrote %s", shlex.quote(ics_name))
            fp_index.write(
                """\
  </body>
</html>
"""
            )
        finally:
            # we do not care if index.html is broken, we want as much output as
            # possible
            fp_index.flush()
            os.replace(fp_index.name, "index.html")
