import aiohttp
from aiohttp import hdrs, web, WSMsgType
from aiohttp.helpers import must_be_empty_body
import asyncio
from functools import lru_cache
from homeassistant.components import frontend, http
from ipaddress import ip_address
import json
from multidict import CIMultiDict
import re
from typing import TYPE_CHECKING, cast
from urllib.parse import urlencode, quote
from yarl import URL

from .const import DOMAIN, LOGGER as _LOGGER, API_BASE, URL_BASE, WorkMode, UIMode, RewriteMode

if TYPE_CHECKING:
    from homeassistant.core import HomeAssistant
    from typing import Any, Callable, Iterable

    from .config import IngressStore, IngressCfg, RewriteCfg, UserInfo

X_INGRESS_NAME = "X-Ingress-Name"
X_ORIGINAL_URL = "X-Original-Url"
X_HASS_ORIGIN = "X-Hass-Origin"
X_INGRESS_PATH = "X-Ingress-Path"
X_INGRESS_SUBPATH = "X-Ingress-Subpath"
HEADER_AUTO_PH = "$auto"
HEADER_USER_ID_PH = "$user_id"
HEADER_USER_NAME_PH = "$user_name"
HEADER_USERNAME_PH = "$username"

INGRESS_MODES = (WorkMode.INGRESS, WorkMode.SUBAPP)
METH_ALLOW_REDIRECT = (hdrs.METH_GET, hdrs.METH_HEAD)

INIT_HEADERS_FILTER = {
    hdrs.CONTENT_LENGTH,
    hdrs.CONTENT_ENCODING,
    hdrs.TRANSFER_ENCODING,
    hdrs.SEC_WEBSOCKET_EXTENSIONS,
    hdrs.SEC_WEBSOCKET_PROTOCOL,
    hdrs.SEC_WEBSOCKET_VERSION,
    hdrs.SEC_WEBSOCKET_KEY,
}
RESPONSE_HEADERS_FILTER = {
    hdrs.TRANSFER_ENCODING,
    hdrs.CONTENT_LENGTH,
    hdrs.CONTENT_TYPE,
    hdrs.CONTENT_ENCODING,
}

MAX_SIMPLE_REQUEST_SIZE = 4194000

DISABLED_TIMEOUT = aiohttp.ClientTimeout(total=None)


