#! /usr/bin/python3

from __future__ import annotations

import collections.abc
import datetime
import email.headerregistry
import email.message
import fnmatch
import gzip
import io
import os.path
import pathlib
import pwd
import socket
import subprocess
import textwrap
import traceback
import typing

from systemd import journal

TODAY = datetime.date.today()
YESTERDAY = TODAY - datetime.timedelta(days=1)

START = datetime.datetime.combine(YESTERDAY, datetime.time(0, 0, 0)).astimezone()
UNTIL = datetime.datetime.combine(TODAY, datetime.time(0, 0, 0)).astimezone()


class Entry:
    """See: man 7 systemd.journal-fields"""

    def __init__(self, raw: journal.Entry) -> None:
        self.raw = raw

    def __getattr__(self, key: str) -> typing.Any:
        return self.raw.get(key)

    @property
    def walltime(self) -> datetime.datetime:
        return self.raw["__REALTIME_TIMESTAMP"]

    @property
    def priority(self) -> journal.LogLevel:
        return self.raw.get("PRIORITY", journal.LOG_WARNING)

    @property
    def who(self) -> str:
        return (
            self._COMM
            or self._SYSTEMD_UNIT
            or self._SYSTEMD_USER_UNIT
            or self.SYSLOG_IDENTIFIER
            or "unknown"
        )


def is_stne(name: typing.Any) -> bool:
    if isinstance(name, str):
        return name.lower().startswith("stne-")
    elif isinstance(name, collections.abc.Iterable):
        return any(is_stne(n) for n in name)
    return False


def get_user() -> str:
    uid = os.getuid()

    try:
        return pwd.getpwuid(uid).pw_name
    except KeyError:
        return f"uid-{uid}"


def sendmail(body: str) -> None:
    host = socket.gethostname()

    msg = email.message.EmailMessage()
    msg.set_charset("utf-8")
    msg["From"] = email.headerregistry.Address(
        display_name=os.path.basename(__file__),
        username=get_user(),
        domain=host,
    )
    msg["Subject"] = f"{YESTERDAY}: Log Report: {host}"

    if len(body) > (100 * 1024):
        msg.set_content("Log data attached")
        msg.add_attachment(
            gzip.compress(body.encode()),
            maintype="application",
            subtype="gzip",
            filename=f"{YESTERDAY}-{host}.log.gz",
        )
    else:
        msg.set_content(body)

    subprocess.run(
        ["sendmail", "-oi", "root"],
        input=msg.as_bytes(),
        check=True,
    )


def state_path() -> typing.Optional[pathlib.Path]:
    state_dir = os.environ.get("STATE_DIRECTORY")
    if not state_dir:
        return None

    return pathlib.Path(state_dir) / "cursor"


def iter_entries() -> typing.Iterator[Entry]:
    r = journal.Reader()
    cursor = ""
    path = state_path()
    sought = False

    if path:
        try:
            cursor = path.read_text()
            r.seek_cursor(cursor)
            r.get_next()

            # Skip records that have already been processed: seek goes to
            # exactly the record given by cursor, if it exists, or the nearest
            # after (courtesy of get_next()) if it doesn't.
            if r.test_cursor(cursor):
                r.get_next()

            sought = True
        except OSError:
            pass

    if not sought:
        r.seek_realtime(START)

    for ent in r:
        e = Entry(ent)
        if e.walltime >= UNTIL:
            break

        cursor = e.__CURSOR
        yield e

    if cursor and path:
        path.write_text(cursor)


acks: typing.Sequence[typing.Callable[[Entry], bool]] = (
    # Everywhere that a stne-* might be mentioned
    lambda e: (
        not any(
            is_stne(name)
            for name in (
                e._COMM,
                e._SYSTEMD_UNIT,
                e._SYSTEMD_USER_UNIT,
                e.SYSLOG_IDENTIFIER,
                e.UNIT,
                e.USER_UNIT,
            )
        )
    ),
    # Stuff that leaks through stne-dm's std{out,err}. Moving the session leader
    # to a different syslog name isn't useful since some stne-*s are run
    # directly by the wm (eg. stne-volume), and their errors still need to be
    # caught.
    lambda e: (
        e._COMM
        in (
            "i3",
            "i3bar",
            "i3status",
            "xhost",
            "xinit",
            "Xorg",
        )
    ),
    # Non-error messages from mta
    lambda e: (
        e._SYSTEMD_UNIT == "stne-mta.service" and e.priority >= journal.LOG_NOTICE
    ),
    # Not interesting
    lambda e: (
        e._COMM == "stne-dm"
        and any(
            e.MESSAGE.startswith(m)
            for m in (
                "I: caught signal, exiting",
                "pam_unix(stne-dm-autologin:session): session closed for user",
                "pam_unix(stne-dm-autologin:session): session opened for user",
            )
        )
    ),
    lambda e: (
        e._COMM == "stne-dm-session"
        and any(
            e.MESSAGE.startswith(m)
            for m in (
                "I: parent fd closed, beginning shutdown",
                "I: caught final signal, exiting",
            )
        )
    ),
    # Yeah, I'm bad at typing my password
    lambda e: (
        e._SYSTEMD_USER_UNIT == "stne-lock.service"
        and any(
            m in e.MESSAGE
            for m in (
                "authentication failure",
                "password check failed for user",
            )
        )
    ),
    # Nice to know, but nothing I can do about it
    lambda e: (
        e._SYSTEMD_USER_UNIT == "stne-color.service"
        and e.MESSAGE.startswith("failed to parse EDID for output")
    ),
    # Boring entries from systemd about stne-* units (eg. "Starting <unit>...")
    lambda e: (
        (is_stne(e.USER_UNIT) or is_stne(e.UNIT)) and e.priority >= journal.LOG_NOTICE
    ),
    # Specific messages from short-lived progs
    lambda e: (
        any(
            fnmatch.fnmatch(e.MESSAGE, m)
            for m in (
                # maim
                "Selection was cancelled by keystroke or right-click.",
                # stk
                "stk-dialog: grab failed",
                # xhost
                "localuser:* being added to access control list",
                "non-network local connections being removed from access control list",
            )
        )
    ),
)


def main() -> None:
    msgs = io.StringIO()

    try:
        for e in iter_entries():
            if any(ack(e) for ack in acks):
                continue

            msg = textwrap.indent(e.MESSAGE.strip(), " " * 4).strip()
            msgs.write(f"{e.walltime} [{e.who}]: {msg}\n")
    except:
        msgs.write(traceback.format_exc())
    finally:
        if body := msgs.getvalue():
            sendmail(body)


if __name__ == "__main__":
    main()
