"""Utility classes to write command line help.
:see: :class:`.UsageActionOptionParser`
"""
from __future__ import annotations
__author__ = 'Paul Landes'
from typing import (
    Tuple, Iterable, List, Union, Optional, Sequence, Set, ClassVar
)
from dataclasses import dataclass, field
import logging
import os
import sys
import re
from itertools import chain
from pathlib import Path
from io import TextIOBase
from optparse import OptionParser
from zensols.util import APIError
from zensols.introspect import IntegerSelection
from zensols.config import Writable, Dictable
from zensols.persist import persisted
from . import OptionMetaData, ActionMetaData, PositionalMetaData
logger = logging.getLogger(__name__)
[docs]
@dataclass
class UsageConfig(Dictable):
    """Configuraiton information for the command line help.
    """
    width: int = field(default=None)
    """The max width to print help."""
    max_first_col: Union[float, int] = field(default=0.4)
    """Maximum width of the first column.  If this is a float, then it is
    computed as a percentage of the terminal width.
    """
    max_metavar_len: Union[float, int] = field(default=0.15)
    """Max length of the option type."""
    max_default_len: Union[float, int] = field(default=0.1)
    """Max length in characters of the default value."""
    left_indent: int = field(default=2)
    """The number of left spaces for the option and positional arguments."""
    inter_col_space: int = field(default=2)
    """The number of spaces between all three columns."""
    sort_actions: bool = field(default=False)
    """If ``True`` sort mnemonic output."""
    doc: str = field(default=None)
    """Overrides the application help documentation."""
    def __post_init__(self):
        if self.width is None:
            try:
                self.width = os.get_terminal_size()[0]
            except OSError as e:
                if logger.isEnabledFor(logging.DEBUG):
                    logger.debug(f'can not get terminal size: {e}')
                self.width = 0
        if self.width == 0:
            self.width = 80
        if self.max_first_col is None:
            self.max_first_col = 0.4
        if isinstance(self.max_first_col, float):
            self.max_first_col = int(self.width * self.max_first_col)
        if isinstance(self.max_metavar_len, float):
            self.max_metavar_len = int(self.width * self.max_metavar_len)
        if isinstance(self.max_default_len, float):
            self.max_default_len = int(self.width * self.max_default_len) 
[docs]
class UsageActionOptionParser(OptionParser):
    """Implements a human readable implementation of :meth:`print_help` for
    action based command line handlers.
    Each action is described with the full documentation with ``--help``.
    However, the short option version (``-h``) creates a much (GNU style)
    summarization of the command and actions.
    If an action or several actions are given with either help flag, only that
    action usage and documentation is printed.
    **Implementation note**: we have to extend :class:`~optparser.OptionParser`
    since the ``-h`` option invokes the print help behavior and then exists
    printing the second pass action options.  Instead, we look for the help
    option in the first pass, print help with the correction options, then
    exit.
    """
[docs]
    def __init__(self, actions: Tuple[ActionMetaData, ...],
                 options: Tuple[OptionMetaData, ...], usage_config: UsageConfig,
                 doc: str = None, default_action: str = None, *args, **kwargs):
        super().__init__(*args, add_help_option=False, **kwargs)
        help_op = OptionMetaData(
            'help', 'h',
            metavar='[actions]',
            dtype=bool,
            doc='show this help message and exit')
        version_op = OptionMetaData(
            'version', None, dtype=bool,
            doc='show the program version and exit')
        options = [help_op, version_op] + list(options)
        if usage_config.doc is not None:
            doc = usage_config.doc
        self._usage_writer = _UsageWriter(
            parser=self,
            actions=actions,
            global_options=options,
            doc=doc,
            usage_config=usage_config,
            default_action=default_action)
        self.add_option(help_op.create_option()) 
[docs]
    def print_help(self, file: TextIOBase = sys.stdout,
                   include_actions: bool = True,
                   action_metas: Sequence[ActionMetaData] = None,
                   action_format: str = False):
        """Write the usage information and help text.
        :param include_actions: if ``True`` write each actions' usage as well
        :param actions: the list of actions to output, or ``None`` for all
        :param action_format: the action format, either ``short`` or ``long``
        """
        self._usage_writer.write(
            writer=file,
            include_actions=include_actions,
            action_metas=action_metas,
            action_format=action_format) 
 
