import datetime
from typing import Optional, List
from collections import UserDict
try:
    import numpy as np
    HAS_NUMPY = True
except ImportError:
    HAS_NUMPY = False
from validator_collection import checkers, validators, errors as validator_errors
from highcharts_core import constants, errors, utility_functions
from highcharts_core.decorators import validate_types
from highcharts_core.metaclasses import HighchartsMeta
from highcharts_core.js_literal_functions import serialize_to_js_literal, assemble_js_literal
from highcharts_core.options.series.data.base import DataBase
[docs]class DataPointCollection(HighchartsMeta):
    """Collection of data points.
    
    This class stores numerical values that Highcharts can interpret 
    from a primitive array in a :class:`numpy.ndarray <numpy:numpy.ndarray>`
    (in the 
    :meth:`.ndarray <highcharts_core.options.series.data.collections.DataPointCollection.ndarray>`
    property) and non-numerical data point properties as Highcharts for Python 
    :class:`DataBase <highcharts_core.options.series.data.base.DataBase>`-descended 
    objects (in the 
    :meth:`.data_points <highcharts_core.options.series.data.collections.DataPointCollection.data_points>`
    property).
    
    .. note::
    
      When serializing to JS literals, if possible, the collection is serialized to a primitive
      array to boost performance within Python *and* JavaScript. However, this may not always be
      possible if data points have non-array-compliant properties configured (e.g. adjusting their 
      style, names, identifiers, etc.). If serializing to a primitive array is not possible, the
      results are serialized as JS literal objects.
    """
    
    def __init__(self, **kwargs):
        self._array = None
        self._ndarray = None
        self._data_points = None
        
        self.array = kwargs.get('array', None)
        self.ndarray = kwargs.get('ndarray', None)
        self.data_points = kwargs.get('data_points', None)
    def __getattr__(self, name):
        """Facilitates the retrieval of a 1D array of values from the collection.
        
        The basic logic is as follows:
        
        1. This method is automatically called when an attribute is not found in the 
           instance.
        2. It checks to see whether the attribute is a valid property of the data point
           class.
        3. If it is, and NumPy is installed, it assembles the array and returns the
           dimension indicated by the attribute name. If NumPy is not installed, it
           returns a simple list with values as per the attribute name.
        4. If ``name`` is not a valid property of the data point class, then it
           calls the ``super().__getattribute__()`` method to handle the attribute.
           
        :param name: The name of the attribute to retrieve.
        :type name: :class:`str <python:str>`
        
        :returns: The value of the attribute.
        
        :raises AttributeError: If ``name`` is not a valid attribute of the data point
          class or the instance.
        """
        data_point_properties = self._get_props_from_array()
        data_point_class = self._get_data_point_class()
        if name in ['_array',
                    'array',
                    '_ndarray',
                    'ndarray',
                    '_data_points',
                    'data_points']:
            return super().__getattr__(name)
        if name in data_point_properties and (
            self.ndarray is not None or self.array is not None
        ):
            if HAS_NUMPY and self.ndarray is not None and name in self.ndarray:
                return self.ndarray[name]
            position = data_point_properties.index(name)
            
            try:
                return [x[position] for x in self.array]
            except (TypeError, IndexError):
                raise AttributeError(name)
        data_points = self._assemble_data_points()
        as_list = [getattr(x, name, None) for x in data_points]
        
        if HAS_NUMPY:
            return np.asarray(as_list)
        
        return as_list
    def __setattr__(self, name, value):
        """Updates the collected data values if ``name`` is a valid property of the 
        data point.
        
        The basic logic is as follows:
        
          1. Check if ``name`` is a valid property of the data point class.
          2. If it is not, then call the ``super().__setattr__()`` method to handle
             the attribute. End the method.
          3. If it is, then check whether the call requires merging into existing
             data (as opposed to wholesale overwrite).
          4. If merging is required, check whether ``value`` is of the same length
             as other existing data. If it is shorter, then pad it with empty values.
             If it is longer, then raise an error.
          5. If NumPy is supported, then convert ``value`` to a NumPy array. Otherwise,
             leave it as is.
          6. If NumPy is supported and an array is present, replace the corresponding 
             slice with the new value.Otherwise, reconstitute the resulting array with 
             new values.
          7. If no array is supported, then set the corresponding property on the data
             points.
        """
        if name.startswith('_'):
            super().__setattr__(name, value)
            return
        elif name in ['array', 'ndarray', 'data_points']:
            super().__setattr__(name, value)
            return
        data_point_properties = self._get_props_from_array()
        try:
            has_ndarray = self.ndarray is not None
            has_array = self.array is not None
            has_data_points = self.data_points is not None
        except AttributeError:
            has_ndarray = False
            has_array = False
            has_data_points = False
        if name in data_point_properties and has_ndarray and name != 'name':
            index = data_point_properties.index(name)
            is_arraylike = utility_functions.is_arraylike(value)
            
            array_dict = self.ndarray.copy()
            # if value is not an array
            if not is_arraylike:
                value = np.full((self.ndarray_length, 1), value)
            extend_ndarray = len(value) > self.ndarray_length
            extend_value = len(value) < self.ndarray_length
            
            # if value has more members (values) than the existing ndarray
            if extend_ndarray:
                for key in self.ndarray:
                    if key == name:
                        continue
                    array_dict[key] = utility_functions.lengthen_array(array_dict[key],
                                                                       members = len(value))
                array_dict[name] = value
            # if value has fewer members (values) than the existing ndarray
            elif extend_value:
                value = utility_functions.lengthen_array(value,
                                                         members = self.ndarray_length)
            array_dict[name] = value
            self._ndarray = array_dict
        elif name in data_point_properties and has_array and name != 'name':
            index = data_point_properties.index(name)
            is_arraylike = utility_functions.is_arraylike(value)
            
            # if value is not an array
            if not is_arraylike:
                value = [value for x in range(len(self.array))]
            if len(value) > len(self.array):
                self.array.extend([[] for x in range(len(value) - len(self.array))])
            elif len(value) < len(self.array):
                value.extend([None for x in range(len(self.array) - len(value))])
            array = []
            for row_index, inner_array in enumerate(self.array):
                revised_array = [x for x in inner_array]
                revised_array = utility_functions.extend_columns(revised_array,
                                                                 index + 1)
                row_value = value[row_index]
                if utility_functions.is_iterable(row_value):
                    revised_array[index] = row_value[index]
                else:
                    revised_array[index] = row_value
                
                array.append(revised_array)
            
            self.array = array
        elif name in data_point_properties and has_data_points:
            is_arraylike = utility_functions.is_arraylike(value)
            if not is_arraylike:
                value = np.full((len(self.data_points), 1), value)
            
            if len(self.data_points) < len(value):
                missing = len(value) - len(self.data_points)
                for i in range(missing):
                    data_point_cls = self._get_data_point_class()
                    empty_data_point = data_point_cls()
                    self.data_points.append(empty_data_point)
                    
            if len(value) < len(self.data_points):
                value = utility_functions.lengthen_array(value,
                                                         members = len(self.data_points))
            for i in range(len(self.data_points)):
                if hasattr(value[i], 'item'):
                    checked_value = value[i].item()
                else:
                    checked_value = value[i]
                try:
                    setattr(self.data_points[i], name, checked_value)
                except validator_errors.CannotCoerceError as error:
                    if isinstance(checked_value, str) and ',' in checked_value:
                        checked_value = checked_value.replace(',', '')
                        setattr(self.data_points[i], name, checked_value)
                    elif checkers.is_numeric(checked_value):
                        checked_value = str(checked_value)
                        setattr(self.data_points[i], name, checked_value)
                    else:
                        raise error
        elif name in data_point_properties and name == 'name':
            index = data_point_properties.index(name)
            
            is_iterable = not isinstance(value,
                                         (str, bytes, dict, UserDict)) and hasattr(value, 
                                                                                   '__iter__')
            if is_iterable:
                as_list = []
                for i in range(len(value)):
                    if HAS_NUMPY:
                        if name != 'name' and data_point_properties[-1] == 'name':
                            inner_list = [np.nan for x in data_point_properties[:-1]]
                        else:
                            inner_list = [np.nan for x in data_point_properties]
                    else:
                        if name != 'name' and data_point_properties[-1] == 'name':
                            inner_list = [None for x in data_point_properties[:-1]]
                        else:
                            inner_list = [None for x in data_point_properties]
                    if index < len(inner_list):
                        inner_list[index] = value[i]
                    as_list.append(inner_list)
            else:
                if name != 'name' and data_point_properties[-1] == 'name':
                    as_list = [None for x in data_point_properties[:-1]]
                else:
                    as_list = [None for x in data_point_properties]
                as_list[index] = value
            if HAS_NUMPY:
                self.ndarray = as_list
            else:
                self.array = as_list
        elif utility_functions.is_arraylike(value):
            if not has_data_points:
                data_point_cls = self._get_data_point_class()
                data_points = [data_point_cls() for x in value]
                for index in range(len(data_points)):
                    try:
                        setattr(data_points[index], name, value[index])
                    except validator_errors.CannotCoerceError:
                        if 'datetime64' in value[index].__class__.__name__:
                            try:
                                coerced_value = value[index].astype(datetime.datetime)
                                IS_DATETIME = True
                            except (ValueError, TypeError):
                                IS_DATETIME = False
                        else:
                            IS_DATETIME = False
                            
                        if IS_DATETIME:
                            setattr(data_points[index], name, coerced_value)
                        elif isinstance(value[index], str) and ',' in value[index]:
                            coerced_value = value[index].replace(',', '')
                            setattr(data_points[index], name, coerced_value)
                        elif checkers.is_numeric(value[index]) or (
                            HAS_NUMPY and isinstance(value[index], np.number)
                        ):
                            coerced_value = str(value[index])
                            setattr(data_points[index], name, coerced_value)
                        else:
                            raise errors.HighchartsValueError(
                                f'Unable to set {name} to {value[index]}. '
                                f'If using a helper method, this is likely '
                                f'due to mismatched columns. Please review '
                                f'your input data.')
                super().__setattr__('data_points', [x for x in data_points])
            elif len(value) <= len(self.data_points):
                for index in range(len(value)):
                    setattr(self.data_points[index], name, value[index])
            else:
                cut_off = len(self.data_points)
                data_point_cls = self._get_data_point_class()
                for index in range(cut_off):
                    setattr(self.data_points[index], name, value[index])
                for index in range(len(value[cut_off:])):
                    data_point = data_point_cls()
                    setattr(data_point, name, value[index])
                    self.data_points.append(data_point)
        elif name == 'name':
            if not has_data_points:
                data_point_cls = self._get_data_point_class()
                if has_ndarray:
                    length = self.ndarray_length
                elif has_array:
                    length = len(self.array)
                else:
                    length = 1
                    
                data_points = [data_point_cls() for x in range(length)]
                for index in range(len(data_points)):
                    setattr(data_points[index], name, value)
                super().__setattr__('data_points', [x for x in data_points])
            else:
                for index in range(len(value)):
                    setattr(self.data_points[index], name, value[index])
        else:
            super().__setattr__(name, value)
    def __len__(self):
        """Returns the number of data points in the collection.
        
        :rtype: :class:`int <python:int>`
        """
        if self.ndarray is not None:
            result = self.ndarray_length
        elif self.array:
            result = len(self.array)
        elif self.data_points:
            result = len(self.data_points)
        else:
            result = 0
            
        return result
    def __iter__(self):
        self._current_index = 0
        return iter(self.to_array(force_object = True))
    
    def __next__(self):
        if self._current_index < len(self):
            x = self.to_array(force_object = True)[self._current_index]
            self._current_index += 1
            return x
        raise StopIteration
    def __bool__(self):
        return len(self) > 0
    @property
    def array(self) -> Optional[List]:
        """Primitive collection of values for data points in the collection. Used if
        `NumPy <https://www.numpy.org>`__ is not available. Defaults to 
        :obj:`None <python:None>`.
        
        .. note::
        
          If `NumPy <https://www.numpy.org>`__ is availalbe, will instead behave as
          an alias for
          :meth:`.ndarray <highcharts_core.options.series.data.collections.DataPointCollection.ndarray>`
        
        :rtype: :class:`list <python:list>` or :obj:`None <python:None>`
        """
        return self._array
    
    @array.setter
    def array(self, value):
        if not value:
            self._array = None
        elif utility_functions.is_iterable(value):
            self._array = [x for x in value]
        else:
            raise errors.HighchartsValueError(f'.array requires an iterable value. '
                                              f'Received: {value}')
    @property
    def data_points(self) -> Optional[List[DataBase]]:
        """The collection of data points for the series. Defaults to
        :obj:`None <python:None>`.
        :rtype: :class:`list <python:list>` of :class:`DataBase` or 
          :obj:`None <python:None>`
        """
        return self._data_points
    
    @data_points.setter
    def data_points(self, value):
        if not value:
            self._data_points = None
        else:
            validated = validate_types(value,
                                       types = self._get_data_point_class(),
                                       force_iterable = True)
            if not checkers.is_iterable(validated, forbid_literals = (str,
                                                                      bytes,
                                                                      dict,
                                                                      UserDict)):
                validated = [validated]
            super().__setattr__('_data_points', validated)
    @property
    def ndarray(self):
        """A :class:`dict <python:dict>` whose keys correspond to data point properties, 
        and whose values are :class:`numpy.ndarray <numpy:numpy.ndarray>` instances that 
        contain the data point collection's values.
        
        :rtype: :class:`dict <python:dict>` or :obj:`None <python:None>`
        """
        return self._ndarray
    @ndarray.setter
    def ndarray(self, value):
        def raise_unsupported_dimension_error(length):
            supported_dimensions = self._get_supported_dimensions()
            
            supported_as_str = ', '.join([str(x) for x in supported_dimensions[:-1]])
            supported_as_str += f', or {str(supported_dimensions[-1])}'
            
            raise errors.HighchartsValueError(f'{self.__name__} supports arrays with '
                                                f'{supported_as_str} dimensions. Received'
                                                f' a value with: {length}')
            
        is_iterable = not isinstance(value, 
                                     (str, bytes, dict, UserDict)) and hasattr(value, 
                                                                               '__iter__')
        if value is None:
            self._ndarray = None
            as_array = False
        elif HAS_NUMPY and not isinstance(value, np.ndarray) and is_iterable:
            length = len(value[0])
            for item in value:
                if len(item) not in self._get_supported_dimensions():
                    raise_unsupported_dimension_error(len(item))
            props_from_array = self._get_props_from_array(length = length)
            as_dict = {}
            for index, prop in enumerate(props_from_array):
                prop_value = [x[index] for x in value]
                as_dict[prop] = utility_functions.to_ndarray(prop_value)
            as_array = utility_functions.to_ndarray(value)
        else:
            as_array = value
        if HAS_NUMPY and isinstance(as_array, np.ndarray):
            dimensions = as_array.ndim
            supported_dimensions = self._get_supported_dimensions()
            if dimensions not in supported_dimensions:
                dimensions = as_array.ndim + 1
                if dimensions not in supported_dimensions:
                    raise_unsupported_dimension_error(dimensions)
            props_from_array = self._get_props_from_array(length = dimensions)
            if props_from_array and props_from_array[-1] != 'name':
                props_from_array.append('name')
            as_dict = {}
            for index, prop in enumerate(props_from_array):
                try:
                    as_dict[prop] = as_array[:, index]
                except IndexError as error:
                    if index == len(props_from_array) - 1 and prop == 'name':
                        pass
                    else:
                        raise error
            self._ndarray = as_dict
        elif value is not None:
            raise errors.HighchartsValueError(f'.ndarray expects a numpy.ndarray '
                                              f'or an iterable that can easily be '
                                              f'coerced to one. Received: '
                                              f'{value.__class__.__name__}')
    @classmethod
    def _get_data_point_class(cls):
        """The Python class to use as the underlying data point within the Collection.
        
        :rtype: class object
        """
        return DataBase
    @classmethod
    def _get_supported_dimensions(cls) -> List[int]:
        """The number of dimensions supported by the collection.
        
        :rtype: :class:`list <python:list>` of :class:`int <python:int>`
        """
        dimensions = cls._get_data_point_class()._get_supported_dimensions()
        last_dimension = dimensions[-1]
        data_point_properties = cls._get_props_from_array()
        if 'name' not in data_point_properties or len(data_point_properties) > last_dimension:
            dimensions.append(last_dimension + 1)
        
        return dimensions
    @classmethod
    def _get_props_from_array(cls, length = None) -> List[str]:
        """Returns a list of the property names that can be set using the
        :meth:`.from_array() <highcharts_core.options.series.data.base.DataBase.from_array>`
        method.
        
        :param length: The length of the array, which may determine the properties to 
          parse. Defaults to :obj:`None <python:None>`, which returns the full list of 
          properties.
        :type length: :class:`int <python:int>` or :obj:`None <python:None>`
        
        :rtype: :class:`list <python:list>` of :class:`str <python:str>`
        """
        data_point_cls = cls._get_data_point_class()
        
        return data_point_cls._get_props_from_array(length)
