from sqlalchemy import Table, Column, UniqueConstraint  # type: ignore
from sqlalchemy.types import String, Integer, Text, JSON  # type: ignore
from sqlalchemy.dialects.mysql import BIGINT as BigInteger  # type: ignore
from typing import Optional, Dict, List, Tuple, Any

from bemani.common import GameConstants, Time
from bemani.data.mysql.base import BaseData, metadata
from bemani.data.types import News, Event, UserID, ArcadeID

"""
Table for storing network news, as edited by an admin. This is displayed
on the front page of the frontend of the network.
"""
news = Table(
    'news',
    metadata,
    Column('id', Integer, nullable=False, primary_key=True),
    Column('timestamp', Integer, nullable=False, index=True),
    Column('title', String(255), nullable=False),
    Column('body', Text, nullable=False),
    mysql_charset='utf8mb4',
)

"""
Table for storing scheduled work history, so that individual game code
can determine if it should run scheduled work or not.
"""
scheduled_work = Table(
    'scheduled_work',
    metadata,
    Column('game', String(32), nullable=False),
    Column('version', Integer, nullable=False),
    Column('name', String(32), nullable=False),
    Column('schedule', String(32), nullable=False),
    Column('year', Integer),
    Column('day', Integer),
    UniqueConstraint('game', 'version', 'name', 'schedule', name='game_version_name_schedule'),
    mysql_charset='utf8mb4',
)

"""
Table for storing audit entries, such as crashes, PCBID denials, daily
song selection, etc. Anything that could be inspected later to verify
correct operation of the network.
"""
audit = Table(
    'audit',
    metadata,
    Column('id', Integer, nullable=False, primary_key=True),
    Column('timestamp', Integer, nullable=False, index=True),
    Column('userid', BigInteger(unsigned=True), index=True),
    Column('arcadeid', Integer, index=True),
    Column('type', String(64), nullable=False, index=True),
    Column('data', JSON, nullable=False),
    mysql_charset='utf8mb4',
)


