import typing  # isort:skip

import base64
import datetime
import json
import os
import time
from copy import deepcopy
from importlib import import_module
from urllib.parse import parse_qs, quote_plus, urlencode, urlparse, urlunparse

import jwt
import websocket
from django.conf import settings
from fernet import Fernet
from flask import Flask, flash, g, redirect, render_template, request, session, url_for
from flask_babel import Locale, _
from flask_bootstrap import Bootstrap
from flask_login import LoginManager, UserMixin, current_user
from flask_login import login_url as make_login_url
from flask_moment import Moment
from flask_sitemapper import Sitemapper
from flask_talisman import Talisman
from flask_wtf.csrf import CSRFProtect
from flask_wtf.file import FileAllowed, FileField, MultipleFileField
from wtforms import Field, SelectFieldBase, FormField
from fuzzywuzzy import process
from markdown import Markdown
import bleach
from markupsafe import Markup

settings.configure()
from django_user_agents.utils import get_user_agent

AVAILABLE_COLORS: typing.List[str] = [
    "success",
    "danger",
    "primary",
    "info",
    "warning",
    "dark",
]

app: Flask = None

WS_URL = "ws://localhost:"
WS_EXCEPTIONS = (
    ConnectionRefusedError,
    websocket._exceptions.WebSocketConnectionClosedException,
    ConnectionResetError,
    ConnectionAbortedError,
)


class User(UserMixin):
    USERS: typing.Dict[str, "User"] = {}

    def __init__(
        self,
        id: int,
        name: str,
        global_name: typing.Optional[str] = None,
        avatar_url: typing.Optional[str] = None,
    ) -> None:
        self.id: int = id
        self.name: str = name
        self.global_name: typing.Optional[str] = global_name
        self.avatar_url: typing.Optional[str] = avatar_url

        self.devices: typing.List[str] = []

        self.__class__.USERS[self.id] = self

    def get_id(self) -> str:
        token = self.generate_token(
            action="login", expiration_timedelta=datetime.timedelta(days=1)
        )
        self.devices.append(token)
        try:
            self.devices.remove(session["_user_id"])
        except (KeyError, ValueError):
            pass
        return token

    def __repr__(self) -> str:
        return f"<User id={self.id!r} name={self.name!r} global_name={self.global_name!r} avatar_url={self.avatar_url!r}>"

    @property
    def display_name(self) -> str:
        return self.global_name or self.name

    @property
    def display_avatar(self) -> str:
        return self.avatar_url or f"https://cdn.discordapp.com/embed/avatars/{(self.id >> 22) % 6}.png"

    @property
    def is_owner(self) -> bool:
        return self.id in app.variables["bot"]["owner_ids"]

    @property
    def is_active(self) -> bool:
        return not self.is_blacklisted

    @property
    def is_blacklisted(self) -> bool:
        return self.id in app.variables["bot"]["blacklisted_users"]

    def generate_token(
        self,
        action: typing.Optional[str] = None,
        expiration_timedelta: datetime.timedelta = datetime.timedelta(minutes=15),
        data: typing.Dict[str, typing.Any] = {},
    ) -> str:
        secret_key = app.config["SECRET_KEY"]
        expiration_time = datetime.datetime.now(tz=datetime.timezone.utc) + expiration_timedelta
        payload = {
            "user_id": self.id,
            "action": action,
            "exp": expiration_time,
        }
        payload.update(**data)
        return jwt.encode(payload, secret_key, algorithm="HS256")

    @classmethod
    def get_user_from_token(
        cls,
        token: str,
        action: typing.Optional[str] = None,
        unique: bool = True,
        return_data: bool = False,
    ) -> typing.Union["User", typing.Tuple["User", typing.Dict[str, typing.Any]]]:
        secret_key = app.config["SECRET_KEY"]
        try:
            data = jwt.decode(token, secret_key, algorithms=["HS256"])
        except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
            raise
        if action is not None and data.get("action") != action:
            raise jwt.InvalidTokenError()
        if token in app.already_used_tokens:
            raise jwt.ExpiredSignatureError()
        if unique:
            app.already_used_tokens.add(token)
        user_id = data.get("user_id")
        user: User = cls.USERS.get(user_id)
        if user is None:
            raise jwt.ExpiredSignatureError()
        return user if not return_data else (user, data)


