#!/usr/bin/python3
"""One module made of web pages, for a child: `kidux-webapp <module id>`.

    kidux-webapp <module id>            open it
    kidux-webapp --print <module id>    print Chromium's command line instead

The launcher runs this, in the module's scope, for a module whose manifest
says `launch = { webapp = "<name>" }`, an application kidux-webapps serves,
or `launch = { web = "https://…" }`, a website on the internet (D85;
launcher.md section 5). It opens Chromium on that address as an
application window (`--app`): no tabs, no address bar, one window, which
the launcher keeps on its workspace above the bar (D43). Not `--kiosk`,
which hides Chromium's own bars only while the window is fullscreen, and
the launcher gives no window the whole screen.

A module that signs a child in to its website (`[sign_in]` in its
manifest, phase-4c-plan.md 4.17) is not handed to Chromium and left: this
stays, holding Chromium's DevTools pipe (`--remote-debugging-pipe`, no
port), opens the module's own Connecting… page, asks the daemon for the
session's cookies, which it gets from the account an adult gave and never
shows this program the password of, puts them in the window, takes it to
the site, runs the module's script there, and goes to its start; a
failure is said on the page, which asks to try again. A module that names
a page script (`page_script`, 4.18), as Wikipedia does for its Back and
Forward, is held the same way, and the script runs in every page its
window shows, in a world of its own beside the page's.

Every module's Chromium is walled in by a proxy that answers nothing,
`--proxy-server=127.0.0.1:1`, with a bypass list of the server and the
hosts the module's manifest names: everything else fails. The managed
policy, /etc/chromium/policies/managed/kidux.json, which
kidux-chromium-policy writes from the installed modules, allows the server
and those hosts and nothing else, whatever starts Chromium (D36, D85).
Chromium's profile is the module's own, under its XDG_CONFIG_HOME, which
stays when the module is removed (D89).

After Kidux's own flags come the machine's, from /etc/kidux/chromium-flags,
which the daemon writes from the panel's Advanced settings (D51, D52), so
that a flag there wins over Kidux's for the same thing: what draws cleanly
on this machine's graphics. A line that is not a flag is left out and said.
"""

import json
import os
import shlex
import sys
import time
from urllib.parse import quote, urlencode

from kidux import browser, modules, paths

CHROMIUM = "/usr/bin/chromium"
SERVER = "http://127.0.0.1:8123"


def language(environ) -> str:
    """The child's language, from the session's locale: es_ES.UTF-8 is es."""
    for name in ("LC_ALL", "LC_MESSAGES", "LANG"):
        value = environ.get(name, "")
        if value and value not in ("C", "POSIX") and not value.startswith("C."):
            return value.split(".")[0].split("_")[0] or "en"
    return "en"


def machine_flags(path=None) -> list[str]:
    """The machine's own flags for Chromium, one a line; none when the file
    is not there."""
    try:
        lines = (path or paths.CHROMIUM_FLAGS).read_text().splitlines()
    except OSError:
        return []
    flags = []
    for line in (line.strip() for line in lines):
        if line.startswith("--"):
            flags.append(line)
        elif line:
            print(f"not a flag, left out: {line!r}", file=sys.stderr)
    return flags


def bypass(module: modules.Module) -> str:
    """The proxy's bypass list: the server, at its one port, then each host of
    the module and every name under it. Nothing else on the machine, on
    another local port, is reachable past the wall."""
    server = SERVER.removeprefix("http://")
    return ";".join([server, *(f"{host};*.{host}" for host in module.hosts)])


def settings(environ) -> dict[str, str]:
    """What an adult set in the module for this child (D90), as the
    launcher put it in the environment: KIDUX_SETTING_TYPE_IN=0 is type_in."""
    found = {}
    for name, value in environ.items():
        key = name[len("KIDUX_SETTING_"):].lower() if name.startswith("KIDUX_SETTING_") else ""
        if modules.SETTING_KEY.match(key):
            found[key] = value
    return dict(sorted(found.items()))


