# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of logilab-common.
#
# logilab-common is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option) any
# later version.
#
# logilab-common is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with logilab-common.  If not, see <http://www.gnu.org/licenses/>.
"""Add an abstraction level to transparently import optik classes from optparse
(python >= 2.3) or the optik package.

It also defines three new types for optik/optparse command line parser :

  * regexp
    argument of this type will be converted using re.compile
  * csv
    argument of this type will be converted using split(',')
  * yn
    argument of this type will be true if 'y' or 'yes', false if 'n' or 'no'
  * named
    argument of this type are in the form <NAME>=<VALUE> or <NAME>:<VALUE>
  * password
    argument of this type wont be converted but this is used by other tools
    such as interactive prompt for configuration to double check value and
    use an invisible field
  * multiple_choice
    same as default "choice" type but multiple choices allowed
  * file
    argument of this type wont be converted but checked that the given file exists
  * color
    argument of this type wont be converted but checked its either a
    named color or a color specified using hexadecimal notation (preceded by a #)
  * time
    argument of this type will be converted to a float value in seconds
    according to time units (ms, s, min, h, d)
  * bytes
    argument of this type will be converted to a float value in bytes
    according to byte units (b, kb, mb, gb, tb)
"""

__docformat__ = "restructuredtext en"

import re
import sys
import time
from copy import copy
from os.path import exists
from logilab.common import attrdict

from typing import Any, Union, List, Optional, Tuple, Dict
from _io import StringIO

# python >= 2.3
from optparse import (  # noqa
    OptionParser as BaseParser,
    Option as BaseOption,
    OptionGroup,
    OptionContainer,
    OptionValueError,
    OptionError,
    Values,
    HelpFormatter,
    SUPPRESS_HELP,
    NO_DEFAULT,
)

try:
    from mx import DateTime

    HAS_MX_DATETIME = True
except ImportError:
    HAS_MX_DATETIME = False

from logilab.common.textutils import splitstrip, TIME_UNITS, BYTE_UNITS, apply_units


def check_regexp(option, opt, value):
    """check a regexp value by trying to compile it
    return the compiled regexp
    """
    if hasattr(value, "pattern"):
        return value
    try:
        return re.compile(value)
    except ValueError:
        raise OptionValueError(f"option {opt}: invalid regexp value: {value!r}")


def check_csv(
    option: Optional["Option"], opt: str, value: Union[List[str], Tuple[str, ...], str]
) -> Union[List[str], Tuple[str, ...]]:
    """check a csv value by trying to split it
    return the list of separated values
    """
    if isinstance(value, (list, tuple)):
        return value
    try:
        return splitstrip(value)
    except ValueError:
        raise OptionValueError(f"option {opt}: invalid csv value: {value!r}")


def check_yn(option: Optional["Option"], opt: str, value: Union[bool, str]) -> bool:
    """check a yn value
    return true for yes and false for no
    """
    if isinstance(value, int):
        return bool(value)
    if value in ("y", "yes"):
        return True
    if value in ("n", "no"):
        return False
    msg = "option %s: invalid yn value %r, should be in (y, yes, n, no)"
    raise OptionValueError(msg % (opt, value))


def check_named(
    option: Optional[Any], opt: str, value: Union[Dict[str, str], str]
) -> Dict[str, str]:
    """check a named value
    return a dictionary containing (name, value) associations
    """
    if isinstance(value, dict):
        return value
    values: List[Tuple[str, str]] = []
    for value in check_csv(option, opt, value):
        # mypy: Argument 1 to "append" of "list" has incompatible type "List[str]";
        # mypy: expected "Tuple[str, str]"
        # we know that the split will give a 2 items list
        if value.find("=") != -1:
            values.append(value.split("=", 1))  # type: ignore
        elif value.find(":") != -1:
            values.append(value.split(":", 1))  # type: ignore
    if values:
        return dict(values)
    msg = "option %s: invalid named value %r, should be <NAME>=<VALUE> or \
<NAME>:<VALUE>"
    raise OptionValueError(msg % (opt, value))


def check_password(option, opt, value):
    """check a password value (can't be empty)"""
    # no actual checking, monkey patch if you want more
    return value


def check_file(option, opt, value):
    """check a file value
    return the filepath
    """
    if exists(value):
        return value
    msg = "option %s: file %r does not exist"
    raise OptionValueError(msg % (opt, value))


# XXX use python datetime
def check_date(option, opt, value):
    """check a file value
    return the filepath
    """
    try:
        return DateTime.strptime(value, "%Y/%m/%d")
    except DateTime.Error:
        raise OptionValueError(f"expected format of {opt} is yyyy/mm/dd")