class IngressView(http.HomeAssistantView):  # type: ignore
    """ingress view to handle request."""

    name = "api:ingress:proxy"
    url = API_BASE + "/{name}/{path:.*}"
    requires_auth = False

    def __init__(
        self, hass: "HomeAssistant", config: "IngressStore", websession: aiohttp.ClientSession
    ):
        self._hass = hass
        self._config = config
        self._websession = websession

    async def _handle_auth(self, request: web.Request) -> web.Response:
        # check required header
        name = request.headers.get(X_INGRESS_NAME)
        url = request.headers.get(X_ORIGINAL_URL)
        if not name or not url:
            raise web.HTTPNotFound

        # check ingress_token from query
        url = URL(url)
        cookie_name = self._config.cookie_name(name)
        if cookie_name in url.query:
            cfg, token = self._config.check_token(self._hass, url.query[cookie_name])
            if cfg:
                # valid, remove ingressToken if has X-Hass-Origin else return 200
                hass_origin = request.headers.get(X_HASS_ORIGIN)
                if hass_origin and request.method in METH_ALLOW_REDIRECT:
                    query = url.query.copy()
                    query.pop(cookie_name)
                    resp = web.HTTPUnauthorized(headers={hdrs.LOCATION: str(url.with_query(query))})
                else:
                    resp = web.Response(headers=cfg.headers)
                resp.set_cookie(cfg.cookie_name, token, httponly=True)
                return resp

        # check ingress_token from cookie
        token = request.cookies.get(cookie_name, "")
        if cfg := self._config.check_token(self._hass, token, False)[0]:
            # valid, return 200
            return web.Response(headers=cfg.headers)

        # cookie invalid, try redirect to entry
        if cfg := self._config.get(name):
            params = {"replace": ""}
            root = cfg.sub_path + "/"
            if url.path.startswith(root):
                url = URL.build(
                    path=url.path[len(root) :], query_string=url.query_string, fragment=url.fragment
                )
                params["index"] = str(url)
            hass_origin = request.headers.get(X_HASS_ORIGIN, "")
            url = f"{hass_origin}/{cfg.entry}?{urlencode(params)}"
            raise web.HTTPUnauthorized(headers={hdrs.LOCATION: url})

        raise web.HTTPNotFound

    async def _handle_redirect(self, cfg: "IngressCfg", path: str) -> web.Response | None:
        # find frontend config
        hass_data, token = self._hass.data, cfg.token

        def get_front_config():
            def get_config(config: dict[str, "Any"]):
                fields = ("url", "index")
                config = {k: config[k] for k in fields if k in config}
                if "index" in config:
                    config["index"] = path.lstrip("/")
                config["ui_mode"] = UIMode.REPLACE
                return config

            for panel in hass_data[DOMAIN]["panels"]:
                panel = hass_data[frontend.DATA_PANELS].get(panel)
                if not panel:
                    continue
                if panel.config.get("token") is token:
                    return get_config(panel.config)
                for child in panel.config.get("children", {}).values():
                    if child.get("token") is token:
                        return get_config(child)

        if not (config := get_front_config()):
            return

        # redirect to target url
        html = f"""\
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8"/>
    <script async src="{URL_BASE}/entrypoint.js"></script>
    <script>(async () => {{
await customElements.whenDefined("ha-panel-ingress");
document.querySelector("ha-panel-ingress").setProperties({{panel: {{
  config: {json.dumps(config)},
}}}});
}})();</script>
  </head>
  <body><ha-panel-ingress></ha-panel-ingress></body>
</html>
"""
        return web.Response(text=html, content_type="text/html")

    async def _handle(
        self, request: web.Request, name: str, path: str
    ) -> web.Response | web.StreamResponse | web.WebSocketResponse:
        if name == "_" and path == "auth":
            return await self._handle_auth(request)

        cfg, token = self._config.check_token(self._hass, name)
        if cfg and request.method in METH_ALLOW_REDIRECT:
            # only redirect when get or head method
            url = f"{API_BASE}/{cfg.name}/"
            path = quote(path) + (f"?{request.query_string}" if request.query_string else "")
            resp = web.HTTPFound(url + path)
            # set self cookie
            resp.set_cookie(cfg.cookie_name, token, path=url, httponly=True)
            # set subapp's cookies
            for cfg in cfg.sub_apps:
                resp.headers.add(
                    hdrs.SET_COOKIE,
                    f"{cfg.cookie_name}={cfg.token['value']}; HttpOnly; Path={API_BASE}/{cfg.name}/",
                )
            raise resp

        if not cfg:
            token = request.cookies.get(self._config.cookie_name(name), "")
            cfg = self._config.check_token(self._hass, token, False)[0]

        user: UserInfo | None = None
        if cfg and cfg.mode in INGRESS_MODES:
            user = self._config.check_user_token(
                request.cookies.get(self._config.user_cookie_name(), "")
            )
            if not user and not cfg.static_token:
                cfg = None

        if not cfg or cfg.mode not in INGRESS_MODES:
            # cookie invalid, try redirect to entry
            if cfg := cfg or self._config.get(name):
                path = quote(path) + (f"?{request.query_string}" if request.query_string else "")
                if cfg.mode == WorkMode.AUTH:
                    if (resp := await self._handle_redirect(cfg, path)) is not None:
                        return resp
                path = urlencode({"replace": "", "index": path})
                raise web.HTTPFound(f"/{cfg.entry}?{path}")
            raise web.HTTPNotFound

        url = _create_url(cfg, path)
        try:
            # Websocket
            if _is_websocket(request):
                return await self._handle_websocket(request, cfg, user, url)
            # Request
            return await self._handle_request(request, cfg, user, url)
        except aiohttp.ClientError as err:
            _LOGGER.debug("Ingress error with %s / %s: %s", cfg.name, url, err)
            raise web.HTTPBadGateway from None

    get = _handle
    post = _handle
    put = _handle
    delete = _handle
    patch = _handle
    # options = _handle
    head = _handle

    async def _handle_websocket(
        self, request: web.Request, cfg: "IngressCfg", user: "UserInfo | None", url: URL
    ) -> web.WebSocketResponse:
        """Ingress route for websocket."""
        req_protocols: Iterable[str]
        if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers:
            req_protocols = [
                str(proto.strip())
                for proto in request.headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",")
            ]
        else:
            req_protocols = ()

        ws_server = web.WebSocketResponse(protocols=req_protocols, autoclose=False, autoping=False)
        await ws_server.prepare(request)

        # Support GET query
        if request.query_string:
            url = url.with_query(request.query_string)

        # Start proxy
        async with self._websession.ws_connect(
            url,
            headers=_init_header(request, cfg, user),
            protocols=req_protocols,
            autoclose=False,
            autoping=False,
        ) as ws_client:
            # Proxy requests
            ws_client = cast(web.WebSocketResponse, ws_client)
            await asyncio.wait(
                [
                    asyncio.create_task(_websocket_forward(ws_server, ws_client)),
                    asyncio.create_task(_websocket_forward(ws_client, ws_server)),
                ],
                return_when=asyncio.FIRST_COMPLETED,
            )

        return ws_server

    async def _handle_request(
        self, request: web.Request, cfg: "IngressCfg", user: "UserInfo | None", url: URL
    ) -> web.Response | web.StreamResponse:
        """Ingress route for request."""
        data = request.content
        if not request.body_exists or (
            (clen := request.headers.get(hdrs.CONTENT_LENGTH))
            and (clen := int(clen)) <= MAX_SIMPLE_REQUEST_SIZE
        ):
            data = await data.read()
        async with self._websession.request(
            request.method,
            url,
            headers=_init_header(request, cfg, user),
            params=request.query,
            allow_redirects=False,
            data=data,
            timeout=DISABLED_TIMEOUT,
            skip_auto_headers={hdrs.CONTENT_TYPE},
        ) as result:
            headers = _response_header(result)
            if ctype := result.headers.get(hdrs.CONTENT_TYPE):
                ctype = ctype.partition(";")[0].strip()
            else:
                ctype = "application/octet-stream"

            rewrite_body = None
            if cfg.rewrites:
                path = url.path
                for rule in cfg.rewrites:
                    if rule.path and not re.match(rule.path, path, re.I):
                        continue
                    if rule.mode == RewriteMode.HEADER:
                        for name, value in headers.items():
                            if rule.name and not re.match(rule.name, name, re.I):
                                continue
                            for i in range(len(value)):
                                value[i] = re.sub(rule.match, rule.replace, value[i])
                    elif rule.mode == RewriteMode.BODY:
                        if rule.name and not re.match(rule.name, ctype, re.I):
                            continue
                        rewrite_body = _make_rewrite(rule, rewrite_body)

            headers = CIMultiDict((k, v) for k, vs in headers.items() for v in vs if v)
            # Simple request
            if rewrite_body or must_be_empty_body(request.method, result.status):
                # Return Response
                body = await result.read()
                if rewrite_body:
                    body = rewrite_body(body)
                return web.Response(
                    headers=headers, status=result.status, content_type=ctype, body=body
                )

            # Stream response
            response = web.StreamResponse(status=result.status, headers=headers)
            response.content_type = ctype

            try:
                await response.prepare(request)
                async for data, _ in result.content.iter_chunks():
                    await response.write(data)
            except (aiohttp.ClientError, aiohttp.ClientPayloadError, ConnectionResetError) as err:
                _LOGGER.debug("Stream error with %s / %s: %s", cfg.name, url, err)
            return response


