# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html

from __future__ import annotations

import os
import re
import sys
import time
import traceback
from typing import TYPE_CHECKING, TextIO, cast

from markdown import markdown

import aqt
from anki.collection import HelpPage
from anki.errors import BackendError, Interrupted
from anki.utils import is_win
from aqt.addons import AddonManager, AddonMeta
from aqt.qt import *
from aqt.utils import openHelp, showWarning, supportText, tooltip, tr

if TYPE_CHECKING:
    from aqt.main import AnkiQt

# so we can be non-modal/non-blocking, without Python deallocating the message
# box ahead of time
_mbox: QMessageBox | None = None


def show_exception(*, parent: QWidget, exception: Exception) -> None:
    "Present a caught exception to the user using a pop-up."
    if isinstance(exception, Interrupted):
        # nothing to do
        return
    global _mbox
    error_lines = []
    help_page = HelpPage.TROUBLESHOOTING
    if isinstance(exception, BackendError):
        if exception.context:
            error_lines.append(exception.context)
        if exception.backtrace:
            error_lines.append(exception.backtrace)
        if exception.help_page is not None:
            help_page = exception.help_page
    else:
        # if the error is not originating from the backend, dump
        # a traceback to the console to aid in debugging
        error_lines = traceback.format_exception(
            None, exception, exception.__traceback__
        )
    error_text = "\n".join(error_lines)
    print(error_lines)
    _mbox = _init_message_box(str(exception), error_text, help_page)
    _mbox.show()


def is_chromium_cert_error(error: str) -> bool:
    """QtWebEngine sometimes spits out 'unknown error' messages to stderr on Windows.

    They appear to be IDS_SETTINGS_CERTIFICATE_MANAGER_UNKNOWN_ERROR in
    chrome/browser/ui/webui/certificates_handler.cc. At a guess, it's the
    NetErrorToString() method.

    The constant appears to get converted to an ID; the resources are found
    in files like this:

    chrome/app/resources/generated_resources_fr-CA.xtb
    2258:<translation id="3380365263193509176">Erreur inconnue</translation>

    List derived with:
    qtwebengine-chromium% rg --no-heading --no-filename --no-line-number \
        3380365263193509176  | perl -pe 's/.*>(.*)<.*/"$1",/' | sort | uniq
        
    This list has been manually updated to add a different Japanese translation, as
    the translations may change in different Chromium releases.

    Judging by error reports, we can't assume the error falls on a separate line:
    https://forums.ankiweb.net/t/topic/22036/
    """
    if not is_win:
        return False
    for msg in (
        "알 수 없는 오류가 발생했습니다.",
        "Bilinmeyen hata",
        "Eroare necunoscută",
        "Erreur inconnue",
        "Erreur inconnue.",
        "Erro descoñecido",
        "Erro desconhecido",
        "Error desconegut",
        "Error desconocido",
        "Errore ezezaguna",
        "Errore sconosciuto",
        "Gabim i panjohur",
        "Hindi kilalang error",
        "Hitilafu isiyojulikana",
        "Iphutha elingaziwa",
        "Ismeretlen hiba",
        "Kesalahan tidak dikenal",
        "Lỗi không xác định",
        "Naməlum xəta",
        "Nepoznata greška",
        "Nepoznata pogreška",
        "Nezināma kļūda",
        "Nežinoma klaida",
        "Neznáma chyba",
        "Neznámá chyba",
        "Neznana napaka",
        "Nieznany błąd",
        "Noma’lum xatolik",
        "Okänt fel",
        "Onbekende fout",
        "Óþekkt villa",
        "Ralat tidak diketahui",
        "Tundmatu viga",
        "Tuntematon virhe",
        "Ukendt fejl",
        "Ukjent feil",
        "Unbekannter Fehler",
        "Unknown error",
        "Άγνωστο σφάλμα",
        "Белгисиз ката",
        "Белгісіз қате",
        "Невідома помилка",
        "Невядомая памылка",
        "Неизвестна грешка",
        "Неизвестная ошибка",
        "Непозната грешка",
        "Үл мэдэгдэх алдаа",
        "უცნობი შეცდომა",
        "Անհայտ սխալ",
        "שגיאה לא ידועה",
        "خطأ غير معروف",
        "خطای ناشناس",
        "نامعلوم خرابی",
        "ያልታወቀ ስህተት",
        "अज्ञात एरर",
        "अज्ञात गड़बड़ी",
        "अज्ञात त्रुटि",
        "অজানা ত্রুটি",
        "অজ্ঞাত আসোঁৱাহ",
        "ਅਗਿਆਤ ਗੜਬੜ",
        "અજ્ઞાત ભૂલ",
        "ଅଜଣା ତୃଟି",
        "அறியப்படாத பிழை",
        "తెలియని ఎర్రర్",
        "ಅಪರಿಚಿತ ದೋಷ",
        "അജ്ഞാതമായ പിശക്",
        "නොදන්නා දෝෂය",
        "ข้อผิดพลาดที่ไม่รู้จัก",
        "ຄວາມຜິດພາດທີ່ບໍ່ຮູ້ຈັກ",
        "မသိရ အမှား",
        "កំហុសឆ្គងមិនស្គាល់",
        "不明なエラー",
        "未知のエラー",
        "未知的錯誤",
        "未知错误",
    ):
        if error.startswith(msg):
            return True
    return False


