# Copyright 2008-2018 pydicom authors. See LICENSE file for details.
# -*- coding: utf-8 -*-
"""Access dicom dictionary information"""
import warnings
from typing import Tuple, Optional, Dict

from pydicom.config import logger
from pydicom.tag import Tag, BaseTag, TagType

# the actual dict of {tag: (VR, VM, name, is_retired, keyword), ...}
from pydicom._dicom_dict import DicomDictionary

# those with tags like "(50xx, 0005)"
from pydicom._dicom_dict import RepeatersDictionary
from pydicom._private_dict import private_dictionaries


# Generate mask dict for checking repeating groups etc.
# Map a true bitwise mask to the DICOM mask with "x"'s in it.
masks: Dict[str, Tuple[int, int]] = {}
for mask_x in RepeatersDictionary:
    # mask1 is XOR'd to see that all non-"x" bits
    # are identical (XOR result = 0 if bits same)
    # then AND those out with 0 bits at the "x"
    # ("we don't care") location using mask2
    mask1 = int(mask_x.replace("x", "0"), 16)
    mask2 = int("".join(["F0"[c == "x"] for c in mask_x]), 16)
    masks[mask_x] = (mask1, mask2)


def mask_match(tag: int) -> Optional[str]:
    """Return the repeaters tag mask for `tag`.

    Parameters
    ----------
    tag : int
        The tag to check.

    Returns
    -------
    str or None
        If the tag is in the repeaters dictionary then returns the
        corresponding masked tag, otherwise returns ``None``.
    """
    for mask_x, (mask1, mask2) in masks.items():
        if (tag ^ mask1) & mask2 == 0:
            return mask_x
    return None


def add_dict_entry(
    tag: int,
    VR: str,
    keyword: str,
    description: str,
    VM: str = '1',
    is_retired: str = ''
) -> None:
    """Update the DICOM dictionary with a new non-private entry.

    Parameters
    ----------
    tag : int
        The tag number for the new dictionary entry.
    VR : str
        DICOM value representation.
    description : str
        The descriptive name used in printing the entry. Often the same as the
        keyword, but with spaces between words.
    VM : str, optional
        DICOM value multiplicity. If not specified, then ``'1'`` is used.
    is_retired : str, optional
        Usually leave as blank string (default). Set to ``'Retired'`` if is a
        retired data element.

    Raises
    ------
    ValueError
        If the tag is a private tag.

    Notes
    -----
    Does not permanently update the dictionary, but only during run-time.
    Will replace an existing entry if the tag already exists in the dictionary.

    See Also
    --------
    pydicom.examples.add_dict_entry
        Example file which shows how to use this function
    add_dict_entries
        Update multiple values at once.

    Examples
    --------

    >>> from pydicom import Dataset
    >>> add_dict_entry(0x10021001, "UL", "TestOne", "Test One")
    >>> add_dict_entry(0x10021002, "DS", "TestTwo", "Test Two", VM='3')
    >>> ds = Dataset()
    >>> ds.TestOne = 'test'
    >>> ds.TestTwo = ['1', '2', '3']

    """
    add_dict_entries({tag: (VR, VM, description, is_retired, keyword)})


def add_dict_entries(
    new_entries_dict: Dict[int, Tuple[str, str, str, str, str]]
) -> None:
    """Update the DICOM dictionary with new non-private entries.

    Parameters
    ----------
    new_entries_dict : dict
        :class:`dict` of form:
        ``{tag: (VR, VM, description, is_retired, keyword), ...}``
        where parameters are as described in :func:`add_dict_entry`.

    Raises
    ------
    ValueError
        If one of the entries is a private tag.

    See Also
    --------
    add_dict_entry
        Add a single entry to the dictionary.

    Examples
    --------

    >>> from pydicom import Dataset
    >>> new_dict_items = {
    ...        0x10021001: ('UL', '1', "Test One", '', 'TestOne'),
    ...        0x10021002: ('DS', '3', "Test Two", '', 'TestTwo'),
    ... }
    >>> add_dict_entries(new_dict_items)
    >>> ds = Dataset()
    >>> ds.TestOne = 'test'
    >>> ds.TestTwo = ['1', '2', '3']

    """

    if any([BaseTag(tag).is_private for tag in new_entries_dict]):
        raise ValueError(
            'Private tags cannot be added using "add_dict_entries" - '
            'use "add_private_dict_entries" instead')

    # Update the dictionary itself
    DicomDictionary.update(new_entries_dict)

    # Update the reverse mapping from name to tag
    keyword_dict.update({val[4]: tag for tag, val in new_entries_dict.items()})


