#!/usr/bin/python3
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, Iterable
import os

from clapp import (
    AppError,
    AppBug,
    BaseApp,
)
from clapp.cli import (
    Bool,
    Pattern,
    LiteralPos,
    ScalarPos,
    MaybeScalarPos,
    P_HELP,
    C_NONOPT,
)
from clapp.table import Table
from clapp.sh import run_cmd, stdout_of_code
from saturnin.context import get_config_repo, get_context
import neaty.log as LOG

from bmo.util import ParsingError, fmt_duration, parse_duration_brief

CONTEXT = get_context()

AttrsT = dict[str, Any]


def collect_nags(stream_name: str | None,
                 nag_repo: NagRepo,
                 ) -> Iterable[Nag]:
    if stream_name is None:
        yield from sorted(nag_repo.nags.values(), key=lambda n: n.name)
    else:
        if stream_name not in nag_repo.streams:
            raise AppError(f"invalid stream: @{stream_name}")
        yield from nag_repo.streams[stream_name].nags


def is_fresh(nag: Nag, flag: Flag) -> bool:
    if flag.age is None:
        return False
    return flag.age < nag.expiry


def print_table(header: list[str], rows: list[list[Any]]) -> None:
    for line in Table.from_data([header] + rows).fmt():
        LOG.debugv('line', line)
        print(line)


class PresentMode(Enum):
    TERM = 'term'
    NOTIFY_SEND = 'notify_send'


def prefix_lines(prefix: str, text: str) -> str:
    return '\n'.join(prefix + line for line in text.splitlines())


def maybe_run_nag(nag: Nag,
                  flag_repo: FlagRepo,
                  mode: PresentMode,
                  ) -> bool:
    flag = flag_repo.get_flag(nag.name)
    if is_fresh(nag, flag):
        LOG.debug(f"skipping fresh nag: {nag.name}, flag.age={flag.age}")
        return False
    LOG.think(f"running nag: {nag.name}")
    try:
        text = stdout_of_code(nag.command, name=nag.name)
    except RuntimeError:
        return False
    present_nag(
        text=text,
        nag=nag,
        mode=mode,
    )
    flag_repo.set_flag(nag.name)
    return True


def present_nag(text: str, nag: Nag, mode: PresentMode) -> None:
    if mode == PresentMode.TERM:
        prefix = nag.prefix or (nag.name + ':')
        print(
            nag.color.code()
            + prefix_lines(prefix, text)
            + Color.NONE.code()
        )
    elif mode == PresentMode.NOTIFY_SEND:
        run_cmd([
            'notify-send',
            '-u', nag.urgency.value,
            '-a', 'bmo-nag',
            nag.name,
            text,
        ])
    else:
        raise AppBug(f"unknown presentation mode: {mode}")


def run_nag_a(params: dict, app: App) -> int:
    nag_repo: NagRepo = params['nag_repo']
    flag_repo: FlagRepo = params['flag_repo']
    nag_name = params.get('NAG', '')
    if not nag_name:
        raise AppBug(f"empty nag name? nag_name={nag_name!r}")
    if nag_name not in nag_repo.nags:
        raise AppError(f"no such nag: nag_name={nag_name!r}")
    ran = maybe_run_nag(
        nag=nag_repo.nags[nag_name],
        flag_repo=flag_repo,
        mode=params['mode'],
    )
    return 0 if ran else 1


def run_stream_a(params: dict, app: App) -> int:
    nag_repo: NagRepo = params['nag_repo']
    flag_repo: FlagRepo = params['flag_repo']
    stream_name = params['STREAM_TO_RUN']
    LOG.think(f"running stream: @{stream_name}")
    for nag in collect_nags(stream_name, nag_repo):
        ran = maybe_run_nag(
            nag=nag,
            flag_repo=flag_repo,
            mode=params['mode'],
        )
        if ran:
            return 0
    return 1


