from collections import defaultdict
from copy import copy
from datetime import datetime
from datetime import timedelta
from dateutil import parser

from .models import Bolus, Meal, TempBasal, Unit


class ParseHistory(object):
    DURATION_IN_MINUTES_KEY = "duration (min)"

    @staticmethod
    def _event_datetime(event):
        return parser.parse(event["timestamp"])


class CleanHistory(ParseHistory):
    """Analyze Medtronic pump history and resolves basic inconsistencies

    Responsibilities:
    - De-duplicates bolus wizard entries
    - Ensures suspend/resume records exist in pairs (inserting an extra event as necessary)
    - Removes any records not in the start_datetime to end_datetime window
    """
    def __init__(self, pump_history, start_datetime=None, end_datetime=None):
        """Initializes a new instance of the history parser

        :param pump_history: A list of pump history events, in reverse-chronological order
        :type pump_history: list(dict)
        :param start_datetime: The start time of history events. If not provided, the oldest
        record's timestamp is used
        :type start_datetime: datetime
        :param end_datetime: The end time of history events. If not provided, the latest record's
        timestamp is used
        :type end_datetime: datetime
        """
        self.clean_history = []
        self.start_datetime = start_datetime
        self.end_datetime = end_datetime

        if len(pump_history) > 0:
            if self.start_datetime is None:
                self.start_datetime = self._event_datetime(pump_history[-1])

            if self.end_datetime is None:
                self.end_datetime = self._event_datetime(pump_history[0])

        # Temporary parsing state
        self._boluswizard_events_by_body = defaultdict(list)
        self._last_resume_event = None
        self._last_temp_basal_duration_event = None

        for event in pump_history:
            self.add_history_event(event)

        # The pump was suspended before the history window began
        if self._last_resume_event is not None:
            self.add_history_event({
                "_type": "PumpSuspend",
                "timestamp": self.start_datetime.isoformat()
            })

    def _filter_events_in_range(self, events):
        start_datetime = self.start_datetime
        end_datetime = self.end_datetime

        def timestamp_in_range(event):
            if event:
                timestamp = self._event_datetime(event)
                if start_datetime <= timestamp <= end_datetime:
                    return True
            return False

        return filter(timestamp_in_range, events)

    def add_history_event(self, event):
        try:
            decoded = getattr(self, "_decode_{}".format(event["_type"].lower()))(event)
        except AttributeError:
            decoded = [event]

        self.clean_history.extend(self._filter_events_in_range(decoded or []))

    def _decode_boluswizard(self, event):
        event_datetime = self._event_datetime(event)

        # BolusWizard records can appear as duplicates with one containing appended data.
        # Criteria are records are less than 1 min apart and have identical bodies
        for seen_event in self._boluswizard_events_by_body[event["_body"]]:
            if abs(self._event_datetime(seen_event) - event_datetime) <= timedelta(minutes=1):
                return None

        self._boluswizard_events_by_body[event["_body"]].append(event)

        return [event]

    def _decode_pumpresume(self, event):
        self._last_resume_event = event

        return [event]

    def _decode_pumpsuspend(self, event):
        events = [event]

        if self._last_resume_event is None:
            events.insert(0, {
                "_type": "PumpResume",
                "timestamp": self.end_datetime.isoformat(),
            })
        else:
            self._last_resume_event = None

        return events

    def _decode_tempbasal(self, event):
        assert self._last_temp_basal_duration_event["timestamp"] == event["timestamp"], \
            "Partial temp basal record found. Please re-run with a larger history window."

        return [event]

    def _decode_tempbasalduration(self, event):
        self._last_temp_basal_duration_event = event

        return [event]


