# Copyright 2008-2023 pydicom authors. See LICENSE file for details.
"""Pydicom configuration options."""

# doc strings following items are picked up by sphinx for documentation

import logging
import os
from contextlib import contextmanager
from typing import Optional, Any, TYPE_CHECKING
from collections.abc import Generator

have_numpy = True
try:
    import numpy  # noqa: F401
except ImportError:
    have_numpy = False

if TYPE_CHECKING:  # pragma: no cover
    from pydicom.dataelem import RawDataElement
    from typing import Protocol

    class ElementCallback(Protocol):
        def __call__(
            self,
            raw_elem: "RawDataElement",
            **kwargs: Any,
        ) -> "RawDataElement": ...


_use_future = False
_use_future_env = os.getenv("PYDICOM_FUTURE")


# Logging system and debug function to change logging level
logger = logging.getLogger("pydicom")
logger.addHandler(logging.NullHandler())

debugging: bool


def debug(debug_on: bool = True, default_handler: bool = True) -> None:
    """Turn on/off debugging of DICOM file reading and writing.

    When debugging is on, file location and details about the elements read at
    that location are logged to the 'pydicom' logger using Python's
    :mod:`logging`
    module.

    Parameters
    ----------
    debug_on : bool, optional
        If ``True`` (default) then turn on debugging, ``False`` to turn off.
    default_handler : bool, optional
        If ``True`` (default) then use :class:`logging.StreamHandler` as the
        handler for log messages.
    """
    global logger, debugging

    if default_handler:
        handler = logging.StreamHandler()
        formatter = logging.Formatter("%(message)s")
        handler.setFormatter(formatter)
        logger.addHandler(handler)

    if debug_on:
        logger.setLevel(logging.DEBUG)
        debugging = True
    else:
        logger.setLevel(logging.WARNING)
        debugging = False


# force level=WARNING, in case logging default is set differently (issue 103)
debug(False, False)


# Set the type used to hold DS values
#    default False; was decimal-based in pydicom 0.9.7
use_DS_decimal: bool = False
"""Set using :func:`DS_decimal` to control if elements with a
VR of **DS** are represented as :class:`~decimal.Decimal`.

Default ``False``.
"""

data_element_callback: Optional["ElementCallback"] = None
"""Set to a callable function to be called from
:func:`~pydicom.filereader.dcmread` every time a
:class:`~pydicom.dataelem.RawDataElement` has been returned,
before it is added to the :class:`~pydicom.dataset.Dataset`.

Default ``None``.

.. deprecated:: 3.0

    ``data_element_callback`` will be removed in v4.0, use
    :meth:`~pydicom.hooks.Hooks.register_callback` instead.
"""

data_element_callback_kwargs: dict[str, Any] = {}
"""Set the keyword arguments passed to :func:`data_element_callback`.

Default ``{}``.

.. deprecated:: 3.0

    ``data_element_callback_kwargs`` will be removed in v4.0, use
    :meth:`~pydicom.hooks.Hooks.register_kwargs` instead.
"""


def reset_data_element_callback() -> None:
    """Reset the :func:`data_element_callback` function to the default.

    .. deprecated:: 3.0

        ``reset_data_element_callback()`` will be removed in v4.0, use
        :meth:`pydicom.hooks.Hooks.reset` instead.
    """
    global data_element_callback
    global data_element_callback_kwargs
    data_element_callback = None
    data_element_callback_kwargs = {}


def DS_numpy(use_numpy: bool = True) -> None:
    """Set whether multi-valued elements with VR of **DS** will be numpy arrays

    .. versionadded:: 2.0

    Parameters
    ----------
    use_numpy : bool, optional
        ``True`` (default) to read multi-value **DS** elements
        as :class:`~numpy.ndarray`, ``False`` to read multi-valued **DS**
        data elements as type :class:`~python.mulitval.MultiValue`

        Note: once a value has been accessed, changing this setting will
        no longer change its type

    Raises
    ------
    ValueError
        If :data:`use_DS_decimal` and `use_numpy` are both True.

    """

    global use_DS_numpy

    if use_DS_decimal and use_numpy:
        raise ValueError(
            "Cannot use numpy arrays to read DS elements if `use_DS_decimal` is True"
        )
    use_DS_numpy = use_numpy