def add_private_dict_entry(
    private_creator: str, tag: int, VR: str, description: str, VM: str = '1'
) -> None:
    """Update the private DICOM dictionary with a new entry.

    .. versionadded:: 1.3

    Parameters
    ----------
    private_creator : str
        The private creator for the new entry.
    tag : int
        The tag number for the new dictionary entry. Note that the
        2 high bytes of the element part of the tag are ignored.
    VR : str
        DICOM value representation.
    description : str
        The descriptive name used in printing the entry.
    VM : str, optional
        DICOM value multiplicity. If not specified, then ``'1'`` is used.

    Raises
    ------
    ValueError
        If the tag is a non-private tag.

    Notes
    -----
    Behaves like :func:`add_dict_entry`, only for a private tag entry.

    See Also
    --------
    add_private_dict_entries
        Add or update multiple entries at once.
    """
    new_dict_val = (VR, VM, description, '')
    add_private_dict_entries(private_creator, {tag: new_dict_val})


def add_private_dict_entries(
    private_creator: str,
    new_entries_dict: Dict[int, Tuple[str, str, str, str]]
) -> None:
    """Update pydicom's private DICOM tag dictionary with new entries.

    .. versionadded:: 1.3

    Parameters
    ----------
    private_creator: str
        The private creator for all entries in `new_entries_dict`.
    new_entries_dict : dict
        :class:`dict` of form ``{tag: (VR, VM, description, is_retired), ...}``
        where parameters are as described in :func:`add_private_dict_entry`.

    Raises
    ------
    ValueError
        If one of the entries is a non-private tag.

    See Also
    --------
    add_private_dict_entry
        Function to add a single entry to the private tag dictionary.

    Examples
    --------
    >>> new_dict_items = {
    ...        0x00410001: ('UL', '1', "Test One"),
    ...        0x00410002: ('DS', '3', "Test Two", '3'),
    ... }
    >>> add_private_dict_entries("ACME LTD 1.2", new_dict_items)
    >>> add_private_dict_entry("ACME LTD 1.3", 0x00410001, "US", "Test Three")
    """

    if not all([BaseTag(tag).is_private for tag in new_entries_dict]):
        raise ValueError(
            "Non-private tags cannot be added using "
            "'add_private_dict_entries()' - use 'add_dict_entries()' instead"
        )

    new_entries = {
        f"{tag >> 16:04x}xx{tag & 0xff:02x}": value
        for tag, value in new_entries_dict.items()
    }
    private_dictionaries.setdefault(private_creator, {}).update(new_entries)


def get_entry(tag: TagType) -> Tuple[str, str, str, str, str]:
    """Return an entry from the DICOM dictionary as a tuple.

    If the `tag` is not in the main DICOM dictionary, then the repeating
    group dictionary will also be checked.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag for the element whose entry is to be retrieved, in any of the
        forms accepted by :func:`~pydicom.tag.Tag`. Only entries in the
        official DICOM dictionary will be checked, not entries in the
        private dictionary.

    Returns
    -------
    tuple of str
        The (VR, VM, name, is_retired, keyword) from the DICOM dictionary.

    Raises
    ------
    KeyError
        If the tag is not present in the DICOM data dictionary.

    See Also
    --------
    get_private_entry
        Return an entry from the private dictionary.
    """
    # Note: tried the lookup with 'if tag in DicomDictionary'
    # and with DicomDictionary.get, instead of try/except
    # Try/except was fastest using timeit if tag is valid (usual case)
    # My test had 5.2 usec vs 8.2 for 'contains' test, vs 5.32 for dict.get
    if not isinstance(tag, BaseTag):
        tag = Tag(tag)
    try:
        return DicomDictionary[tag]
    except KeyError:
        if not tag.is_private:
            mask_x = mask_match(tag)
            if mask_x:
                return RepeatersDictionary[mask_x]
        raise KeyError(f"Tag {tag} not found in DICOM dictionary")