[docs]    @classmethod
    def from_array(cls, value):
        """Creates a 
        :class:`DataPointCollection <highcharts_core.options.series.data.collections.DataPointCollection>` 
        instance from an array of values.
        
        :param value: The value that should contain the data which will be converted into
          data point instances.
        :type value: iterable or :class:`numpy.ndarray <numpy:numpy.ndarray>`
       
        :returns: A single-object collection of data points.
        :rtype: :class:`DataPointCollection <highcharts_core.options.series.data.collections.DataPointCollection>`
          or :obj:`None <python:None>`
          
        :raises HighchartsDependencyError: if `NumPy <https://numpy.org>`__ is not installed
        """
        if HAS_NUMPY and isinstance(value, np.ndarray) and value.dtype != np.dtype('O'):
            return cls.from_ndarray(value)
        elif HAS_NUMPY and isinstance(value, np.ndarray):
            as_list = value.tolist()
        else:
            as_list = value
        data_points = cls._get_data_point_class().from_array(as_list)
        return cls(data_points = data_points) 
[docs]    @classmethod
    def from_ndarray(cls, value):
        """Creates a 
        :class:`DataPointCollection <highcharts_core.options.series.data.collections.DataPointCollection>` 
        instance from an array of values.
        
        :param value: The value that should contain the data which will be converted into
          data point instances.
        :type value: :class:`numpy.ndarray <numpy:numpy.ndarray>`
       
        :returns: A single-object collection of data points.
        :rtype: :class:`DataPointCollection <highcharts_core.options.series.data.collections.DataPointCollection>`
          or :obj:`None <python:None>`
          
        :raises HighchartsDependencyError: if `NumPy <https://numpy.org>`__ is not installed
        """
        if not HAS_NUMPY:
            raise errors.HighchartsDependencyError('DataPointCollection requires NumPy '
                                                   'be installed. The runtime '
                                                   'environment does not currently have '
                                                   'NumPy installed. Please use the data '
                                                   'point pattern instead, or install NumPy'
                                                   ' using "pip install numpy" or similar.')
        
        if not isinstance(value, np.ndarray):
            raise errors.HighchartsValueError(f'Expected a NumPy ndarray instance, but '
                                              f'received: {value.__class__.__name__}')
        if value.dtype == np.dtype('O'):
            return cls.from_array(value.tolist())
        return cls(ndarray = value) 
    
    @property
    def requires_js_object(self) -> bool:
        """Indicates whether or not the data point *must* be serialized to a JS literal 
        object or whether it can be serialized to a primitive array.
        
        :returns: ``True`` if the data point *must* be serialized to a JS literal object.
          ``False`` if it can be serialized to an array.
        :rtype: :class:`bool <python:bool>`
        """
        if not self.data_points:
            return False
        from_array_props = [utility_functions.to_camelCase(x)
                            for x in self._get_props_from_array()]
        
        data_points_as_dict = [x.to_dict() for x in self.data_points]
        for data_point in data_points_as_dict:
            for prop in from_array_props:
                if prop in data_point:
                    del data_point[prop]
                    
        data_points = sum([1 for x in data_points_as_dict if x])
        
        if data_points:
            return True
        
        return False
    @property
    def ndarray_length(self) -> int:
        """The length of the array stored in 
        :meth:`.ndarray <highcharts_core.options.series.data.collections.DataPointCollection.ndarray>`.
        
        :rtype: :class:`int <python:int>`
        """
        if not self.ndarray:
            return 0
        return len(self.ndarray[list(self.ndarray.keys())[0]])
    def _assemble_data_points(self):
        """Assemble a collection of 
        :class:`DataBase <highcharts_core.options.series.data.base.DataBase>`-descended
        objects from the provided data. The algorithm should be as follows:
        
        1. Take any data points provided in the 
          :meth:`.data_points <highcharts_core.options.series.data.collections.DataPointCollection.data_points>` property.
        
        2. If the 
          :meth:`.ndarray <highcharts_core.options.series.data.collections.DataPointcollection.data_points>`
          is empty, return the data points as-is.
        
        3. Strip the data points of properties from the 
          :meth:`._get_props_from_array() <highcharts_core.options.series.data.collections.DataPointCollection._get_props_from_array>`
          method.
          
        4. Populate the data points with property values from
          :meth:`.ndarray <highcharts_core.options.series.data.collections.DataPointcollection.data_points>`.
          
        5. Return the re-hydrated data points.
        
        :rtype: :class:`list <python:list>` of 
          :class:`DataBase <highcharts_core.options.series.data.base.DataBase>`
          instances.
        """
        if self.data_points is not None:
            data_points = [x for x in self.data_points]
        else:
            data_points = []
        if self.ndarray is None and not self.array:
            return data_points
        
        for index, data_point in enumerate(data_points):
            for prop in self._get_props_from_array():
                if getattr(data_point, prop) is not None:
                    setattr(data_points[index], prop, None)
        if HAS_NUMPY and self.ndarray is not None:
            if len(data_points) < self.ndarray_length:
                missing = self.ndarray_length - len(data_points)
                for i in range(missing):
                    data_points.append(self._get_data_point_class()())
            for index in range(self.ndarray_length):
                inner_list = [self.ndarray[key][index] for key in self.ndarray]
                data_points[index].populate_from_array(inner_list)
        else:
            if len(data_points) < len(self.array):
                missing = len(self.array) - len(data_points)
                for i in range(missing):
                    data_points.append(self._get_data_point_class()())
                    
            for index in range(len(self.array)):
                array = self.array[index]
                data_points[index].populate_from_array(array)
        return data_points
    def _assemble_ndarray(self):
        """Assemble a :class:`numpy.ndarray <numpy:numpy.ndarray>` from the contents
        of
        :meth:`.data_points <highcharts_core.options.series.data.collections.DataPointCollection.data_points>`.
        
        .. warning::
        
          This method will *ignore* properties that Highcharts (JS) cannot support in a 
          primitive nested array structure.
        
        :returns: A :class:`numpy.ndarray <numpy:numpy.ndarray>` of the data points.
        :rtype: :class:`numpy.ndarray <numpy:numpy.ndarray>`
        
        """
        if not self.data_points:
            return np.ndarray.empty()
        
        props = self._get_props_from_array()
        if props and props[-1] == 'name':
            props = props[:-1]
        as_list = [[getattr(data_point, x, constants.EnforcedNull)
                    for x in props]
                   for data_point in self.data_points]
        
        return utility_functions.to_ndarray(as_list)
