#!/usr/bin/python3

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from string import Template
from typing import Any, Iterable, Mapping, Protocol, TypeVar
import os

import clapp
from bmo._meta import VERSION as APP_VERSION
from bmo.util import (
    BashCode,
    Cmd,
    CmdResult,
    CmdSpec,
    Text,
    TextFile,
)
from clapp import AppBug, AppError, BaseApp
from clapp.cli import (
    Pattern,
    ScalarPos,
    LiteralPos,
    C_NONOPT,
)
import cpi
from saturnin.context import Context, MetafileConfigRepo, get_context, get_config_repo
import neaty.log as LOG

AttrsT = dict[str, Any]


class HistoryRepositoryP(Protocol):
    histsize: HistSizeT
    histfile: TextFile

    def remove_item(self, item: str) -> None:
        """
        Remove *item* from history file
        """
        ...

    def save_item(self, item: str):
        """
        Save *item* to history
        """
        ...

    def load_items(self) -> list[str]:
        """
        Return list items from history as of strings.
        """
        ...


@dataclass
class Logger:
    menu_name: str
    context: Context
    _logfiles: AttrsT = field(default_factory=dict)

    def logfile(self, ext: str) -> TextFile:
        """
        Return TextFile object for "our" file by extension *ext*.

        All files accessed by given Menu and its agents must be
        limited to saturnin's data_home, and named by menu name
        and extension.

        For example, if a menu agent of a menu 'foo' asks for file
        by extension 'bar', it must get a TextFile object which
        refers to file 'foo.bar' under saturnin's data_home.

        The data_home path depends on saturnin's options, build-time
        parameters and current environment variables but typically it
        will be based on XDG path for thi application, e.g.
        '$HOME/.local/share/bmo/foo.bar'.
        """
        def find_or_make_lf(path: str) -> TextFile:
            if path not in self._logfiles:
                self._logfiles[path] = TextFile(path)
            return self._logfiles[path]
        data_home = self.context.appinfo.data_home
        path = f"{data_home}/menu/{self.menu_name}.{ext}"
        return find_or_make_lf(path)

    def log_cmd_result(self, result: CmdResult) -> None:
        """
        Save result to .log file
        """
        stamp = datetime.now().isoformat()
        txt = result.fmt().prefix(stamp + ': ')
        self.logfile('log').append(txt)


class MenuAgentP(Protocol):
    logger: Logger
    env: MenuEnv

    def __init__(self,
                 logger: Logger,
                 **kwargs,
                 ):
        ...


class SelectorP(MenuAgentP, Protocol):

    def select(self, items: Iterable[str]) -> str:
        ...


class ProviderP(MenuAgentP, Protocol):

    def backpropagate(self, item: str) -> None:
        """
        Propagate selection to next provision
        """
        ...

    def provide(self) -> Iterable[str]:
        """
        Provide items as list of strings
        """
        ...


class ConsumerP(MenuAgentP, Protocol):

    def consume(self, item: str) -> None:
        """Call consumer and feed it *item*"""
        ...


@dataclass
class ListingItemProvider:
    logger: Logger
    env: MenuEnv
    items: list[str]

    def backpropagate(self, item: str) -> None:
        pass

    def provide(self) -> Iterable[str]:
        return self.items


@dataclass
class CommandItemProvider:
    logger: Logger
    env: MenuEnv
    code: BashCode

    def backpropagate(self, item: str) -> None:
        pass

    def provide(self) -> Iterable[str]:
        cmd_spec = CmdSpec.from_code(self.code)
        cmd = Cmd(
            spec=cmd_spec,
            role='provider',
        )
        result = cmd.run()
        LOG.debug(f'CommandItemProvider.provide():result.out={result.out!r}')
        self.logger.log_cmd_result(result)
        return result.out.as_lines


@dataclass
class HistoryItemProvider:
    logger: Logger
    env: MenuEnv
    history_repo: HistoryRepositoryP
    """
    Provider that provides items by reading MENU_NAME.history
    """

    def backpropagate(self, item: str) -> None:
        self.history_repo.save_item(item)

    def provide(self) -> Iterable[str]:
        return reversed(self.history_repo.load_items())