def _make_rewrite(
    rule: "RewriteCfg", rewrite_body: "Callable[[bytes], bytes] | None"
) -> "Callable[[bytes], bytes]":
    if rewrite_body is None:
        rewrite_body = lambda body: body
    return lambda body: re.sub(rule.match.encode(), rule.replace.encode(), rewrite_body(body))


@lru_cache
def _create_url(cfg: "IngressCfg", path: str) -> URL:
    """Create URL to service."""
    base_path = f"{cfg.sub_path}/"
    try:
        url = cfg.origin.join(URL(base_path + quote(path.lstrip("/"))))
    except ValueError as err:
        raise web.HTTPBadRequest from err
    if not url.path.startswith(base_path):
        raise web.HTTPBadRequest
    return url


@lru_cache(maxsize=32)
def _forwarded_for_header(forward_for: str | None, peer_name: str) -> str:
    """Create X-Forwarded-For header."""
    connected_ip = ip_address(peer_name)
    return f"{forward_for}, {connected_ip!s}" if forward_for else f"{connected_ip!s}"


def _init_header(
    request: web.Request, cfg: "IngressCfg", user: "UserInfo | None"
) -> dict[str, str]:
    """Create initial header."""
    headers: dict[str, str] = {}
    for name, value in request.headers.items():
        name = std_header_name(name)
        if name in INIT_HEADERS_FILTER:
            continue
        if name == hdrs.COOKIE:
            if not (value := cfg.remove_token_from_cookie(value)):
                continue
        headers[name] = value
    for name, value in cfg.headers.items():
        if value == HEADER_USERNAME_PH:
            if value := user["username"] if user else None:
                headers[name] = value
        elif value == HEADER_USER_ID_PH:
            if value := user["id"] if user else None:
                headers[name] = value
        elif value == HEADER_USER_NAME_PH:
            if value := user["name"] if user else None:
                headers[name] = value
        elif value != HEADER_AUTO_PH:
            headers[name] = value

    # Ingress information
    headers[X_INGRESS_PATH] = f"{API_BASE}/{cfg.name}"
    headers[X_INGRESS_SUBPATH] = cfg.sub_path

    # Set X-Forwarded-For
    assert request.transport
    if (peername := request.transport.get_extra_info("peername")) is None:
        _LOGGER.error("Can't set forward_for header, missing peername")
        raise web.HTTPBadRequest
    headers[hdrs.X_FORWARDED_FOR] = _forwarded_for_header(
        request.headers.get(hdrs.X_FORWARDED_FOR), peername[0]
    )

    # Set X-Forwarded-Host
    if not (forward_host := request.headers.get(hdrs.X_FORWARDED_HOST)):
        forward_host = request.host
    headers[hdrs.X_FORWARDED_HOST] = forward_host

    # Set X-Forwarded-Proto
    if not (forward_proto := request.headers.get(hdrs.X_FORWARDED_PROTO)):
        forward_proto = request.scheme
    headers[hdrs.X_FORWARDED_PROTO] = forward_proto

    # Replace Origin placeholder
    if hdrs.ORIGIN in headers and cfg.headers.get(hdrs.ORIGIN) == HEADER_AUTO_PH:
        headers[hdrs.ORIGIN] = f"{forward_proto}://{forward_host}"

    return headers