class ReconcileHistory(ParseHistory):
    """Analyze Medtronic pump history and reconciles dependencies between records

    Responsibilities:
    - Modifies temporary basal duration to account for cancelled and overlapping basals
    - Duplicates and modifies temporary basal records to account for delivery pauses when suspended
    """
    def __init__(self, clean_history):
        """Initializes a new instance of the history parser

        The input history is expected to have no open-ended suspend windows, which can be resolved
        by the CleanHistory class.

        :param clean_history: A list of pump history events in reverse-chronological order
        :type clean_history: list(dict)
        """
        self.reconciled_history = []

        # Temporary parsing state
        self._last_suspend_event = None
        self._last_temp_basal_event = None
        self._last_temp_basal_duration_event = None

        for event in reversed(clean_history):
            self.add_history_event(event)

    def add_history_event(self, event):
        try:
            decoded = getattr(self, "_decode_{}".format(event["_type"].lower()))(event)
        except AttributeError:
            decoded = [event]

        for decoded_event in decoded:
            self.reconciled_history.insert(0, decoded_event)

    def _basal_event_datetimes(self, basal_event):
        basal_start_datetime = self._event_datetime(basal_event)
        basal_end_datetime = basal_start_datetime + timedelta(
            minutes=basal_event[self.DURATION_IN_MINUTES_KEY]
        )
        return basal_start_datetime, basal_end_datetime

    def _trim_last_temp_basal_to_datetime(self, trim_datetime):
        if self._last_temp_basal_duration_event is not None:
            basal_event = self._last_temp_basal_duration_event
            basal_start_datetime, basal_end_datetime = self._basal_event_datetimes(basal_event)

            if basal_end_datetime > trim_datetime:
                basal_event[self.DURATION_IN_MINUTES_KEY] = int(
                    (trim_datetime - basal_start_datetime).total_seconds() / 60.0
                )

    def _decode_pumpresume(self, event):
        events = [event]

        if self._last_temp_basal_duration_event is not None:
            suspend_datetime = self._event_datetime(self._last_suspend_event)
            resume_datetime = self._event_datetime(event)
            basal_duration_event = self._last_temp_basal_duration_event
            _, basal_end_datetime = self._basal_event_datetimes(basal_duration_event)

            self._trim_last_temp_basal_to_datetime(suspend_datetime)

            if basal_end_datetime > resume_datetime:
                # Duplicate and restart the temp basal still scheduled
                new_basal_duration_event = copy(basal_duration_event)
                new_basal_rate_event = copy(self._last_temp_basal_event)

                # Adjust start time
                for new_event in (new_basal_duration_event, new_basal_rate_event):
                    for key in ("date", "_date", "timestamp"):
                        new_event[key] = event[key]
                    new_event["_description"] = "{} generated due to interleaved PumpSuspend" \
                                                " event".format(new_event["_type"])

                # Adjust duration
                new_basal_duration_event[self.DURATION_IN_MINUTES_KEY] = int(
                    (basal_end_datetime - resume_datetime).total_seconds() / 60.0
                )

                events.append(new_basal_rate_event)
                events.append(new_basal_duration_event)

        return events

    def _decode_pumpsuspend(self, event):
        self._last_suspend_event = event

        return [event]

    def _decode_tempbasal(self, event):
        self._last_temp_basal_event = event

        return [event]

    def _decode_tempbasalduration(self, event):
        self._trim_last_temp_basal_to_datetime(self._event_datetime(event))

        self._last_temp_basal_duration_event = event

        return [event]