@dataclass
class CommandAndHistoryItemProvider:
    """
    Provider that provides items by running 'generator' command
    """
    logger: Logger
    env: MenuEnv
    cip: CommandItemProvider
    hip: HistoryItemProvider

    def backpropagate(self, item: str) -> None:
        self.hip.history_repo.save_item(item)

    def _merge(self, command_items, history_items):
        def once(i):
            if i in seen:
                return []
            else:
                seen.add(i)
                return [i]
        seen = set()
        out = []
        for item in history_items:
            if item in command_items:
                out.extend(once(item))
        for item in command_items:
            out.extend(once(item))
        return out

    def provide(self) -> Iterable[str]:
        command_items = self.cip.provide()
        history_items = self.hip.provide()
        items = list(self._merge(command_items, history_items))
        LOG.debug(f'CommandAndHistoryItemProvider.provide():items={items!r}')
        return items


@dataclass
class HistoryRepository:
    histsize: HistSizeT
    histfile: TextFile

    def _grow_history(self, item):
        """
        Produce new history with *item* at the end

        Also de-duplicate the history items and conform to constraints
        such as histsize.
        """
        size = cpi.INF if self.histsize == INF else self.histsize
        return (
            cpi.Col(self.load_items())
            .select(bool)
            .dedup()
            .reject(lambda s: s == item)
            .append(item)
            .tail(size)
        ).items

    def remove_item(self, item):
        """
        Remove *item* from history file
        """
        size = cpi.INF if self.histsize == INF else self.histsize
        txt = Text.from_lines(
            cpi.Col(self.load_items())
            .select(bool)
            .dedup()
            .reject(lambda s: s == item)
            .tail(size)
        )
        self.histfile.save(txt)

    def save_item(self, item):
        """
        Save *item* to history
        """
        txt = Text.from_lines(
            self._grow_history(item)
        )
        self.histfile.save(txt)

    def load_items(self):
        """
        Return list items from history as of strings.
        """
        return self.histfile.load().as_lines


@dataclass
class CommandItemSelector:
    logger: Logger
    env: MenuEnv
    code: BashCode

    def _take_only_one(self, items: list[str]) -> str:
        """
        Return single element of *items*, which must be singleton

        If there's more than one or no items, raise clapp.AppError
        """
        size = len(items)
        if size == 1:
            return items[0]
        if size == 0:
            raise AppError("selector got no items from command")
        if size > 1:
            raise AppError(f"selector got too many items: {size} from command")
        raise AppError(f"bug: weird item list size: {size}")

    def select(self, items: Iterable[str]) -> str:
        cmd_spec = CmdSpec.from_code(self.code)
        cmd = Cmd(
            spec=cmd_spec,
            stdin=Text.from_lines(items),
            role='selector',
        )
        result = cmd.run()
        self.logger.log_cmd_result(result)
        LOG.debug(f'CommandItemSelector.select():result.out={result.out!r}')
        return self._take_only_one(result.out.as_lines)


@dataclass
class CommandItemConsumer:
    logger: Logger
    env: MenuEnv
    code: BashCode

    def consume(self, item: str) -> None:
        cmd_spec = CmdSpec.from_code(self.code)
        cmd = Cmd(
            spec=cmd_spec,
            stdin=Text.from_lines([item]),
            role='consumer',
        )
        result = cmd.run()
        self.logger.log_cmd_result(result)


class ProviderMode(Enum):
    ITEMS = 'items'
    CMD = 'cmd'
    HISTORY = 'history'
    CMD_AND_HISTORY = 'command+history'


class Inf:
    pass


INF = Inf()


@dataclass
class Pipeline:
    provider: ProviderP
    selector: SelectorP
    consumer: ConsumerP

    def run(self) -> None:
        """
        Run all the steps
        """
        items = self.provider.provide()
        selected = self.selector.select(items)
        self.provider.backpropagate(selected)
        self.consumer.consume(selected)


_T = TypeVar('_T')


def require_single(seq: list[_T], hint: str = 'item') -> _T:
    if len(seq) < 1:
        raise AppError(f"missing: {hint}")
    if len(seq) > 1:
        raise AppError(f'too many: {hint} found {len(seq)} times')
    return seq[0]


def require_some(seq: list[_T], hint: str = 'item') -> _T:
    if len(seq) < 1:
        raise AppError(f"missing: {hint}")
    return seq[0]