if not os.environ.get("DEBUG"):

    def excepthook(etype, val, tb) -> None:  # type: ignore
        sys.stderr.write("%s\n" % ("".join(traceback.format_exception(etype, val, tb))))

    sys.excepthook = excepthook


def _init_message_box(
    user_text: str, debug_text: str, help_page=HelpPage.TROUBLESHOOTING
):
    global _mbox

    _mbox = QMessageBox()
    _mbox.setWindowTitle("Anki")
    _mbox.setText(user_text)
    _mbox.setIcon(QMessageBox.Icon.Warning)
    _mbox.setTextFormat(Qt.TextFormat.PlainText)

    def show_help():
        openHelp(help_page)

    def copy_debug_info():
        QApplication.clipboard().setText(debug_text)
        tooltip(tr.errors_copied_to_clipboard(), parent=_mbox)

    help = _mbox.addButton(QMessageBox.StandardButton.Help)
    if debug_text:
        debug_info = _mbox.addButton(
            tr.errors_copy_debug_info_button(), QMessageBox.ButtonRole.ActionRole
        )
        debug_info.disconnect()
        debug_info.clicked.connect(copy_debug_info)
    cancel = _mbox.addButton(QMessageBox.StandardButton.Cancel)
    cancel.setText(tr.actions_close())

    help.disconnect()
    help.clicked.connect(show_help)

    return _mbox