current_user: User


def register_extensions(_app: Flask) -> None:
    global app
    app = _app
    app.login_manager: LoginManager = LoginManager()
    app.login_manager.session_protection: str = "strong"
    app.config["USE_SESSION_FOR_NEXT"]: bool = False
    app.login_manager.login_view: str = "login_blueprint.login"
    app.login_manager.login_message: str = _(
        "Please log in to access this page."
    )  # Just to have the string in the translation files...
    app.login_manager.login_message_category: str = "info"
    app.login_manager.refresh_view: str = "login_blueprint.login"
    app.login_manager.needs_refresh_message: str = _(
        "To protect your account, please reauthenticate to access this page."
    )  # Just to have the string in the translation files...
    app.login_manager.needs_refresh_message_category: str = "info"
    app.login_manager.localize_callback: typing.Any = _
    app.login_manager.init_app(app)

    @app.login_manager.user_loader
    def user_loader(token: str) -> None:
        try:
            user: User = User.get_user_from_token(token=token, action="login", unique=False)
        except jwt.ExpiredSignatureError:
            return
        except jwt.InvalidTokenError:
            remote_addr = request.environ.get("HTTP_X_FORWARDED_FOR", request.remote_addr)
            if remote_addr in app.data["core"]["blacklisted_ips"]:
                return
            # app.data["core"]["blacklisted_ips"].append(remote_addr)
            return
        if not user.is_active or token not in user.devices:
            return
        return user

    # https://github.com/GoogleCloudPlatform/flask-talisman
    app.talisman: Talisman = Talisman()
    old_force_https = app.talisman._force_https

    def _force_https():
        if request.remote_addr == "127.0.0.1" or request.remote_addr == "::1":
            return
        private_ip_ranges = [
            ("10.0.0.0", "10.255.255.255"),
            ("172.16.0.0", "172.31.255.255"),
            ("192.168.0.0", "192.168.255.255"),
        ]
        if any(start <= request.remote_addr <= end for start, end in private_ip_ranges):
            return
        return old_force_https()

    app.talisman._force_https = _force_https
    app.talisman.init_app(app, content_security_policy=None)

    app.config["WTF_CSRF_ENABLED"]: bool = True
    app.config["WTF_CSRF_SECRET_KEY"]: str = base64.urlsafe_b64decode(
        Fernet.generate_key().decode()
    )
    app.csrf_protect: CSRFProtect = CSRFProtect()
    app.csrf_protect.init_app(app)
    initial_protect = app.csrf_protect.protect

    def protect():
        initial_protect()
        g.csrf_valid = False

    app.csrf_protect.protect = protect
    initial_init_field = Field.__init__

    def init_field(field, *args, **kwargs):
        initial_init_field(field, *args, **kwargs)
        if isinstance(field, FormField):
            return
        if hasattr(field, "_value"):
            field._real_value = field._value
        field._value = lambda: (field._real_value() if hasattr(field, "_real_value") else "") or (
            (field.default if isinstance(field.default, typing.List) else str(field.default))
            if field.default is not None
            else ""
        )
        if isinstance(field, SelectFieldBase):
            old_choices_generator = field._choices_generator

            def _choices_generator(choices):
                for value, label, selected, render_kw in old_choices_generator(choices):
                    yield (
                        value,
                        label,
                        selected
                        or (
                            field.coerce(value) == field._value()
                            if not isinstance(field._value(), typing.List)
                            else field.coerce(value) in field._value()
                        ),
                        render_kw,
                    )

            field._choices_generator = _choices_generator
        elif isinstance(field, (FileField, MultipleFileField)):
            if (
                file_allowed := next(
                    (
                        validator
                        for validator in field.validators
                        if isinstance(validator, FileAllowed)
                    ),
                    None,
                )
            ) is not None:
                field.flags.accept = ", ".join(
                    [f".{extension}" for extension in file_allowed.upload_set]
                )

    Field.__init__ = init_field

    app.bootstrap: Bootstrap = Bootstrap()
    app.bootstrap.init_app(app)

    app.moment: Moment = Moment()
    app.moment.init_app(app)

    app.markdown: Markdown = Markdown()

    @app.template_filter("markdown")
    def markdown_filter(text: str) -> Markup:
        text = bleach.clean(text, tags=[], strip=False)
        return Markup(app.markdown.convert(text.replace("\n", "<br />")))

    app.site_mapper: Sitemapper = Sitemapper(https=not app.testing)