def list_nags_a(params: dict, app: App) -> int:
    def collect_rows() -> Iterable[list[str]]:
        nags = collect_nags(
            stream_name=params.get('STREAM_TO_LIST'),
            nag_repo=params['nag_repo'],
        )
        for nag in nags:
            yield [nag.name, fmt_duration(nag.expiry), nag.command]
    LOG.think("listing all nags")
    rows = list(collect_rows())
    print_table(['NAME', 'EXPIRY', 'COMMAND'], rows)
    return 0 if rows else 1


def list_streams_a(params: dict, app: App) -> int:
    def collect_rows() -> Iterable[list[str]]:
        for stream_name, stream in params['nag_repo'].streams.items():
            yield [stream_name, ' '.join(n.name for n in stream.nags)]
    LOG.think("listing streams")
    rows = list(collect_rows())
    print_table(['NAME', 'NAGS'], rows)
    return 0 if rows else 1


def show_flags_a(params: dict, app: App) -> int:
    def collect_rows() -> Iterable[list[str]]:
        flag_repo: FlagRepo = params['flag_repo']
        nag_repo: NagRepo = params['nag_repo']
        for flag in flag_repo.list_flags():
            if flag.name not in nag_repo.nags:
                continue
            nag = nag_repo.nags[flag.name]
            yield [fmt_duration(flag.age or 0), fmt_duration(nag.expiry), nag.name]
    LOG.think("listing flags")
    rows = list(collect_rows())
    print_table(['AGE', 'EXPIRY', 'NAG'], rows)
    return 0 if rows else 1


def drop_flags_a(params: dict, app: App) -> int:
    LOG.think("dropping all flags")
    flag_repo: FlagRepo = params['flag_repo']
    for flag in flag_repo.list_flags():
        flag_repo.unset_flag(flag.name)
    return 0


class Color(Enum):
    BLACK = "black"
    RED = "red"
    GREEN = "green"
    YELLOW = "yellow"
    BLUE = "blue"
    MAGENTA = "magenta"
    CYAN = "cyan"
    WHITE = "white"
    LBLACK = "lblack"
    LRED = "lred"
    LGREEN = "lgreen"
    LYELLOW = "lyellow"
    LBLUE = "lblue"
    LMAGENTA = "lmagenta"
    LCYAN = "lcyan"
    LWHITE = "lwhite"
    NONE = "none"

    def code(self):
        ansi = {
            'black': "\033[0;30m",
            'red': "\033[0;31m",
            'green': "\033[0;32m",
            'yellow': "\033[0;33m",
            'blue': "\033[0;34m",
            'magenta': "\033[0;35m",
            'cyan': "\033[0;36m",
            'white': "\033[0;37m",
            'lblack': "\033[1;30m",
            'lred': "\033[1;31m",
            'lgreen': "\033[1;32m",
            'lyellow': "\033[1;33m",
            'lblue': "\033[1;34m",
            'lmagenta': "\033[1;35m",
            'lcyan': "\033[1;36m",
            'lwhite': "\033[1;37m",
            'none': "\033[1;0m",
        }
        return ansi[self.value]


class Urgency(Enum):
    CRITICAL = 'critical'
    NORMAL = 'normal'
    LOW = 'low'


@dataclass
class Nag:
    name: str
    command: str
    expiry: int
    color: Color = Color.NONE
    urgency: Urgency = Urgency.NORMAL
    prefix: str = ""

    @classmethod
    def from_sc_data(cls,
                     name: str,
                     data: dict[str, list[str]],
                     ):
        def maybe_last(key):
            if not data.get(key, []):
                return None
            return data[key][-1]

        def maybe_color() -> Iterable[tuple[str, Color]]:
            text = maybe_last('term-color')
            if not text:
                return
            try:
                yield 'color', Color(text)
            except ValueError:
                raise AppError(f"invalid color value: {text!r} when {loc}")

        def maybe_urgency() -> Iterable[tuple[str, Urgency]]:
            text = maybe_last('notify-urgency')
            if not text:
                return
            try:
                yield 'urgency', Urgency(text)
            except ValueError:
                raise AppError(f"invalid color value: {text!r} when {loc}")

        def maybe_prefix() -> Iterable[tuple[str, str]]:
            text = maybe_last('prefix')
            if not text:
                return
            yield 'prefix', text

        def parse_command() -> str:
            try:
                return data.get('command', [])[-1]
            except IndexError:
                raise AppError(f"missing mandatory key: 'command' when {loc}")

        def parse_expiry() -> int:
            try:
                text = data.get('expiry', [])[-1]
            except IndexError:
                raise AppError(f"missing mandatory key: 'expiry' when {loc}")
            try:
                return int(parse_duration_brief(text.strip().split()))
            except ParsingError as e:
                raise AppError(f"invalid expiry value: {e.value!r} when {loc}")

        def collect_args() -> Iterable[tuple[str, Any]]:
            yield 'name', name
            yield 'command', parse_command()
            yield 'expiry', parse_expiry()
            yield from maybe_color()
            yield from maybe_urgency()
            yield from maybe_prefix()

        loc = f"loading nag at [nag.def.{name}]"
        kwargs = dict(collect_args())
        return cls(**kwargs)


