import gzip
import importlib
import json
import logging
from pathlib import Path
from unittest import mock

import pytest
import tornado.testing
from tornado import httpclient
from tornado import websocket
from tornado.web import create_signed_value

import mitmproxy_rs
from mitmproxy import log
from mitmproxy import options
from mitmproxy.test import tflow
from mitmproxy.tools.web import app
from mitmproxy.tools.web import master as webmaster

here = Path(__file__).parent.absolute()


@pytest.fixture(scope="module")
def no_tornado_logging():
    logging.getLogger("tornado.access").disabled = True
    logging.getLogger("tornado.application").disabled = True
    logging.getLogger("tornado.general").disabled = True
    yield
    logging.getLogger("tornado.access").disabled = False
    logging.getLogger("tornado.application").disabled = False
    logging.getLogger("tornado.general").disabled = False


def get_json(resp: httpclient.HTTPResponse):
    return json.loads(resp.body.decode())


@pytest.mark.parametrize("filename", list((here / "../../../../web/gen").glob("*.py")))
async def test_generated_files(filename):
    mod = importlib.import_module(f"web.gen.{filename.stem}")
    expected = await mod.make()
    actual = mod.filename.read_text().replace("\r\n", "\n")
    assert actual == expected, (
        f"{mod.filename} must be regenerated by running {filename.resolve()}."
    )


def test_all_handlers_have_auth():
    for _, handler in app.handlers:
        assert issubclass(handler, app.AuthRequestHandler)