def register_blueprints(app: Flask) -> None:
    for module_name in ("base", "login", "third_parties"):
        module = import_module(f"reddash.app.{module_name}.routes")
        app.register_blueprint(module.blueprint)

    @app.errorhandler(404)
    async def not_found_error(error):
        return render_template("errors/404.html", error_message=error.description), 404

    @app.errorhandler(403)
    async def access_forbidden(error):
        return render_template("errors/403.html", error_message=error.description), 403

    @app.errorhandler(500)
    async def internal_error(error):
        return render_template("errors/500.html", error_message=error.description), 500

    @app.route("/error-<error>")
    async def route_errors(error):
        if error not in ("404", "403", "500"):
            return redirect(url_for("base_blueprint.index"))
        return render_template(f"errors/{error}.html"), int(error)

    @app.before_request
    def block_ip():
        request.META = {"HTTP_USER_AGENT": request.headers.get("USER_AGENT", "")}
        request.user_agent = get_user_agent(request)
        # if request.path not in ("/", "/login") and not request.path.startswith("/static") and not (request.path.startswith("/set") and request.path.count("/") == 1):
        #     return render_template("errors/404.html"), 404
        remote_addr = request.environ.get("HTTP_X_FORWARDED_FOR", request.remote_addr)
        if request.path.startswith(("/static", "/api/stream", "/blacklisted")):
            return
        if remote_addr in app.data["core"]["blacklisted_ips"]:
            return redirect(url_for("login_blueprint.blacklisted"))
        if (
            app.locked
            and not current_user.is_owner
            and not request.path.startswith("/static")
            and request.blueprint != "login_blueprint"
            and request.endpoint
            not in (
                "base_blueprint.index",
                "base_blueprint.sitemap",
                "base_blueprint.robots",
                "base_blueprint.credits",
            )
            and not (request.path.startswith("/set") and request.path.count("/") == 1)
        ):
            flash(_("The Dashboard is currently locked."), category="danger")
            return redirect(url_for("base_blueprint.index", next=request.url))


def apply_themes(app: Flask) -> None:
    @app.context_processor
    def override_url_for() -> typing.Dict[str, str]:
        return dict(url_for=_generate_url_for_theme)

    def _generate_url_for_theme(endpoint, **values) -> str:
        if endpoint.endswith("static"):
            themename = values.get("theme", None) or app.config.get("DEFAULT_THEME", None)
            if themename:
                theme_file = "{}/{}".format(themename, values.get("filename", ""))
                if os.path.isfile(os.path.join(app.static_folder, theme_file)):
                    values["filename"] = theme_file
            values["q"] = time.time()  # So Flask doesn't cache.
        if request.args.get("debug_toolbar") in ("True", "true", "1", "t", "on"):
            values["debug_toolbar"] = "True"
        return url_for(endpoint, **values)