@dataclass
class Flag:
    name: str
    age: int | None


@dataclass
class Stream:
    name: str
    nags: list[Nag]


@dataclass
class NagRepo:
    nags: dict[str, Nag]
    streams: dict[str, Stream]

    @classmethod
    def from_sc(cls):

        def collect_nags() -> Iterable[Nag]:
            raw_by_nag: dict[str, dict[str, list[str]]] = {}
            for key, values in config_data.items():
                if not key.startswith('def.'):
                    continue
                name, key = key.removeprefix('def.').split('.', maxsplit=1)
                if name not in raw_by_nag:
                    raw_by_nag[name] = {}
                if key not in raw_by_nag[name]:
                    raw_by_nag[name][key] = []
                raw_by_nag[name][key].extend(values)
            for name, data in raw_by_nag.items():
                nag = Nag.from_sc_data(name=name, data=data)
                yield nag

        def collect_streams(nags_by_name) -> Iterable[Stream]:
            #
            # For every stream, collect names first, only then start linking
            # nags to stream.
            #
            raw_names: dict[str, list[str]] = {}
            for key, values in config_data.items():
                # key - stream.STREAM
                # values - list of space-separated lists of nag names
                if not key.startswith('stream.'):
                    continue
                name = key.removeprefix('stream.')
                if name not in raw_names:
                    raw_names[name] = []
                raw_names[name].extend(' '.join(values).strip().split())
            for stream_name, nag_names in raw_names.items():
                nags = []
                for nag_name in nag_names:
                    if nag_name not in nags_by_name:
                        LOG.warn(f'ignoring undefined nag in stream: {nag_name!r} in {stream_name!r}')
                        continue
                    nags.append(nags_by_name[nag_name])
                yield Stream(
                    name=stream_name,
                    nags=nags,
                )

        config_repo = get_config_repo()
        config_data = config_repo.data('nag')
        nags = {
            n.name: n
            for n in collect_nags()
        }
        streams = {
            s.name: s
            for s in collect_streams(nags_by_name=nags)
        }
        return NagRepo(
            nags=nags,
            streams=streams,
        )

    def get_stream_by_name(self, stream_name: str) -> Stream:
        if stream_name not in self.streams:
            raise ValueError(f"no such stream: stream_name={stream_name}")
        return self.streams[stream_name]


@dataclass
class FlagRepo:
    root: Path

    @classmethod
    def from_saturnin(cls):
        return FlagRepo(
            root=CONTEXT.appinfo.cache_home / 'nag' / 'flags',
        )

    def _safe_path(self, name) -> Path:
        if '/' in name or '\n' in name:
            raise ValueError(f"name would result to unsafe path: {name!r}")
        return self.root / name

    def get_flag(self, name) -> Flag:
        path = self._safe_path(name)
        if not path.exists():
            return Flag(name=name, age=None)
        now = datetime.now().timestamp()
        age = int(now - path.stat().st_mtime)
        return Flag(name=name, age=age)

    def list_flags(self) -> Iterable[Flag]:
        for path in self.root.iterdir():
            if path.is_dir():
                continue
            path.relative_to(self.root)
            now = datetime.now().timestamp()
            age = int(now - path.stat().st_mtime)
            yield Flag(
                name=str(path.relative_to(self.root)),
                age=age,
            )

    def set_flag(self, name):
        self._safe_path(name).parent.mkdir(parents=True, exist_ok=True)
        self._safe_path(name).touch()

    def unset_flag(self, name):
        self._safe_path(name).unlink(missing_ok=True)


