"""This module allows getting information and changing Alma holding records"""

from typing import Optional, Literal, List, ClassVar, Union
import logging
from ..record import Record, check_error, XmlData
import almapiwrapper.inventory as inventory
from lxml import etree
import os


class Holding(Record):
    """Class representing a holding object. Holdings are only in the IZ.

    Several possibilities for building holdings:
    - 'get_holdings' method of IzBib object
    - Holding(mms_id, holding_id, zone, env)
    - Holding(bib=BibIz, holding_id=holding_id)

    if no 'holding_id' is provided, but 'data' is provided and create_holding is True, then
    it creates a new holding.

    :ivar holding_id: holding ID
    :ivar zone: zone of the record
    :ivar env: environment of the entity: 'P' for production and 'S' for sandbox
    :ivar bib: class:`almapiwrapper.inventory.IzBib` object
    :ivar data: :class:`almapiwrapper.record.XmlData` or `etree.Element` object,
        useful to force update a record from a backup
    :ivar error: boolean indicating if an error occurred during the last operation
    :ivar error_msg: string containing the error message if an error occurred during the last operation

    :cvar api_base_url_bibs: base URL for bibs API calls
    :cvar area: area of the API, here 'Bibs'
    :cvar format: format of the data xml for holdings

    :param mms_id: record mms_id
    :param holding_id: holding ID
    :param zone: zone of the record
    :param env: environment of the entity: 'P' for production and 'S' for sandbox
    :param bib: class:`almapiwrapper.inventory.IzBib` object
    :param data: :class:`almapiwrapper.record.XmlData` or `etree.Element` object,
        useful to force create a new holding (if no 'holding_id' is provided and create_holding is True)
    :param create_holding: boolean, if True try to create a new holding (if no 'holding_id' is provided)
    """

    api_base_url_bibs: ClassVar[str] = f'{Record.api_base_url}/bibs'
    area = 'Bibs'
    format = 'xml'

    def __init__(self,
                 mms_id: Optional[str] = None,
                 holding_id: Optional[str] = None,
                 zone: Optional[str] = None,
                 env: Literal['P', 'S'] = 'P',
                 bib: Optional[inventory.IzBib] = None,
                 data: Optional[Union['XmlData', etree.Element]] = None,
                 create_holding: Optional[bool] = False) -> None:
        """Construct a holding record

        Several possibilities for building holdings:
        - 'get_holdings' method of IzBib object
        - Holding(mms_id, holding_id, zone, env)
        - Holding(bib=BibIz, holding_id=holding_id)

        if no 'holding_id' is provided, but 'data' is provided and create_holding is True, then
        it creates a new holding.
        """
        self._items = None
        self.error = False
        self.error_msg = None
        self._data = None
        self.bib = bib
        self.holding_id = holding_id

        if self.bib is not None:
            self.zone = self.bib.zone
            self.env = self.bib.env

        else:
            self.zone = zone
            self.env = env
            self.bib = inventory.IzBib(mms_id, self.zone, self.env)

            # If there is an error in the bibliographic record, it is spread out in the holding record
            if self.bib.error:
                self.error = True

        # Create a new holding if 'create_holding' is True
        if self.holding_id is None and data is not None and create_holding is True:
            if data.__class__.__name__ == '_Element':
                data = XmlData(etree.tostring(data))
            self._create_holding(data)

    def __repr__(self) -> str:
        """Get a string representation of the object. Useful for logs.

        :return: string
        """
        return f"{self.__class__.__name__}('{self.bib.mms_id}', '{self.holding_id}', '{self.zone}', '{self.env}')"

    def _fetch_data(self) -> Optional[XmlData]:
        """Fetch holding data via API

        Store the data in the 'data' attribute.

        :return: None
        """
        r = self._api_call('get',
                           f'{self.api_base_url_bibs}/{self.bib.mms_id}/holdings/{self.holding_id}',
                           headers=self._get_headers())

        if r.ok:
            logging.info(f'{repr(self)}: holding data available')
            return XmlData(r.content)
        else:
            self._handle_error(r, 'unable to fetch holding data')
            return None

    def _create_holding(self, data: XmlData) -> None:
        """Create a holding and link it to the provided bibliographic record

        :param data: data used to create the holding

        :return: None
        """
        r = self._api_call('post',
                           f'{self.api_base_url_bibs}/{self.bib.mms_id}/holdings',
                           headers=self._get_headers(),
                           data=bytes(data))

        if r.ok:
            self.data = XmlData(r.content)
            self.holding_id = self.get_holding_id()
            logging.info(f'{repr(self)}: holding created')
        else:
            self._handle_error(r, 'unable to create the new holding')

    @check_error
    def get_holding_id(self) -> str:
        """Fetch holding ID in the data of 'data' attribute.

        Useful for creating a new holding.

        :return: str with holding id

        .. note::
            If the record encountered an error, this
            method will be skipped.
        """
        return self.data.find('.//holding_id').text

    @check_error
    def save(self) -> 'Holding':
        """Save holding in a folder for each MMS ID

        Example: records/UBS_9963486250105504/hol_22314215780005504_01.xml

        :return: :class:`almapiwrapper.inventory.Holding` object

        .. note::
            If the record encountered an error, this
            method will be skipped.
        """
        filepath = f'records/{self.zone}_{self.bib.mms_id}/hol_{self.holding_id}.xml'
        self._save_from_path(filepath)
        return self

    @check_error
    def update(self) -> 'Holding':
        """Update data of a holding

        :return: :class:`almapiwrapper.inventory.Holding` object

        .. note::
            If the record encountered an error, this
            method will be skipped.
        """
        r = self._api_call('put',
                           f'{self.api_base_url_bibs}/{self.bib.mms_id}/holdings/{self.holding_id}',
                           data=etree.tostring(self.data),
                           headers=self._get_headers())

        if r.ok:
            self.data = XmlData(r.content)
            logging.info(f'{repr(self)}: holding data updated')
        else:
            self._handle_error(r, 'unable to update holding data')

        return self

    @check_error
    def delete(self, force: Optional[bool] = False) -> None:
        """Delete holding

        :return: None

        .. note::
            If the record encountered an error, this
            method will be skipped.
        """
        if force:
            self.delete_items()

        r = self._api_call('delete',
                           f'{self.api_base_url_bibs}/{self.bib.mms_id}/holdings/{self.holding_id}',
                           headers=self._get_headers())
        if r.ok:
            logging.info(f'{repr(self)} deleted')
        else:
            self._handle_error(r, 'unable to delete the holding')

    @check_error
    def get_items(self) -> List['inventory.Item']:
        """Retrieve the list of items and loads the xml data of these items

        To avoid reloading this information, the items references are stored
        in the private attribute '_items'.

        :return: list of :class:`almapiwrapper.bib.Item` objects

        .. note::
            If the record encountered an error, this
            method will be skipped.
        """
        # Check if items have been already fetched
        if self._items is not None:
            return self._items

        # Fetch the item's data through apis
        r = self._api_call('get',
                           f'{self.api_base_url_bibs}/{self.bib.mms_id}/holdings/{self.holding_id}/items',
                           params={'limit': '100'},
                           headers=self._get_headers())

        if r.ok:
            root = etree.fromstring(r.content, parser=self.parser)
        else:
            self._handle_error(r, 'unable to fetch items')
            return []

        items_data = root.findall('.//item_data')
        if len(items_data) > 99:
            logging.warning(f'{repr(self)}: at least 100 items, risk of missing items')

        # No item found
        if len(items_data) == 0:
            logging.warning(f'{repr(self)}: no item found')
            self._items = []
            return self._items

        logging.info(f'{repr(self)}: {len(items_data)} items fetched')

        # Fetch items one by one
        self._items = []
        for item in items_data:
            item_id = item.find('.//pid').text
            self._items.append(inventory.Item(holding=self, item_id=item_id))

        return self._items

    @check_error
    def delete_items(self) -> None:
        """Delete all items of the holding

        :return: None

        .. note::
            If the record encountered an error, this
            method will be skipped.
        """
        for item in self.get_items():
            item.delete()

    @property
    @check_error
    def library(self) -> Optional[str]:
        """Property of the holding returning the library code

        :return: str containing library code
        """
        library = self.data.find('.//datafield[@tag="852"]/subfield[@code="b"]')
        if library is None:
            logging.warning(f'{repr(self)}: no library in the holding')
            return None
        return library.text

    @library.setter
    @check_error
    def library(self, library_code: str) -> None:
        """This setter is able to update the 852$b of the holding

        The field should already exist.

        :param library_code: code of the library to insert in 852$b field

        :return: None

        .. note::
            If the record encountered an error, this
            method will be skipped.
        """
        library = self.data.find('.//datafield[@tag="852"]/subfield[@code="b"]')
        if library is None:
            logging.error(f'{repr(self)}: no library field in the holding -> not possible to update it')
            self.error = True
        else:
            logging.info(f'{repr(self)}: library changed from "{library.text}" to "{library_code}"')
            library.text = library_code

    @property
    @check_error
    def location(self) -> Optional[str]:
        """Property of the holding returning the library code

        :return: str containing library code

        .. note::
            If the record encountered an error, this
            method will be skipped.
        """
        location = self.data.find('.//datafield[@tag="852"]/subfield[@code="c"]')
        if location is None:
            logging.warning(f'{repr(self)}: no location in the holding')
            return None
        return location.text

    @location.setter
    @check_error
    def location(self, location_code: str) -> None:
        """lThis setter is able to update the 852$c of the holding

        The field should already exist.

        :param location_code: location code to set to the holding

        :return: None

        .. note::
            If the record encountered an error, this
            method will be skipped.
        """
        location = self.data.find('.//datafield[@tag="852"]/subfield[@code="c"]')
        if location is None:
            logging.error(f'{repr(self)}: no location field in the holding -> not possible to update it')
        else:
            logging.info(f'{repr(self)}: location changed from "{location.text}" to "{location_code}"')
            location.text = location_code

    @property
    @check_error
    def callnumber(self) -> Optional[str]:
        """Property of the holding returning the callnumber

        :return: str containing callnumber

        .. note::
            If the record encountered an error, this
            method will be skipped.
        """
        field852 = self.data.find('.//datafield[@tag="852"]')
        if field852 is None:
            return None

        callnumber_field = field852.find('./subfield[@code="j"]')

        if callnumber_field is None:
            callnumber_field = field852.find('./subfield[@code="h"]')

        if callnumber_field is None:
            logging.warning(f'{repr(self)}: no callnumber field in the holding')
            return None

        return callnumber_field.text

    @callnumber.setter
    @check_error
    def callnumber(self, callnumber_txt: str) -> None:
        """This setter is able to update the 852$j or 852$h of the holding

        The field should already exist.

        :param callnumber_txt: text of the callnumber to be set in j or h subfield

        :return: None

        .. note::
            If the record encountered an error, this
            method will be skipped.
        """
        field852 = self.data.find('.//datafield[@tag="852"]')
        if field852 is None:
            return

        callnumber_field = field852.find('./subfield[@code="j"]')

        if callnumber_field is None:
            callnumber_field = field852.find('./subfield[@code="h"]')

        if callnumber_field is None:
            logging.error(f'{repr(self)}: no callnumber field in the holding -> not possible to update it')
            return

        logging.info(f'{repr(self)}: callnumber changed from "{callnumber_field.text}" to "{callnumber_txt}"')
        callnumber_field.text = callnumber_txt

    @staticmethod
    def get_data_from_disk(mms_id: str, holding_id: str, zone: str) -> Optional[XmlData]:
        """Fetch the data of the described record

        :param mms_id: bib record mms_id
        :param holding_id: holding record ID
        :param zone: zone of the record

        :return: :class:`almapiwrapper.record.XmlData` or None
        """
        if not os.path.isdir(f'records/{zone}_{mms_id}'):
            return None

        # Fetch all available filenames related to this record
        file_names = sorted([file_name for file_name in os.listdir(f'records/{zone}_{mms_id}')
                             if file_name.startswith(f'hol_{holding_id}') is True])

        if len(file_names) == 0:
            return None

        return XmlData(filepath=f'records/{zone}_{mms_id}/{file_names[-1]}')
