import enum
import json
import os
import time

import numpy as np
import requests
from bec_lib import MessageEndpoints, bec_logger, messages
from ophyd import ADComponent as ADCpt
from ophyd import Device, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV, Staged

from ophyd_devices.epics.devices.psi_detector_base import CustomDetectorMixin, PSIDetectorBase

logger = bec_logger.logger

MIN_READOUT = 3e-3


class PilatusError(Exception):
    """Base class for exceptions in this module."""


class PilatusTimeoutError(PilatusError):
    """Raised when the Pilatus does not respond in time during unstage."""


class TriggerSource(enum.IntEnum):
    """Trigger source options for the detector"""

    INTERNAL = 0
    EXT_ENABLE = 1
    EXT_TRIGGER = 2
    MULTI_TRIGGER = 3
    ALGINMENT = 4


class SLSDetectorCam(Device):
    """SLS Detector Camera - Pilatus

    Base class to map EPICS PVs to ophyd signals.
    """

    num_images = ADCpt(EpicsSignalWithRBV, "NumImages")
    num_frames = ADCpt(EpicsSignalWithRBV, "NumExposures")
    delay_time = ADCpt(EpicsSignalWithRBV, "NumExposures")
    trigger_mode = ADCpt(EpicsSignalWithRBV, "TriggerMode")
    acquire = ADCpt(EpicsSignal, "Acquire")
    armed = ADCpt(EpicsSignalRO, "Armed")

    read_file_timeout = ADCpt(EpicsSignal, "ImageFileTmot")
    detector_state = ADCpt(EpicsSignalRO, "StatusMessage_RBV")
    status_message_camserver = ADCpt(EpicsSignalRO, "StringFromServer_RBV", string=True)
    acquire_time = ADCpt(EpicsSignal, "AcquireTime")
    acquire_period = ADCpt(EpicsSignal, "AcquirePeriod")
    threshold_energy = ADCpt(EpicsSignalWithRBV, "ThresholdEnergy")
    file_path = ADCpt(EpicsSignalWithRBV, "FilePath")
    file_name = ADCpt(EpicsSignalWithRBV, "FileName")
    file_number = ADCpt(EpicsSignalWithRBV, "FileNumber")
    auto_increment = ADCpt(EpicsSignalWithRBV, "AutoIncrement")
    file_template = ADCpt(EpicsSignalWithRBV, "FileTemplate")
    file_format = ADCpt(EpicsSignalWithRBV, "FileNumber")
    gap_fill = ADCpt(EpicsSignalWithRBV, "GapFill")


