#!/usr/bin/env python3
# Copyright: 2016, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

"""Command line option handling."""

from __future__ import annotations

import argparse
import collections.abc as abc
import dataclasses
import logging
import os
import pathlib
import typing as t

from decopy.common._regex import rex
from decopy.common.core_types import Options
from decopy.common.log import logger


class Defaults:
    exclude_file_regex: str = r"""
    # Ignore general backup files
    ~$|
    # Ignore emacs recovery files
    \.\#.*$|
    # Ignore vi swap files
    \..*\.swp$|
    # Ignore baz-style junk files or directories
    ^,,|
    # File-names that should be ignored (never directories)
    ^(?:DEADJOE|\.cvsignore|\.arch-inventory|\.bzrignore|\.gitignore)$|
    # File or directory names that should be ignored
    ^(?:CVS|RCS|\.deps|\{arch\}|\.arch-ids|\.svn|\.hg|_darcs|\.git|
    \.shelf|_MTN|\.bzr(?:\.backup|tags)?)$
    """

    exclude_directory_regex: str = r"""
    # Ignore baz-style junk files or directories
    ^,,|
    # File or directory names that should be ignored
    ^(?:CVS|RCS|\.deps|\{arch\}|\.arch-ids|\.svn|\.hg|_darcs|\.git|
    \.pc|\.shelf|_MTN|\.bzr(?:\.backup|tags)?)$|
    # Python compiled files
    ^__pycache__$
    """

    # Exclude debian/copyright and debian/changelog by default
    exclude_special_regex: str = r"""
    ^debian/(copyright|changelog)$
    """

    # Empty exclude regexp, to be overridden by users
    exclude_fullname_regex: str = r"""
    ^$
    """

    # Top directory for processing files
    root: str = "."

    # Files/dirs to parse
    files: t.ClassVar[list[str]] = []

    # Mode to work on:
    # partial: only process the files/dirs specified and generate the output
    #          for this subset
    # full: (default) parse the files/dirs specified, and generate the
    #       copyright information for the whole source tree
    mode: str = "full"

    # copyright 1.0 file, relative to the package dir
    copyright_file: str = "debian/copyright"

    # group by license or by copyright
    group_by: str = "license"

    # Split groups on sub dirs containing licenses
    split_on_license: bool = True

    # Split the debian/* file paragraph
    split_debian: bool = True

    # Treat all files as text
    text: bool = False

    # Try to use glob patterns in the File: output
    glob: bool = True

    # Progress bar
    progress: bool = True

    # Number of parallel jobs (0 for auto)
    jobs: int = 0

    # output filename, empty for stdout
    output: str = ""


# Defaults that the Options dataclass already declares (in core_types). The CLI
# reads them from there so each such default lives in exactly one place; fields
# Options requires (mode, root, etc.) keep their CLI default in Defaults above.
_OPTION_DEFAULTS: dict[str, t.Any] = {
    field.name: field.default
    for field in dataclasses.fields(Options)
    if field.default is not dataclasses.MISSING
}


def _add_boolean_argument(
    parser: argparse.ArgumentParser,
    name: str,
    *,
    default: bool,
    dest: str | None = None,
) -> None:
    """Helper to add options --name --no-name and set the default"""
    if dest is None:
        dest = name.replace("-", "_")
    _ = parser.add_argument(f"--{name}", action="store_true", dest=dest)
    _ = parser.add_argument(f"--no-{name}", action="store_false", dest=dest)
    parser.set_defaults(**{dest: default})


def _filename(arg: str) -> str:
    """argparse type: accept a path only if it exists."""
    if not pathlib.Path(arg).exists():
        msg = f"{arg} does not exist"
        raise argparse.ArgumentTypeError(msg)
    return arg