def website(module: modules.Module, lang: str, environ=None) -> str:
    """A website's address, with {lang} the child's language and each
    {<key>} of one of the module's settings what an adult gave, encoded for
    an address; empty when nothing was given. A secret never goes into an
    address: it is not in the child's session at all (D90)."""
    found = module.launch["web"].replace("{lang}", lang)
    given = settings(environ or {})
    for setting in module.settings:
        if setting.kind != "secret":
            found = found.replace("{" + setting.key + "}", quote(given.get(setting.key, ""), safe=""))
    return found


def address(module: modules.Module, lang: str, environ=None) -> str:
    """Where Chromium opens: the server's application, with the child's
    language and the module's settings, or the website, with the child's
    language and the settings its address names."""
    if isinstance(module.launch.get("webapp"), str):
        query = urlencode({"lang": lang, **settings(environ or {})})
        return f"{SERVER}/{module.launch['webapp']}/?{query}"
    if module.sign_in is not None:
        # Its own page first, Connecting…, which the module's package puts
        # where kidux-webapps serves it; the window goes on to the site once
        # the child is signed in (sign_in_and_follow).
        return f"{SERVER}/{module.id}/?lang={lang}"
    if modules.web_host(module.launch):
        return website(module, lang, environ)
    raise ValueError(f"module {module.id} is neither a web application nor a website")


def argv(module: modules.Module, environ, flags=()) -> list[str]:
    """Chromium's command line for `module`, the machine's `flags` after
    Kidux's own; ValueError for a module that is not made of web pages."""
    lang = language(environ)
    url = address(module, lang, environ)
    config = environ.get("XDG_CONFIG_HOME") or os.path.join(environ.get("HOME", ""), ".config")
    command = [
        CHROMIUM,
        "--ozone-platform=wayland",
        "--no-first-run",
        "--no-default-browser-check",
        "--disable-session-crashed-bubble",
        "--password-store=basic",
    ]
    if "webapp" in module.launch:
        # WebGL drawn in software where the machine has no graphics Chromium
        # takes, as a virtual machine or an old computer's: Scratch's stage
        # and TurboWarp's need WebGL, and Chromium no longer falls back to
        # SwiftShader by itself. It is "unsafe" for pages from anywhere, so
        # only the server's own applications get it (D85).
        command.append("--enable-unsafe-swiftshader")
    return [
        *command,
        # The wall: a proxy that answers nothing, and only the module's own
        # hosts past it.
        "--proxy-server=127.0.0.1:1",
        f"--proxy-bypass-list={bypass(module)}",
        f"--user-data-dir={config}/chromium",
        f"--lang={lang}",
        f"--app={url}",
        *flags,
    ]


def site(module: modules.Module, lang: str) -> str:
    """The website itself, with {lang} the child's language."""
    return module.launch["web"].replace("{lang}", lang)


#: What the module's Connecting… page is told, by the daemon's error.
FAILURES = {"SignInRefused": "refused", "SignInUnreachable": "unreachable"}

#: The world a module's page script runs in: its own, so that the page's
#: scripts and the module's share the page and none of their names.
WORLD = "kidux"


def told(path: str, lang: str) -> str:
    """A module's script, told the child's language as window.KIDUX.lang."""
    with open(path, encoding="utf-8") as file:
        return f"window.KIDUX = {json.dumps({'lang': lang})};\n{file.read()}"


def add_page_script(pipe, page: str, module: modules.Module, lang: str, now: bool) -> None:
    """Have every page the window shows from now run the module's page
    script; `now`, the page it shows already too."""
    pipe.call("Page.addScriptToEvaluateOnNewDocument", {
        "source": told(module.page_script, lang), "worldName": WORLD,
        "runImmediately": now}, page)


#: How long the module's own page is given to load.
PAGE_SECONDS = 60


def ready(pipe, page: str, seconds: float = PAGE_SECONDS) -> bool:
    """Wait until the module's own page is loaded and offers `window.kidux`:
    on a profile Chromium has never run with, it shows about:blank for a
    while first, and what is said to that is lost."""
    end = time.monotonic() + seconds
    while True:
        found = pipe.call("Runtime.evaluate", {"expression": "typeof window.kidux",
                                               "returnByValue": True}, page)
        if found.get("result", {}).get("value") == "object":
            return True
        if time.monotonic() >= end:
            return False
        time.sleep(0.25)


