# SPDX-FileCopyrightText: Freya Bruhin (The Compiler) <mail@qutebrowser.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later

"""Web server for end2end tests.

This script gets called as a QProcess from end2end/conftest.py.

Some of the handlers here are inspired by the server project, but simplified
for qutebrowser's needs. Note that it probably doesn't handle e.g. multiple
parameters or headers with the same name properly.
"""

import sys
import errno
import json
import time
import threading
import mimetypes
import pathlib
from http import HTTPStatus

import cheroot.wsgi
import flask

app = flask.Flask(__name__)
_redirect_later_event = None


END2END_DIR = pathlib.Path(__file__).resolve().parents[1]


@app.route('/')
def root():
    """Show simple text."""
    return flask.Response(b'qutebrowser test webserver, '
                          b'<a href="/user-agent">user agent</a>')


@app.route('/data/<path:path>')
@app.route('/data2/<path:path>')  # for per-URL settings
def send_data(path):
    """Send a given data file to qutebrowser.

    If a directory is requested, its index.html is sent.
    """
    data_dir = END2END_DIR / 'data'
    if (data_dir / path).is_dir():
        path += '/index.html'
    return flask.send_from_directory(data_dir, path)


@app.route('/redirect-later')
def redirect_later():
    """302 redirect to / after the given delay.

    If delay is -1, wait until a request on redirect-later-continue is done.
    """
    global _redirect_later_event
    delay = float(flask.request.args.get('delay', '1'))
    if delay == -1:
        _redirect_later_event = threading.Event()
        ok = _redirect_later_event.wait(timeout=30 * 1000)
        assert ok
        _redirect_later_event = None
    else:
        time.sleep(delay)
    x = flask.redirect('/')
    return x


@app.route('/redirect-later-continue')
def redirect_later_continue():
    """Continue a redirect-later request."""
    if _redirect_later_event is None:
        return flask.Response(b'Timed out or no redirect pending.')
    else:
        _redirect_later_event.set()
        return flask.Response(b'Continued redirect.')


@app.route('/redirect-self')
def redirect_self():
    """302 Redirects to itself."""
    return app.make_response(flask.redirect(flask.url_for('redirect_self')))


@app.route('/redirect/<int:n>')
def redirect_n_times(n):
    """302 Redirects n times."""
    assert n > 0
    return flask.redirect(flask.url_for('redirect_n_times', n=n-1))


@app.route('/relative-redirect')
def relative_redirect():
    """302 Redirect once."""
    response = app.make_response('')
    response.status_code = HTTPStatus.FOUND
    response.headers['Location'] = flask.url_for('root')
    return response


@app.route('/absolute-redirect')
def absolute_redirect():
    """302 Redirect once."""
    response = app.make_response('')
    response.status_code = HTTPStatus.FOUND
    response.headers['Location'] = flask.url_for('root', _external=True)
    return response


@app.route('/redirect-to')
def redirect_to():
    """302/3XX Redirects to the given URL."""
    # We need to build the response manually and convert to UTF-8 to prevent
    # werkzeug from "fixing" the URL. This endpoint should set the Location
    # header to the exact string supplied.
    response = app.make_response('')
    response.status_code = HTTPStatus.FOUND
    response.headers['Location'] = flask.request.args['url']
    return response


@app.route('/content-size')
def content_size():
    """Send two bytes of data without a content-size."""
    def generate_bytes():
        yield b'*'
        time.sleep(0.2)
        yield b'*'

    response = flask.Response(generate_bytes(), headers={
        "Content-Type": "application/octet-stream",
    })
    response.status_code = HTTPStatus.OK
    return response


@app.route('/twenty-mb')
def twenty_mb():
    """Send 20MB of data."""
    def generate_bytes():
        yield b'*' * 20 * 1024 * 1024

    response = flask.Response(generate_bytes(), headers={
        "Content-Type": "application/octet-stream",
        "Content-Length": str(20 * 1024 * 1024),
    })
    response.status_code = HTTPStatus.OK
    return response


@app.route('/500-inline')
def internal_error_attachment():
    """A 500 error with Content-Disposition: inline."""
    response = flask.Response(b"", headers={
        "Content-Type": "application/octet-stream",
        "Content-Disposition": 'inline; filename="attachment.jpg"',
    })
    response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
    return response


@app.route('/500')
def internal_error():
    """A normal 500 error."""
    r = flask.make_response()
    r.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
    return r


@app.route('/cookies')
def view_cookies():
    """Show cookies."""
    return flask.jsonify(cookies=flask.request.cookies)


@app.route('/cookies/set')
def set_cookies():
    """Set cookie(s) as provided by the query string."""
    r = app.make_response(flask.redirect(flask.url_for('view_cookies')))
    for key, value in flask.request.args.items():
        r.set_cookie(key=key, value=value)
    return r


@app.route('/cookies/set-custom')
def set_custom_cookie():
    """Set a cookie with a custom max_age/expires."""
    r = app.make_response(flask.redirect(flask.url_for('view_cookies')))
    max_age = flask.request.args.get('max_age')
    r.set_cookie(key='cookie', value='value',
                 max_age=int(max_age) if max_age else None)
    return r


