"""Node.js RASP on cPanel: installed while its feature flag is on.

The agent decides only whether Node.js RASP belongs on this server, and
installs or removes it. Everything else is Node.js RASP's own: it attaches
itself to the Passenger apps and Web Apps (imunify-rasp-admin
hosting-attach, run once here after the install), rescans the server
hourly, and updates itself from the release directory.

The flag comes first. Flag off: an install this agent made is uninstalled
(an admin's own install is left alone). Flag on, on cPanel with EasyApache 4,
Node.js (ea-nodejsNN) or Web Apps (ea-podman), x86-64 and systemd: the
published release is downloaded, checked, unpacked and enabled, then
attached. Once installed, nothing is done while the flag stays on.
"""

import asyncio
import hashlib
import json
import logging
import os
import platform
import re
import shutil
import tarfile
import tempfile
import time
import urllib.request
from pathlib import Path
from typing import Optional

from defence360agent.internals import feature_flags
from defence360agent.subsys import web_server
from defence360agent.subsys.panels.cpanel import cPanel
from defence360agent.subsys.panels.hosting_panel import HostingPanel
from defence360agent.utils import atomic_rewrite, run_outside_sandbox

logger = logging.getLogger(__name__)

FLAG = "nodejs_rasp_cpanel"
EA_NODEJS = Path("/opt/cpanel")
EA_PODMAN = Path("/opt/cpanel/ea-podman")
SYSTEMD_RUNTIME_DIR = Path("/run/systemd/system")
# Where the agent unpacks a release for enable, and its record. enable
# copies the release into /opt/imunify-rasp, so nothing else stays here.
PREFIX = Path("/var/imunify360/nodejs-rasp")
# {"step": "enabled" | "attached", "md5sum": ..., "refused": {...}}: what
# this agent installed and how far it got. Its presence marks an install as
# the agent's own.
MARKER = PREFIX / "install.json"
RELEASE_URL = "https://files.imunify360.com/static/nodejs-rasp/release"
DESCRIPTION_URL = f"{RELEASE_URL}/description.json"
TARBALL_NAME = "imunify-rasp.tgz"
TARBALL_URL = f"{RELEASE_URL}/{TARBALL_NAME}"
PACKAGE_CLI = "package/viewer/linux-x64/imunify-rasp"
# A link through the selected release to its CLI: enable creates it, and
# it answers for whatever release the package's own sync selected since.
SYSTEM_CLI = Path("/usr/sbin/imunify-rasp")
ADMIN = Path("/usr/libexec/imunify-rasp/imunify-rasp-admin")
MAX_DESCRIPTION_BYTES = 1024 * 1024
MAX_TARBALL_BYTES = 256 * 1024 * 1024
DOWNLOAD_TIMEOUT = 300
# A release enable refused is not downloaded again for this long.
REFUSAL_RETRY = 24 * 3600

_MD5 = re.compile(r"[0-9a-f]{32}")
_SHA512 = re.compile(r"[0-9a-f]{128}")
# enable and uninstall: accepted, a step still open (the next run finishes
# it); hosting-attach: partial (the package's hourly rescan finishes it).
_INCOMPLETE = 3


class InvalidRelease(Exception):
    pass


class RaspCliError(Exception):
    pass


def _has_ea_nodejs() -> bool:
    return any(EA_NODEJS.glob("ea-nodejs*/bin/node"))


def is_supported() -> bool:
    """Where Node.js RASP is installed while the flag is on."""
    return (
        HostingPanel().NAME == cPanel.NAME
        and web_server.is_EA4_available()
        and (_has_ea_nodejs() or EA_PODMAN.is_dir())
        # The package ships x86-64 native CLI, collector and admin binaries.
        and platform.machine() == "x86_64"
        and SYSTEMD_RUNTIME_DIR.is_dir()
    )


def _read_marker() -> Optional[dict]:
    try:
        marker = json.loads(MARKER.read_text())
    except FileNotFoundError:
        return None
    except (OSError, ValueError):
        # Unreadable, but the agent's all the same.
        return {}
    return marker if isinstance(marker, dict) else {}