def check_color(option, opt, value):
    """check a color value and returns it
    /!\\ does *not* check color labels (like 'red', 'green'), only
    checks hexadecimal forms
    """
    # Case (1) : color label, we trust the end-user
    if re.match("[a-z0-9 ]+$", value, re.I):
        return value
    # Case (2) : only accepts hexadecimal forms
    if re.match("#[a-f0-9]{6}", value, re.I):
        return value
    # Else : not a color label neither a valid hexadecimal form => error
    msg = "option %s: invalid color : %r, should be either hexadecimal \
    value or predefined color"
    raise OptionValueError(msg % (opt, value))


def check_time(option, opt, value):
    if isinstance(value, (int, float)):
        return value
    return apply_units(value, TIME_UNITS)


def check_bytes(option: Optional["Option"], opt: str, value: Any) -> int:
    if hasattr(value, "__int__"):
        return value
    # mypy: Incompatible return value type (got "Union[float, int]", expected "int")
    # we force "int" using "final=int"
    return apply_units(value, BYTE_UNITS, final=int)  # type: ignore


class Option(BaseOption):
    """override optik.Option to add some new option types"""

    TYPES = BaseOption.TYPES + (
        "regexp",
        "csv",
        "yn",
        "named",
        "password",
        "multiple_choice",
        "file",
        "color",
        "time",
        "bytes",
    )
    ATTRS = BaseOption.ATTRS + ["hide", "level"]
    TYPE_CHECKER = copy(BaseOption.TYPE_CHECKER)
    TYPE_CHECKER["regexp"] = check_regexp
    TYPE_CHECKER["csv"] = check_csv
    TYPE_CHECKER["yn"] = check_yn
    TYPE_CHECKER["named"] = check_named
    TYPE_CHECKER["multiple_choice"] = check_csv
    TYPE_CHECKER["file"] = check_file
    TYPE_CHECKER["color"] = check_color
    TYPE_CHECKER["password"] = check_password
    TYPE_CHECKER["time"] = check_time
    TYPE_CHECKER["bytes"] = check_bytes
    if HAS_MX_DATETIME:
        TYPES += ("date",)
        TYPE_CHECKER["date"] = check_date

    def __init__(self, *opts: str, **attrs: Any) -> None:
        BaseOption.__init__(self, *opts, **attrs)
        # mypy: "Option" has no attribute "hide"
        # we test that in the if
        if hasattr(self, "hide") and self.hide:  # type: ignore
            self.help = SUPPRESS_HELP

    def _check_choice(self) -> None:
        """FIXME: need to override this due to optik misdesign"""
        if self.type in ("choice", "multiple_choice"):
            # mypy: "Option" has no attribute "choices"
            # we know that option of this type has this attribute
            if self.choices is None:  # type: ignore
                raise OptionError("must supply a list of choices for type 'choice'", self)
            elif not isinstance(self.choices, (tuple, list)):  # type: ignore
                raise OptionError(
                    "choices must be a list of strings ('%s' supplied)"
                    % str(type(self.choices)).split("'")[1],  # type: ignore
                    self,
                )
        elif self.choices is not None:  # type: ignore
            raise OptionError(f"must not supply choices for type {self.type!r}", self)

    # mypy: Unsupported target for indexed assignment
    # black magic?
    BaseOption.CHECK_METHODS[2] = _check_choice  # type: ignore

    def process(self, opt: str, value: str, values: Values, parser: BaseParser) -> int:
        # First, convert the value(s) to the right type.  Howl if any
        # value(s) are bogus.
        value = self.convert_value(opt, value)
        if self.type == "named":
            assert self.dest is not None
            existant = getattr(values, self.dest)
            if existant:
                existant.update(value)
                value = existant
        # And then take whatever action is expected of us.
        # This is a separate method to make life easier for
        # subclasses to add new actions.
        # mypy: Argument 2 to "take_action" of "Option" has incompatible type "Optional[str]";
        # mypy: expected "str"
        # is it ok?
        return self.take_action(self.action, self.dest, opt, value, values, parser)  # type: ignore


class OptionParser(BaseParser):
    """override optik.OptionParser to use our Option class"""

    def __init__(self, option_class: type = Option, *args: Any, **kwargs: Any) -> None:
        # mypy: Argument "option_class" to "__init__" of "OptionParser" has incompatible type
        # mypy: "type"; expected "Option"
        # mypy is doing really weird things with *args/**kwargs and looks buggy
        BaseParser.__init__(self, option_class=option_class, *args, **kwargs)  # type: ignore

    def format_option_help(self, formatter: Optional[HelpFormatter] = None) -> str:
        if formatter is None:
            formatter = self.formatter
        outputlevel = getattr(formatter, "output_level", 0)
        formatter.store_option_strings(self)
        result = []
        result.append(formatter.format_heading("Options"))
        formatter.indent()
        if self.option_list:
            result.append(OptionContainer.format_option_help(self, formatter))
            result.append("\n")
        for group in self.option_groups:
            # mypy: "OptionParser" has no attribute "level"
            # but it has one no?
            if group.level <= outputlevel and (  # type: ignore
                group.description or level_options(group, outputlevel)
            ):
                result.append(group.format_help(formatter))
                result.append("\n")
        formatter.dedent()
        # Drop the last "\n", or the header if no options or option groups:
        return "".join(result[:-1])