@pytest.mark.usefixtures("no_tornado_logging", "tdata")
class TestApp(tornado.testing.AsyncHTTPTestCase):
    def get_app(self):
        async def make_master() -> webmaster.WebMaster:
            o = options.Options(http2=False)
            return webmaster.WebMaster(o, with_termlog=False)

        m: webmaster.WebMaster = self.io_loop.asyncio_loop.run_until_complete(
            make_master()
        )
        f = tflow.tflow(resp=True)
        f.id = "42"
        f.request.content = b"foo\nbar"
        f2 = tflow.tflow(ws=True, resp=True)
        f2.request.content = None
        f2.response.content = None
        f2.id = "43"
        m.view.add([f, f2])
        m.view.add([tflow.tflow(err=True)])
        m.events._add_log(log.LogEntry("test log", "info"))
        m.events.done()
        self.master = m
        self.view = m.view
        self.events = m.events
        webapp = app.Application(m, None)
        webapp.settings["xsrf_cookies"] = False
        return webapp

    @property
    def auth_cookie(self) -> str:
        auth_cookie = create_signed_value(
            secret=self._app.settings["cookie_secret"],
            name=self._app.settings["auth_cookie_name"](),
            value=app.AuthRequestHandler.AUTH_COOKIE_VALUE,
        ).decode()
        return f"{self._app.settings['auth_cookie_name']()}={auth_cookie}"

    def fetch(self, *args, **kwargs) -> httpclient.HTTPResponse:
        kwargs.setdefault("headers", {}).setdefault("Cookie", self.auth_cookie)
        # tornado disallows POST without content by default.
        return super().fetch(*args, **kwargs, allow_nonstandard_methods=True)

    def put_json(self, url, data: dict) -> httpclient.HTTPResponse:
        return self.fetch(
            url,
            method="PUT",
            body=json.dumps(data),
            headers={"Content-Type": "application/json"},
        )

    def test_index(self):
        response = self.fetch("/")
        assert response.code == 200
        assert b'="/' not in response.body, (
            "HTML content should not contain root-relative paths"
        )

    def test_filter_help(self):
        assert self.fetch("/filter-help").code == 200

    def test_javascript_mime_type(self):
        """Test that JavaScript files are served with the correct MIME type."""
        # Verify that .js files are served with text/javascript MIME type
        # This is critical for ES6 module scripts which enforce strict MIME type checking

        # Find any .js file in the static directory
        static_dir = Path(app.__file__).parent / "static"
        js_files = list(static_dir.glob("*.js"))
        assert js_files, "No .js files found in static directory"

        # Get the filename of the first .js file
        js_filename = js_files[0].name

        # Fetch the JavaScript file and verify the Content-Type header
        resp = self.fetch(f"/static/{js_filename}")
        assert resp.code == 200
        assert resp.headers.get("Content-Type") == "text/javascript"

    def test_flows(self):
        resp = self.fetch("/flows")
        assert resp.code == 200
        assert get_json(resp)[0]["request"]["contentHash"]
        assert get_json(resp)[2]["error"]

    def test_flows_dump(self):
        resp = self.fetch("/flows/dump")
        assert b"address" in resp.body

    def test_flows_dump_filter(self):
        resp = self.fetch("/flows/dump?filter=foo")
        assert b"" == resp.body

    def test_flows_dump_filter_error(self):
        resp = self.fetch("/flows/dump?filter=[")
        assert resp.code == 400

    def test_clear(self):
        events = self.events.data.copy()
        flows = list(self.view)

        assert self.fetch("/clear", method="POST").code == 200

        assert not len(self.view)
        assert not len(self.events.data)

        # restore
        for f in flows:
            self.view.add([f])
        self.events.data = events

    def test_resume(self):
        for f in self.view:
            f.intercept()

        assert self.fetch("/flows/42/resume", method="POST").code == 200
        assert sum(f.intercepted for f in self.view) >= 1
        assert self.fetch("/flows/resume", method="POST").code == 200
        assert all(not f.intercepted for f in self.view)

    def test_kill(self):
        for f in self.view:
            f.backup()
            f.intercept()

        assert self.fetch("/flows/42/kill", method="POST").code == 200
        assert sum(f.killable for f in self.view) >= 1
        assert self.fetch("/flows/kill", method="POST").code == 200
        assert all(not f.killable for f in self.view)
        for f in self.view:
            f.revert()

    def test_flow_delete(self):
        f = self.view.get_by_id("42")
        assert f

        assert self.fetch("/flows/42", method="DELETE").code == 200

        assert not self.view.get_by_id("42")
        self.view.add([f])

        assert self.fetch("/flows/1234", method="DELETE").code == 404

    def test_flow_update(self):
        f = self.view.get_by_id("42")
        assert f.request.method == "GET"
        f.backup()

        upd = {
            "request": {
                "method": "PATCH",
                "port": 123,
                "headers": [("foo", "bar")],
                "trailers": [("foo", "bar")],
                "content": "req",
            },
            "response": {
                "msg": "Non-Authorisé",
                "code": 404,
                "headers": [("bar", "baz")],
                "trailers": [("foo", "bar")],
                "content": "resp",
            },
            "marked": ":red_circle:",
            "comment": "I'm a modified comment!",
        }
        assert self.put_json("/flows/42", upd).code == 200
        assert f.request.method == "PATCH"
        assert f.request.port == 123
        assert f.request.headers["foo"] == "bar"
        assert f.request.text == "req"
        assert f.response.msg == "Non-Authorisé"
        assert f.response.status_code == 404
        assert f.response.headers["bar"] == "baz"
        assert f.response.text == "resp"
        assert f.comment == "I'm a modified comment!"

        upd = {
            "request": {
                "trailers": [("foo", "baz")],
            },
            "response": {
                "trailers": [("foo", "baz")],
            },
        }
        assert self.put_json("/flows/42", upd).code == 200
        assert f.request.trailers["foo"] == "baz"

        f.revert()

        assert self.put_json("/flows/42", {"foo": 42}).code == 400
        assert self.put_json("/flows/42", {"request": {"foo": 42}}).code == 400
        assert self.put_json("/flows/42", {"response": {"foo": 42}}).code == 400
        assert self.fetch("/flows/42", method="PUT", body="{}").code == 400
        assert (
            self.fetch(
                "/flows/42",
                method="PUT",
                headers={"Content-Type": "application/json"},
                body="!!",
            ).code
            == 400
        )

    def test_flow_duplicate(self):
        resp = self.fetch("/flows/42/duplicate", method="POST")
        assert resp.code == 200
        f = self.view.get_by_id(resp.body.decode())
        assert f
        assert f.id != "42"
        self.view.remove([f])

    def test_flow_revert(self):
        f = self.view.get_by_id("42")
        f.backup()
        f.request.method = "PATCH"
        self.fetch("/flows/42/revert", method="POST")
        assert not f._backup

    def test_flow_replay(self):
        with mock.patch("mitmproxy.command.CommandManager.call") as replay_call:
            assert self.fetch("/flows/42/replay", method="POST").code == 200
            assert replay_call.called

    def test_flow_content(self):
        f = self.view.get_by_id("42")
        f.backup()
        f.response.headers["Content-Disposition"] = 'inline; filename="filename.jpg"'

        r = self.fetch("/flows/42/response/content.data")
        assert r.body == b"message"
        assert r.headers["Content-Disposition"] == 'attachment; filename="filename.jpg"'

        del f.response.headers["Content-Disposition"]
        f.request.path = "/foo/bar.jpg"
        assert (
            self.fetch("/flows/42/response/content.data").headers["Content-Disposition"]
            == "attachment; filename=bar.jpg"
        )

        f.response.content = b""
        r = self.fetch("/flows/42/response/content.data")
        assert r.code == 200
        assert r.body == b""

        f.revert()

    def test_flow_content_returns_raw_content_when_decoding_fails(self):
        f = self.view.get_by_id("42")
        f.backup()

        f.response.headers["Content-Encoding"] = "gzip"
        # replace gzip magic number with garbage
        invalid_encoded_content = gzip.compress(b"Hello world!").replace(
            b"\x1f\x8b", b"\xff\xff"
        )
        f.response.raw_content = invalid_encoded_content

        r = self.fetch("/flows/42/response/content.data")
        assert r.body == invalid_encoded_content
        assert r.code == 200

        f.revert()

    def test_update_flow_content(self):
        assert (
            self.fetch("/flows/42/request/content.data", method="POST", body="new").code
            == 200
        )
        f = self.view.get_by_id("42")
        assert f.request.content == b"new"
        assert f.modified()
        f.revert()

    def test_update_flow_content_multipart(self):
        body = (
            b"--somefancyboundary\r\n"
            b'Content-Disposition: form-data; name="a"; filename="a.txt"\r\n'
            b"\r\n"
            b"such multipart. very wow.\r\n"
            b"--somefancyboundary--\r\n"
        )
        assert (
            self.fetch(
                "/flows/42/request/content.data",
                method="POST",
                headers={
                    "Content-Type": 'multipart/form-data; boundary="somefancyboundary"'
                },
                body=body,
            ).code
            == 200
        )
        f = self.view.get_by_id("42")
        assert f.request.content == b"such multipart. very wow."
        assert f.modified()
        f.revert()

    def test_flow_contentview(self):
        assert get_json(self.fetch("/flows/42/request/content/raw")) == {
            "description": "",
            "syntax_highlight": "none",
            "text": "foo\nbar",
            "view_name": "Raw",
        }
        assert get_json(self.fetch("/flows/42/request/content/raw?lines=1")) == {
            "description": "",
            "syntax_highlight": "none",
            "text": "foo\n",
            "view_name": "Raw",
        }
        assert self.fetch("/flows/42/messages/content/raw").code == 400

    def test_flow_contentview_websocket(self):
        assert get_json(self.fetch("/flows/43/messages/content/raw?lines=2")) == [
            {
                "text": "hello binary",
                "view_name": "Raw",
                "syntax_highlight": "none",
                "description": "",
                "from_client": True,
                "timestamp": 946681203,
            },
            {
                "text": "hello text",
                "view_name": "Raw",
                "syntax_highlight": "none",
                "description": "",
                "from_client": True,
                "timestamp": 946681204,
            },
        ]

    def test_commands(self):
        resp = self.fetch("/commands")
        assert resp.code == 200
        assert get_json(resp)["set"]["help"]

    def test_command_execute(self):
        resp = self.fetch("/commands/unknown", method="POST")
        assert resp.code == 200
        assert get_json(resp) == {"error": "Unknown command: unknown"}
        resp = self.fetch("/commands/commands.history.get", method="POST")
        assert resp.code == 200
        assert get_json(resp) == {"value": []}

    def test_events(self):
        resp = self.fetch("/events")
        assert resp.code == 200
        assert get_json(resp)[0]["level"] == "info"

    def test_options(self):
        j = get_json(self.fetch("/options"))
        assert isinstance(j, dict)
        assert isinstance(j["anticache"], dict)

    def test_option_update(self):
        assert self.put_json("/options", {"anticache": True}).code == 200
        assert self.put_json("/options", {"wtf": True}).code == 400
        assert self.put_json("/options", {"anticache": "foo"}).code == 400

    def test_option_save(self):
        assert self.fetch("/options/save", method="POST").code == 200

    def test_err(self):
        with mock.patch("mitmproxy.tools.web.app.IndexHandler.get") as f:
            f.side_effect = RuntimeError
            assert self.fetch("/").code == 500

    @tornado.testing.gen_test
    def test_websocket(self):
        ws_req = httpclient.HTTPRequest(
            f"ws://localhost:{self.get_http_port()}/updates",
            headers={"Cookie": self.auth_cookie},
        )

        ws_client = yield websocket.websocket_connect(ws_req)
        self.master.options.anticomp = True

        r1 = yield ws_client.read_message()
        response = json.loads(r1)
        assert response == {
            "type": "options/update",
            "payload": {
                "anticomp": {
                    "value": True,
                    "choices": None,
                    "default": False,
                    "help": "Try to convince servers to send us un-compressed data.",
                    "type": "bool",
                }
            },
        }
        ws_client.close()

        # trigger on_close by opening a second connection.
        ws_client2 = yield websocket.websocket_connect(ws_req)
        ws_client2.close()

    @tornado.testing.gen_test
    def test_websocket_filter_application(self):
        ws_req = httpclient.HTTPRequest(
            f"ws://localhost:{self.get_http_port()}/updates",
            headers={"Cookie": self.auth_cookie},
        )
        ws_client = yield tornado.websocket.websocket_connect(ws_req)

        # test update filter message
        message = json.dumps(
            {
                "type": "flows/updateFilter",
                "payload": {
                    "name": "search",
                    "expr": "~bq foo",
                },
            }
        ).encode()
        yield ws_client.write_message(message)

        response = yield ws_client.read_message()
        response = json.loads(response)

        assert response == {
            "type": "flows/filterUpdate",
            "payload": {
                "name": "search",
                "matching_flow_ids": ["42"],
            },
        }

        # test add flow
        f = tflow.tflow(resp=True)
        f.id = "41"
        f.request.content = b"foo\nbarbar"

        app.ClientConnection.broadcast_flow("flows/add", f)

        response = yield ws_client.read_message()
        response = json.loads(response)

        assert response["type"] == "flows/add"
        assert response["payload"]["matching_filters"] == {"search": True}
        assert response["payload"]["flow"]["id"] == "41"

        # test update flow
        f.request.content = b"bar"

        app.ClientConnection.broadcast_flow("flows/update", f)

        response = yield ws_client.read_message()
        response = json.loads(response)

        assert response["type"] == "flows/update"
        assert response["payload"]["matching_filters"] == {"search": False}
        assert response["payload"]["flow"]["id"] == "41"

        # test filter removal
        message = json.dumps(
            {
                "type": "flows/updateFilter",
                "payload": {
                    "name": "search",
                    "expr": "",
                },
            }
        ).encode()
        yield ws_client.write_message(message)

        response = yield ws_client.read_message()
        response = json.loads(response)

        assert response == {
            "type": "flows/filterUpdate",
            "payload": {
                "name": "search",
                "matching_flow_ids": None,
            },
        }

        ws_client.close()

    @tornado.testing.gen_test
    def test_websocket_filter_command_error(self):
        # can't do pytest.parametrize, so we do this.
        for data in [
            """{"type": "flows/updateFilter"}""",
            """{"type": "unknownCommand"}""",
            "invalid json",
        ]:
            ws_req = httpclient.HTTPRequest(
                f"ws://localhost:{self.get_http_port()}/updates",
                headers={"Cookie": self.auth_cookie},
            )
            ws_client = yield tornado.websocket.websocket_connect(ws_req)

            yield ws_client.write_message(data)
            response = yield ws_client.read_message()

            # Connection should be closed
            self.assertIsNone(response)
            self.assertEqual(ws_client.close_code, 1011)
            self.assertEqual(ws_client.close_reason, "Internal server error.")

    def test_process_list(self):
        try:
            mitmproxy_rs.process_info.active_executables()
        except NotImplementedError:
            pytest.skip(
                "mitmproxy_rs.process_info.active_executables not available on this platform."
            )
        resp = self.fetch("/processes")
        assert resp.code == 200
        assert get_json(resp)

    def test_process_icon(self):
        try:
            mitmproxy_rs.process_info.executable_icon("invalid")
        except NotImplementedError:
            pytest.skip(
                "mitmproxy_rs.process_info.executable_icon not available on this platform."
            )
        except Exception:
            pass
        resp = self.fetch("/executable-icon")
        assert resp.code == 400
        assert "Missing 'path' parameter." in resp.body.decode()

        resp = self.fetch("/executable-icon?path=invalid_path")
        assert resp.code == 200
        assert resp.headers["Content-Type"] == "image/png"
        assert resp.body == app.TRANSPARENT_PNG

    def test_login_with_token_header(self):
        web_password = self.master.addons.get("webauth")._password
        headers = {"Cookie": "", "Authorization": f"Bearer {web_password}"}
        assert self.fetch("/", headers=headers).code == 200

    def test_login_with_token_param(self):
        web_password = self.master.addons.get("webauth")._password
        headers = {"Cookie": ""}
        assert self.fetch(f"/?token={web_password}", headers=headers).code == 200

    def test_login_with_malformed_auth_header(self):
        headers = {"Cookie": "", "Authorization": f"Bearer"}
        assert self.fetch("/", headers=headers).code == 403

    def test_login_with_invalid_auth_header(self):
        headers = {"Cookie": "", "Authorization": f"Bearer invalid_token"}
        assert self.fetch("/", headers=headers).code == 403

    def test_unauthorized_api(self):
        headers = {"Cookie": ""}
        assert self.fetch("/", headers=headers).code == 403

    @tornado.testing.gen_test
    def test_unauthorized_websocket(self):
        try:
            yield websocket.websocket_connect(
                f"ws://localhost:{self.get_http_port()}/updates"
            )
        except httpclient.HTTPClientError as e:
            assert e.code == 403
        else:
            assert False

    def test_auth_cookie_port_suffix_modification(self):
        opts = self.master.options

        old_port = opts.web_port
        new_port = 8082
        opts.web_port = new_port

        try:
            assert self._app.settings["auth_cookie_name"]().endswith(str(new_port))
        finally:
            opts.web_port = old_port
