#!/usr/bin/env python3
"""wikijs-config.py — idempotenter Bootstrap + Konfiguration von Wiki.js.

Deploybares Äquivalent des manuellen Setup-Assistenten (ADR-0014, #0048/#0049).
Läuft als k8s-Job, nachdem Wiki.js oben ist; treibt die Admin-GraphQL-API. Nur
stdlib. Alle Verträge wurden am 2026-08-12 live gegen die laufende Instanz geprüft.

Ablauf:
  warten -> (falls Setup) /finalize mit SOPS-Admin -> warten auf Normalmodus
  -> login -> Gruppen + Seitenregeln (Abschottung) -> OIDC-Strategy setzen
  -> local auf der Login-Seite ausblenden (hideLocal).
Idempotent: jeder Schritt prüft erst den Ist-Zustand.

Variante B ("kein manuelles Login"): local wird NICHT deaktiviert, sondern nur per
Wiki.js-eigenem hideLocal aus der Login-Seite ausgeblendet. Grund: (a) local ist
nicht löschbar und Wiki.js braucht eine Formular-Strategie, sonst rendert die
Login-Seite leer; (b) der Job selbst loggt sich über local ein; (c) Break-Glass
bleibt über /login?all erreichbar.
"""
from __future__ import annotations

import json
import os
import sys
import time
import urllib.request
import urllib.error

WIKI = os.environ.get("WIKI_URL", "http://wikijs:3000")
ADMIN_EMAIL = os.environ["WIKI_ADMIN_EMAIL"]
ADMIN_PW = os.environ["WIKI_ADMIN_PASSWORD"]
SITE_URL = os.environ.get("WIKI_SITE_URL", "https://wiki.axion1337.chat")
CLIENT_ID = os.environ["OIDC_CLIENT_ID"]
CLIENT_SECRET = os.environ["OIDC_CLIENT_SECRET"]
AUTH = os.environ.get("AUTHENTIK_URL", "https://auth.axion1337.chat")
APP_SLUG = os.environ.get("OIDC_APP_SLUG", "wiki-js")
# Der Strategy-Key bestimmt die Callback-URL (/login/<key>/callback) und MUSS mit
# der redirect_uri im Authentik-Provider übereinstimmen.
STRATEGY_KEY = os.environ.get("OIDC_STRATEGY_KEY", "d3e7d0e4-adff-4421-b016-7758c44fd697")

ADMIN_PERMS = [
    "manage:system", "manage:users", "manage:groups", "manage:navigation",
    "manage:pages", "write:pages", "read:pages", "manage:assets", "write:assets",
    "read:assets", "manage:comments", "write:comments", "read:comments",
    "read:history", "read:source", "write:styles", "write:scripts",
    "manage:theme", "manage:api",
]
READER_PERMS = ["read:pages", "read:assets", "read:comments"]

# Akzentfarbe fürs App-/Inhalts-UI (#0050): blau wie das alte Docusaurus.
# Gilt NICHT für die Login-Seite — die rendert Wiki.js ohne Custom-CSS.
ACCENT_CSS = (
    ".v-application a{color:#2b6cb0}"
    ".v-application .primary--text{color:#2b6cb0!important;caret-color:#2b6cb0!important}"
    ".v-application .primary{background-color:#2b6cb0!important;border-color:#2b6cb0!important}"
    ".theme--dark.v-application a{color:#63b3ed}"
    ".theme--dark.v-application .primary--text{color:#63b3ed!important;caret-color:#63b3ed!important}"
    ".theme--dark.v-application .primary{background-color:#63b3ed!important;border-color:#63b3ed!important}"
)


def log(msg: str) -> None:
    print(f"[wikijs-config] {msg}", flush=True)


def req(path: str, data=None, headers=None, method=None):
    body = json.dumps(data).encode() if data is not None else None
    r = urllib.request.Request(WIKI + path, data=body, method=method or ("POST" if body else "GET"))
    r.add_header("Content-Type", "application/json")
    for k, v in (headers or {}).items():
        r.add_header(k, v)
    with urllib.request.urlopen(r, timeout=30) as resp:
        raw = resp.read().decode()
        return resp.status, (json.loads(raw) if raw else {})