def _write_marker(marker: dict) -> None:
    _make_dirs(PREFIX)
    atomic_rewrite(
        str(MARKER), json.dumps(marker), backup=False, permissions=0o644
    )


def _make_dirs(path: Path) -> None:
    """mkdir -p, each directory it creates 0755 whatever the umask.

    The agent runs with umask 0o007; enable trusts a package only where no
    one but root can write to it or to any directory above it.
    """
    missing = []
    while not path.is_dir():
        missing.append(path)
        path = path.parent
    for directory in reversed(missing):
        directory.mkdir(mode=0o755, exist_ok=True)
        directory.chmod(0o755)


async def rasp_state() -> Optional[dict]:
    """version --json of the selected release; None where none is."""
    if not SYSTEM_CLI.exists():
        return None
    returncode, out, err = await run_outside_sandbox(
        [str(SYSTEM_CLI), "version", "--json"]
    )
    if returncode != 0:
        raise RaspCliError(
            f"imunify-rasp version exited {returncode}:"
            f" {err.decode(errors='replace').strip()}"
        )
    return json.loads(out)


def _fetch(url: str, limit: int) -> bytes:
    with urllib.request.urlopen(url, timeout=DOWNLOAD_TIMEOUT) as response:
        body = response.read(limit + 1)
    if len(body) > limit:
        raise InvalidRelease(f"{url} is larger than {limit} bytes")
    return body


def _published_md5() -> str:
    """The md5sum description.json publishes for the tarball."""
    try:
        items = json.loads(_fetch(DESCRIPTION_URL, MAX_DESCRIPTION_BYTES))[
            "items"
        ]
        md5 = next(i["md5sum"] for i in items if i["name"] == TARBALL_NAME)
    except (ValueError, TypeError, KeyError, StopIteration):
        raise InvalidRelease(
            f"{DESCRIPTION_URL} does not list {TARBALL_NAME}"
        ) from None
    if not isinstance(md5, str) or not _MD5.fullmatch(md5.lower()):
        raise InvalidRelease(
            f"{DESCRIPTION_URL} has no md5sum for {TARBALL_NAME}"
        )
    return md5.lower()


def _download(md5: str, directory: Path) -> Path:
    """The tarball, checked against its md5sum and its published sha512."""
    line = _fetch(f"{TARBALL_URL}.sha512", 1024).decode(errors="replace")
    sha512 = line.split()[0].lower() if line.split() else ""
    if not _SHA512.fullmatch(sha512):
        raise InvalidRelease(f"{TARBALL_URL}.sha512 is not a sha512 line")
    _make_dirs(directory)
    fd, name = tempfile.mkstemp(
        prefix="download.", suffix=".tgz", dir=directory
    )
    path = Path(name)
    digests = hashlib.md5(usedforsecurity=False), hashlib.sha512()
    size = 0
    try:
        with os.fdopen(fd, "wb") as out, urllib.request.urlopen(
            TARBALL_URL, timeout=DOWNLOAD_TIMEOUT
        ) as response:
            while chunk := response.read(1 << 20):
                size += len(chunk)
                if size > MAX_TARBALL_BYTES:
                    raise InvalidRelease(
                        f"{TARBALL_URL} is larger than"
                        f" {MAX_TARBALL_BYTES} bytes"
                    )
                for digest in digests:
                    digest.update(chunk)
                out.write(chunk)
        # A cache may still serve the previous file for a while after a
        # release: the next period retries.
        if [d.hexdigest() for d in digests] != [md5, sha512]:
            raise InvalidRelease(
                f"{TARBALL_URL} does not match its published checksums"
            )
    except BaseException:
        path.unlink(missing_ok=True)
        raise
    return path