def _build_parser() -> argparse.ArgumentParser:
    """Assemble the argument parser. Defaults come from Defaults (CLI-only
    values) and _OPTION_DEFAULTS (values the Options dataclass owns)."""
    parser = argparse.ArgumentParser(
        description="License checker and copyright dep5 helper",
        fromfile_prefix_chars="@",
    )
    _ = parser.add_argument("-X", "--exclude", default=Defaults.exclude_fullname_regex)

    _ = parser.add_argument(
        "--mode",
        choices=["full", "partial", "changed"],
        default=Defaults.mode,
    )
    _ = parser.add_argument(
        "--changed",
        action="store_const",
        const="changed",
        dest="mode",
        help=(
            "shorthand for --mode=changed: scope to the files changed since "
            "the copyright file was last committed (needs a git repository)"
        ),
    )
    _ = parser.add_argument(
        "--since",
        default=_OPTION_DEFAULTS["since"],
        help=(
            "baseline git ref for --changed (default: the commit that last "
            "modified the copyright file)"
        ),
    )

    _ = parser.add_argument("--copyright-file", default=Defaults.copyright_file)

    _ = parser.add_argument("--debug", action="store_true")
    _ = parser.add_argument("-v", "--verbose", action="store_true")
    _ = parser.add_argument("-q", "--quiet", action="store_true")

    _ = parser.add_argument("-a", "--text", action="store_true")

    _ = parser.add_argument(
        "--explain",
        action="store_true",
        default=_OPTION_DEFAULTS["explain"],
        help=(
            "report what is out of date in debian/copyright instead of "
            "regenerating it; exits 1 if anything is stale"
        ),
    )

    _ = parser.add_argument(
        "--group-by",
        choices=["license", "copyright"],
        default=Defaults.group_by,
    )
    _add_boolean_argument(parser, "split-on-license", default=Defaults.split_on_license)
    _add_boolean_argument(parser, "split-debian", default=Defaults.split_debian)
    _add_boolean_argument(
        parser,
        "gitignore",
        default=_OPTION_DEFAULTS["use_gitignore"],
        dest="use_gitignore",
    )

    _add_boolean_argument(parser, "glob", default=Defaults.glob)
    _add_boolean_argument(
        parser, "fill-licenses", default=_OPTION_DEFAULTS["fill_licenses"]
    )

    _add_boolean_argument(parser, "progress", default=Defaults.progress)
    _add_boolean_argument(parser, "spdx", default=_OPTION_DEFAULTS["spdx"])
    _add_boolean_argument(
        parser, "expand-archives", default=_OPTION_DEFAULTS["expand_archives"]
    )
    _ = parser.add_argument(
        "--max-archive-depth",
        type=int,
        default=_OPTION_DEFAULTS["max_archive_depth"],
        help="Max depth of nested archive expansion (default: 1)",
    )
    _ = parser.add_argument(
        "--max-archive-member-size",
        type=int,
        default=_OPTION_DEFAULTS["max_archive_member_size"],
        help="Skip archive members larger than this many bytes (default: 10 MB)",
    )
    _ = parser.add_argument(
        "--jobs",
        "-j",
        type=int,
        default=Defaults.jobs,
        help="Number of jobs to run in parallel (0 for auto)",
    )

    _ = parser.add_argument("-o", "--output", default=Defaults.output)

    _ = parser.add_argument("--root", default=Defaults.root, type=_filename)

    _ = parser.add_argument("files", nargs="*", default=Defaults.files, type=_filename)

    return parser


def _configure_logging(namespace: argparse.Namespace) -> None:
    """Set the module logger level from the verbosity flags."""
    if namespace.debug:
        level = logging.DEBUG
    elif namespace.verbose:
        level = logging.INFO
    elif namespace.quiet:
        level = logging.ERROR
    else:
        level = logging.WARNING
    logging.basicConfig(format="[%(levelname)s] %(message)s")
    logger.setLevel(level)


def _compile_excludes(namespace: argparse.Namespace) -> None:
    """Compile the exclude patterns onto the namespace as the Options re fields."""
    namespace.exclude_fullname_re = rex.compile(
        namespace.exclude,
        rex.IGNORECASE | rex.VERBOSE,
    )
    namespace.exclude_special_re = rex.compile(
        Defaults.exclude_special_regex,
        rex.IGNORECASE | rex.VERBOSE,
    )
    namespace.exclude_file_re = rex.compile(Defaults.exclude_file_regex, rex.VERBOSE)
    namespace.exclude_directory_re = rex.compile(
        Defaults.exclude_directory_regex,
        rex.VERBOSE,
    )


def _normalise_paths(namespace: argparse.Namespace) -> None:
    """Normalise the output sentinel and resolve root/files against the tree."""
    # - can also be used to specify the stdout
    if namespace.output == "-":
        namespace.output = ""

    # Force an absolute path for root, and sanitize the user option
    # (see #855778, for problem caused by a trailing /)
    namespace.root = str(pathlib.Path(namespace.root).absolute())
    # Transform positional arguments into paths that are relative to the root
    namespace.files = [os.path.relpath(i, namespace.root) for i in namespace.files]


def _to_options(namespace: argparse.Namespace) -> Options:
    """Build Options from the namespace by matching field names.

    Every Options field name that the namespace carries is copied over; fields
    Options defaults and the CLI does not set (e.g. files_excluded) keep their
    dataclass default. Namespace-only attributes (verbose, quiet, exclude) are
    ignored.
    """
    field_names = {field.name for field in dataclasses.fields(Options)}
    return Options(
        **{
            name: getattr(namespace, name)
            for name in field_names
            if hasattr(namespace, name)
        }
    )


def process_options(args: abc.Sequence[str] | None = None) -> Options:
    parser = _build_parser()
    namespace = parser.parse_args(args)
    _compile_excludes(namespace)
    _normalise_paths(namespace)
    _configure_logging(namespace)
    return _to_options(namespace)