class ErrorHandler(QObject):
    "Catch stderr and write into buffer."

    ivl = 100
    fatal_error_encountered = False

    errorTimer = pyqtSignal()

    def __init__(self, mw: AnkiQt) -> None:
        QObject.__init__(self, mw)
        self.mw = mw
        self.timer: QTimer | None = None
        qconnect(self.errorTimer, self._setTimer)
        self.pool = ""
        self._oldstderr = sys.stderr
        sys.stderr = cast(TextIO, self)

    def unload(self) -> None:
        sys.stderr = self._oldstderr
        sys.excepthook = None

    def write(self, data: str) -> None:
        # dump to stdout
        sys.stdout.write(data)
        # save in buffer
        self.pool += data
        # and update timer
        self.setTimer()

    def setTimer(self) -> None:
        # we can't create a timer from a different thread, so we post a
        # message to the object on the main thread
        self.errorTimer.emit()  # type: ignore

    def _setTimer(self) -> None:
        if not self.timer:
            self.timer = QTimer(self.mw)
            qconnect(self.timer.timeout, self.onTimeout)
        self.timer.setInterval(self.ivl)
        self.timer.setSingleShot(True)
        self.timer.start()

    def tempFolderMsg(self) -> str:
        return tr.qt_misc_unable_to_access_anki_media_folder()

    def onTimeout(self) -> None:
        if self.fatal_error_encountered:
            # suppress follow-up errors caused by the poisoned lock
            return
        error = self.pool
        self.pool = ""
        self.mw.progress.clear()
        if "AbortSchemaModification" in error:
            return
        if "DeprecationWarning" in error:
            return
        if "10013" in error:
            showWarning(tr.qt_misc_your_firewall_or_antivirus_program_is())
            return
        if "invalidTempFolder" in error:
            showWarning(self.tempFolderMsg())
            return
        if "Beautiful Soup is not an HTTP client" in error:
            return
        if "database or disk is full" in error or "Errno 28" in error:
            showWarning(tr.qt_misc_your_computers_storage_may_be_full())
            return
        if "disk I/O error" in error:
            showWarning(markdown(tr.errors_accessing_db()))
            return
        if "unable to get local issuer certificate" in error and is_win:
            showWarning(tr.errors_windows_ssl_updates())
            return
        if is_chromium_cert_error(error):
            return

        debug_text = supportText() + "\n" + error

        if "PanicException" in error:
            self.fatal_error_encountered = True
            # ensure no collection-related timers like backup fire
            self.mw.col = None
            user_text = "A fatal error occurred, and Anki must close. Please report this message on the forums."
        else:
            user_text = tr.errors_standard_popup2()
            if self.mw.addonManager.dirty:
                user_text += "\n\n" + self._addonText(error)
                debug_text += addon_debug_info()

        _mbox = _init_message_box(user_text, debug_text)

        if self.fatal_error_encountered:
            _mbox.exec()
            sys.exit(1)
        else:
            _mbox.show()

    def _addonText(self, error: str) -> str:
        matches = re.findall(r"addons21(/|\\)(.*?)(/|\\)", error)
        if not matches:
            return tr.errors_may_be_addon()
        # reverse to list most likely suspect first, dict to deduplicate:
        addons = [
            aqt.mw.addonManager.addonName(i[1])
            for i in dict.fromkeys(reversed(matches))
        ]
        addons_str = ", ".join(addons)
        return tr.addons_possibly_involved(addons=addons_str)


def addon_fmt(addmgr: AddonManager, addon: AddonMeta) -> str:
    installed = "0"
    if addon.installed_at:
        try:
            installed = time.strftime(
                "%Y-%m-%dT%H:%M", time.localtime(addon.installed_at)
            )
        except (OverflowError, OSError):
            print("invalid timestamp for", addon.provided_name)
    if addon.provided_name:
        name = addon.provided_name
    else:
        name = "''"
    user = addmgr.getConfig(addon.dir_name)
    default = addmgr.addonConfigDefaults(addon.dir_name)
    if user == default:
        modified = "''"
    else:
        modified = "mod"
    return (
        f"{name} ['{addon.dir_name}', {installed}, '{addon.human_version}', {modified}]"
    )


def addon_debug_info() -> str:
    from aqt import mw

    addmgr = mw.addonManager
    active = []
    activeids = []
    inactive = []
    for addon in addmgr.all_addon_meta():
        if addon.enabled:
            active.append(addon_fmt(addmgr, addon))
            if addon.ankiweb_id():
                activeids.append(addon.dir_name)
        else:
            inactive.append(addon_fmt(addmgr, addon))
    newline = "\n"
    info = f"""\
===Add-ons (active)===
(add-on provided name [Add-on folder, installed at, version, is config changed])
{newline.join(sorted(active))}

===IDs of active AnkiWeb add-ons===
{" ".join(activeids)}

===Add-ons (inactive)===
(add-on provided name [Add-on folder, installed at, version, is config changed])
{newline.join(sorted(inactive))}
"""
    return info