def DS_decimal(use_Decimal_boolean: bool = True) -> None:
    """Set DS class to be derived from :class:`decimal.Decimal` or
    :class:`float`.

    If this function is never called, the default in *pydicom* >= 0.9.8
    is for DS to be based on :class:`float`.

    Parameters
    ----------
    use_Decimal_boolean : bool, optional
        ``True`` (default) to derive :class:`~pydicom.valuerep.DS` from
        :class:`decimal.Decimal`, ``False`` to derive it from :class:`float`.

    Raises
    ------
    ValueError
        If `use_Decimal_boolean` and :data:`use_DS_numpy` are
        both ``True``.
    """
    global use_DS_decimal

    use_DS_decimal = use_Decimal_boolean

    if use_DS_decimal and use_DS_numpy:
        raise ValueError("Cannot set use_DS_decimal True if use_DS_numpy is True")

    import pydicom.valuerep

    if use_DS_decimal:
        pydicom.valuerep.DSclass = pydicom.valuerep.DSdecimal
    else:
        pydicom.valuerep.DSclass = pydicom.valuerep.DSfloat


# Configuration flags
use_DS_numpy = False
"""Set using the function :func:`DS_numpy` to control
whether arrays of VR **DS** are returned as numpy arrays.
Default: ``False``.

.. versionadded:: 2.0
"""

use_IS_numpy = False
"""Set to False to avoid IS values being returned as numpy ndarray objects.
Default: ``False``.

.. versionadded:: 2.0
"""

allow_DS_float = False
"""Set to ``True`` to allow :class:`~pydicom.valuerep.DSdecimal`
instances to be created using :class:`floats<float>`; otherwise, they must be
explicitly converted to :class:`str`, with the user explicitly setting the
precision of digits and rounding.

Default ``False``.
"""


enforce_valid_values = False
"""Deprecated.
Use :attr:`Settings.reading_validation_mode` instead.
"""


# Constants used to define how data element values shall be validated
IGNORE = 0
"""If one of the validation modes is set to this value, no value validation
will be performed.
"""

WARN = 1
"""If one of the validation modes is set to this value, a warning is issued if
a value validation error occurs.
"""

RAISE = 2
"""If one of the validation modes is set to this value, an exception is raised
if a value validation error occurs.
"""


class Settings:
    """Collection of several configuration values.
    Accessed via the singleton :attr:`settings`.

    .. versionadded:: 2.3
    """

    def __init__(self) -> None:
        self._reading_validation_mode: int | None = None
        # in future version, writing invalid values will raise by default,
        # currently the default value depends on enforce_valid_values
        self._writing_validation_mode: int | None = RAISE if _use_future else None
        self._infer_sq_for_un_vr: bool = True

        # Chunk size to use when reading from buffered DataElement values
        self._buffered_read_size = 8192

    @property
    def buffered_read_size(self) -> int:
        """Get or set the chunk size when reading from buffered
        :class:`~pydicom.dataelem.DataElement` values.

        Parameters
        ----------
        size : int
            The chunk size to use, must be greater than 0 (default 8192).
        """
        return self._buffered_read_size

    @buffered_read_size.setter
    def buffered_read_size(self, size: int) -> None:
        if size <= 0:
            raise ValueError("The read size must be greater than 0")

        self._buffered_read_size = size

    @property
    def reading_validation_mode(self) -> int:
        """Defines behavior of validation while reading values, compared with
        the DICOM standard, e.g. that DS strings are not longer than
        16 characters and contain only allowed characters.

        * :attr:`WARN` will emit a warning in the case of an invalid value (default)
        * :attr:`RAISE` will raise an error instead
        * :attr:`IGNORE` will bypass the validation (with the exception of some
          encoding errors).
        """
        # upwards compatibility
        if self._reading_validation_mode is None:
            return RAISE if enforce_valid_values else WARN
        return self._reading_validation_mode

    @reading_validation_mode.setter
    def reading_validation_mode(self, value: int) -> None:
        self._reading_validation_mode = value

    @property
    def writing_validation_mode(self) -> int:
        """Defines behavior for value validation while writing a value.
        See :attr:`Settings.reading_validation_mode`.
        """
        if self._writing_validation_mode is None:
            return RAISE if enforce_valid_values else WARN
        return self._writing_validation_mode

    @writing_validation_mode.setter
    def writing_validation_mode(self, value: int) -> None:
        self._writing_validation_mode = value

    @property
    def infer_sq_for_un_vr(self) -> bool:
        """If ``True``, and the VR of a known data element is encoded as
        **UN** in an explicit encoding for an undefined length data element,
        the VR is changed to SQ per PS 3.5, section 6.2.2. Can be set to
        ``False`` where the content of the tag shown as **UN** is not DICOM
        conformant and would lead to a failure if accessing it.
        """
        return self._infer_sq_for_un_vr

    @infer_sq_for_un_vr.setter
    def infer_sq_for_un_vr(self, value: bool) -> None:
        self._infer_sq_for_un_vr = value