@dataclass
class _Formatter(Writable):
    """A formattingn base class that has utility methods.
    """
    _BACKTICKS_REGEX = re.compile(r"``([^`]+)``")
    def _format_doc(self, doc: str = None) -> str:
        doc = '' if doc is None else doc
        doc = re.sub(self._BACKTICKS_REGEX, r'"\1"', doc)
        return doc
    def _write_one_col(self, text: str, depth: int, writer: TextIOBase):
        text = self._trunc(text)
        self._write_line(text, depth, writer)
    def _write_three_col(self, a: str, b: str, c: str, depth: int,
                         writer: TextIOBase):
        a = '' if a is None else a
        b = '' if b is None else b
        c = '' if c is None else c
        w1 = self.usage_formatter.two_col_width
        w2 = self.usage_formatter.three_col_width
        a = self._trunc(a, self.usage_formatter.max_first_col)
        fmt = '{:<' + str(w1) + '}{:<' + str(w2) + '}{}'
        s = fmt.format(a, b, c)
        sp = self._get_str_space(w1 + w2)
        self._write_wrap(s, depth, writer, subsequent_indent=sp)
@dataclass
class _OptionFormatter(_Formatter):
    """Write the option, which includes the option name and documenation.
    """
    usage_formatter: _UsageWriter
    opt: OptionMetaData
    usage_config: UsageConfig
    def __post_init__(self):
        self.WRITABLE_MAX_COL = self.usage_config.width
        opt = self.opt
        self.doc = self._format_doc(self.opt.doc)
        left_indent: str = ' ' * self.usage_config.left_indent
        max_olen: int = self.usage_config.max_metavar_len
        sep: str = '' if opt.short_name is None else ', '
        long_opt: str = opt.long_option
        short_opt: str = '' if opt.short_option is None else opt.short_option
        metavar: str = '' if opt.metavar is None else opt.metavar
        mlen, over = self._get_min_default_len()
        self._opt_str = f'{left_indent}{short_opt}{sep}{long_opt}'
        if not issubclass(opt.dtype, IntegerSelection) and \
           len(metavar) > max_olen:
            if metavar.find('|') > -1:
                metavar = metavar[1:-1]
                if len(self.doc) > 0:
                    self.doc += ', '
                self.doc += f"X is one of: {', '.join(metavar.split('|'))}"
                self._opt_str += ' X'
            else:
                if len(self.doc) > 0:
                    self.doc += ', of '
                self.doc += f'type {metavar}'
        else:
            self._opt_str += f' {metavar}'
        if over:
            self.doc += f' with default {self.opt.default_str}'
    def _get_min_default_len(self) -> Tuple[Optional[int], bool]:
        mdlen: int = None
        over: bool = False
        if self.opt.default is not None and self.opt.dtype != bool:
            mdlen: int = self.usage_config.max_default_len
            over = (len(self.opt.default_str) + 3) > mdlen
        return mdlen, over
    @property
    def default(self) -> str:
        mlen, over = self._get_min_default_len()
        if mlen is not None:
            s: str = self.opt.default_str
            if over:
                s = s[:mlen] + '...'
            return s
        else:
            return ''
    def add_first_col_width(self, widths: List[int]):
        widths.append(len(self._opt_str))
    def write(self, depth: int = 0, writer: TextIOBase = sys.stdout):
        self._write_three_col(
            self._opt_str, self.default, self.doc, depth, writer)
@dataclass
class _PositionalFormatter(_Formatter):
    usage_formatter: _UsageFormatter
    pos: PositionalMetaData
    def __post_init__(self):
        self.WRITABLE_MAX_COL = self.usage_formatter.usage_config.width
        spl = self.usage_formatter.writer.usage_config.left_indent
        sp = self._get_str_space(spl)
        mv = ''
        if self.pos.metavar is not None:
            mv = f' {self.pos.metavar}'
        self.name = f'{sp}{self.pos.name}{mv}'
        self.doc = self._format_doc(self.pos.doc)
    def add_first_col_width(self, widths: List[int]):
        widths.append(len(self.name))
    def write(self, depth: int = 0, writer: TextIOBase = sys.stdout):
        self._write_three_col(self.name, '', self.doc, depth, writer)