def gql(query: str, jwt: str | None = None, variables: dict | None = None):
    h = {"Authorization": f"Bearer {jwt}"} if jwt else {}
    _, d = req("/graphql", {"query": query, "variables": variables or {}}, h)
    if d.get("errors"):
        raise RuntimeError(json.dumps(d["errors"]))
    return d["data"]


def wait_reachable():
    # Startseite ist HTML, nicht JSON -> nicht über req()/json.loads prüfen.
    for _ in range(60):
        try:
            with urllib.request.urlopen(WIKI + "/", timeout=10) as r:
                if r.status < 500:
                    return
        except urllib.error.HTTPError as e:
            if e.code < 500:
                return
        except Exception:
            pass
        time.sleep(3)
    sys.exit("Wiki.js nicht erreichbar")


def in_setup_mode() -> bool:
    # Normalmodus: die (öffentliche) login-Mutation ist beantwortbar. Setup-Modus:
    # das Haupt-Schema ist nicht geladen -> die Mutation wirft.
    try:
        gql('mutation{authentication{login(username:"probe@invalid",password:"x",'
            'strategy:"local"){responseResult{succeeded}}}}')
        return False
    except Exception:
        return True


def finalize():
    log("Setup-Modus -> finalize mit SOPS-Admin")
    _, d = req("/finalize", {
        "adminEmail": ADMIN_EMAIL,
        "adminPassword": ADMIN_PW,
        "adminPasswordConfirm": ADMIN_PW,
        "siteUrl": SITE_URL,
        "telemetry": False,
    })
    if not d.get("ok"):
        sys.exit(f"finalize fehlgeschlagen: {d}")
    log("finalize ok, warte auf Normalmodus (Neustart)")
    time.sleep(10)
    for _ in range(40):
        if not in_setup_mode():
            return
        time.sleep(3)
    sys.exit("Normalmodus nicht erreicht")


def login():
    # Gibt JWT zurück. local bleibt bewusst aktiviert (Break-Glass + der Job loggt sich
    # damit ein) — None nur, falls local doch mal deaktiviert wurde, dann kann der Job
    # nichts tun.
    d = gql(
        'mutation($u:String!,$p:String!){authentication{login(username:$u,password:$p,'
        'strategy:"local"){responseResult{succeeded message} jwt}}}',
        variables={"u": ADMIN_EMAIL, "p": ADMIN_PW},
    )["authentication"]["login"]
    return d["jwt"] if d["responseResult"]["succeeded"] else None


def _set_strategies(jwt: str, oidc_config: list):
    strategies = [
        {  # local bleibt aktiviert: der Konfig-Job loggt sich damit ein und es ist der
           # Break-Glass-Zugang (/login?all). Auf der Login-Seite wird es per hideLocal
           # ausgeblendet (siehe set_hide_local) — kein manuelles Login im Alltag.
            "key": "local", "strategyKey": "local", "displayName": "Local",
            "order": 0, "isEnabled": True, "selfRegistration": False,
            "domainWhitelist": [], "autoEnrollGroups": [], "config": [],
        },
        {
            "key": STRATEGY_KEY, "strategyKey": "oidc", "displayName": "Authentik",
            "order": 1, "isEnabled": True, "selfRegistration": True,
            "domainWhitelist": [], "autoEnrollGroups": [], "config": oidc_config,
        },
    ]
    r = gql(
        'mutation($s:[AuthenticationStrategyInput]!){authentication{updateStrategies(strategies:$s)'
        '{responseResult{succeeded message}}}}',
        jwt, {"s": strategies},
    )["authentication"]["updateStrategies"]["responseResult"]
    if not r["succeeded"]:
        sys.exit(f"updateStrategies fehlgeschlagen: {r['message']}")