# mypy error: error: "Type[OptionGroup]" has no attribute "level"
# monkeypatching
OptionGroup.level = 0  # type: ignore


def level_options(group: BaseParser, outputlevel: int) -> List[BaseOption]:
    # mypy: "Option" has no attribute "help"
    # but it does
    return [
        option
        for option in group.option_list
        if (getattr(option, "level", 0) or 0) <= outputlevel and option.help is not SUPPRESS_HELP  # type: ignore  # noqa
    ]


def format_option_help(self, formatter):
    result = []
    outputlevel = getattr(formatter, "output_level", 0) or 0
    for option in level_options(self, outputlevel):
        result.append(formatter.format_option(option))
    return "".join(result)


# mypy error: Cannot assign to a method
# but we still do it because magic
OptionContainer.format_option_help = format_option_help  # type: ignore


class ManHelpFormatter(HelpFormatter):
    """Format help using man pages ROFF format"""

    def __init__(
        self,
        indent_increment: int = 0,
        max_help_position: int = 24,
        width: int = 79,
        short_first: int = 0,
    ) -> None:
        HelpFormatter.__init__(self, indent_increment, max_help_position, width, short_first)

    def format_heading(self, heading: str) -> str:
        return f".SH {heading.upper()}\n"

    def format_description(self, description):
        return description

    def format_option(self, option: BaseParser) -> str:
        try:
            # mypy: "Option" has no attribute "option_strings"
            # we handle if it doesn't
            optstring = option.option_strings  # type: ignore
        except AttributeError:
            optstring = self.format_option_strings(option)
        # mypy: "OptionParser" has no attribute "help"
        # it does
        if option.help:  # type: ignore
            # mypy: Argument 1 to "expand_default" of "HelpFormatter" has incompatible type
            # mypy: "OptionParser"; expected "Option"
            # it still works?
            help_text = self.expand_default(option)  # type: ignore
            help = " ".join([line.strip() for line in help_text.splitlines()])
        else:
            help = ""
        return f""".IP "{optstring}"
{help}
"""

    def format_head(self, optparser: OptionParser, pkginfo: attrdict, section: int = 1) -> str:
        long_desc = ""
        pgm = optparser.get_prog_name()
        short_desc = self.format_short_description(pgm, pkginfo.description)
        if hasattr(pkginfo, "long_desc"):
            long_desc = self.format_long_description(pgm, pkginfo.long_desc)
        return "%s\n%s\n%s\n%s" % (
            self.format_title(pgm, section),
            short_desc,
            self.format_synopsis(pgm),
            long_desc,
        )

    def format_title(self, pgm: str, section: int) -> str:
        date = "-".join([str(num) for num in time.localtime()[:3]])
        return f'.TH {pgm} {section} "{date}" {pgm}'

    def format_short_description(self, pgm: str, short_desc: str) -> str:
        return r""".SH NAME
.B %s
\- %s
""" % (
            pgm,
            short_desc.strip(),
        )

    def format_synopsis(self, pgm: str) -> str:
        return f""".SH SYNOPSIS
.B  {pgm}
[
.I OPTIONS
] [
.I <arguments>
]
"""

    def format_long_description(self, pgm, long_desc):
        long_desc = "\n".join([line.lstrip() for line in long_desc.splitlines()])
        long_desc = long_desc.replace("\n.\n", "\n\n")
        if long_desc.lower().startswith(pgm):
            long_desc = long_desc[len(pgm) :]
        return f""".SH DESCRIPTION
.B {pgm}
{long_desc.strip()}
"""

    def format_tail(self, pkginfo: attrdict) -> str:
        tail = """.SH SEE ALSO
/usr/share/doc/pythonX.Y-%s/

.SH BUGS
Please report bugs on the project\'s mailing list:
%s

.SH AUTHOR
%s <%s>
""" % (
            getattr(pkginfo, "debian_name", pkginfo.modname),
            pkginfo.mailinglist,
            pkginfo.author,
            pkginfo.author_email,
        )

        if hasattr(pkginfo, "copyright"):
            tail += f"""
.SH COPYRIGHT
{pkginfo.copyright}
"""

        return tail


def generate_manpage(
    optparser: OptionParser,
    pkginfo: attrdict,
    section: int = 1,
    stream: StringIO = sys.stdout,
    level: int = 0,
) -> None:
    """generate a man page from an optik parser"""
    formatter = ManHelpFormatter()
    # mypy: "ManHelpFormatter" has no attribute "output_level"
    # dynamic attribute?
    formatter.output_level = level  # type: ignore
    formatter.parser = optparser
    print(formatter.format_head(optparser, pkginfo, section), file=stream)
    print(optparser.format_option_help(formatter), file=stream)
    print(formatter.format_tail(pkginfo), file=stream)


__all__ = ("OptionParser", "Option", "OptionGroup", "OptionValueError", "Values")
