#########################################################################
# This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
# License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
#########################################################################


# Catch and report webapp client errors to the SMC server.
# This is based on bugsnag's MIT licensed lib: https://github.com/bugsnag/bugsnag-js
# The basic idea is to wrap very early at a very low level of the event system,
# such that all libraries loaded later are sitting on top of this.
# Additionally, special care is taken to browser brands and their capabilities.
# Finally, additional data about the webapp client is gathered and sent with the error report.

# list of string-identifyers of errors, that were already reported.
# this avoids excessive resubmission of errors
already_reported = []

FUNCTION_REGEX = /function\s*([\w\-$]+)?\s*\(/i

ignoreOnError = 0

shouldCatch = true

# set this to true, to enable the webapp error reporter for development
enable_for_testing = false
if BACKEND? and BACKEND
    # never enable on the backend -- used by static react rendering.
    ENABLED = false
else
    ENABLED = (not DEBUG) or enable_for_testing

# this is the MAIN function of this module
# it's exported publicly and also used in various spots where exceptions are already
# caught and reported to the browser's console.
reportException = (exception, name, severity, comment) ->
    if !exception or typeof exception == "string"
        return
    # setting those *Number defaults to `undefined` breaks somehow on its way
    # to the DB (it only wants NULL or an int). -1 is signaling that there is no info.
    sendError(
        name: name || exception.name
        message: exception.message || exception.description
        comment: comment ? ''
        stacktrace: stacktraceFromException(exception) || generateStacktrace()
        file: exception.fileName || exception.sourceURL
        path: window.location.href
        lineNumber: exception.lineNumber || exception.line || -1
        columnNumber: exception.columnNumber || -1
        severity: severity || "default"
    )

WHITELIST = ['componentWillMount has been renamed', 'componentWillReceiveProps has been renamed', 'a whole package of antd']
isWhitelisted = (opts) ->
    s = JSON.stringify(opts)
    for x in WHITELIST
        if s.indexOf(x) != -1
            return true
    return false

# this is the final step sending the error report.
# it gathers additional information about the webapp client.
currentlySendingError = false;
sendError = (opts) ->
    #console.log("sendError", currentlySendingError, opts);
    if currentlySendingError
        # errors can be crazy and easily DOS the user's connection.  Since this table is
        # just something we manually check sometimes, not sending too many errors is
        # best.  We send at most one at a time.  See https://github.com/sagemathinc/cocalc/issues/5771
        return
    currentlySendingError = true
    require.ensure [], =>
        try
            #console.log 'sendError', opts
            if isWhitelisted(opts)
                #console.log 'sendError: whitelisted'
                # Ignore this antd message in browser.
                return
            misc = require('@cocalc/util/misc')
            opts = misc.defaults opts,
                name            : misc.required
                message         : misc.required
                comment         : ''
                stacktrace      : ''
                file            : ''
                path            : ''
                lineNumber      : -1
                columnNumber    : -1
                severity        : 'default'
            fingerprint = misc.uuidsha1([opts.name, opts.message, opts.comment].join('::'))
            if fingerprint in already_reported and not DEBUG
                return
            already_reported.push(fingerprint)
            # attaching some additional info
            feature = require('@cocalc/frontend/feature')
            opts.user_agent  = navigator?.userAgent
            opts.browser     = feature.get_browser()
            opts.mobile      = feature.IS_MOBILE
            opts.smc_version = SMC_VERSION
            opts.build_date  = BUILD_DATE
            opts.smc_git_rev = COCALC_GIT_REVISION
            opts.uptime      = misc.get_uptime()
            opts.start_time  = misc.get_start_time_ts()
            if DEBUG then console.info('error reporter sending:', opts)
            try
                # During initial load in some situations evidently webapp_client
                # is not yet initialized, and webapp_client is undefined.  (Maybe
                # a typescript rewrite of everything relevant will help...).  In
                # any case, for now we
                #   https://github.com/sagemathinc/cocalc/issues/4769
                # As an added bonus, by try/catching and retrying once at least,
                # we are more likely to get the error report in case of a temporary
                # network or other glitch....
                console.log 'sendError: import webapp_client'

                {webapp_client} = require('@cocalc/frontend/webapp-client')   # can possibly be undefined
                # console.log 'sendError: sending error'
                await webapp_client.tracking_client.webapp_error(opts)  # might fail.
                # console.log 'sendError: got response'
            catch err
                console.info("failed to report error; trying again in 30 seconds", err, opts)
                {delay} = require('awaiting');
                await delay(30000)
                try
                    {webapp_client} = require('@cocalc/frontend/webapp-client')
                    await webapp_client.tracking_client.webapp_error(opts)
                catch err
                    console.info("failed to report error", err)
        finally
            currentlySendingError = false

# neat trick to get a stacktrace when there is none
generateStacktrace = () ->
    generated = stacktrace = null
    MAX_FAKE_STACK_SIZE = 10
    ANONYMOUS_FUNCTION_PLACEHOLDER = "[anonymous]"

    try
        throw new Error("")
    catch exception
        generated = "<generated>\n"
        stacktrace = stacktraceFromException(exception)

    if not stacktrace
        generated = "<generated-ie>\n"
        functionStack = []
        try
            curr = arguments.callee.caller.caller
            while curr && functionStack.length < MAX_FAKE_STACK_SIZE
                if FUNCTION_REGEX.test(curr.toString())
                    fn = RegExp.$1 ? ANONYMOUS_FUNCTION_PLACEHOLDER
                else
                    fn = ANONYMOUS_FUNCTION_PLACEHOLDER
                functionStack.push(fn)
                curr = curr.caller
        catch e
            #console.error(e)
        stacktrace = functionStack.join("\n")
    return generated + stacktrace

stacktraceFromException = (exception) ->
    return exception.stack || exception.backtrace || exception.stacktrace

# Disable catching on IE < 10 as it destroys stack-traces from generateStackTrace()
# OF COURSE, COCALC doesn't support any version of IE at all, so ...
if (not window.atob)
    shouldCatch = false

# Disable catching on browsers that support HTML5 ErrorEvents properly.
# This lets debug on unhandled exceptions work.
# TODO: enabling the block below distorts (at least) Chrome error messages.
# Maybe Chrome's window.onerror doesn't work as assumed?
# else if window.ErrorEvent
#     try
#         if new window.ErrorEvent("test").colno == 0
#             shouldCatch = false
#     catch e
#         # No action needed

# flag to ignore "onerror" when already wrapped in the event handler
ignoreNextOnError = () ->
    ignoreOnError += 1
    window.setTimeout((-> ignoreOnError -= 1))

# this is the "brain" of all this
wrap = (_super) ->
    try
        if typeof _super != "function"
            return _super

        if !_super._wrapper
            _super._wrapper = () ->
                if shouldCatch
                    try
                        return _super.apply(this, arguments)
                    catch e
                        reportException(e, null, "error")
                        ignoreNextOnError()
                        throw e
                else
                    return _super.apply(this, arguments)

            _super._wrapper._wrapper = _super._wrapper

        return _super._wrapper

    catch e
        return _super

# replaces an attribute of an object by a function that has it as an argument
polyFill = (obj, name, makeReplacement) ->
    original = obj[name]
    replacement = makeReplacement(original)
    obj[name] = replacement

# wrap all prototype objects that have event handlers
# first one is for chrome, the first three for FF, the rest for IE, Safari, etc.
if ENABLED
    "EventTarget Window Node ApplicationCache AudioTrackList ChannelMergerNode CryptoOperation EventSource FileReader HTMLUnknownElement IDBDatabase IDBRequest IDBTransaction KeyOperation MediaController MessagePort ModalWindow Notification SVGElementInstance Screen TextTrack TextTrackCue TextTrackList WebSocket WebSocketWorker Worker XMLHttpRequest XMLHttpRequestEventTarget XMLHttpRequestUpload".replace(/\w+/g, (global) ->
        prototype = window[global]?.prototype
        if prototype?.hasOwnProperty?("addEventListener")
            polyFill(prototype, "addEventListener", (_super) ->
                return (e, f, capture, secure) ->
                    try
                        if f and f.handleEvent
                            f.handleEvent = wrap(f.handleEvent)
                    catch err
                        #console.log(err)
                    return _super.call(this, e, wrap(f), capture, secure)
            )

            polyFill(prototype, "removeEventListener", (_super) ->
                return (e, f, capture, secure) ->
                    _super.call(this, e, f, capture, secure)
                    return _super.call(this, e, wrap(f), capture, secure)
            )
    )

if ENABLED
    polyFill(window, "onerror", (_super) ->
        return (message, url, lineNo, charNo, exception) ->
            # IE 6+ support.
            if !charNo and window.event
                charNo = window.event.errorCharacter

            #if DEBUG
            #    console.log("intercepted window.onerror", message, url, lineNo, charNo, exception)

            if ignoreOnError == 0
                name = exception?.name or "window.onerror"
                stacktrace = (exception and stacktraceFromException(exception)) or generateStacktrace()
                sendError(
                    name        : name
                    message     : message
                    file        : url
                    path        : window.location.href
                    lineNumber  : lineNo
                    columnNumber: charNo
                    stacktrace  : stacktrace
                    severity    : "error"
                )

            # Fire the existing `window.onerror` handler, if one exists
            if _super
                _super(message, url, lineNo, charNo, exception)
    )

# timing functions

hijackTimeFunc = (_super) ->
    return (f, t) ->
        if typeof f == "function"
            f = wrap(f)
            args = Array.prototype.slice.call(arguments, 2)
            return _super((-> f.apply(this, args)), t)
        else
            return _super(f, t)

if ENABLED
    polyFill(window, "setTimeout", hijackTimeFunc)
    polyFill(window, "setInterval", hijackTimeFunc)

if ENABLED and window.requestAnimationFrame
    polyFill(window, "requestAnimationFrame", (_super) ->
        (callback) ->
            return _super(wrap(callback))
    )

if ENABLED and window.setImmediate
    polyFill(window, "setImmediate", (_super) ->
        return () ->
            args = Array.prototype.slice.call(arguments)
            args[0] = wrap(args[0])
            return _super.apply(this, args)
    )

# console terminal

sendLogLine = (severity, args) ->
    require.ensure [], =>
        misc = require('@cocalc/util/misc')
        if typeof(args) == 'object'
            message = misc.trunc_middle(misc.to_json(args), 1000)
        else
            message = Array.prototype.slice.call(args).join(", ")
        sendError(
            name        : 'Console Output'
            message     : message
            file        : ''
            path        : window.location.href
            lineNumber  : -1
            columnNumber: -1
            stacktrace  : generateStacktrace()
            severity    : severity
        )

wrapFunction = (object, property, newFunction) ->
    oldFunction = object[property]
    object[property] = () ->
        newFunction.apply(this, arguments)
        if typeof oldFunction == "function"
            oldFunction.apply(this, arguments)

if ENABLED and window.console?
    wrapFunction(console, "warn",  (-> sendLogLine("warn", arguments)))
    wrapFunction(console, "error", (-> sendLogLine("error", arguments)))

if ENABLED
    window.addEventListener "unhandledrejection",(e) ->
        require.ensure [], =>
            # just to make sure there is a message
            reason = e.reason ? '<no reason>'
            if typeof(reason) == 'object'
                misc = require('@cocalc/util/misc')
                reason = "#{reason.stack ? reason.message ? misc.trunc_middle(misc.to_json(reason), 1000)}"
            e.message = "unhandledrejection: #{reason}"
            reportException(e, "unhandledrejection")

# public API

exports.reportException = reportException

if DEBUG
    window.cc ?= {}
    window.cc.webapp_error_reporter =
        shouldCatch             : -> shouldCatch
        ignoreOnError           : -> ignoreOnError
        already_reported        : -> already_reported
        stacktraceFromException : stacktraceFromException
        generateStacktrace      : generateStacktrace
        sendLogLine             : sendLogLine
        reportException         : reportException
        is_enabled              : -> ENABLED