[docs]    def to_array(self, force_object = False, force_ndarray = False) -> List:
        """Generate the array representation of the data points (the inversion 
        of 
        :meth:`.from_array() <highcharts_core.options.series.data.base.DataBase.from_array>`).
        
        .. warning::
        
          If any data points *cannot* be serialized to a JavaScript array,
          this method will instead return the untrimmed :class:`dict <python:dict>`
          representation of the data points as a fallback.
        :param force_object: if ``True``, forces the return of the instance's
          untrimmed :class:`dict <python:dict>` representation. Defaults to ``False``.
          .. warning::
          
            Values in
            :meth:`.ndarray <highcharts_core.options.series.data.collections.DataPointCollection.ndarray>`
            are *ignored* within this operation in favor of data points stored in
            :meth:`.data_points <highcharts_core.options.series.data.collections.DataPointCollection.data_points>`.
            
            However, if there are no data points in
            :meth:`.data_points <highcharts_core.options.series.data.collections.DataPointCollection.data_points>`
            then data point objects will be assembled based on
            :meth:`.ndarray <highcharts_core.options.series.data.collections.DataPointCollection.ndarray>`.
            
        :type force_object: :class:`bool <python:bool>`
        
        :param force_ndarray: if ``True``, forces the return of the instance's
          data points as a :class:`numpy.ndarray <numpy:numpy.ndarray>`. Defaults to 
          ``False``.
          
          .. warning::
          
            Properties of any 
            :meth:`.data_points <highcharts_core.options.series.data.collections.DataPointCollection.data_points>`
            are *ignored* within this operation if
            :meth:`.ndarray <highcharts_core.options.series.data.collections.DataPointCollection.ndarray>`
            is populated.
            
            However, if
            :meth:`.ndarray <highcharts_core.options.series.data.collections.DataPointCollection.ndarray>`
            is not populated, then a :class:`numpy.ndarray <numpy:numpy.ndarray>` will 
            be assembled from values in 
            :meth:`.data_points <highcharts_core.options.series.data.collections.DataPointCollection.data_points>`
            (ignoring properties that Highcharts (JS) cannot interpret as a primitive array).
            
        :type force_ndarray: :class:`bool <python:bool>`
        
        :raises HighchartsValueError: if both `force_object` and `force_ndarray` are
          ``True``
        :returns: The array representation of the data point collection.
        :rtype: :class:`list <python:list>`
        """
        if force_object and force_ndarray:
            raise errors.HighchartsValueError('force_object and force_ndarray cannot '
                                              'both be True')
        if self.ndarray is None and not self.array and not self.data_points:
            return []
        
        if force_object and self.data_points and not self.array:
            return [x for x in self.data_points]
        elif force_object and self.ndarray is not None:
            return [x for x in self._assemble_data_points()]
        elif force_object and self.array is not None:
            return [x for x in self._assemble_data_points()]
        if force_ndarray and not HAS_NUMPY:
            raise errors.HighchartsDependencyError('Cannot force ndarray if NumPy is '
                                                   'not available in the runtime '
                                                   'environment. Please install using '
                                                   '"pip install numpy" or similar.')
        elif force_ndarray and self.ndarray is not None:
            return utility_functions.from_ndarray(self.ndarray)
        elif force_ndarray and self.data_points:
            as_ndarray = self._assemble_ndarray()
            return utility_functions.from_ndarray(as_ndarray)
        if self.ndarray is not None and not self.requires_js_object:
            as_list = []
            columns = []
            for key in self.ndarray:
                value = self.ndarray[key]
                if utility_functions.is_ndarray(value):
                    columns.append(utility_functions.from_ndarray(value))
                else:
                    columns.append(value)
            as_list = [list(x) for x in zip(*columns)]
            
            return as_list
        elif self.array is not None and not self.requires_js_object:
            return [x for x in self.array]
        
        if not self.array and self.data_points:
            return [x for x in self.data_points]
        return [x for x in self._assemble_data_points()] 
    @classmethod
    def _get_kwargs_from_dict(cls, as_dict):
        """Convenience method which returns the keyword arguments used to initialize the
        class from a Highcharts Javascript-compatible :class:`dict <python:dict>` object.
        :param as_dict: The HighCharts JS compatible :class:`dict <python:dict>`
          representation of the object.
        :type as_dict: :class:`dict <python:dict>`
        :returns: The keyword arguments that would be used to initialize an instance.
        :rtype: :class:`dict <python:dict>`
        """
        kwargs = {
            'array': as_dict.get('array', None),
            'ndarray': as_dict.get('ndarray', None),
            'data_points': as_dict.get('dataPoints', None),
        }
        return kwargs
    def _to_untrimmed_dict(self, in_cls = None) -> dict:
        untrimmed = {
            'array': self.array,
            'ndarray': self.ndarray,
            'dataPoints': self.data_points,
        }
        return untrimmed