def like_stream_name(arg: str) -> bool:
    return arg.startswith('@') and len(arg) > 1


def parse_stream_name(arg: str) -> str:
    if not like_stream_name(arg):
        raise AppBug(f"invalid stream name argument: {arg!r}")
    return arg.removeprefix('@')


class App(BaseApp):

    name: str = CONTEXT.usage_name

    @property
    def arg_scheme(self):
        run_options = {
            'DESKTOP_NOTIFY': Bool('-N|--desktop-notify', trigger={'mode': PresentMode.NOTIFY_SEND}),
            'DRY': Bool('-n|--dry'),
        }
        S = Pattern()
        S.add_pattern(P_HELP)
        S.add_pattern(Pattern(
            options=run_options,
            trigger={'clapp.action': run_stream_a}
        )),
        S.add_pattern(Pattern(
            options=run_options,
            positionals=[
                ('STREAM_TO_RUN', ScalarPos(cond=like_stream_name, factory=parse_stream_name)),
            ],
            trigger={'clapp.action': run_stream_a}
        )),
        S.add_pattern(Pattern(
            options=run_options,
            positionals=[
                ('NAG', ScalarPos(cond=C_NONOPT)),
            ],
            trigger={'clapp.action': run_nag_a},
        )),
        S.add_pattern(Pattern(
            positionals=[
                ('_', LiteralPos('-l')),
                ('STREAM_TO_LIST', MaybeScalarPos(cond=like_stream_name, factory=parse_stream_name)),
            ],
            trigger={'clapp.action': list_nags_a}
        )),
        S.add_pattern(Pattern(
            positionals=[
                ('_', LiteralPos('-L')),
            ],
            trigger={'clapp.action': list_streams_a}
        )),
        S.add_pattern(Pattern(
            positionals=[
                ('_', LiteralPos('-r')),
            ],
            trigger={'clapp.action': show_flags_a}
        )),
        S.add_pattern(Pattern(
            positionals=[
                ('_', LiteralPos('-R')),
            ],
            trigger={'clapp.action': drop_flags_a}
        )),
        return S

    def init_params(self, from_args: AttrsT) -> AttrsT:
        repos = {
            'nag_repo': NagRepo.from_sc(),
            'flag_repo': FlagRepo.from_saturnin(),
        }
        return (
            {
                'mode': PresentMode.TERM,
                'NAG': '',
                'STREAM_TO_RUN': os.environ.get('BMO_NAG_STREAM', 'main'),
            }
            | repos
            | from_args
        )

    @property
    def usage_patterns(self) -> list[str]:
        return [
            "[options] NAG",
            "[options] [@STREAM]",
            "[options] -l [@STREAM]",
            "[options] -L",
            "[options] -r",
            "[options] -R",
        ]

    @property
    def help_lines(self) -> list[str]:
        return self.base_usage + [
            "",
            "First usage runs NAG as defined in nag.ini.",
            "",
            "Second usageruns first stale nag in STREAM (defined in the",
            "same file) or in stream set by BMO_NAG_STREAM environment",
            "variable ('main' by default)",
            "",
            "commands:",
            "  -l     show list of nags",
            "  -L     show list of streams",
            "  -r     show timestamps",
            "  -R     remove all timestamps",
            "",
            "options:",
            "  -N, --desktop-notify   nag via desktop notification (notify-send)",
            "",
            "First, you need to define NAGs in nag.ini. See bmo-nag.md",
            "(should be shipped with this script) for details.",
        ]


if __name__ == '__main__':
    LOG.debugv('CONTEXT.appinfo', CONTEXT.appinfo)
    App.main()