def dictionary_is_retired(tag: TagType) -> bool:
    """Return ``True`` if the element corresponding to `tag` is retired.

    Only performs the lookup for official DICOM elements.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag for the element whose retirement status is being checked, in
        any of the forms accepted by :func:`~pydicom.tag.Tag`.

    Returns
    -------
    bool
        ``True`` if the element's retirement status is 'Retired', ``False``
        otherwise.

    Raises
    ------
    KeyError
        If the tag is not present in the DICOM data dictionary.
    """
    return 'retired' in get_entry(tag)[3].lower()


def dictionary_VR(tag: TagType) -> str:
    """Return the VR of the element corresponding to `tag`.

    Only performs the lookup for official DICOM elements.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag for the element whose value representation (VR) is being
        retrieved, in any of the forms accepted by :func:`~pydicom.tag.Tag`.

    Returns
    -------
    str
        The VR of the corresponding element.

    Raises
    ------
    KeyError
        If the tag is not present in the DICOM data dictionary.
    """
    return get_entry(tag)[0]


def dictionary_VM(tag: TagType) -> str:
    """Return the VM of the element corresponding to `tag`.

    Only performs the lookup for official DICOM elements.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag for the element whose value multiplicity (VM) is being
        retrieved, in any of the forms accepted by :func:`~pydicom.tag.Tag`.

    Returns
    -------
    str
        The VM of the corresponding element.

    Raises
    ------
    KeyError
        If the tag is not present in the DICOM data dictionary.
    """
    return get_entry(tag)[1]


def dictionary_description(tag: TagType) -> str:
    """Return the description of the element corresponding to `tag`.

    Only performs the lookup for official DICOM elements.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag for the element whose description is being retrieved, in any
        of the forms accepted by :func:`~pydicom.tag.Tag`.

    Returns
    -------
    str
        The description of the corresponding element.

    Raises
    ------
    KeyError
        If the tag is not present in the DICOM data dictionary.
    """
    return get_entry(tag)[2]


def dictionary_keyword(tag: TagType) -> str:
    """Return the keyword of the element corresponding to `tag`.

    Only performs the lookup for official DICOM elements.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag for the element whose keyword is being retrieved, in any of
        the forms accepted by :func:`~pydicom.tag.Tag`.

    Returns
    -------
    str
        The keyword of the corresponding element.

    Raises
    ------
    KeyError
        If the tag is not present in the DICOM data dictionary.
    """
    return get_entry(tag)[4]


def dictionary_has_tag(tag: TagType) -> bool:
    """Return ``True`` if `tag` is in the official DICOM data dictionary.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag to check, in any of the forms accepted by
        :func:`~pydicom.tag.Tag`.

    Returns
    -------
    bool
        ``True`` if the tag corresponds to an element present in the official
        DICOM data dictionary, ``False`` otherwise.
    """
    try:
        return Tag(tag) in DicomDictionary
    except Exception:
        return False


def keyword_for_tag(tag: TagType) -> str:
    """Return the keyword of the element corresponding to `tag`.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag for the element whose keyword is being retrieved, in any of
        the forms accepted by :func:`~pydicom.tag.Tag`.

    Returns
    -------
    str
        If the element is in the DICOM data dictionary then returns the
        corresponding element's keyword, otherwise returns ``''``. For
        group length elements will always return ``'GroupLength'``.
    """
    try:
        return dictionary_keyword(tag)
    except KeyError:
        return ""


# Provide for the 'reverse' lookup. Given the keyword, what is the tag?
keyword_dict: Dict[str, int] = {
    dictionary_keyword(tag): tag for tag in DicomDictionary
}


def tag_for_keyword(keyword: str) -> Optional[int]:
    """Return the tag of the element corresponding to `keyword`.

    Only performs the lookup for official DICOM elements.

    Parameters
    ----------
    keyword : str
        The keyword for the element whose tag is being retrieved.

    Returns
    -------
    int or None
        If the element is in the DICOM data dictionary then returns the
        corresponding element's tag, otherwise returns ``None``.
    """
    return keyword_dict.get(keyword)


def repeater_has_tag(tag: int) -> bool:
    """Return ``True`` if `tag` is in the DICOM repeaters data dictionary.

    Parameters
    ----------
    tag : int
        The tag to check.

    Returns
    -------
    bool
        ``True`` if the tag is a non-private element tag present in the
        official DICOM repeaters data dictionary, ``False`` otherwise.
    """
    return (mask_match(tag) in RepeatersDictionary)