[docs]    def to_js_literal(self,
                      filename = None,
                      encoding = 'utf-8',
                      careful_validation = False) -> Optional[str]:
        """Return the object represented as a :class:`str <python:str>` containing the
        JavaScript object literal.
        :param filename: The name of a file to which the JavaScript object literal should
          be persisted. Defaults to :obj:`None <python:None>`
        :type filename: Path-like
        :param encoding: The character encoding to apply to the resulting object. Defaults
          to ``'utf-8'``.
        :type encoding: :class:`str <python:str>`
        :param careful_validation: if ``True``, will carefully validate JavaScript values
        along the way using the
        `esprima-python <https://github.com/Kronuz/esprima-python>`__ library. Defaults
        to ``False``.
        
        .. warning::
        
            Setting this value to ``True`` will significantly degrade serialization
            performance, though it may prove useful for debugging purposes.
        :type careful_validation: :class:`bool <python:bool>`
        :rtype: :class:`str <python:str>` or :obj:`None <python:None>`
        """
        if filename:
            filename = validators.path(filename)
        untrimmed = self.to_array()
        is_ndarray = all([isinstance(x, list) for x in untrimmed])
        if not is_ndarray:
            as_str = '['
            as_str += ','.join([x.to_js_literal(encoding = encoding,
                                                careful_validation = careful_validation)
                                for x in untrimmed])
            as_str += ']'
        else:
            serialized = serialize_to_js_literal(untrimmed,
                                                 encoding = encoding,
                                                 careful_validation = careful_validation)
            as_str = serialized
        if filename:
            with open(filename, 'w', encoding = encoding) as file_:
                file_.write(as_str)
        return as_str