@dataclass
class _ActionFormatter(_Formatter):
    """Write the action, which includes the name, positional arguments, and
    documentation in one line, then the options afterward.
    """
    usage_formatter: _UsageFormatter
    action: ActionMetaData
    usage_config: UsageConfig = field()
    action_name: str = field(default=None)
    opts: Tuple[_OptionFormatter] = field(default=None)
    pos: Tuple[_PositionalFormatter] = field(default=None)
    def __post_init__(self):
        self.WRITABLE_MAX_COL = self.usage_config.width
        self.opts = tuple(map(
            lambda of: _OptionFormatter(
                self.usage_formatter, of, self.usage_config),
            self.action.options))
        self.pos = tuple(map(
            lambda pos: _PositionalFormatter(self.usage_formatter, pos),
            self.action.positional))
        self.doc = self._format_doc(self.action.doc)
    @property
    @persisted('_position_args_str')
    def position_args_str(self) -> Optional[str]:
        if len(self.action.positional) > 0:
            pargs = ' '.join(map(lambda p: p.name, self.action.positional))
            return f'<{pargs}>'
    @property
    @persisted('_action_desc')
    def action_desc(self) -> str:
        is_def: bool = self.usage_formatter.default_action == self.action.name
        action_name: str = self.action.name
        pos_args: Optional[str] = self.position_args_str
        if pos_args is not None:
            action_name = f'{action_name} {pos_args}'
        if is_def:
            action_name = f'{action_name} (default)'
        return action_name
    def add_first_col_width(self, widths: List[int]):
        widths.append(len(self.action_desc))
        for of in self.opts:
            of.add_first_col_width(widths)
        for pos in self.pos:
            pos.add_first_col_width(widths)
    def write(self, depth: int = 0, writer: TextIOBase = sys.stdout,
              format: str = 'long'):
        if format == 'long':
            self._write_three_col(
                self.action_desc, '', self.doc, depth, writer)
            for pos in self.pos:
                self._write_object(pos, depth, writer)
            for opt in self.opts:
                self._write_object(opt, depth, writer)
        elif format == 'short':
            self._write_one_col(self.action_desc, depth, writer)
        else:
            raise APIError(f'No such action format: {format}')