@app.route('/basic-auth/<user>/<passwd>')
def basic_auth(user='user', passwd='passwd'):
    """Prompt the user for authorization using HTTP Basic Auth."""
    auth = flask.request.authorization
    if not auth or auth.username != user or auth.password != passwd:
        r = flask.make_response()
        r.status_code = HTTPStatus.UNAUTHORIZED
        r.headers = {'WWW-Authenticate': 'Basic realm="Fake Realm"'}
        return r

    return flask.jsonify(authenticated=True, user=user)


@app.route('/drip')
def drip():
    """Drip data over a duration."""
    duration = float(flask.request.args.get('duration'))
    numbytes = int(flask.request.args.get('numbytes'))
    pause = duration / numbytes

    def generate_bytes():
        for _ in range(numbytes):
            yield b"*"
            time.sleep(pause)

    response = flask.Response(generate_bytes(), headers={
        "Content-Type": "application/octet-stream",
        "Content-Length": str(numbytes),
    })
    response.status_code = HTTPStatus.OK
    return response


@app.route('/404')
def status_404():
    r = flask.make_response()
    r.status_code = HTTPStatus.NOT_FOUND
    return r


@app.route('/headers')
def view_headers():
    """Return HTTP headers."""
    return flask.jsonify(headers=dict(flask.request.headers))


@app.route('/headers-link/<int:port>')
def headers_link(port):
    """Get a (possibly cross-origin) link to /headers."""
    return flask.render_template('headers-link.html', port=port)


@app.route('/https-script/<int:port>')
def https_script(port):
    """Get a script loaded via HTTPS."""
    return flask.render_template('https-script.html', port=port)


@app.route('/https-iframe/<int:port>')
def https_iframe(port):
    """Get an iframe loaded via HTTPS."""
    return flask.render_template('https-iframe.html', port=port)


@app.route('/response-headers')
def response_headers():
    """Return a set of response headers from the query string."""
    headers = flask.request.args
    response = flask.jsonify(headers)
    response.headers.extend(headers)

    response = flask.jsonify(dict(response.headers))
    response.headers.extend(headers)
    return response


@app.route('/query')
def query():
    return flask.jsonify(flask.request.args)


@app.route('/user-agent')
def view_user_agent():
    """Return User-Agent."""
    return flask.jsonify({'user-agent': flask.request.headers['user-agent']})


@app.route('/restrictive-csp')
def restrictive_csp():
    csp = "img-src 'self'; default-src none"  # allow favicon.ico
    return flask.Response(b"", headers={"Content-Security-Policy": csp})


@app.route('/favicon.ico')
def favicon():
    # WORKAROUND for https://github.com/PyCQA/pylint/issues/5783
    # pylint: disable-next=no-member,useless-suppression
    icon_dir = END2END_DIR.parents[1] / 'qutebrowser' / 'icons'
    return flask.send_from_directory(
        icon_dir, 'qutebrowser.ico', mimetype='image/vnd.microsoft.icon')


@app.after_request
def log_request(response):
    """Log a webserver request."""
    request = flask.request
    data = {
        'verb': request.method,
        'path': request.full_path if request.query_string else request.path,
        'status': response.status_code,
    }
    print(json.dumps(data), file=sys.stderr, flush=True)
    return response


class WSGIServer(cheroot.wsgi.Server):

    """A custom WSGIServer that prints a line on stderr when it's ready.

    Attributes:
        _ready: Internal state for the 'ready' property.
        _printed_ready: Whether the initial ready message was printed.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._ready = False
        self._printed_ready = False

    @property
    def ready(self):
        return self._ready

    @ready.setter
    def ready(self, value):
        if value and not self._printed_ready:
            port = self.bind_addr[1]
            scheme = 'http' if self.ssl_adapter is None else 'https'
            print(f' * Running on {scheme}://127.0.0.1:{port}/ (Press CTRL+C to quit)',
                  file=sys.stderr, flush=True)
            self._printed_ready = True
        self._ready = value


def unraisable_hook(unraisable: "sys.UnraisableHookArgs") -> None:
    if (
        sys.version_info[:2] >= (3, 13)
        and isinstance(unraisable.exc_value, OSError)
        and (
            unraisable.exc_value.errno == errno.EBADF
            or (
                sys.platform == "win32"
                # pylint: disable-next=no-member
                and unraisable.exc_value.winerror == errno.WSAENOTSOCK
            )
        )
        and (
            (
                # Python 3.14
                unraisable.object is None
                and unraisable.err_msg.startswith(
                    "Exception ignored while calling deallocator <function IOBase.__del__ at "
                )
            )
            or (
                unraisable.object is not None
                and unraisable.object.__qualname__ == "IOBase.__del__"
            )
        )
    ):
        # WORKAROUND for bogus exceptions with cheroot:
        # https://github.com/cherrypy/cheroot/issues/734
        return
    sys.__unraisablehook__(unraisable)


def init_unraisable_hook() -> None:
    sys.unraisablehook = unraisable_hook


def main():
    init_unraisable_hook()

    app.template_folder = END2END_DIR / 'templates'
    assert app.template_folder.is_dir(), app.template_folder

    if mimetypes.guess_type('worker.js')[0] == 'text/plain':
        # WORKAROUND for https://github.com/pallets/flask/issues/1045
        # Needed for Windows on GitHub Actions for some reason...
        mimetypes.add_type('application/javascript', '.js')

    port = int(sys.argv[1])
    server = WSGIServer(('127.0.0.1', port), app)
    server.start()


if __name__ == '__main__':
    main()