REPEATER_KEYWORDS = [val[4] for val in RepeatersDictionary.values()]


def repeater_has_keyword(keyword: str) -> bool:
    """Return ``True`` if `keyword` is in the DICOM repeaters data dictionary.

    Parameters
    ----------
    keyword : str
        The keyword to check.

    Returns
    -------
    bool
        ``True`` if the keyword corresponding to an element present in the
        official DICOM repeaters data dictionary, ``False`` otherwise.
    """
    return keyword in REPEATER_KEYWORDS


# PRIVATE DICTIONARY handling
# functions in analogy with those of main DICOM dict
def get_private_entry(
    tag: TagType, private_creator: str
) -> Tuple[str, str, str, str]:
    """Return an entry from the private dictionary corresponding to `tag`.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag for the element whose entry is to be retrieved, in any of the
        forms accepted by :func:`~pydicom.tag.Tag`. Only entries in the
        private dictionary will be checked.
    private_creator : str
        The name of the private creator.

    Returns
    -------
    tuple of str
        The (VR, VM, name, is_retired) from the private dictionary.

    Raises
    ------
    KeyError
        If the tag or private creator is not present in the private dictionary.

    See Also
    --------
    get_entry
        Return an entry from the DICOM data dictionary.
    """
    if not isinstance(tag, BaseTag):
        tag = Tag(tag)

    try:
        private_dict = private_dictionaries[private_creator]
    except KeyError as exc:
        raise KeyError(
            f"Private creator '{private_creator}' not in the private "
            "dictionary"
        ) from exc
    except TypeError as exc:
        msg = (f"{tag.private_creator} '{private_creator}' "
               f"is not a valid private creator")
        warnings.warn(msg)
        raise KeyError(msg) from exc

    # private elements are usually agnostic for
    # "block" (see PS3.5-2008 7.8.1 p44)
    # Some elements in _private_dict are explicit;
    # most have "xx" for high-byte of element
    #  so here put in the "xx" in the block position for key to look up
    group_str = f"{tag.group:04x}"
    elem_str = f"{tag.elem:04x}"
    keys = [
        f"{group_str}{elem_str}",
        f"{group_str}xx{elem_str[-2:]}",
        f"{group_str[:2]}xxxx{elem_str[-2:]}"
    ]
    keys = [k for k in keys if k in private_dict]
    if not keys:
        raise KeyError(
            f"Tag '{tag}' not in private dictionary "
            f"for private creator '{private_creator}'"
        )
    dict_entry = private_dict[keys[0]]

    return dict_entry


def private_dictionary_VR(tag: TagType, private_creator: str) -> str:
    """Return the VR of the private element corresponding to `tag`.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag for the element whose value representation (VR) is being
        retrieved, in any of the forms accepted by :func:`~pydicom.tag.Tag`.
    private_creator : str
        The name of the private creator.

    Returns
    -------
    str
        The VR of the corresponding element.

    Raises
    ------
    KeyError
        If the tag is not present in the private dictionary.
    """
    return get_private_entry(tag, private_creator)[0]


def private_dictionary_VM(tag: TagType, private_creator: str) -> str:
    """Return the VM of the private element corresponding to `tag`.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag for the element whose value multiplicity (VM) is being
        retrieved, in any of the forms accepted by :func:`~pydicom.tag.Tag`.
    private_creator : str
        The name of the private creator.

    Returns
    -------
    str
        The VM of the corresponding element.

    Raises
    ------
    KeyError
        If the tag is not present in the private dictionary.
    """
    return get_private_entry(tag, private_creator)[1]


def private_dictionary_description(tag: TagType, private_creator: str) -> str:
    """Return the description of the private element corresponding to `tag`.

    Parameters
    ----------
    tag : int or str or Tuple[int, int]
        The tag for the element whose description is being retrieved, in any
        of the forms accepted by :func:`~pydicom.tag.Tag`.
    private_creator : str
        The name of the private creator.

    Returns
    -------
    str
        The description of the corresponding element.

    Raises
    ------
    KeyError
        If the tag is not present in the private dictionary,
        or if the private creator is not valid.
    """
    return get_private_entry(tag, private_creator)[2]
