from typing import Optional
from typing_extensions import Final

from bemani.backend.base import Base, Status
from bemani.protocol import Node
from bemani.common import Time, CardCipher


class PASELIHandler(Base):
    """
    A mixin that can be used to provide PASELI services to a game.

    Handle PASELI requests. The game will check out a session at the beginning
    of the game, make PASELI purchases against that session, and then close it
    at the end of of a game. This handler ensures that this works for all games.
    """

    INFINITE_PASELI_AMOUNT: Final[int] = 57300

    """
    Override this in your subclass if the particular game/series
    needs a different padding amount to display PASELI transactions
    on the operator menu.
    """
    paseli_padding: int = 1

    def handle_eacoin_request(self, request: Node) -> Optional[Node]:
        """
        First, a generic handler to catch if paseli is not enabled, so that each
        individual method below doesn't need to handle it.
        """
        if not self.config.paseli.enabled:
            # Refuse to respond, we don't have PASELI enabled
            print("PASELI not enabled, ignoring eacoin request")
            root = Node.void('eacoin')
            root.set_attribute('status', str(Status.NOT_ALLOWED))
            return root

        # This is fine, let the individual request handlers handle this packet.
        return None

    def handle_eacoin_checkin_request(self, request: Node) -> Node:
        root = Node.void('eacoin')
        cardid = request.child_value('cardid')
        pin = request.child_value('passwd')

        if cardid is None or pin is None:
            # Refuse to return anything
            print("Invalid eacoin checkin request, missing cardid or pin")
            root.set_attribute('status', str(Status.NO_PROFILE))
            return root

        userid = self.data.local.user.from_cardid(cardid)
        if userid is None:
            # Refuse to do anything
            print("No user for eacoin checkin request")
            root.set_attribute('status', str(Status.NO_PROFILE))
            return root

        valid = self.data.local.user.validate_pin(userid, pin)
        if not valid:
            # Refuse to do anything
            print("User entered invalid pin for eacoin checkin request")
            root.set_attribute('status', str(Status.INVALID_PIN))
            return root

        session = self.data.local.user.create_session(userid)

        if self.config.paseli.infinite:
            balance = PASELIHandler.INFINITE_PASELI_AMOUNT
        else:
            if self.config.machine.arcade is None:
                # There's no arcade for this machine, but infinite is not
                # enabled, so there's no way to find a balance.
                balance = 0
            else:
                balance = self.data.local.user.get_balance(userid, self.config.machine.arcade)

        root.add_child(Node.s16('sequence', 0))
        root.add_child(Node.u8('acstatus', 0))
        root.add_child(Node.string('acid', 'DUMMY_ID'))
        root.add_child(Node.string('acname', 'DUMMY_NAME'))
        root.add_child(Node.s32('balance', balance))
        root.add_child(Node.string('sessid', session))
        return root

    def handle_eacoin_opcheckin_request(self, request: Node) -> Node:
        root = Node.void('eacoin')
        passwd = request.child_value('passwd')

        if passwd is None:
            # Refuse to return anything
            print("Invalid eacoin checkin request, missing passwd")
            root.set_attribute('status', str(Status.NO_PROFILE))
            return root

        if self.config.machine.arcade is None:
            # Machine doesn't belong to an arcade
            print("Machine doesn't belong to an arcade")
            root.set_attribute('status', str(Status.NO_PROFILE))
            return root

        arcade = self.data.local.machine.get_arcade(self.config.machine.arcade)
        if arcade is None:
            # Refuse to do anything
            print("No arcade for operator checkin request")
            root.set_attribute('status', str(Status.NO_PROFILE))
            return root

        if arcade.pin != passwd:
            # Refuse to do anything
            print("User entered invalid pin for operator checkin request")
            root.set_attribute('status', str(Status.INVALID_PIN))
            return root

        session = self.data.local.machine.create_session(arcade.id)
        root.add_child(Node.string('sessid', session))
        return root

    def handle_eacoin_consume_request(self, request: Node) -> Node:

        def make_resp(status: int, balance: int) -> Node:
            root = Node.void('eacoin')
            root.add_child(Node.u8('acstatus', status))
            root.add_child(Node.u8('autocharge', 0))
            root.add_child(Node.s32('balance', balance))
            return root

        session = request.child_value('sessid')
        payment = request.child_value('payment')
        service = request.child_value('service')
        details = request.child_value('detail')
        if session is None or payment is None:
            # Refuse to do anything
            print("Invalid eacoin consume request, missing sessid or payment")
            return make_resp(2, 0)

        userid = self.data.local.user.from_session(session)
        if userid is None:
            # Refuse to do anything
            print("Invalid session for eacoin consume request")
            return make_resp(2, 0)

        if self.config.paseli.infinite:
            balance = PASELIHandler.INFINITE_PASELI_AMOUNT - payment
        else:
            if self.config.machine.arcade is None:
                # There's no arcade for this machine, but infinite is not
                # enabled, so there's no way to find a balance, assume failed
                # consume payment.
                balance = None
            else:
                # Look up the new balance based on this delta. If there isn't enough,
                # we will end up returning None here and exit without performing.
                balance = self.data.local.user.update_balance(userid, self.config.machine.arcade, -payment)

            if balance is None:
                print("Not enough balance for eacoin consume request")
                return make_resp(1, self.data.local.user.get_balance(userid, self.config.machine.arcade))
            else:
                self.data.local.network.put_event(
                    'paseli_transaction',
                    {
                        'delta': -payment,
                        'balance': balance,
                        'service': -service,
                        'reason': details,
                        'pcbid': self.config.machine.pcbid,
                    },
                    userid=userid,
                    arcadeid=self.config.machine.arcade,
                )

        return make_resp(0, balance)

    def handle_eacoin_getlog_request(self, request: Node) -> Node:
        root = Node.void('eacoin')
        sessid = request.child_value('sessid')
        logtype = request.child_value('logtype')
        target = request.child_value('target')
        limit = request.child_value('perpage')
        offset = request.child_value('offset')

        # Try to determine whether its a user or an arcade session
        userid = self.data.local.user.from_session(sessid)
        if userid is None:
            arcadeid = self.data.local.machine.from_session(sessid)
        else:
            arcadeid = None

        # Bail out if we don't have any idea what session this is
        if userid is None and arcadeid is None:
            print("Unable to determine session type")
            return root

        # If we're a user session, also look up the current arcade
        # so we display only entries that happened on this arcade.
        if userid is not None:
            arcade = self.data.local.machine.get_arcade(self.config.machine.arcade)
            if arcade is None:
                print("Machine doesn't belong to an arcade")
                return root
            arcadeid = arcade.id

        # Now, look up all transactions for this specific group
        events = self.data.local.network.get_events(
            userid=userid,
            arcadeid=arcadeid,
            event='paseli_transaction',
        )

        # Further filter it down to the current PCBID
        events = [event for event in events if event.data.get('pcbid') == target]

        # Grab the end of day today as a timestamp
        end_of_today = Time.end_of_today()
        time_format = '%Y-%m-%d %H:%M:%S'
        date_format = '%Y-%m-%d'

        # Set up common structure
        lognode = Node.void(logtype)
        topic = Node.void('topic')
        lognode.add_child(topic)
        summary = Node.void('summary')
        lognode.add_child(summary)

        # Display what day we are summed to
        topic.add_child(Node.string('sumdate', Time.format(Time.now(), date_format)))

        if logtype == 'last7days':
            # We show today in the today total, last 7 days prior in the week total
            beginning_of_today = end_of_today - Time.SECONDS_IN_DAY
            end_of_week = beginning_of_today
            beginning_of_week = end_of_week - Time.SECONDS_IN_WEEK

            topic.add_child(Node.string('sumfrom', Time.format(beginning_of_week, date_format)))
            topic.add_child(Node.string('sumto', Time.format(end_of_week, date_format)))
            today_total = sum([
                -event.data.get_int('delta') for event in events
                if event.timestamp >= beginning_of_today and event.timestamp < end_of_today
            ])

            today_total = sum([
                -event.data.get_int('delta') for event in events
                if event.timestamp >= beginning_of_today and event.timestamp < end_of_today
            ])
            week_txns = [
                -event.data.get_int('delta') for event in events
                if event.timestamp >= beginning_of_week and event.timestamp < end_of_week
            ]
            week_total = sum(week_txns)
            if len(week_txns) > 0:
                week_avg = int(sum(week_txns) / len(week_txns))
            else:
                week_avg = 0

            # We display the totals for each day starting with yesterday and up through 7 days prior.
            # Index starts at 0 = yesterday, 1 = the day before, etc...
            items = []
            for days in range(0, 7):
                end_of_day = end_of_week - (days * Time.SECONDS_IN_DAY)
                start_of_day = end_of_day - Time.SECONDS_IN_DAY

                items.append(sum([
                    -event.data.get_int('delta') for event in events
                    if event.timestamp >= start_of_day and event.timestamp < end_of_day
                ]))

            topic.add_child(Node.s32('today', today_total))
            topic.add_child(Node.s32('average', week_avg))
            topic.add_child(Node.s32('total', week_total))
            summary.add_child(Node.s32_array('items', items))

        if logtype == 'last52weeks':
            # Start one week back, since the operator can look at last7days for newer stuff.
            beginning_of_today = end_of_today - Time.SECONDS_IN_DAY
            end_of_52_weeks = beginning_of_today - Time.SECONDS_IN_WEEK

            topic.add_child(Node.string('sumfrom', Time.format(end_of_52_weeks - (52 * Time.SECONDS_IN_WEEK), date_format)))
            topic.add_child(Node.string('sumto', Time.format(end_of_52_weeks, date_format)))

            # We index backwards, where index 0 = the first week back, 1 = the next week back after that, etc...
            items = []
            for weeks in range(0, 52):
                end_of_range = end_of_52_weeks - (weeks * Time.SECONDS_IN_WEEK)
                beginning_of_range = end_of_range - Time.SECONDS_IN_WEEK

                items.append(sum([
                    -event.data.get_int('delta') for event in events
                    if event.timestamp >= beginning_of_range and event.timestamp < end_of_range
                ]))

            summary.add_child(Node.s32_array('items', items))

        if logtype == 'eachday':
            start_ts = Time.now()
            end_ts = Time.now()
            weekdays = [0] * 7

            for event in events:
                event_day = Time.days_into_week(event.timestamp)
                weekdays[event_day] = weekdays[event_day] - event.data.get_int('delta')
                if event.timestamp < start_ts:
                    start_ts = event.timestamp

            topic.add_child(Node.string('sumfrom', Time.format(start_ts, date_format)))
            topic.add_child(Node.string('sumto', Time.format(end_ts, date_format)))
            summary.add_child(Node.s32_array('items', weekdays))

        if logtype == 'eachhour':
            start_ts = Time.now()
            end_ts = Time.now()
            hours = [0] * 24

            for event in events:
                event_hour = int((event.timestamp % Time.SECONDS_IN_DAY) / Time.SECONDS_IN_HOUR)
                hours[event_hour] = hours[event_hour] - event.data.get_int('delta')
                if event.timestamp < start_ts:
                    start_ts = event.timestamp

            topic.add_child(Node.string('sumfrom', Time.format(start_ts, date_format)))
            topic.add_child(Node.string('sumto', Time.format(end_ts, date_format)))
            summary.add_child(Node.s32_array('items', hours))

        if logtype == 'detail':
            history = Node.void('history')
            lognode.add_child(history)

            # Respect details paging
            if offset is not None:
                events = events[offset:]
            if limit is not None:
                events = events[:limit]

            # Output the details themselves
            for event in events:
                card_no = ''
                if event.userid is not None:
                    user = self.data.local.user.get_user(event.userid)
                    if user is not None:
                        cards = self.data.local.user.get_cards(user.id)
                        if len(cards) > 0:
                            card_no = CardCipher.encode(cards[0])

                item = Node.void('item')
                history.add_child(item)
                item.add_child(Node.string('date', Time.format(event.timestamp, time_format)))
                item.add_child(Node.s32('consume', -event.data.get_int('delta')))
                item.add_child(Node.s32('service', -event.data.get_int('service')))
                item.add_child(Node.string('cardtype', ''))
                item.add_child(Node.string('cardno', ' ' * self.paseli_padding + card_no))
                item.add_child(Node.string('title', ''))
                item.add_child(Node.string('systemid', ''))

        if logtype == 'lastmonths':
            year, month, _ = Time.todays_date()
            this_month = Time.timestamp_from_date(year, month)
            last_month = Time.timestamp_from_date(year, month - 1)
            month_before = Time.timestamp_from_date(year, month - 2)

            topic.add_child(Node.string('sumfrom', Time.format(month_before, date_format)))
            topic.add_child(Node.string('sumto', Time.format(this_month, date_format)))

            for (start, end) in [(month_before, last_month), (last_month, this_month)]:
                year, month, _ = Time.date_from_timestamp(start)

                items = []
                for day in range(0, 31):
                    begin_ts = start + (day * Time.SECONDS_IN_DAY)
                    end_ts = begin_ts + Time.SECONDS_IN_DAY
                    if begin_ts >= end:
                        # Passed the end of this month
                        items.append(0)
                    else:
                        # Sum up all the txns for this day
                        items.append(sum([
                            -event.data.get_int('delta') for event in events
                            if event.timestamp >= begin_ts and event.timestamp < end_ts
                        ]))

                item = Node.void('item')
                summary.add_child(item)
                item.add_child(Node.s32('year', year))
                item.add_child(Node.s32('month', month))
                item.add_child(Node.s32_array('items', items))

        root.add_child(Node.u8('processing', 0))
        root.add_child(lognode)
        return root

    def handle_eacoin_opchpass_request(self, request: Node) -> Node:
        root = Node.void('eacoin')
        oldpass = request.child_value('passwd')
        newpass = request.child_value('newpasswd')

        if oldpass is None or newpass is None:
            # Refuse to return anything
            print("Invalid eacoin pass change request, missing passwd")
            root.set_attribute('status', str(Status.NO_PROFILE))
            return root

        if self.config.machine.arcade is None:
            # Machine doesn't belong to an arcade
            print("Machine doesn't belong to an arcade")
            root.set_attribute('status', str(Status.NO_PROFILE))
            return root

        arcade = self.data.local.machine.get_arcade(self.config.machine.arcade)
        if arcade is None:
            # Refuse to do anything
            print("No arcade for operator pass change request")
            root.set_attribute('status', str(Status.NO_PROFILE))
            return root

        if arcade.pin != oldpass:
            # Refuse to do anything
            print("User entered invalid pin for operator pass change request")
            root.set_attribute('status', str(Status.INVALID_PIN))
            return root

        arcade.pin = newpass
        self.data.local.machine.put_arcade(arcade)
        return root

    def handle_eacoin_checkout_request(self, request: Node) -> Node:
        session = request.child_value('sessid')
        if session is not None:
            # Destroy the session so it can't be used for any other purchases
            self.data.local.user.destroy_session(session)

        root = Node.void('eacoin')
        return root

    def handle_eacoin_opcheckout_request(self, request: Node) -> Node:
        session = request.child_value('sessid')
        if session is not None:
            # Destroy the session so it can't be used for any other purchases
            self.data.local.machine.destroy_session(session)

        root = Node.void('eacoin')
        return root