def sign_in(pipe, page: str, module: modules.Module, lang: str, client) -> None:
    """On the module's Connecting… page, have the daemon sign the child in,
    give the window the session's cookies, take it to the site, run the
    module's script there in the child's language, and go to its start. A
    failure is shown on the page, which asks to try again; no account set
    is said on the page too, which offers to sign in by hand: that takes
    the window to the site and runs the module's script for it there,
    which opens where the site signs one in; without one, the site as it
    is."""
    pipe.call("Runtime.enable", {}, page)
    pipe.call("Runtime.addBinding", {"name": "kiduxTryAgain"}, page)
    if not ready(pipe, page):
        print(f"{module.id}'s own page did not load", file=sys.stderr)

    def show(state: str) -> None:
        pipe.call("Runtime.evaluate", {
            "expression": f"window.kidux && window.kidux.show({json.dumps(state)})"}, page)

    while True:
        show("connecting")
        try:
            cookies = client.sign_in_module(module.id)
            break
        except Exception as error:  # noqa: BLE001 - what it was is all the page needs
            said = str(error)
            if "SignInNotSet" in said:
                show("notset")
            else:
                show(next((state for name, state in FAILURES.items() if name in said),
                          "unreachable"))
                print(f"could not sign in to {module.id}: {said}", file=sys.stderr)
            # The page's button: "manual" to sign in by hand, anything else
            # to try again.
            asked = pipe.event("Runtime.bindingCalled").get("params", {}).get("payload", "")
            if asked == "manual":
                cookies = None
                break
    if module.page_script:
        add_page_script(pipe, page, module, lang, now=False)
    if cookies is None:
        pipe.events.clear()
        pipe.call("Page.navigate", {"url": site(module, lang)}, page)
        if module.sign_in.manual:
            pipe.event("Page.loadEventFired")
            pipe.call("Runtime.evaluate", {"expression": told(module.sign_in.manual, lang),
                                           "awaitPromise": True}, page)
        pipe.call("Runtime.disable", {}, page)
        return
    pipe.call("Storage.setCookies", {"cookies": cookies})
    pipe.events.clear()
    pipe.call("Page.navigate", {"url": site(module, lang)}, page)
    pipe.event("Page.loadEventFired")
    if module.sign_in.script:
        pipe.call("Runtime.evaluate", {"expression": told(module.sign_in.script, lang),
                                       "awaitPromise": True}, page)
    if module.sign_in.start:
        pipe.call("Page.navigate", {"url": module.sign_in.start}, page)
    pipe.call("Runtime.disable", {}, page)


def drive(module: modules.Module, command: list[str], lang: str, client) -> int:
    """Start Chromium holding its pipe, sign the child in or add the page
    script, as the module's manifest says, and keep the pipe, reading what
    Chromium says, until the window is closed; returns Chromium's status."""
    process, pipe = browser.start(command)
    try:
        page = pipe.page()
        pipe.call("Page.enable", {}, page)
        if module.sign_in is not None:
            sign_in(pipe, page, module, lang, client)
        elif module.page_script:
            add_page_script(pipe, page, module, lang, now=True)
        pipe.drain()
    except browser.BrowserGone:
        pass
    return process.wait()


def main(args: list[str]) -> int:
    printing = args[1:2] == ["--print"]
    if len(args) != (3 if printing else 2):
        print("usage: kidux-webapp [--print] <module id>", file=sys.stderr)
        return 2
    module = modules.read(args[-1]) if modules.ID.match(args[-1] or "") else None
    if module is None:
        print(f"not an installed module: {args[-1]!r}", file=sys.stderr)
        return 2
    try:
        command = argv(module, os.environ, machine_flags())
    except ValueError as error:
        print(error, file=sys.stderr)
        return 2
    if printing:
        print(shlex.join(command))
        return 0
    if module.sign_in is not None or module.page_script:
        from kidux import client
        return drive(module, command, language(os.environ), client.Client())
    os.execv(command[0], command)
    return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv))