def _unpack(tarball: Path, staging: Path) -> Path:
    """The package's CLI, unpacked under staging as only root can write it."""
    _make_dirs(staging)
    try:
        with tarfile.open(tarball, "r:gz") as archive:
            # The data filter refuses links out of the tree, absolute and
            # parent names and devices, and clears group and other write.
            archive.extractall(staging, filter="data")
    except (tarfile.TarError, OSError) as error:
        raise InvalidRelease(f"{TARBALL_URL}: {error}") from None
    # npm leaves no directory entries: tarfile made them under the agent's
    # umask.
    for directory, _, _ in os.walk(staging):
        os.chmod(directory, 0o755)
    cli = staging / PACKAGE_CLI
    if not cli.is_file():
        raise InvalidRelease(f"{TARBALL_URL} has no {PACKAGE_CLI}")
    return cli


async def _run(argv) -> int:
    argv = [str(arg) for arg in argv]
    returncode, out, err = await run_outside_sandbox(argv)
    if returncode != 0:
        message = (err or out).decode(errors="replace").strip()
        logger.warning("%s exited %s: %s", " ".join(argv), returncode, message)
    return returncode


async def install(marker: Optional[dict]) -> bool:
    """Download, check, unpack and enable the published release.

    False: a release enable refused lately, left alone for now.
    """
    loop = asyncio.get_running_loop()
    md5 = await loop.run_in_executor(None, _published_md5)
    refused = (marker or {}).get("refused") or {}
    if (
        refused.get("md5sum") == md5
        and time.time() - refused.get("at", 0) < REFUSAL_RETRY
    ):
        return False
    staging = PREFIX / f"staging.{os.getpid()}"
    tarball = await loop.run_in_executor(None, _download, md5, PREFIX)
    try:
        cli = await loop.run_in_executor(None, _unpack, tarball, staging)
        # Claimed before enable: an agent stopped once enable has run
        # still knows the install is its own.
        _write_marker({**(marker or {}), "step": "enabling", "md5sum": md5})
        returncode = await _run([cli, "enable"])
    finally:
        tarball.unlink(missing_ok=True)
        await loop.run_in_executor(
            None, lambda: shutil.rmtree(staging, ignore_errors=True)
        )
    if returncode in (0, _INCOMPLETE):
        _write_marker({"step": "enabled", "md5sum": md5})
        logger.info("Node.js RASP enabled")
        return True
    _write_marker(
        {**(marker or {}), "refused": {"md5sum": md5, "at": time.time()}}
    )
    raise RaspCliError(f"imunify-rasp enable exited {returncode}")


async def attach() -> None:
    """Hand the applications over to Node.js RASP, once."""
    returncode = await _run([ADMIN, "hosting-attach", "--json"])
    # Partial: what the rescan cannot finish (no mod_env, a foreign file)
    # a retry from here would not either.
    if returncode not in (0, _INCOMPLETE):
        raise RaspCliError(f"hosting-attach exited {returncode}")
    _write_marker({**(_read_marker() or {}), "step": "attached"})
    logger.info("Node.js RASP attached to the applications")


async def remove() -> None:
    """Uninstall the Node.js RASP this agent installed."""
    if _read_marker() is None:
        return
    state = await rasp_state()
    if state and state.get("installed"):
        # uninstall takes it out of the applications first.
        returncode = await _run([SYSTEM_CLI, "uninstall"])
        if returncode == _INCOMPLETE:
            return
        if returncode != 0:
            raise RaspCliError(f"imunify-rasp uninstall exited {returncode}")
        logger.info("Node.js RASP uninstalled")
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, shutil.rmtree, PREFIX)


async def reconcile() -> None:
    if not feature_flags.is_enabled(FLAG):
        await remove()
        return
    if not is_supported():
        return
    marker = _read_marker()
    state = await rasp_state()
    if state and state.get("installed"):
        # An admin's own install, or one already attached: nothing to do.
        # Enabled, or stopped during enable: attach it now.
        if marker is not None and marker.get("step") in (
            "enabling",
            "enabled",
        ):
            await attach()
        return
    if await install(marker):
        await attach()