InigrepData = dict[str, list[str]]
MenuEnv = dict[str, str]
HistSizeT = int | Inf


@dataclass
class MenuRepository:
    menu_name: str
    data: InigrepData
    defaults: InigrepData

    @classmethod
    def new(cls, menu_name: str, config_repo: MetafileConfigRepo):
        data = config_repo.data(prefix=f'menu.def.{menu_name}')
        defaults = config_repo.data(prefix='menu._default_')
        LOG.debugv('data', data)
        LOG.debugv('defaults', defaults)
        return cls(
            menu_name=menu_name,
            data=config_repo.data(prefix=f'menu.def.{menu_name}'),
            defaults=config_repo.data(prefix='menu._default_'),
        )

    def _get11(self, key: str) -> str:
        found = self.data.get(key, [])
        if not found:
            found = self.defaults.get(key, [])
        LOG.debugv('found', found)
        if not found:
            raise AppError(
                "missing mandatory menu definition key:"
                f" {key!r} in menu.def.{self.menu_name}"
            )
        if len(found) > 1:
            raise AppError(
                "definition key can only appear once:"
                f" {key!r} in menu.def.{self.menu_name}"
            )
        return found[0]

    def _have(self, key: str) -> bool:
        found = self.data.get(key, self.defaults.get(key, []))
        if not found:
            found = self.defaults.get(key, [])
        LOG.debugv('found', found)
        return bool(found)

    def _get01(self, key: str) -> str | None:
        found = self.data.get(key, [])
        if not found:
            found = self.defaults.get(key, [])
        if not found:
            return None
        if len(found) > 1:
            raise AppError(f"definition key can only appear once: {key!r}")
        return found[0]

    def _get0N(self, key: str) -> list[str]:
        return self.data.get(key, [])

    def load_bash_code(self,
                       key: str,
                       ) -> BashCode:
        return BashCode.from_str(self._get11(key))

    def load_env(self) -> MenuEnv:
        """
        Load env variables

        Pre-existing environment variables are expanded using
        *string.Template*, except that undefined variables are expanded as
        empty strings.

        Variables specified here are also expanded recursively up to three
        levels.

        For example, this will work:

            [menu.def.foo]
                env = FOO_ROOT=$HOME/foo
                env = FOO_SUBDIR=$FOO_ROOT/subdir
                generator = ls "$FOO_SUBDIR"

        """

        def parse(L) -> tuple[str, str]:
            if '=' not in L:
                raise AppError(f"invalid 'env' line: {L!r}")
            key, value = [s.strip() for s in L.split('=')]
            if not key:
                raise AppError(f"invalid 'env' line: {L!r}")
            return key, value

        def expand1(V, source, hint):
            return Template(V).safe_substitute(source)

        def expand(env: dict[str, str], source: Mapping[str, str]):
            return {k: expand1(v, source, k) for k, v in env.items()}

        plain = dict(parse(line) for line in self._get0N('env'))
        exp1 = expand(plain, os.environ)
        exp2 = expand(exp1, exp1)
        exp3 = expand(exp2, exp2)
        return exp3

    def load_mode(self) -> ProviderMode:
        fallback = 'items' if self._have('items') else 'cmd'
        return ProviderMode(self._get01('mode') or fallback)

    def load_histsize(self) -> HistSizeT:
        raw = self._get01('histsize') or os.environ.get('BMO_MENU_HISTSIZE', 'inf')
        assert isinstance(raw, str)
        if raw == 'inf':
            return INF
        if int(raw) > -1:
            return int(raw)
        hint = "neither 'inf' nor a positive integer"
        raise AppError(
            f"invalid histsize ({hint}): {raw!r}"
        )

    def load_pipeline(self, logger: Logger) -> Pipeline:
        env = self.load_env()
        return Pipeline(
            provider=self.load_provider(env=env, logger=logger),
            selector=self.load_selector(env=env, logger=logger),
            consumer=self.load_consumer(env=env, logger=logger),
        )

    def load_provider(self,
                      env: MenuEnv,
                      logger: Logger,
                      ) -> ProviderP:
        def prohibit_by_mode(mode, key):
            if not self._have(key):
                return
            raise AppError(
                "option must not be set with menu mode:"
                f" {key!r} with {mode.value!r} in {self.menu_name!r}"
            )
        mode = ProviderMode(self._get01('mode') or 'cmd')
        if mode == ProviderMode.ITEMS:
            prohibit_by_mode(mode, 'generator')
            return ListingItemProvider(
                logger=logger,
                env=env,
                items=self._get0N('items'),
            )
        if mode == ProviderMode.CMD:
            prohibit_by_mode(mode, 'items')
            return CommandItemProvider(
                logger=logger,
                env=env,
                code=self.load_bash_code('generator'),
            )
        if mode == ProviderMode.HISTORY:
            prohibit_by_mode(mode, 'items')
            prohibit_by_mode(mode, 'generator')
            history_repo = HistoryRepository(
                histsize=self.load_histsize(),
                histfile=logger.logfile('history'),
            )
            return HistoryItemProvider(
                logger=logger,
                env=env,
                history_repo=history_repo,
            )
        if mode == ProviderMode.CMD_AND_HISTORY:
            prohibit_by_mode(mode, 'items')
            history_repo = HistoryRepository(
                histsize=self.load_histsize(),
                histfile=logger.logfile('history'),
            )
            return CommandAndHistoryItemProvider(
                logger=logger,
                env=env,
                cip=CommandItemProvider(
                    logger=logger,
                    env=env,
                    code=self.load_bash_code('generator'),
                ),
                hip=HistoryItemProvider(
                    logger=logger,
                    env=env,
                    history_repo=history_repo,
                ),
            )
        raise AppBug(f"no provider implementation for mode: {mode.value!r}")

    def load_selector(self,
                      env: MenuEnv,
                      logger: Logger,
                      ) -> SelectorP:
        parts = [self._get11('selector')]
        if self._have('selector_args'):
            parts.append(self._get11('selector_args'))
        code = BashCode.from_str(' '.join(parts))
        LOG.debugv('parts', parts)
        LOG.debugv('code', code)
        return CommandItemSelector(
            logger=logger,
            env=env,
            code=code,
        )

    def load_consumer(self,
                      env: MenuEnv,
                      logger: Logger,
                      ) -> ConsumerP:
        return CommandItemConsumer(
            logger=logger,
            env=env,
            code=self.load_bash_code('consumer'),
        )