settings = Settings()
"""The global configuration object of type :class:`Settings` to access some
of the settings. More settings may move here in later versions.

.. versionadded:: 2.3
"""


@contextmanager
def disable_value_validation() -> Generator:
    """Context manager to temporarily disable value validation
    both for reading and writing.
    Can be used for performance reasons if the values are known to be valid.
    """
    reading_mode = settings._reading_validation_mode
    writing_mode = settings._writing_validation_mode
    try:
        settings.reading_validation_mode = IGNORE
        settings.writing_validation_mode = IGNORE
        yield
    finally:
        settings._reading_validation_mode = reading_mode
        settings._writing_validation_mode = writing_mode


@contextmanager
def strict_reading() -> Generator:
    """Context manager to temporarily enably strict value validation
    for reading."""
    original_reading_mode = settings._reading_validation_mode
    try:
        settings.reading_validation_mode = RAISE
        yield
    finally:
        settings._reading_validation_mode = original_reading_mode


convert_wrong_length_to_UN = False
"""Convert a field VR to "UN" and return bytes if bytes length is invalid.
Default ``False``.
"""

datetime_conversion = False
"""Set to ``True`` to convert the value(s) of elements with a VR of DA, DT and
TM to :class:`datetime.date`, :class:`datetime.datetime` and
:class:`datetime.time` respectively.
Note that when datetime conversion is enabled then range matching in
C-GET/C-FIND/C-MOVE queries is not possible anymore. So if you need range
matching we recommend to do the conversion manually.

Default ``False``

References
----------
* :dcm:`Range Matching<part04/sect_C.2.2.2.5.html>`
"""

use_none_as_empty_text_VR_value = False
""" If ``True``, the value of a decoded empty data element with
a text VR is ``None``, otherwise (the default), it is is an empty string.
For all other VRs the behavior does not change - the value is en empty
list for VR **SQ** and ``None`` for all other VRs.
Note that the default of this value may change to ``True`` in a later version.
"""

replace_un_with_known_vr = True
""" If ``True``, and the VR of a known data element is encoded as **UN** in
an explicit encoding, the VR is changed to the known value.
Can be set to ``False`` where the content of the tag shown as **UN** is
not DICOM conformant and would lead to a failure if accessing it.

.. versionadded:: 2.0
"""

show_file_meta = True
"""
If ``True`` (default), the 'str' and 'repr' methods
of :class:`~pydicom.dataset.Dataset` begin with a separate section
displaying the file meta information data elements

.. versionadded:: 2.0
"""

import pydicom.pixel_data_handlers.numpy_handler as np_handler  # noqa
import pydicom.pixel_data_handlers.rle_handler as rle_handler  # noqa
import pydicom.pixel_data_handlers.pillow_handler as pillow_handler  # noqa
import pydicom.pixel_data_handlers.jpeg_ls_handler as jpegls_handler  # noqa
import pydicom.pixel_data_handlers.gdcm_handler as gdcm_handler  # noqa
import pydicom.pixel_data_handlers.pylibjpeg_handler as pylibjpeg_handler  # noqa

pixel_data_handlers = [
    np_handler,
    gdcm_handler,
    pillow_handler,
    jpegls_handler,
    pylibjpeg_handler,
    rle_handler,
]
"""Handlers for converting (7FE0,0010) *Pixel Data*.

.. currentmodule:: pydicom.dataset

This is an ordered list of *Pixel Data* handlers that the
:meth:`~Dataset.convert_pixel_data` method will use to try to extract a
correctly sized numpy array from the *Pixel Data* element.

Handlers shall have four methods:

def supports_transfer_syntax(transfer_syntax: UID)
    Return ``True`` if the handler supports the transfer syntax indicated in
    :class:`Dataset` `ds`, ``False`` otherwise.

def is_available():
    Return ``True`` if the handler's dependencies are installed, ``False``
    otherwise.

def get_pixeldata(ds):
    Return a correctly sized 1D :class:`numpy.ndarray` derived from the
    *Pixel Data* in :class:`Dataset` `ds` or raise an exception. Reshaping the
    returned array to the correct dimensions is handled automatically.

def needs_to_convert_to_RGB(ds):
    Return ``True`` if the *Pixel Data* in the :class:`Dataset` `ds` needs to
    be converted to the RGB colourspace, ``False`` otherwise.

The first handler that both announces that it supports the transfer syntax
and does not raise an exception, either in getting the data or when the data
is reshaped to the correct dimensions, is the handler that will provide the
data.

If they all fail only the last exception is raised.

If none raise an exception, but they all refuse to support the transfer
syntax, then this fact is announced in a :class:`NotImplementedError`
exception.
"""