class ResolveHistory(ParseHistory):
    """Converts Medtronic pump history to a sequence of general record types

    Each record is a dictionary representing one of the following types:

    - `Bolus`: Insulin delivery events in Units, or Units/hour
    - `Meal`: Grams of carbohydrate
    - `TempBasal`: Paced insulin delivery events in Units/hour, or Percent of scheduled basal

    The following history events are parsed:

    - TempBasal and TempBasalDuration are combined into TempBasal records
    - PumpSuspend and PumpResume are combined into TempBasal records of 0%
    - Square Bolus is converted to a Bolus record
    - Normal Bolus is converted to a Bolus record
    - BolusWizard carb entry is converted to a Meal record
    - JournalEntryMealMarker is converted to a Meal record

    Events that are not related to the record types or seem to have no effect are dropped.
    """
    def __init__(self, reconciled_history, current_datetime=None):
        """Initializes a new instance of the history parser

        The input history is expected to have no open-ended suspend windows, which can be resolved
        by the CleanHistory class.

        If not provided, `current_datetime` will default to datetime.now(), which is assumed to be
        a naive datetime in the same timezone as the pump. This is not a safe assumption to make
        for most fresh Raspberry Pi setups.

        :param reconciled_history: A list of pump history events in reverse-chronological order
        :type reconciled_history: list(dict)
        :param current_datetime: The datetime at which the history was generated
        :type current_datetime: datetime
        """
        self.resolved_records = []
        self.current_datetime = current_datetime or datetime.now()

        # Temporary parsing state
        self._resume_datetime = None
        self._temp_basal_duration = None

        for event in reconciled_history:
            self.add_history_event(event)

    def add_history_event(self, event):
        try:
            decoded = getattr(self, "_decode_{}".format(event["_type"].lower()))(event)
        except AttributeError:
            pass
        else:
            if decoded is not None:
                self.resolved_records.append(decoded)

    def _decode_bolus(self, event):
        start_at = self._event_datetime(event)
        delivered = event["amount"]
        programmed = event["programmed"]

        if max(delivered, programmed) > 0:
            if event["type"] == "square":
                duration = event["duration"]

                rate = programmed / (duration / 60.0)

                # If less than 100% of the programmed dose was delivered and we're past the delivery
                # window, then shorten the actual duration by the ratio of delivered insulin.
                if start_at + timedelta(minutes=duration) < self.current_datetime:
                    duration = int(duration * delivered / programmed)
                    programmed = delivered

                return Bolus(
                    start_at=start_at,
                    end_at=start_at + timedelta(minutes=duration),
                    amount=rate,
                    unit=Unit.units_per_hour,
                    description="Square bolus: {}U over {}min".format(programmed, duration)
                )

            else:
                return Bolus(
                    start_at=start_at,
                    end_at=start_at,
                    amount=delivered,
                    unit=Unit.units,
                    description="Normal bolus: {}U".format(programmed)
                )

    def _decode_boluswizard(self, event):
        return self._decode_journalentrymealmarker(event)

    def _decode_journalentrymealmarker(self, event):
        carb_input = event["carb_input"]
        start_at = self._event_datetime(event)

        if carb_input:
            return Meal(
                start_at=start_at,
                end_at=start_at,
                amount=carb_input,
                unit=Unit.grams,
                description=event["_type"]
            )

    def _decode_pumpresume(self, event):
        self._resume_datetime = self._event_datetime(event)

    def _decode_pumpsuspend(self, event):
        assert self._resume_datetime is not None, "Unbalanced Suspend/Resume events found"

        start_at = self._event_datetime(event)
        end_at = self._resume_datetime

        self._resume_datetime = None

        if end_at > start_at:
            return TempBasal(
                start_at=self._event_datetime(event),
                end_at=end_at,
                amount=0,
                unit=Unit.percent_of_basal,
                description="Pump Suspend"
            )

    def _decode_tempbasal(self, event):
        assert self._temp_basal_duration is not None, "Temp basal duration not found"

        start_at = self._event_datetime(event)
        end_at = start_at + timedelta(minutes=self._temp_basal_duration)

        if end_at > start_at:
            amount = event["rate"]
            unit = Unit.percent_of_basal if event["temp"] == "percent" else Unit.units_per_hour

            return TempBasal(
                start_at=start_at,
                end_at=end_at,
                amount=amount,
                unit=unit,
                description="TempBasal {} {}".format(amount, unit)
            )

    def _decode_tempbasalduration(self, event):
        self._temp_basal_duration = event[self.DURATION_IN_MINUTES_KEY]