def ensure_oidc(jwt: str):
    # Quellcode-verifiziert: Wiki.js liest jeden Config-Wert via
    # _.get(JSON.parse(value), 'v', null) -> der Wert MUSS als {"v": …} kodiert sein.
    cfg = {
        "clientId": CLIENT_ID, "clientSecret": CLIENT_SECRET,
        "authorizationURL": f"{AUTH}/application/o/authorize/",
        "tokenURL": f"{AUTH}/application/o/token/",
        "userInfoURL": f"{AUTH}/application/o/userinfo/",
        "issuer": f"{AUTH}/application/o/{APP_SLUG}/",
        "logoutURL": f"{AUTH}/application/o/{APP_SLUG}/end-session/",
        "emailClaim": "email", "displayNameClaim": "name",
        "groupsClaim": "groups", "mapGroups": True,
    }
    oidc_config = [{"key": k, "value": json.dumps({"v": v})} for k, v in cfg.items()]
    _set_strategies(jwt, oidc_config)
    log("OIDC-Strategy gesetzt (local bleibt aktiv als Break-Glass)")


def set_hide_local(jwt: str):
    # Variante B ohne local zu deaktivieren: Wiki.js' eingebautes hideLocal entfernt den
    # local-Provider aus der Login-Seite (nur noch "Login with Authentik" sichtbar).
    # Break-Glass bleibt über /login?all. authHideLocal ist ein Site-Config-Feld.
    r = gql('mutation{site{updateConfig(authHideLocal:true){responseResult{succeeded message}}}}',
            jwt)["site"]["updateConfig"]["responseResult"]
    if not r["succeeded"]:
        sys.exit(f"authHideLocal setzen fehlgeschlagen: {r['message']}")
    log("Login-Seite: local ausgeblendet (hideLocal=true; Break-Glass via /login?all)")


def ensure_theming(jwt: str):
    # Branding kommt aus gemounteten statischen Dateien (/_assets/img/branding/, siehe
    # wikijs.yaml + platform-branding ConfigMap): öffentlich ausgeliefert, kein read:assets
    # für Guests, keine externe URL. Dark als Default (#0050).
    logo = "/_assets/img/branding/logo.png"
    bg = "/_assets/img/bg/alpenglow.jpg"
    tc = gql('{theming{config{theme iconset darkMode tocPosition injectCSS injectHead injectBody}}}',
             jwt)["theming"]["config"]
    gql('mutation($t:String!,$i:String!,$d:Boolean!,$tp:String,$c:String,$h:String,$b:String){'
        'theming{setConfig(theme:$t,iconset:$i,darkMode:$d,tocPosition:$tp,injectCSS:$c,injectHead:$h,'
        'injectBody:$b){responseResult{succeeded message}}}}',
        jwt, {"t": tc["theme"], "i": tc["iconset"], "d": True, "tp": "right",
              "c": ACCENT_CSS, "h": tc["injectHead"], "b": tc["injectBody"]})
    # Site-Titel (oben links / Browser-Tab) aus der Deployment-Variable — nicht mehr
    # das hartkodierte "Wiki.js". Reproduzierbar über WIKI_SITE_TITLE.
    title = os.environ.get("WIKI_SITE_TITLE", "Wiki").strip() or "Wiki"
    gql('mutation($bg:String!,$logo:String!,$ttl:String!){site{updateConfig('
        'authLoginBgUrl:$bg,logoUrl:$logo,title:$ttl){responseResult{succeeded message}}}}',
        jwt, {"bg": bg, "logo": logo, "ttl": title})
    log(f"Theming gesetzt (Titel '{title}', Dark-Default, Logo + Hintergrund aus Mounts)")


