# Copyright 2020 pydicom authors. See LICENSE file for details.
"""Pydicom command line interface program

Each subcommand is a module within pydicom.cli, which
defines an add_subparser(subparsers) function to set argparse
attributes, and calls set_defaults(func=callback_function)

"""

import argparse
from importlib.metadata import entry_points
import re
import sys
from typing import cast, Any
from collections.abc import Callable

from pydicom import dcmread
from pydicom.data.data_manager import get_charset_files, get_testdata_file
from pydicom.dataset import Dataset


subparsers: argparse._SubParsersAction | None = None


# Restrict the allowed syntax tightly, since use Python `eval`
# on the expression. Do not allow callables, or assignment, for example.
re_kywd_or_item = (
    r"\w+"  # Keyword (\w allows underscore, needed for file_meta)
    r"(\[(-)?\d+\])?"  # Optional [index] or [-index]
)

re_file_spec_object = re.compile(re_kywd_or_item + r"(\." + re_kywd_or_item + r")*$")

filespec_help = (
    "File specification, in format [pydicom::]filename[::element]. "
    "If `pydicom::` prefix is present, then use the pydicom "
    "test file with that name. If `element` is given, "
    "use only that data element within the file. "
    "Examples: "
    "path/to/your_file.dcm, "
    "your_file.dcm::StudyDate, "
    "pydicom::rtplan.dcm::BeamSequence[0], "
    "yourplan.dcm::BeamSequence[0].BeamNumber"
)


def eval_element(ds: Dataset, element: str) -> Any:
    try:
        return eval("ds." + element, {"ds": ds})
    except AttributeError:
        raise argparse.ArgumentTypeError(
            f"Data element '{element}' is not in the dataset"
        )
    except IndexError as e:
        raise argparse.ArgumentTypeError(f"'{element}' has an index error: {e}")


def filespec_parts(filespec: str) -> tuple[str, str, str]:
    """Parse the filespec format into prefix, filename, element

    Format is [prefix::filename::element]

    Note that ':' can also exist in valid filename, e.g. r'c:\temp\test.dcm'
    """

    *prefix_file, last = filespec.split("::")

    if not prefix_file:  # then only the filename component
        return "", last, ""

    prefix = "pydicom" if prefix_file[0] == "pydicom" else ""
    if prefix:
        prefix_file.pop(0)

    # If list empty after pop above, then have pydicom::filename
    if not prefix_file:
        return prefix, last, ""

    return prefix, "".join(prefix_file), last


def filespec_parser(filespec: str) -> list[tuple[Dataset, Any]]:
    """Utility to return a dataset and an optional data element value within it

    Note: this is used as an argparse 'type' for adding parsing arguments.

    Parameters
    ----------
    filespec: str
        A filename with optional `pydicom::` prefix and optional data element,
        in format:
            [pydicom::]<filename>[::<element>]
        If an element is specified, it must be a path to a data element,
        sequence item (dataset), or a sequence.
        Examples:
            your_file.dcm
            your_file.dcm::StudyDate
            pydicom::rtplan.dcm::BeamSequence[0]
            pydicom::rtplan.dcm::BeamSequence[0].BeamLimitingDeviceSequence

    Returns
    -------
    List[Tuple[Dataset, Any]]
        Matching pairs of (dataset, data element value)
        This usually is a single pair, but a list is returned for future
        ability to work across multiple files.

    Note
    ----
        This function is meant to be used in a call to an `argparse` library's
        `add_argument` call for subparsers, with name="filespec" and
        `type=filespec_parser`. When used that way, the resulting args.filespec
        will contain the return values of this function
        (e.g. use `ds, element_val = args.filespec` after parsing arguments)
        See the `pydicom.cli.show` module for an example.

    Raises
    ------
    argparse.ArgumentTypeError
        If the filename does not exist in local path or in pydicom test files,
        or if the optional element is not a valid expression,
        or if the optional element is a valid expression but does not exist
        within the dataset
    """
    prefix, filename, element = filespec_parts(filespec)

    # Get the pydicom test filename even without prefix, in case user forgot it
    try:
        pydicom_filename = cast(str, get_testdata_file(filename))
    except ValueError:  # will get this if absolute path passed
        pydicom_filename = ""

    # Check if filename is in charset files
    if not pydicom_filename:
        try:
            char_filenames = get_charset_files(filename)
            if char_filenames:
                pydicom_filename = char_filenames[0]
        except NotImplementedError:  # will get this if absolute path passed
            pass

    if prefix == "pydicom":
        filename = pydicom_filename

    # Check element syntax first to avoid unnecessary load of file
    if element and not re_file_spec_object.match(element):
        raise argparse.ArgumentTypeError(
            f"Component '{element}' is not valid syntax for a "
            "data element, sequence, or sequence item"
        )

    # Read DICOM file
    try:
        ds = dcmread(filename, force=True)
    except FileNotFoundError:
        extra = (
            (f", \nbut 'pydicom::{filename}' test data file is available")
            if pydicom_filename
            else ""
        )
        raise argparse.ArgumentTypeError(f"File '{filename}' not found{extra}")
    except Exception as e:
        raise argparse.ArgumentTypeError(f"Error reading '{filename}': {e}")

    if not element:
        return [(ds, None)]

    data_elem_val = eval_element(ds, element)

    return [(ds, data_elem_val)]


def help_command(args: argparse.Namespace) -> None:
    if subparsers is None:
        print("No subcommands are available")
        return

    subcommands: list[str] = list(subparsers.choices.keys())
    if args.subcommand and args.subcommand in subcommands:
        subparsers.choices[args.subcommand].print_help()
    else:
        print("Use pydicom help [subcommand] to show help for a subcommand")
        subcommands.remove("help")
        print(f"Available subcommands: {', '.join(subcommands)}")


SubCommandType = dict[str, Callable[[argparse._SubParsersAction], None]]


def get_subcommand_entry_points() -> SubCommandType:
    subcommands = {}
    for entry_point in entry_points(group="pydicom_subcommands"):
        subcommands[entry_point.name] = entry_point.load()

    return subcommands


def main(args: list[str] | None = None) -> None:
    """Entry point for 'pydicom' command line interface

    Parameters
    ----------
    args : List[str], optional
        Command-line arguments to parse.  If ``None``, then :attr:`sys.argv`
        is used.
    """
    global subparsers

    py_version = sys.version.split()[0]

    parser = argparse.ArgumentParser(
        prog="pydicom",
        description=f"pydicom command line utilities (Python {py_version})",
    )
    subparsers = parser.add_subparsers(help="subcommand help")

    help_parser = subparsers.add_parser("help", help="display help for subcommands")
    help_parser.add_argument(
        "subcommand", nargs="?", help="Subcommand to show help for"
    )
    help_parser.set_defaults(func=help_command)

    # Get subcommands to register themselves as a subparser
    subcommands = get_subcommand_entry_points()
    for subcommand in subcommands.values():
        subcommand(subparsers)

    ns = parser.parse_args(args)
    if not vars(ns):
        parser.print_help()
    else:
        ns.func(ns)