def add_constants(app: Flask) -> None:
    def process_meta_tags() -> typing.Dict[str, typing.Any]:
        meta = deepcopy(app.data["ui"]["meta"])
        meta["color"] = request.cookies.get("color", meta["default_color"])
        meta["available_colors"]: typing.List[str] = AVAILABLE_COLORS
        meta["background_theme"] = request.cookies.get(
            "background_theme", meta["default_background_theme"]
        )
        meta["sidebar_theme"] = request.cookies.get("sidebar_theme", meta["default_sidebar_theme"])
        return {"meta": meta}

    def process_sidebar() -> typing.List[typing.Dict]:
        sidebar = sorted(app.data["ui"]["sidebar"], key=lambda x: x["pos"])
        final = []
        for item in sidebar:
            item = dict(item.items())
            if item["session"] is True and not current_user.is_authenticated:
                continue
            if item["session"] is False and current_user.is_authenticated:
                continue
            if item["owner"] and not (current_user.is_authenticated and current_user.is_owner):
                continue
            # I have to localize here opposed to storing it because... well... then it's not localized.
            if item["name"] == "builtin-home":
                item["name"] = _("Home page")
            elif item["name"] == "builtin-commands":
                item["name"] = _("Commands")
            elif item["name"] == "builtin-dashboard":
                item["name"] = _("Dashboard")
            elif item["name"] == "builtin-third_parties":
                item["name"] = _("Third Parties")
            elif item["name"] == "builtin-credits":
                item["name"] = _("Credits")
            elif item["name"] == "builtin-login":
                item["name"] = _("Login")
            elif item["name"] == "builtin-logout":
                item["name"] = _("Logout")
            elif item["name"] == "builtin-admin":
                item["name"] = _("Admin")
            # if not item["is_http"]:
            try:
                item["url"] = url_for(item["route"])
            except Exception:
                continue
            if (
                item["route"].split(".")[0] == "login_blueprint"
                and request.endpoint != "base_blueprint.index"
                and request.blueprint != "login_blueprint"
            ):
                item["url"] = make_login_url(item["route"], next_url=request.url)
            item["active"] = item["route"] == request.endpoint
            final.append(item)
        return final

    def url_for_query(_anchor: typing.Optional[str] = None, **kwargs) -> str:
        full_url = request.url
        url_components = urlparse(full_url)
        query_params = parse_qs(url_components.query)
        query_params.update(kwargs)
        for kwarg, value in query_params.copy().items():
            if value is None or value == "None":
                del query_params[kwarg]
        new_query_params = {k: v if isinstance(v, str) else v for k, v in query_params.items()}
        new_query_string = urlencode(new_query_params, doseq=True, quote_via=quote_plus).replace(
            "amp%3B", ""
        )
        updated_url_components = url_components._replace(query=new_query_string)
        if _anchor is not None:
            updated_url_components = updated_url_components._replace(fragment=_anchor)
        relative_url = urlunparse(updated_url_components._replace(scheme="", netloc="", params="", fragment=""))
        return relative_url

    def number_to_text_with_suffix(number: float) -> str:
        suffixes = [
            "k",
            "m",
            "b",
            "t",
            "q",
            "Q",
            "s",
            "S",
            "o",
            "n",
            "d",
            "U",
            "D",
            "T",
            "Qa",
            "Qi",
            "Sx",
            "Sp",
            "Oc",
            "No",
            "Vi",
        ]
        index = None
        while abs(number) >= 1000 and (index if index is not None else -1) < len(suffixes) - 1:
            number /= 1000.0
            if index is None:
                index = -1
            index += 1
        # return f"{number:.1f}{suffixes[index] if index is not None else ''}"
        if number == int(number):
            formatted_number = int(number)
        elif f'{number:.1f}' != "0.0":
            formatted_number = int(float(f"{number:.1f}")) if float(f"{number:.1f}") == int(float(f"{number:.1f}")) else f"{number:.1f}"
        else:
            formatted_number = int(float(f"{number:.2f}")) if float(f"{number:.2f}") == int(float(f"{number:.2f}")) else f"{number:.2f}"
        suffix = suffixes[index] if index is not None else ""
        return f"{formatted_number}{suffix}"

    @app.context_processor
    def inject_variables() -> typing.Dict[str, typing.Any]:
        variables = deepcopy(app.variables)
        variables["locales"] = app.config["LOCALE_DICT"]
        variables["safelocales"] = json.dumps(app.config["LOCALE_DICT"])
        variables["selectedlocale"] = session.get("lang_code")
        variables["sidebar"] = process_sidebar()
        uptime = datetime.datetime.fromtimestamp(
            app.variables["stats"]["uptime"]
        )
        utc_now = datetime.datetime.utcnow().replace(second=0, microsecond=0)
        real_timedelta = utc_now - uptime
        timedelta: datetime.timedelta = utc_now - uptime.replace(
            hour=utc_now.hour
            if real_timedelta > datetime.timedelta(days=30)
            else uptime.hour,
            minute=utc_now.minute
            if real_timedelta > datetime.timedelta(days=1)
            else uptime.minute,
            second=0,
            microsecond=0,
        )
        if timedelta.total_seconds() > 60 * 60 * 24 * 365:
            timedelta = datetime.timedelta(days=timedelta.days // 30 * 30)
        variables["stats"]["uptime_timedelta"] = humanize_timedelta(timedelta=timedelta)
        variables.update(**process_meta_tags())
        return dict(
            version="1.0",
            variables=variables,
            full_login_url=make_login_url(app.login_manager.login_view, next_url=request.url)
            if request.endpoint != "base_blueprint.index"
            and request.blueprint != "login_blueprint"
            else url_for(app.login_manager.login_view),
            url_for_query=url_for_query,
            number_to_text_with_suffix=number_to_text_with_suffix,
        )


def initialize_babel(app: Flask) -> None:
    app.config["BABEL_TRANSLATION_DIRECTORIES"]: str = "translations"
    app.config["LANGUAGES"]: typing.List[str] = [
        "en-US",
        "af-ZA",
        "ar-SA",
        "bg-BG",
        "ca-ES",
        "cs-CZ",
        "da-DK",
        "de-DE",
        "el-GR",
        "es-ES",
        "fi-FI",
        "fr-FR",
        "he-IL",
        "hu-HU",
        "id-ID",
        "it-IT",
        "ja-JP",
        "ko-KR",
        "nl-NL",
        "nb-NO",
        "pl-PL",
        "pt-BR",
        "pt-PT",
        "ro-RO",
        "ru-RU",
        "sk-SK",
        "sv-SE",
        "tr-TR",
        "uk-UA",
        "vi-VN",
        "zh-CN",
        "zh-HK",
        "zh-TW",
    ]
    locale_dict: typing.Dict[str, str] = {}
    for locale in app.config["LANGUAGES"]:
        loc = Locale.parse(locale, sep="-")
        lang = loc.get_language_name()
        if territory := loc.get_territory_name():
            lang = f"{lang} - {territory}"
        locale_dict[locale] = lang
    app.config["LOCALE_DICT"]: typing.Dict[str, str] = locale_dict

    @app.before_request
    def pull_locale() -> None:
        # Locale is determined in the following priority:
        # - `lang_code` kwarg
        # - `lang_code` cookie
        # - `lang_code` session value
        # - default from browser
        locale = None
        lang = request.args.get("lang_code") or request.cookies.get(
            "lang_code"
        )  # Url is visible by user, so it's the priority.
        lang = lang or session.get("lang_code")  # User either didn't have `lang_code` argument or wasnt able to match a locale. Let's check if theres something in the session.
        if lang:
            # User had `lang_code` argument in request, lets check if its valid.
            processed = process.extractOne(lang, app.config["LANGUAGES"])
            if processed[1] < 80:
                # Too low of a match, abort lang_code argument and go to session value.
                lang = None
            else:
                # User had `lang_code` argument, and it closely matched a registered locale.
                locale = processed[0]
        # Let's save that so it will be used on next request as well.
        session["lang_code"] = locale

    # @app.babel.localeselector
    def get_locale() -> str:
        return (
            session.get("lang_code") or request.accept_languages.best_match(app.config["LANGUAGES"], default="en-US")
        ).replace("-", "_")

    app.extensions["babel"].locale_selector = get_locale


def initialize_websocket(app: Flask) -> bool:
    app.ws: websocket.WebSocket = websocket.WebSocket()
    try:
        app.ws.connect(f"ws://{app.config['WEBSOCKET_HOST']}:{app.config['WEBSOCKET_PORT']}")
    except WS_EXCEPTIONS:
        app.ws.close()
        app.ws = None
        return False
    return True


async def secure_send(
    app: Flask, request: typing.Dict[str, typing.Any]
) -> typing.Dict[str, typing.Any]:
    if app.cog is not None:
        from aiohttp_json_rpc.protocol import JsonRpcMsg, JsonRpcMsgTyp

        try:
            method = app.cog.bot.rpc._rpc.methods[request["method"]]
        except KeyError:
            return {"error": {"message": "Method not found"}}
        result = await method(
            http_request="GET",
            rpc=app.cog.bot.rpc._rpc,
            msg=JsonRpcMsg(type=JsonRpcMsgTyp.REQUEST, data=request),
        )
        return {"result": result}
    result = None
    try:
        app.ws.send(json.dumps(request))
        result = json.loads(app.ws.recv())
    except WS_EXCEPTIONS:
        app.logger.warning("Connection reset.")
        app.ws.close()
        app.ws = None
        return False
    else:
        return result


def check_for_disconnect(app: Flask, method: str, result: typing.Dict[str, typing.Any]) -> bool:
    if (
        "error" in result
        and result["error"]["message"] == "Method not found"
        and method == "DASHBOARDRPC__GET_VARIABLES"
        or "result" in result
        and isinstance(result["result"], typing.Dict)
        and result["result"].get("disconnected", False)
    ):
        app.config["RPC_CONNECTED"]: bool = False
        if app.ws is not None:
            app.ws.close()
            app.ws = None
        return False
    return True


async def get_result(app: Flask, request: typing.Dict[str, typing.Any]):
    if app.cog is not None:
        from aiohttp_json_rpc.protocol import JsonRpcMsg, JsonRpcMsgTyp
        try:
            method = app.cog.bot.rpc._rpc.methods[request["method"]]
        except KeyError:
            return {"status": 1, "message": _("Not connected to bot.")}
        result = await method(
            http_request="GET",
            rpc=app.cog.bot.rpc._rpc,
            msg=JsonRpcMsg(type=JsonRpcMsgTyp.REQUEST, data=request),
        )
        return {"status": 0, "result": result}
    app.ws.send(json.dumps(request))
    result = json.loads(app.ws.recv())
    if "error" in result:
        if result["error"]["message"] == "Method not found":
            return {"status": 1, "message": _("Not connected to bot.")}
        app.logger.error(result["error"])
        return {"status": 1, "message": _("Something went wrong.")}
    if not result["result"] or isinstance(result["result"], typing.Dict) and result["result"].get("disconnected", False):
        return {"status": 1, "message": _("Not connected to bot.")}
    return result["result"]


def notify_owner_of_blacklist(app: Flask, ip: str) -> None:
    while True:
        if app.cog is not None or app.ws and app.ws.connected:
            request = {
                "jsonrpc": "2.0",
                "id": 0,
                "method": "DASHBOARDRPC__NOTIFY_OWNERS_OF_BLACKLIST",
                "params": [ip],
            }
            result = secure_send(app, request)
            if not result or "error" in result:
                time.sleep(1)
                continue
            break
        time.sleep(1)


# This is taken from Red-DiscordBot's `chat_formatting.py` (https://github.com/Cog-Creators/Red-DiscordBot/blob/V3/develop/redbot/core/utils/chat_formatting.py#L521-L574).
def humanize_timedelta(
    *, timedelta: typing.Optional[datetime.timedelta] = None, seconds: typing.Optional[int] = None
) -> str:
    """
    Get a locale aware human timedelta representation.
    This works with either a timedelta object or a number of seconds.
    Fractional values will be omitted, and values less than 1 second
    an empty string.
    Parameters
    ----------
    timedelta: Optional[datetime.timedelta]
        A timedelta object
    seconds: Optional[int]
        A number of seconds
    Returns
    -------
    str
        A locale aware representation of the timedelta or seconds.
    Raises
    ------
    ValueError
        The function was called with neither a number of seconds nor a timedelta object
    """
    try:
        obj = seconds if seconds is not None else timedelta.total_seconds()
    except AttributeError:
        raise ValueError("You must provide either a timedelta or a number of seconds.")

    seconds = int(obj)
    periods = [
        (_("year"), _("years"), 60 * 60 * 24 * 365),
        (_("month"), _("months"), 60 * 60 * 24 * 30),
        (_("day"), _("days"), 60 * 60 * 24),
        (_("hour"), _("hours"), 60 * 60),
        (_("minute"), _("minutes"), 60),
        (_("second"), _("seconds"), 1),
    ]

    strings = []
    for period_name, plural_period_name, period_seconds in periods:
        if seconds >= period_seconds:
            period_value, seconds = divmod(seconds, period_seconds)
            if period_value == 0:
                continue
            unit = plural_period_name if period_value > 1 else period_name
            strings.append(f"{period_value} {unit}")

    return ", ".join(strings)