def _response_header(response: aiohttp.ClientResponse) -> dict[str, list[str]]:
    """Create response header."""
    headers: dict[str, list[str]] = {}
    for name, value in response.headers.items():
        name = std_header_name(name)
        if name in RESPONSE_HEADERS_FILTER:
            continue
        headers.setdefault(name, []).append(value)
    return headers


def _is_websocket(request: web.Request) -> bool:
    """Return True if request is a websocket."""
    headers = request.headers
    return bool(
        "upgrade" in headers.get(hdrs.CONNECTION, "").lower()
        and headers.get(hdrs.UPGRADE, "").lower() == "websocket"
    )


async def _websocket_forward(ws_from: web.WebSocketResponse, ws_to: web.WebSocketResponse) -> None:
    """Handle websocket message directly."""
    try:
        async for msg in ws_from:
            if msg.type is WSMsgType.TEXT:
                await ws_to.send_str(msg.data)
            elif msg.type is WSMsgType.BINARY:
                await ws_to.send_bytes(msg.data)
            elif msg.type is WSMsgType.PING:
                await ws_to.ping()
            elif msg.type is WSMsgType.PONG:
                await ws_to.pong()
            elif ws_to.closed:
                await ws_to.close(code=ws_to.close_code, message=msg.extra)  # type: ignore
    except RuntimeError:
        _LOGGER.debug("Ingress Websocket runtime error")
    except ConnectionResetError:
        _LOGGER.debug("Ingress Websocket Connection Reset")


def _init():
    from multidict import istr

    special_hdrs = {}
    for name in dir(hdrs):
        if type(value := getattr(hdrs, name)) != istr or (key := value.title()) == value:
            continue
        special_hdrs[key] = value

    def std_header_name(name: str):
        name = name.title()
        return special_hdrs.get(name, name)

    return std_header_name


std_header_name = _init()