class PilatusSetup(CustomDetectorMixin):
    """Pilatus setup class for cSAXS

    Parent class: CustomDetectorMixin

    """

    def initialize_default_parameter(self) -> None:
        """Set default parameters for Eiger9M detector"""
        self.update_readout_time()

    def update_readout_time(self) -> None:
        """Set readout time for Eiger9M detector"""
        readout_time = (
            self.parent.scaninfo.readout_time
            if hasattr(self.parent.scaninfo, "readout_time")
            else self.parent.MIN_READOUT
        )
        self.parent.readout_time = max(readout_time, self.parent.MIN_READOUT)

    def initialize_detector(self) -> None:
        """Initialize detector"""
        # Stops the detector
        self.stop_detector()
        # Sets the trigger source to GATING
        self.parent.set_trigger(TriggerSource.EXT_ENABLE)

    def prepare_detector(self) -> None:
        """
        Prepare detector for scan.

        Includes checking the detector threshold,
        setting the acquisition parameters and setting the trigger source
        """
        self.set_detector_threshold()
        self.set_acquisition_params()
        self.parent.set_trigger(TriggerSource.EXT_ENABLE)

    def set_detector_threshold(self) -> None:
        """
        Set correct detector threshold to 1/2 of current X-ray energy, allow 5% tolerance

        Threshold might be in ev or keV
        """

        # get current beam energy from device manageer
        mokev = self.parent.device_manager.devices.mokev.obj.read()[
            self.parent.device_manager.devices.mokev.name
        ]["value"]
        factor = 1

        # Check if energies are eV or keV, assume keV as the default
        unit = getattr(self.parent.cam.threshold_energy, "units", None)
        if unit is not None and unit == "eV":
            factor = 1000

        # set energy on detector
        setpoint = int(mokev * factor)

        # set threshold on detector
        threshold = self.parent.cam.threshold_energy.read()[self.parent.cam.threshold_energy.name][
            "value"
        ]
        if not np.isclose(setpoint / 2, threshold, rtol=0.05):
            self.parent.cam.threshold_energy.set(setpoint / 2)

    def set_acquisition_params(self) -> None:
        """Set acquisition parameters for the detector"""

        # Set number of images and frames (frames is for internal burst of detector)
        self.parent.cam.num_images.put(
            int(self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger)
        )
        self.parent.cam.num_frames.put(1)

        # Update the readout time of the detector
        self.update_readout_time()

    def create_directory(self, filepath: str) -> None:
        """Create directory if it does not exist"""
        os.makedirs(filepath, exist_ok=True)

    def stop_detector_backend(self) -> None:
        """Stop the file writer zmq service for pilatus_2"""
        self.close_file_writer()
        time.sleep(0.1)
        self.stop_file_writer()
        time.sleep(0.1)

    def close_file_writer(self) -> None:
        """
        Close the file writer for pilatus_2

        Delete the data from x12sa-pd-2

        """
        url = "http://x12sa-pd-2:8080/stream/pilatus_2"
        try:
            res = self.send_requests_delete(url=url)
            if not res.ok:
                res.raise_for_status()
        except Exception as exc:
            logger.info(f"Pilatus2 close threw Exception: {exc}")

    def stop_file_writer(self) -> None:
        """
        Stop the file writer for pilatus_2

        Runs on xbl-daq-34
        """
        url = "http://xbl-daq-34:8091/pilatus_2/stop"
        res = self.send_requests_put(url=url)
        if not res.ok:
            res.raise_for_status()

    def prepare_data_backend(self) -> None:
        """
        Prepare the detector backend of pilatus for a scan

        A zmq service is running on xbl-daq-34 that is waiting
        for a zmq message to start the writer for the pilatus_2 x12sa-pd-2

        """

        self.stop_detector_backend()

        self.parent.filepath = self.parent.filewriter.compile_full_filename("pilatus_2.h5")
        self.parent.cam.file_path.put("/dev/shm/zmq/")
        self.parent.cam.file_name.put(
            f"{self.parent.scaninfo.username}_2_{self.parent.scaninfo.scan_number:05d}"
        )
        self.parent.cam.auto_increment.put(1)  # auto increment
        self.parent.cam.file_number.put(0)  # first iter
        self.parent.cam.file_format.put(0)  # 0: TIFF
        self.parent.cam.file_template.put("%s%s_%5.5d.cbf")

        # TODO better to remove hard coded path with link to home directory/pilatus_2
        basepath = f"/sls/X12SA/data/{self.parent.scaninfo.username}/Data10/pilatus_2/"
        self.parent.filepath_raw = os.path.join(
            basepath,
            self.parent.filewriter.get_scan_directory(self.parent.scaninfo.scan_number, 1000, 5),
        )
        # Make directory if needed
        self.create_directory(self.parent.filepath_raw)

        headers = {"Content-Type": "application/json", "Accept": "application/json"}
        # start the stream on x12sa-pd-2
        url = "http://x12sa-pd-2:8080/stream/pilatus_2"
        data_msg = {
            "source": [
                {
                    "searchPath": "/",
                    "searchPattern": "glob:*.cbf",
                    "destinationPath": self.parent.filepath_raw,
                }
            ]
        }
        res = self.send_requests_put(url=url, data=data_msg, headers=headers)
        logger.info(f"{res.status_code} -  {res.text} - {res.content}")

        if not res.ok:
            res.raise_for_status()

        # start the data receiver on xbl-daq-34
        url = "http://xbl-daq-34:8091/pilatus_2/run"
        data_msg = [
            "zmqWriter",
            self.parent.scaninfo.username,
            {
                "addr": "tcp://x12sa-pd-2:8888",
                "dst": ["file"],
                "numFrm": int(
                    self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger
                ),
                "timeout": 2000,
                "ifType": "PULL",
                "user": self.parent.scaninfo.username,
            },
        ]
        res = self.send_requests_put(url=url, data=data_msg, headers=headers)
        logger.info(f"{res.status_code}  - {res.text} - {res.content}")

        if not res.ok:
            res.raise_for_status()

        # Wait for server to become available again
        time.sleep(0.1)
        logger.info(f"{res.status_code} -{res.text} - {res.content}")

        # Send requests.put to xbl-daq-34 to wait for data
        url = "http://xbl-daq-34:8091/pilatus_2/wait"
        data_msg = [
            "zmqWriter",
            self.parent.scaninfo.username,
            {
                "frmCnt": int(
                    self.parent.scaninfo.num_points * self.parent.scaninfo.frames_per_trigger
                ),
                "timeout": 2000,
            },
        ]
        try:
            res = self.send_requests_put(url=url, data=data_msg, headers=headers)
            logger.info(f"{res}")

            if not res.ok:
                res.raise_for_status()
        except Exception as exc:
            logger.info(f"Pilatus2 wait threw Exception: {exc}")

    def send_requests_put(self, url: str, data: list = None, headers: dict = None) -> object:
        """
        Send a put request to the given url

        Args:
            url (str): url to send the request to
            data (dict): data to be sent with the request (optional)
            headers (dict): headers to be sent with the request (optional)

        Returns:
            status code of the request
        """
        return requests.put(url=url, data=json.dumps(data), headers=headers, timeout=5)

    def send_requests_delete(self, url: str, headers: dict = None) -> object:
        """
        Send a delete request to the given url

        Args:
            url (str): url to send the request to
            headers (dict): headers to be sent with the request (optional)

        Returns:
            status code of the request
        """
        return requests.delete(url=url, headers=headers, timeout=5)

    def pre_scan(self) -> None:
        """
        Pre_scan function call

        This function is called just before the scan core.
        Here it is used to arm the detector for the acquisition

        """
        self.arm_acquisition()

    def arm_acquisition(self) -> None:
        """Arms the detector for the acquisition"""
        self.parent.cam.acquire.put(1)
        # TODO is this sleep needed? to be tested with detector and for how long
        time.sleep(0.5)

    def publish_file_location(self, done: bool = False, successful: bool = None) -> None:
        """
        Publish the filepath to REDIS and publish the event for the h5_converter

        We publish two events here:
        - file_event: event for the filewriter
        - public_file: event for any secondary service (e.g. radial integ code)

        Args:
            done (bool): True if scan is finished
            successful (bool): True if scan was successful
        """
        pipe = self.parent.connector.pipeline()
        if successful is None:
            msg = messages.FileMessage(
                file_path=self.parent.filepath,
                done=done,
                metadata={"input_path": self.parent.filepath_raw},
            )
        else:
            msg = messages.FileMessage(
                file_path=self.parent.filepath,
                done=done,
                successful=successful,
                metadata={"input_path": self.parent.filepath_raw},
            )
        self.parent.connector.set_and_publish(
            MessageEndpoints.public_file(self.parent.scaninfo.scan_id, self.parent.name),
            msg,
            pipe=pipe,
        )
        self.parent.connector.set_and_publish(
            MessageEndpoints.file_event(self.parent.name), msg, pipe=pipe
        )
        pipe.execute()

    def finished(self) -> None:
        """Check if acquisition is finished."""
        # pylint: disable=protected-access
        # TODO: at the moment this relies on device.mcs.obj._staged attribute
        signal_conditions = [
            (lambda: self.parent.device_manager.devices.mcs.obj._staged, Staged.no)
        ]
        if not self.wait_for_signals(
            signal_conditions=signal_conditions,
            timeout=self.parent.timeout,
            check_stopped=True,
            all_signals=True,
        ):
            raise PilatusTimeoutError(
                f"Reached timeout with detector state {signal_conditions[0][0]}, std_daq state"
                f" {signal_conditions[1][0]} and received frames of {signal_conditions[2][0]} for"
                " the file writer"
            )
        self.stop_detector()
        self.stop_detector_backend()

    def stop_detector(self) -> None:
        """Stop detector"""
        self.parent.cam.acquire.put(0)

    def check_scan_id(self) -> None:
        """Checks if scan_id has changed and stops the scan if it has"""
        old_scan_id = self.parent.scaninfo.scan_id
        self.parent.scaninfo.load_scan_metadata()
        if self.parent.scaninfo.scan_id != old_scan_id:
            self.parent.stopped = True


class PilatuscSAXS(PSIDetectorBase):
    """Pilatus_2 300k detector for CSAXS

    Parent class: PSIDetectorBase

    class attributes:
        custom_prepare_cls (Eiger9MSetup)   : Custom detector setup class for cSAXS,
                                              inherits from CustomDetectorMixin
        cam (SLSDetectorCam)                : Detector camera
        MIN_READOUT (float)                 : Minimum readout time for the detector

    """

    # Specify which functions are revealed to the user in BEC client
    USER_ACCESS = ["describe"]

    # specify Setup class
    custom_prepare_cls = PilatusSetup
    # specify minimum readout time for detector
    MIN_READOUT = 3e-3
    # specify class attributes
    cam = ADCpt(SLSDetectorCam, "cam1:")

    def set_trigger(self, trigger_source: TriggerSource) -> None:
        """Set trigger source for the detector"""
        value = trigger_source
        self.cam.trigger_mode.put(value)


if __name__ == "__main__":
    pilatus_2 = PilatuscSAXS(name="pilatus_2", prefix="X12SA-ES-PILATUS300K:", sim_mode=True)