class NetworkData(BaseData):

    def get_all_news(self) -> List[News]:
        """
        Grab all news in the system.

        Returns:
            A list of News objects sorted by timestamp.
        """
        sql = "SELECT id, timestamp, title, body FROM news ORDER BY timestamp DESC"
        cursor = self.execute(sql)
        return [
            News(
                result['id'],
                result['timestamp'],
                result['title'],
                result['body'],
            ) for result in cursor.fetchall()
        ]

    def create_news(self, title: str, body: str) -> int:
        """
        Given a title and body, create a new news entry.

        Parameters:
            title - String title of the entry.
            body - String body of the entry, may contain HTML.

        Returns:
            The ID of the newly created entry.
        """
        sql = "INSERT INTO news (timestamp, title, body) VALUES (:timestamp, :title, :body)"
        cursor = self.execute(sql, {'timestamp': Time.now(), 'title': title, 'body': body})
        return cursor.lastrowid

    def get_news(self, newsid: int) -> Optional[News]:
        """
        Given a news ID, grab that news entry from the DB.

        Parameters:
            newsid - Integer specifying news ID.

        Returns:
            A News object if the news entry was found or None otherwise.
        """
        sql = "SELECT timestamp, title, body FROM news WHERE id = :id"
        cursor = self.execute(sql, {'id': newsid})
        if cursor.rowcount != 1:
            # Couldn't find an entry with this ID
            return None

        result = cursor.fetchone()
        return News(
            newsid,
            result['timestamp'],
            result['title'],
            result['body'],
        )

    def put_news(self, news: News) -> None:
        """
        Given a news object, store it back into the DB.

        Parameters:
            news - A News object to be updated.
        """
        sql = "UPDATE news SET title = :title, body = :body WHERE id = :id"
        self.execute(sql, {'id': news.id, 'title': news.title, 'body': news.body})

    def destroy_news(self, newsid: int) -> None:
        """
        Given a news ID, remove that news entry from the DB.

        Parameters:
            newsid - Integer specifying news ID.
        """
        sql = "DELETE FROM news WHERE id = :id LIMIT 1"
        self.execute(sql, {'id': newsid})

    def get_schedule_duration(self, schedule: str) -> Tuple[int, int]:
        """
        Given a schedule type, returns the timestamp for the start and end
        of the current schedule of this type.
        """
        if schedule not in ['daily', 'weekly']:
            raise Exception('Logic error, specify either \'daily\' or \'weekly\' for schedule type!')

        if schedule == 'daily':
            return (Time.beginning_of_today(), Time.end_of_today())

        if schedule == 'weekly':
            return (Time.beginning_of_this_week(), Time.end_of_this_week())

        # Should never happen
        return (0, 0)

    def should_schedule(self, game: GameConstants, version: int, name: str, schedule: str) -> bool:
        """
        Given a game/version/name pair and a schedule value, return whether
        this scheduled work is overdue or not.
        """
        if schedule not in ['daily', 'weekly']:
            raise Exception('Logic error, specify either \'daily\' or \'weekly\' for schedule type!')

        sql = (
            "SELECT year, day FROM scheduled_work "
            "WHERE game = :game AND version = :version AND "
            "name = :name AND schedule = :schedule"
        )
        cursor = self.execute(sql, {'game': game.value, 'version': version, 'name': name, 'schedule': schedule})
        if cursor.rowcount != 1:
            # No scheduled work was registered, so time to get going!
            return True

        result = cursor.fetchone()

        if schedule == 'daily':
            # Just look at the day and year, make sure it matches
            year, day = Time.days_into_year()
            if year != result['year']:
                # Wrong year, so we certainly need to run!
                return True
            if day != result['day']:
                # Wrong day and we're daily, so need to run!
                return True

        if schedule == 'weekly':
            # Find the beginning of the week (Monday), as days since epoch.
            if Time.week_in_days_since_epoch() != result['day']:
                # Wrong week, so we should run!
                return True

        # We have already run this work for this schedule
        return False

    def mark_scheduled(self, game: GameConstants, version: int, name: str, schedule: str) -> None:
        if schedule not in ['daily', 'weekly']:
            raise Exception('Logic error, specify either \'daily\' or \'weekly\' for schedule type!')

        if schedule == 'daily':
            year, day = Time.days_into_year()
            sql = (
                "INSERT INTO scheduled_work (game, version, name, schedule, year, day) " +
                "VALUES (:game, :version, :name, :schedule, :year, :day) " +
                "ON DUPLICATE KEY UPDATE year=VALUES(year), day=VALUES(day)"
            )
            self.execute(
                sql,
                {
                    'game': game.value,
                    'version': version,
                    'name': name,
                    'schedule': schedule,
                    'year': year,
                    'day': day,
                },
            )

        if schedule == 'weekly':
            days = Time.week_in_days_since_epoch()
            sql = (
                "INSERT INTO scheduled_work (game, version, name, schedule, day) " +
                "VALUES (:game, :version, :name, :schedule, :day) " +
                "ON DUPLICATE KEY UPDATE day=VALUES(day)"
            )
            self.execute(
                sql,
                {
                    'game': game.value,
                    'version': version,
                    'name': name,
                    'schedule': schedule,
                    'day': days,
                },
            )

    def put_event(
        self,
        event: str,
        data: Dict[str, Any],
        timestamp: Optional[int]=None,
        userid: Optional[UserID]=None,
        arcadeid: Optional[ArcadeID]=None,
    ) -> None:
        if timestamp is None:
            timestamp = Time.now()
        sql = "INSERT INTO audit (timestamp, userid, arcadeid, type, data) VALUES (:ts, :uid, :aid, :type, :data)"
        self.execute(sql, {'ts': timestamp, 'type': event, 'data': self.serialize(data), 'uid': userid, 'aid': arcadeid})

    def get_events(
        self,
        userid: Optional[UserID]=None,
        arcadeid: Optional[ArcadeID]=None,
        event: Optional[str]=None,
        limit: Optional[int]=None,
        since_id: Optional[int]=None,
        until_id: Optional[int]=None,
    ) -> List[Event]:
        # Base query
        sql = "SELECT id, timestamp, userid, arcadeid, type, data FROM audit "

        # Lets get specific!
        wheres = []
        if userid is not None:
            wheres.append("userid = :userid")
        if arcadeid is not None:
            wheres.append("arcadeid = :arcadeid")
        if event is not None:
            wheres.append("type = :event")
        if since_id is not None:
            wheres.append("id >= :since_id")
        if until_id is not None:
            wheres.append("id < :until_id")
        if len(wheres) > 0:
            sql = sql + f"WHERE {' AND '.join(wheres)} "

        # Order it newest to oldest
        sql = sql + "ORDER BY id DESC"
        if limit is not None:
            sql = sql + " LIMIT :limit"
        cursor = self.execute(sql, {'userid': userid, 'arcadeid': arcadeid, 'event': event, 'limit': limit, 'since_id': since_id, 'until_id': until_id})
        events = []
        for result in cursor.fetchall():
            if result['userid'] is not None:
                userid = UserID(result['userid'])
            else:
                userid = None
            if result['arcadeid'] is not None:
                arcadeid = ArcadeID(result['arcadeid'])
            else:
                arcadeid = None
            events.append(
                Event(
                    result['id'],
                    result['timestamp'],
                    userid,
                    arcadeid,
                    result['type'],
                    self.deserialize(result['data']),
                ),
            )
        return events

    def delete_events(self, oldest_event_ts: int) -> None:
        """
        Given a timestamp of the oldset event we should keep around, delete
        all events older than this timestamp.
        """
        sql = "DELETE FROM audit WHERE timestamp < :ts"
        self.execute(sql, {'ts': oldest_event_ts})