def ensure_git_storage(jwt: str):
    # Git-Storage: Wiki.js-Inhalt nach Gitea syncen (ADR-0015). Der Cluster erreicht
    # git.lab nicht -> Gitea ist das Ziel, ein CI-Job kanonisiert Gitea->git.lab.
    # Nur der PAT (GIT_STORAGE_TOKEN) ist ein Secret; Repo/User/Branch sind Klartext.
    # Fehlt der Token, wird der Schritt übersprungen -> der Job bleibt lauffähig, bevor
    # der Storage eingerichtet ist. Config-Werte brauchen die {"v":…}-Kodierung.
    token = os.environ.get("GIT_STORAGE_TOKEN", "").strip()
    repo = os.environ.get("GIT_STORAGE_REPO", "").strip()
    user = os.environ.get("GIT_STORAGE_USER", "").strip()
    if not (token and repo and user):
        log("Git-Storage übersprungen (GIT_STORAGE_TOKEN/REPO/USER nicht gesetzt)")
        return
    cfg = {
        "authType": "basic", "repoUrl": repo,
        "branch": os.environ.get("GIT_STORAGE_BRANCH", "main"),
        "basicUsername": user, "basicPassword": token, "verifySSL": True,
        "defaultEmail": os.environ.get("GIT_STORAGE_EMAIL", "wiki@localhost"),
        "defaultName": os.environ.get("GIT_STORAGE_NAME", "Wiki"),
        "sshPrivateKeyMode": "path", "localRepoPath": "./data/repo",
        "alwaysNamespace": False, "gitBinaryPath": "",
    }
    config = [{"key": k, "value": json.dumps({"v": v})} for k, v in cfg.items()]
    target = {"isEnabled": True, "key": "git", "mode": "sync",
              "syncInterval": "PT5M", "config": config}
    r = gql('mutation($t:[StorageTargetInput]!){storage{updateTargets(targets:$t)'
            '{responseResult{succeeded message}}}}',
            jwt, {"t": [target]})["storage"]["updateTargets"]["responseResult"]
    if not r["succeeded"]:
        sys.exit(f"Git-Storage fehlgeschlagen: {r['message']}")
    log(f"Git-Storage aktiviert (Gitea, sync): {repo}")


def group_id(jwt: str, name: str):
    for g in gql("{groups{list{id name}}}", jwt)["groups"]["list"]:
        if g["name"] == name:
            return g["id"]
    return None


def ensure_group(jwt: str, name: str, perms: list, rules: list):
    gid = group_id(jwt, name)
    if gid is None:
        gql('mutation($n:String!){groups{create(name:$n){responseResult{succeeded message}}}}',
            jwt, {"n": name})
        gid = group_id(jwt, name)
        log(f"Gruppe '{name}' angelegt (id {gid})")
    gql(
        'mutation($id:Int!,$n:String!,$p:[String]!,$r:[PageRuleInput]!){groups{update('
        'id:$id,name:$n,redirectOnLogin:"/",permissions:$p,pageRules:$r){responseResult{succeeded message}}}}',
        jwt, {"id": gid, "n": name, "p": perms, "r": rules},
    )
    log(f"Gruppe '{name}' -> Rechte+Seitenregeln gesetzt")


def rule(rid: str, deny: bool, perms: list, path: str, match: str = "START"):
    return {"id": rid, "deny": deny, "match": match, "roles": perms,
            "path": path, "locales": []}


def main():
    log(f"Ziel: {WIKI}")
    wait_reachable()
    if in_setup_mode():
        finalize()
    jwt = login()
    if jwt is None:
        log("lokaler Login nicht möglich (local wurde deaktiviert) — kann nicht konfigurieren, Ende.")
        return
    log("eingeloggt")
    # Erst Gruppen (Schreibvorgänge), DANN OIDC — ensure_oidc deaktiviert zuletzt local.
    # authentik Admins: alles lesen+schreiben. wiki-anwender: nur /anwender lesen.
    ensure_group(jwt, "authentik Admins", ADMIN_PERMS, [rule("adm", False, ["read:pages", "write:pages", "manage:pages"], "")])
    # wiki-anwender: nur den Anwender-Bereich lesen + die Startseite (sonst wäre "/"
    # für sie gesperrt). betrieb/* matcht keine Regel -> Default-Deny (Abschottung).
    ensure_group(jwt, "wiki-anwender", READER_PERMS, [
        rule("anw", False, READER_PERMS, "anwender"),
        rule("anwhome", False, READER_PERMS, "home", match="EXACT"),
    ])
    # Guests (id 2) alle Rechte entziehen — Login-Pflicht, keine öffentliche Sicht.
    gql('mutation{groups{update(id:2,name:"Guests",redirectOnLogin:"/",permissions:[],pageRules:[]){responseResult{succeeded}}}}', jwt)
    ensure_oidc(jwt)
    set_hide_local(jwt)
    ensure_theming(jwt)
    ensure_git_storage(jwt)
    log("fertig — Wiki.js konfiguriert")


if __name__ == "__main__":
    main()