@dataclass
class _UsageFormatter(_Formatter):
    """Write the global options and all actions.
    """
    writer: _UsageWriter
    actions: Tuple[ActionMetaData, ...]
    usage_config: UsageConfig
    global_options: Tuple[OptionMetaData, ...]
    glob_option_formatters: List[_OptionFormatter] = field(default=None)
    action_formatters: List[_ActionFormatter] = field(default=None)
    pos_formatters: List[_PositionalFormatter] = field(default=None)
    def __post_init__(self):
        self.WRITABLE_MAX_COL = self.usage_config.width
        self.glob_option_formatters = list(
            map(lambda o: _OptionFormatter(self, o, self.usage_config),
                self.global_options))
        self.action_formatters = list(
            map(lambda a: _ActionFormatter(self, a, self.usage_config),
                self.actions))
        self.pos_formatters = []
        if self.is_singleton_action:
            for af in self.action_formatters:
                self.glob_option_formatters.extend(af.opts)
                self.pos_formatters.extend(af.pos)
            self.action_formatters.clear()
    @property
    def is_singleton_action(self) -> bool:
        return len(self.visible_actions) == 1
    @property
    def default_action(self) -> str:
        return self.writer.default_action
    @property
    def max_first_col(self) -> int:
        return self.writer.usage_config.max_first_col
    def _get_opt_formatters(self) -> Iterable[_OptionFormatter]:
        return chain.from_iterable(
            [chain.from_iterable(
                map(lambda f: f.opts, self.action_formatters)),
             self.glob_option_formatters])
    @property
    @persisted('_two_col_width_pw')
    def two_col_width(self) -> int:
        widths = []
        for af in self.action_formatters:
            af.add_first_col_width(widths)
        for go in self.glob_option_formatters:
            go.add_first_col_width(widths)
        for po in self.pos_formatters:
            po.add_first_col_width(widths)
        return max(widths) + self.usage_config.inter_col_space
    @property
    @persisted('_three_col_width_pw')
    def three_col_width(self) -> int:
        return max(len(a.default) for a in self._get_opt_formatters()) + \
            self.usage_config.inter_col_space
    @property
    @persisted('_visible_actions')
    def visible_actions(self) -> Tuple[ActionMetaData]:
        return tuple(filter(lambda a: a.is_usage_visible, self.actions))
    def get_option_usage_names(self, expand: bool = True) -> str:
        actions: Tuple[ActionMetaData] = self.visible_actions
        action_names: Tuple[str, ...] = tuple(map(lambda a: a.name, actions))
        if len(action_names) > 1:
            if expand:
                names = '|'.join(action_names)
            else:
                names = 'actions'
            if self.default_action is None:
                opts = f"<{names}> "
            else:
                opts = f"[{names}] "
        elif len(action_names) > 0:
            if logger.isEnabledFor(logging.DEBUG):
                logger.debug(f'action: {self.actions[0]}')
            opts = ', '.join(map(lambda p: p.name, actions[0].positional))
            if len(opts) > 0:
                opts = f'<{opts}> '
        else:
            opts = ''
        return opts
    @property
    def has_opts(self) -> bool:
        return len(self.glob_option_formatters) > 0
    def _write_options(self, depth: int, writer: TextIOBase) -> bool:
        has_opts: bool = self.has_opts
        if self.has_opts:
            self._write_line('Options:', depth, writer)
            for i, of in enumerate(self.glob_option_formatters):
                of.write(depth, writer)
        return has_opts
    def _write_actions(self, depth: int, writer: TextIOBase,
                       action_metas: Sequence[ActionMetaData],
                       action_format: str):
        def filter_action(f: _ActionFormatter) -> bool:
            return f.action.is_usage_visible and \
                (am_set is None or f.action.name in am_set)
        is_short: bool = action_format == 'short'
        am_set: Set[str] = None
        if action_metas is not None:
            am_set = set(map(lambda a: a.name, action_metas))
        # get only visible actions
        fmts: Tuple[_ActionFormatter] = tuple(filter(
            filter_action, self.action_formatters))
        n_fmt: int = len(fmts)
        lead_str: str = ''
        if is_short:
            fmt: _ActionFormatter
            for fmt in fmts:
                blocks: List[str] = [fmt.action.name]
                pos_args: Optional[str] = fmt.position_args_str
                if pos_args is not None:
                    blocks.append(pos_args)
                self.writer.write_short_usage(
                    depth=depth,
                    writer=writer,
                    opts=tuple(map(lambda f: f.opt, fmt.opts)),
                    usage=self._get_str_space(len(self.writer.USAGE_STR)),
                    start_blocks=blocks)
        else:
            if n_fmt > 0 and (action_metas is None or len(action_metas) == 0):
                self._write_line('Actions:', depth, writer)
            i: int
            fmt: _ActionFormatter
            for i, fmt in enumerate(fmts):
                writer.write(lead_str)
                fmt.write(depth, writer, format=action_format)
                if i < n_fmt - 1:
                    self._write_empty(writer)
    def write(self, depth: int = 0, writer: TextIOBase = sys.stdout,
              include_singleton_positional: bool = True,
              include_global_options: bool = True,
              include_actions: bool = True,
              action_metas: Sequence[ActionMetaData] = None,
              action_format: str = 'long'):
        if self.is_singleton_action and include_singleton_positional and \
           len(self.pos_formatters) > 0:
            self._write_line('Positional:', depth, writer)
            for po in self.pos_formatters:
                self._write_object(po, depth, writer)
            if include_global_options or include_actions:
                self._write_empty(writer)
        if include_global_options:
            if self._write_options(depth, writer) and include_actions and \
               len(self.action_formatters) > 0:
                self._write_empty(writer)
        if include_actions:
            self._write_actions(depth, writer, action_metas, action_format)