def open_menu_a(params: dict, app: App) -> int:
    repo = MenuRepository.new(
        menu_name=params['NAME'],
        config_repo=get_config_repo(),
    )
    logger = Logger(
        menu_name=params['NAME'],
        context=get_context(),
    )
    pipeline = repo.load_pipeline(logger=logger)
    pipeline.run()
    return 0


def list_menus_a(params: dict, app: App) -> int:
    names = []
    config_repo = get_config_repo()
    for section in config_repo.list_sections():
        if not section.startswith('menu.def.'):
            continue
        elems = section.split('.')
        names.append(elems[2])
    print(Text.from_lines(sorted(names)).as_str.strip())
    return 0


class App(BaseApp):

    version: str = APP_VERSION

    name: str = 'bmo menu'

    arg_scheme: Pattern = Pattern(patterns=[
        Pattern(positionals=[
            ('NAME', ScalarPos(cond=C_NONOPT))
        ], trigger={'clapp.action': open_menu_a}),
        Pattern(positionals=[
            ('_', LiteralPos('-o|--open|--')),
            ('NAME', ScalarPos()),
        ], trigger={'clapp.action': open_menu_a}),
        Pattern(positionals=[
            ('clapp.Action', LiteralPos('--help')),
        ], trigger={'clapp.Action': clapp.ShowHelpAction}),
        Pattern(positionals=[
            ('clapp.Action', LiteralPos('-l|--list')),
        ], trigger={'clapp.action': list_menus_a}),
    ])

    @property
    def usage_patterns(self) -> list[str]:
        return [
            "-l|--list",
            "[--open] NAME",
            "--help",
        ]

    @property
    def help_lines(self) -> list[str]:
        return self.base_usage + [
            "",
            "commands:",
            "  -l|--list        List available menus",
            "  --open NAME      Open menu named NAME",
            "",
            "NAME must be name of menu defined in menu.ini.  See document",
            "shipped with bmo for example.",
        ]


if __name__ == "__main__":
    App.main()