APPLY_J2K_CORRECTIONS = True
"""Use the information within JPEG 2000 data to correct the returned pixel data

.. versionadded:: 2.1

If ``True`` (default), then for handlers that support JPEG 2000 pixel data,
use the component precision and sign to correct the returned ndarray when
using the pixel data handlers. If ``False`` then only rely on the element
values within the dataset when applying corrections.
"""

assume_implicit_vr_switch = True
"""If invalid VR encountered, assume file switched to implicit VR

.. versionadded:: 2.2

If ``True`` (default), when reading an explicit VR file,
if a VR is encountered that is not a valid two bytes within A-Z,
then assume the original writer switched to implicit VR.  This has been
seen in particular in some sequences.  This does not test that
the VR is a valid DICOM VR, just that it has valid characters.
"""


INVALID_KEYWORD_BEHAVIOR = "WARN"
"""Control the behavior when setting a :class:`~pydicom.dataset.Dataset`
attribute that's not a known element keyword.

.. versionadded:: 2.1

If ``"WARN"`` (default), then warn when an element value is set using
``Dataset.__setattr__()`` and the keyword is camel case but doesn't match a
known DICOM element keyword. If ``"RAISE"`` then raise a :class:`ValueError`
exception. If ``"IGNORE"`` then neither warn nor raise.

Examples
--------

>>> from pydicom import config
>>> config.INVALID_KEYWORD_BEHAVIOR = "WARN"
>>> ds = Dataset()
>>> ds.PatientName = "Citizen^Jan"  # OK
>>> ds.PatientsName = "Citizen^Jan"
../pydicom/dataset.py:1895: UserWarning: Camel case attribute 'PatientsName'
used which is not in the element keyword data dictionary
"""

INVALID_KEY_BEHAVIOR = "WARN"
"""Control the behavior when invalid keys are used with
:meth:`~pydicom.dataset.Dataset.__contains__` (e.g. ``'invalid' in ds``).

.. versionadded:: 2.1

Invalid keys are objects that cannot be converted to a
:class:`~pydicom.tag.BaseTag`, such as unknown element keywords or invalid
element tags like ``0x100100010``.

If ``"WARN"`` (default), then warn when an invalid key is used, if ``"RAISE"``
then raise a :class:`ValueError` exception. If ``"IGNORE"`` then neither warn
nor raise.

Examples
--------

>>> from pydicom import config
>>> config.INVALID_KEY_BEHAVIOR = "RAISE"
>>> ds = Dataset()
>>> 'PatientName' in ds  # OK
False
>>> 'PatientsName' in ds
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File ".../pydicom/dataset.py", line 494, in __contains__
    raise ValueError(msg) from exc
ValueError: Invalid value used with the 'in' operator: must be an
element tag as a 2-tuple or int, or an element keyword
"""


if _use_future_env:
    if _use_future_env.lower() in ["true", "yes", "on", "1"]:
        _use_future = True
    elif _use_future_env.lower() in ["false", "no", "off", "0"]:
        _use_future = False
    else:
        raise ValueError(
            "Unknown setting for environment variable "
            "PYDICOM_FUTURE. Use True or False."
        )


def future_behavior(enable_future: bool = True) -> None:
    """Imitate the behavior for the next major version of *pydicom*.

    .. versionadded:: 2.1

    This can be used to ensure your code is "future-proof" for known
    upcoming changes in the next major version of *pydicom*. Typically,
    deprecations become errors, and default values of config flags may change.

    Parameters
    ----------
    enable_future: bool
        Set ``True`` (default) to emulate future pydicom behavior,
        ``False`` to reset to current pydicom behavior.

    See also
    --------
    :attr:`INVALID_KEYWORD_BEHAVIOR`
    :attr:`INVALID_KEY_BEHAVIOR`

    """
    global _use_future, INVALID_KEYWORD_BEHAVIOR

    if enable_future:
        _use_future = True
        INVALID_KEYWORD_BEHAVIOR = "RAISE"
        settings._writing_validation_mode = RAISE
    else:
        _use_future = False
        INVALID_KEYWORD_BEHAVIOR = "WARN"
        settings._writing_validation_mode = None


if _use_future:
    future_behavior()