@dataclass
class _UsageWriter(_Formatter):
    """Generates the usage and help messages for an
    :class:`optparse.OptionParser`.
    """
    USAGE_STR: ClassVar[str] = 'Usage: '
    parser: OptionParser = field()
    """Parses the command line in to primitive Python data structures."""
    actions: Tuple[ActionMetaData, ...] = field()
    """The set of actions to document as a usage."""
    global_options: Tuple[OptionMetaData, ...] = field()
    """Application level options (i.e. level, config, verbose etc)."""
    doc: str = field()
    """The application document string."""
    usage_config: UsageConfig = field(default_factory=UsageConfig)
    """Configuraiton information for the command line help."""
    default_action: str = field(default=None)
    """The default mnemonic use when the user does not supply one."""
    usage_formatter: _UsageFormatter = field(default=None)
    """The usage formatter used to generate the documentation."""
    def __post_init__(self):
        self.WRITABLE_MAX_COL = self.usage_config.width
        if self.usage_config.sort_actions:
            actions = sorted(self.actions, key=lambda a: a.name)
        else:
            actions = self.actions
        self.usage_formatter = _UsageFormatter(
            self, actions, self.usage_config, self.global_options)
    @property
    def program_name(self) -> str:
        prog: str = '<python>'
        if len(sys.argv) > 0:
            prog_path: Path = Path(sys.argv[0])
            prog = prog_path.name
        return prog
    def _get_short_option_str(self, opts: Tuple[OptionMetaData, ...]) -> str:
        def filter_short(o: OptionMetaData) -> bool:
            return o.dtype == bool and o.short_name is not None
        def fmt_long(o: OptionMetaData) -> str:
            if o.dtype == bool:
                return f'[{o.shortest_option}]'
            return f'[{o.shortest_option} {o.metavar}]'
        shorts: str = '|'.join(map(lambda o: o.short_option,
                                   filter(filter_short, opts)))
        longs: str = ' '.join(map(fmt_long,
                                  filter(lambda o: not filter_short(o), opts)))
        if len(shorts) > 0:
            shorts = f'[{shorts}]'
        sp: str = ' ' if len(shorts) > 0 and len(longs) > 0 else ''
        return shorts + sp + longs
    def write_short_usage(self, depth: int, writer: TextIOBase,
                          opts: Tuple[OptionMetaData, ...],
                          usage: str = None,
                          start_blocks: Sequence[str] = None,
                          end_blocks: Sequence[str] = None):
        def filter_short(o: OptionMetaData) -> bool:
            return o.dtype == bool and o.short_name is not None
        def fmt_long(o: OptionMetaData) -> str:
            if o.dtype == bool:
                return f'[{o.shortest_option}]'
            return f'[{o.shortest_option} {o.metavar}]'
        usage = self.USAGE_STR if usage is None else usage
        usage_ind: int = len(usage)
        sp: str = self._sp(depth)
        option_sp: str = self._get_str_space(
            usage_ind + len(self.program_name) + 1)
        option_ind: int = len(option_sp)
        shorts: str = '|'.join(map(
            lambda o: o.short_option, filter(filter_short, opts)))
        blocks: List[str] = [(usage + self.program_name)]
        ind: int = 0
        if start_blocks is not None:
            blocks.extend(start_blocks)
        if len(shorts) > 0:
            blocks.append(f'[{shorts}]')
        opt: OptionMetaData
        for opt in filter(lambda o: not filter_short(o), opts):
            blocks.append(fmt_long(opt))
        if end_blocks is not None:
            blocks.extend(end_blocks)
        writer.write(sp)
        block: str
        for i, block in enumerate(blocks):
            write_sp: bool = (i > 0)
            if i > 0:
                if i < len(blocks):
                    next_width: int = ind + len(blocks[i]) + 1
                    if next_width > self.WRITABLE_MAX_COL:
                        writer.write('\n')
                        writer.write(option_sp)
                        ind = option_ind
                        write_sp = False
            if write_sp:
                writer.write(' ')
                ind += 1
            writer.write(block)
            ind += len(block)
        writer.write('\n')
    def _write_long_usage(self, depth: int, writer: TextIOBase):
        prog: str = self.program_name
        opt_usage: str = '[options]:'
        opts = self.usage_formatter.get_option_usage_names()
        usage = f'{self.USAGE_STR}{prog} {opts}{opt_usage}'
        if len(usage) > (self.usage_config.width - len(opt_usage)):
            opts = self.usage_formatter.get_option_usage_names(expand=False)
            usage = f'{prog} {opts}{opt_usage}'
        writer.write(usage)
        self._write_empty(writer)
        self._write_empty(writer)
    def write(self, depth: int = 0, writer: TextIOBase = sys.stdout,
              include_singleton_positional: bool = True,
              include_global_options: bool = True,
              include_actions: bool = True,
              action_metas: Sequence[ActionMetaData] = None,
              action_format: str = 'long'):
        is_short: bool = action_format == 'short'
        # if user specified help action(s) on the command line, only print the
        # action(s)
        if action_metas is None:
            if is_short:
                self.write_short_usage(
                    depth=depth,
                    writer=writer,
                    opts=(),
                    start_blocks=('[-h|--help]', '[--version]'))
                self.write_short_usage(
                    depth=depth,
                    writer=writer,
                    opts=tuple(filter(
                        lambda o: o.long_name not in {'help', 'version'},
                        self.global_options)),
                    usage=self._get_str_space(len(self.USAGE_STR)),
                    start_blocks=(('<action>' if self.default_action is None
                                   else '[action]'),),
                    end_blocks=('[action options]',))
            else:
                self._write_long_usage(depth, writer)
            if self.doc is not None and not is_short:
                doc = self._format_doc(self.doc)
                self._write_wrap(doc, depth, writer)
                self._write_empty(writer)
        else:
            include_global_options = False
        if action_format == 'short':
            include_singleton_positional = False
            include_global_options = False
        self.usage_formatter.write(
            depth, writer,
            include_singleton_positional=include_singleton_positional,
            include_global_options=include_global_options,
            include_actions=include_actions,
            action_metas=action_metas,
            action_format=action_format)