class NormalizeRecords(object):
    """Adjusts the time and basal amounts of records relative to a basal schedule and a timestamp

    If a `basal_schedule` is provided, the TempBasal `amount` is replaced with a relative dose in
    Units/hour. TempBasal records might be split into multiples to account for boundary crossings in
    the basal schedule.

    If a `zero_datetime` is provided, the values for the `start_at` and `end_at` keys are
    replaced with signed integers representing the number of minutes from zero.
    """
    def __init__(self, resolved_records, basal_schedule=None, zero_datetime=None):
        """Initializes a new instance of the record parser

        The record input is expected to be in the format returned by the ResolveHistory class.

        If `basal_schedule` or `zero_datetime` are not provided, than the record changes are not
        made.

        :param resolved_records: A list of pump records in reverse-chronological order
        :type resolved_records: list(.models.BaseRecord)
        :param basal_schedule: A list of basal rates scheduled by time in chronological order
        :type basal_schedule: list(dict)
        :param zero_datetime: The timestamp by which to center the relative times
        :type zero_datetime: datetime
        """
        self.normalized_records = []

        self.basal_schedule = basal_schedule

        for event in resolved_records:
            self.add_history_event(event)

        if zero_datetime is not None:
            for event in self.normalized_records:
                for key in [key for key in event.iterkeys() if key.endswith("_at")]:
                    event[key] = int(round((
                        parser.parse(event[key]) - zero_datetime
                    ).total_seconds() / 60))

    def add_history_event(self, event):
        try:
            decoded = getattr(self, "_decode_{}".format(event["type"].lower()))(event)
        except AttributeError:
            decoded = [event]

        self.normalized_records.extend(decoded or [])

    def basal_rates_in_range(self, start_time, end_time):
        """Returns a list of the current basal rates effective between the specified times

        :param start_time:
        :type start_time: datetime.time
        :param end_time:
        :type end_time: datetime.time
        :return: A list of basal rates
        :rtype: list(dict)

        :raises AssertionError: The argument values are invalid
        """
        assert (start_time < end_time)

        start_index = 0
        end_index = len(self.basal_schedule)

        for index, basal_rate in enumerate(self.basal_schedule):
            basal_start_time = parser.parse(basal_rate["start"]).time()
            if start_time >= basal_start_time:
                start_index = index
            if end_time < basal_start_time:
                end_index = index
                break

        return self.basal_schedule[start_index:end_index]

    def _basal_adjustments_in_range(
            self,
            start_datetime,
            end_datetime,
            percent=None,
            absolute=None,
            description=""
    ):
        """Returns a list of TempBasal objects representing the specified adjustment to basal rate

        :param start_datetime: The start time of the basal adjustment
        :type start_datetime: datetime
        :param end_datetime: The end time of the basal adjustment
        :type end_datetime: datetime
        :param percent: A multiplier to apply to the current basal rate
        :type percent: int
        :param absolute: A specified temporary basal absolute, in U/hour
        :type absolute: float
        :param description: A description to attach to each new event
        :type description: basestring

        :return: A list of TempBasal objects
        :rtype: list(TempBasal)

        :raises AssertionError: The arguments are either missing or invalid
        """
        assert (start_datetime < end_datetime)
        assert (end_datetime - start_datetime < timedelta(hours=24))
        assert (percent is not None or absolute is not None)

        start_time = start_datetime.time()
        end_time = end_datetime.time()

        # If the requested timestamps cross a day boundary, return the combination of each
        # single-day call
        if start_time > end_time:
            return self._basal_adjustments_in_range(
                start_datetime,
                start_datetime.replace(hour=23, minute=59, second=59),
                percent=percent,
                absolute=absolute
            ) + self._basal_adjustments_in_range(
                end_datetime.replace(hour=0, minute=0, second=0),
                end_datetime,
                percent=percent,
                absolute=absolute
            )

        temp_basal_events = []
        basal_rates = self.basal_rates_in_range(start_time, end_time)

        for index, basal_rate in enumerate(basal_rates):
            basal_start_time = parser.parse(basal_rate["start"]).time()

            # If we are in a list longer than one element, adjust the boundary timestamps to the
            # basal time
            if index > 0:
                if start_time <= basal_start_time:
                    t0 = datetime.combine(start_datetime.date(), basal_start_time)
                else:
                    t0 = datetime.combine(end_datetime.date(), basal_start_time)
                temp_basal_events[-1]["end_at"] = t0.isoformat()
            else:
                t0 = start_datetime

            t1 = end_datetime

            if t1 - t0 > timedelta(minutes=0):
                # Find the delta of the new rate
                rate = absolute
                if percent is not None:
                    rate = basal_rate["rate"] * percent / 100.0

                amount = rate - basal_rate["rate"]

                temp_basal_events.insert(0, TempBasal(
                    start_at=t0,
                    end_at=t1,
                    amount=amount,
                    unit=Unit.units_per_hour,
                    description=description
                ))

        return temp_basal_events

    def _decode_tempbasal(self, event):
        if self.basal_schedule is not None:
            start_datetime = parser.parse(event["start_at"])
            end_datetime = parser.parse(event["end_at"])

            if end_datetime - start_datetime > timedelta(minutes=0):
                adjustment = "percent" if event["unit"] == Unit.percent_of_basal else "absolute"

                events = self._basal_adjustments_in_range(
                    start_datetime,
                    end_datetime,
                    description=event["description"],
                    **{adjustment: event["amount"]}
                )

                return events
