Source: Router/orgaSettings/registryTypes.js

/**
 * App-Registry — Datenmodell-Konstanten und ID-Helfer
 *
 * Das Registry legt Apps, Domains und Assets als Objekte in `ObjectBase` ab und
 * verbindet sie über `Links`. Diese Datei hält die Literale und die Regeln für
 * die UID-Behandlung an **einer** Stelle — vorher lagen sie als String-Literale
 * über Service und Router verstreut, was bei einem Tippfehler erst im
 * fehlgeschlagenen INSERT auffällt (MariaDB ist bei `enum` strikt).
 *
 * ## UID-Format — die eine Regel, an der alles hängt
 *
 * In dieser Datenbank existieren **zwei** UUID-Darstellungen nebeneinander, und
 * sie sind nicht austauschbar:
 *
 * | Form | Länge | Erzeuger / Leser |
 * |---|---|---|
 * | `UUID-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | 41 | `U_UUID2BIN()` (SQL), `UUID2hex()` / `HEX2uuid()` (JS) |
 * | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | 36 | `UUID2BIN()` / `BIN2UUID()` (SQL) |
 *
 * Die JS-Seite von `@commtool/sql-query` und `U_UUID2BIN()` benutzen **dieselbe**
 * Form (41 Zeichen, mit Präfix) — das ist der Grund, warum `cast: ['UUID']` beim
 * Lesen und `U_UUID2BIN(?)` beim Schreiben zusammenpassen. Die rohe 36-Zeichen-Form
 * gehört zu `UUID2BIN`/`BIN2UUID` und wird hier **nicht** verwendet.
 *
 * ## UIDs werden nicht in JavaScript erzeugt
 *
 * Der naheliegende Weg — `crypto.randomUUID()` im Service — ist aus zwei Gründen
 * falsch: `randomUUID()` liefert eine **v4** (zufällig, nicht sortierbar), und ihr
 * Hex-String passt in keines der beiden SQL-Formate (beide erwarten die
 * Bindestriche). Erzeugt wird deshalb **in der Datenbank** über `SELECT UIDV1()`
 * (`UIDV1() = UUID2BIN(UUID())`, echte v1, sortierbar, korrekte Byte-Reihenfolge).
 */

// ── ObjectBase.Type ───────────────────────────────────────────────────────────

/** Eine App (Auslieferungseinheit). `UIDBelongsTo` → Organisation. */
export const OBJ_TYPE_APP = 'app';

/** Eine Domain/Host-Zuordnung. `UIDBelongsTo` → Organisation. */
export const OBJ_TYPE_APP_DOMAIN = 'appDomain';

/** Ein App-Artefakt (Icon, Favicon, …). `UIDBelongsTo` → App. */
export const OBJ_TYPE_APP_ASSET = 'appAsset';

// ── Links.Type ────────────────────────────────────────────────────────────────

/**
 * Verbindet eine **App** (als Link-`UID`) mit einer **Domain** (`UIDTarget`).
 *
 * Richtung ist bewusst „von der App weg" — und damit **gleich** wie bei
 * `appAsset`. Ein gemischtes Modell (App→Asset, Domain→App) hätte jede Abfrage
 * zu einer Frage der Erinnerung gemacht. Der Preis ist eine Umkehrung beim
 * Auflösen: „welche App hängt an diesem Host" muss über den `UIDTarget` der
 * Domain suchen, nicht über ihren `UID`.
 */
export const LINK_TYPE_APP_DOMAIN = 'appDomain';

/** Verbindet eine **App** (als Link-`UID`) mit einem **Asset** (`UIDTarget`). */
export const LINK_TYPE_APP_ASSET = 'appAsset';

// ── Asset-Typen (`Data.assetType`) ───────────────────────────────────────────

export const ASSET_TYPE_ICON = 'icon';
export const ASSET_TYPE_FAVICON = 'favicon';
export const ASSET_TYPE_LOGO = 'logo';
export const ASSET_TYPE_MANIFEST = 'manifest';

// ── Domains: `type` und `status` sind zwei verschiedene Dinge ────────────────

/**
 * Eine Domain trägt **zwei** unabhängige Aussagen. Sie lagen früher in einem
 * einzigen Feld: in Vault stand `{"kpe.de":"verified","ct":"internal"}` — eine
 * Domänenart neben einem Prüfstand. Das ging nicht auf, denn es sind zwei
 * Achsen:
 *
 * | Achse | Werte | Frage |
 * |---|---|---|
 * | `type` | `internal` \| `external` | Wo liegt der Host? |
 * | `status` | `pending` \| `verified` | Ist er nachgewiesen? |
 *
 * In einem Feld führte das zu einem stillen Widerspruch: die Oberfläche bot
 * `internal`/`external` an, der Bestand enthielt `verified`, und
 * `validateDomains` wies damit **den eigenen Bestand** als ungültig zurück.
 * Getrennt ist jede Achse für sich prüfbar — und der DNS-Nachweis hat einen
 * Platz, ohne ein dritter „Typ" zu werden.
 */
export const DOMAIN_TYPE_INTERNAL = 'internal';
export const DOMAIN_TYPE_EXTERNAL = 'external';

export const DOMAIN_STATUS_PENDING = 'pending';
export const DOMAIN_STATUS_VERIFIED = 'verified';

/**
 * Bringt einen Domain-Eintrag auf die Form `{ type, status }`.
 *
 * Nimmt **beide** Formen an, denn der Altbestand in Vault ist eine
 * Zeichenkette (`{"kpe.de":"verified"}`). Die Zuordnung ist eindeutig, weil die
 * alten Werte sich gegenseitig ausschließen:
 *
 * | Alt | `type` | `status` | Begründung |
 * |---|---|---|---|
 * | `internal` | internal | verified | Ein System-Präfix liegt im eigenen Namensraum — nachzuweisen gibt es nichts |
 * | `external` | external | pending | Eine Kunden-Domain bleibt offen, bis der DNS-Eintrag zeigt |
 * | `verified` | external | verified | Nur Kunden-Domains wurden je von Hand verifiziert — sie tragen einen Punkt |
 *
 * @param {unknown} value - Zeichenkette (Altbestand) oder `{type, status}`
 * @returns {{type: string, status: string}|null} `null`, wenn nicht deutbar
 */
export const normalizeDomainEntry = (value) => {
    if (typeof value === 'string') {
        const raw = value.trim().toLowerCase();
        if (raw === DOMAIN_TYPE_INTERNAL) return { type: DOMAIN_TYPE_INTERNAL, status: DOMAIN_STATUS_VERIFIED };
        if (raw === DOMAIN_TYPE_EXTERNAL) return { type: DOMAIN_TYPE_EXTERNAL, status: DOMAIN_STATUS_PENDING };
        if (raw === DOMAIN_STATUS_VERIFIED) return { type: DOMAIN_TYPE_EXTERNAL, status: DOMAIN_STATUS_VERIFIED };
        return null;
    }
    if (value && typeof value === 'object') {
        const entry = /** @type {{type?: unknown, status?: unknown}} */ (value);
        const rawType = typeof entry.type === 'string' ? entry.type.trim().toLowerCase() : '';
        // Auch das `type`-Feld kann einen Altbestand-Status tragen: ältere Zeilen
        // wurden als `{ type: 'verified' }` geschrieben. Ohne diesen Zweig läse
        // man daraus „external + pending" und verlöre den Nachweis.
        if (rawType === DOMAIN_STATUS_VERIFIED) return { type: DOMAIN_TYPE_EXTERNAL, status: DOMAIN_STATUS_VERIFIED };
        const type = rawType === DOMAIN_TYPE_INTERNAL ? DOMAIN_TYPE_INTERNAL : DOMAIN_TYPE_EXTERNAL;
        const status = entry.status === DOMAIN_STATUS_VERIFIED ? DOMAIN_STATUS_VERIFIED
            : entry.status === DOMAIN_STATUS_PENDING ? DOMAIN_STATUS_PENDING
                // Ohne Angabe entscheidet die Art: intern ist per Definition gültig,
                // eine Kunden-Domain ist es erst nach dem Nachweis.
                : (type === DOMAIN_TYPE_INTERNAL ? DOMAIN_STATUS_VERIFIED : DOMAIN_STATUS_PENDING);
        return { type, status };
    }
    return null;
};

/**
 * Bringt eine ganze Domain-Map auf `{ domain: {type, status} }`.
 * @param {unknown} map - Map in Alt- oder Neuform
 * @returns {Record<string, {type: string, status: string}>}
 */
export const normalizeDomainMap = (map) => {
    const result = /** @type {Record<string, {type: string, status: string}>} */ ({});
    if (!map || typeof map !== 'object') return result;
    for (const [domain, value] of Object.entries(map)) {
        const entry = normalizeDomainEntry(value);
        if (entry) result[domain] = entry;
    }
    return result;
};

/**
 * Gegenstück zu {@link normalizeDomainEntry} für den **Vault-Schreibpfad**.
 *
 * Solange `REGISTRY_READ_MODE` nicht auf `db` steht, schreibt `saveOrgDomains`
 * weiter nach Vault — und dort lesen `shared-auth` (`organizationDomains.js`)
 * und die übrigen Konsumenten eine **Zeichenkette**: sie vergleichen mit
 * `state === 'internal'` bzw. `state === 'verified'`. Ein Objekt dort würde sie
 * brechen. Die Rückübersetzung ist verlustfrei, weil die drei Altwerte die drei
 * sinnvollen Kombinationen genau abdecken.
 *
 * @param {{type: string, status: string}} entry
 * @returns {'internal'|'external'|'verified'}
 */
export const toLegacyDomainValue = (entry) => {
    if (entry.type === DOMAIN_TYPE_INTERNAL) return DOMAIN_TYPE_INTERNAL;
    return entry.status === DOMAIN_STATUS_VERIFIED ? DOMAIN_STATUS_VERIFIED : DOMAIN_TYPE_EXTERNAL;
};

/**
 * Normalisiert einen Domain-Namen: Kleinschreibung, ohne führenden `*.`/`.` und
 * ohne abschließenden Punkt. `*.Kpe.de.` → `kpe.de`.
 * @param {unknown} value
 * @returns {string|null}
 */
export const normalizeDomainValue = (value) => {
    if (typeof value !== 'string') return null;
    const raw = value.trim().toLowerCase()
        .replace(/^\*\./, '')
        .replace(/^\./, '')
        .replace(/\.$/, '');
    return raw || null;
};

/** Basis-Domain, aus der interne Hosts gebildet werden (`APP_BASE_DOMAIN`). */
export const APP_BASE_DOMAIN_DEFAULT = 'commtool.org';

/**
 * Baut den Host, unter dem eine App erreichbar ist.
 *
 * Der **Punkt entscheidet**, und das ist keine eigene Erfindung: dieselbe Regel
 * steht in `shared-auth` (`organizationDomains.js`) und im Portal-Bot
 * (`portal.controller.js` → `buildAppUrl`). Sie wird hier nur nachgebildet,
 * damit Routing und Anmeldung nicht auseinanderlaufen.
 *
 * | `domain` der App | Ergebnis |
 * |---|---|
 * | `db.app.kpe.de` (mit Punkt) | `db.app.kpe.de` — eine Kunden-Domain gilt, wie sie steht |
 * | `sjm` (ohne Punkt) | `sjm.admin.app.commtool.org` — Präfix, App-Kennung, Basis |
 *
 * @param {string|null} appId
 * @param {unknown} domain
 * @param {string} [baseDomain] - aus `APP_BASE_DOMAIN`
 * @returns {string|null}
 */
export const appHostFor = (appId, domain, baseDomain = APP_BASE_DOMAIN_DEFAULT) => {
    const value = normalizeDomainValue(domain);
    if (!appId || !value) return null;
    return value.includes('.') ? value : `${value}.${appId}.${baseDomain}`;
};

// ── Domain-Regeln ─────────────────────────────────────────────────────────────

/**
 * Präfixe, die eine Organisation nicht belegen darf.
 *
 * Sie liegen im System-Namensraum: `admin`, `api`, `auth` … würden mit
 * Infrastruktur-Hosts verwechselt, die von der Plattform selbst vergeben
 * werden. Eine Organisation, die `admin` beansprucht, bekäme
 * `admin.{app}.commtool.org` — und damit einen Host, der wie die
 * Administrationsumgebung aussieht.
 */
export const RESERVED_PREFIXES = new Set([
    'admin', 'api', 'auth', 'vault', 'www', 'mail', 'smtp', 'ns', 'ns1', 'ns2',
    'ftp', 'ssh', 'vpn', 'git', 'registry', 'cdn', 'app', 'dev', 'test',
    'staging', 'prod', 'support', 'help', 'status', 'monitor', 'ops',
]);

/** intern: Kleinbuchstaben, Ziffern, Bindestriche — kein führender/letzter Bindestrich */
const INTERNAL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;

/** extern: mindestens zwei Labels, gültige TLD */
const EXTERNAL_RE = /^([a-z0-9][a-z0-9-]{0,61}[a-z0-9]\.)+[a-z]{2,}$/;

/**
 * Prüft einen einzelnen Domain-Eintrag gegen die Regeln seiner Art.
 *
 * Gibt eine Meldung für **Menschen** zurück, keinen Code: sie landet direkt in
 * der Fehlerliste der Oberfläche.
 *
 * @param {string} domain - bereits normalisiert
 * @param {unknown} value - Altform (Zeichenkette) oder `{ type, status }`
 * @returns {string|null} `null` = gültig
 */
export const validateDomainEntry = (domain, value) => {
    const rawType = value && typeof value === 'object' ? value.type : value;
    const rawStatus = value && typeof value === 'object' ? value.status : undefined;

    // Erst die Art, dann der Status: eine unbekannte Art macht die Statusprüfung
    // sinnlos, und die Meldung wäre dann verwirrend.
    if (rawType !== DOMAIN_TYPE_INTERNAL && rawType !== DOMAIN_TYPE_EXTERNAL && rawType !== DOMAIN_STATUS_VERIFIED) {
        return `Unknown domain type "${rawType}". Use "internal" or "external".`;
    }
    if (rawStatus !== undefined && rawStatus !== DOMAIN_STATUS_PENDING && rawStatus !== DOMAIN_STATUS_VERIFIED) {
        return `Unknown domain status "${rawStatus}". Use "pending" or "verified".`;
    }

    const entry = normalizeDomainEntry(value);
    if (!entry) return `Unknown domain type "${rawType}". Use "internal" or "external".`;

    if (entry.type === DOMAIN_TYPE_INTERNAL) {
        if (!INTERNAL_RE.test(domain))
            return 'Only lowercase letters, digits and hyphens allowed; may not start or end with a hyphen.';
        if (RESERVED_PREFIXES.has(domain))
            return `"${domain}" is a reserved system name.`;
        return null;
    }

    if (!EXTERNAL_RE.test(domain)) return 'Not a valid domain name (e.g. myclub.com).';
    return null;
};

/**
 * Sucht einen Konflikt zwischen einer Domain und dem Bestand **anderer**
 * Organisationen.
 *
 * Verglichen wird nur die Achse `type`. Ob eine Domain verifiziert ist, ändert
 * nichts daran, wem sie gehört — würde der Status mitgezählt, ließe sich
 * dieselbe Domain zweimal vergeben, solange eine Seite den Nachweis noch nicht
 * erbracht hat.
 *
 * Zwei Regeln:
 *  - gleiche Art + gleicher Name → direkter Konflikt
 *  - intern `foo` ↔ extern `foo.*` → Präfix-Konflikt (dieselbe Wurzel)
 *
 * @param {string} domain
 * @param {unknown} value
 * @param {Record<string, Record<string, unknown>>} allOrgDomains
 * @param {string} currentOrgId
 * @returns {string|null} die UID der kollidierenden Organisation, oder `null`
 */
export const findDomainConflict = (domain, value, allOrgDomains, currentOrgId) => {
    const type = normalizeDomainEntry(value)?.type;
    if (!type) return null;

    for (const [orgId, domains] of Object.entries(allOrgDomains ?? {})) {
        if (orgId === currentOrgId) continue;
        for (const [existingDomain, existingValue] of Object.entries(domains ?? {})) {
            const existingType = normalizeDomainEntry(existingValue)?.type;
            if (!existingType) continue;

            if (type === existingType && domain === existingDomain) return orgId;

            if (type === DOMAIN_TYPE_INTERNAL && existingType === DOMAIN_TYPE_EXTERNAL) {
                if (existingDomain.split('.')[0] === domain) return orgId;
            }
            if (type === DOMAIN_TYPE_EXTERNAL && existingType === DOMAIN_TYPE_INTERNAL) {
                if (domain.split('.')[0] === existingDomain) return orgId;
            }
        }
    }
    return null;
};

// ── Backend-Zweige ────────────────────────────────────────────────────────────

/**
 * Ein Backend-Zweig heißt wie er heißt: ein kurzer Name für **eine** Adressierung
 * des Backends (`test`, `prod`, `canary`).
 *
 * Bewusst **kein `enum`**: ein neuer Zweig darf keine Schemaänderung und kein
 * Deploy sein. Die Form wird deshalb beim Schreiben geprüft — dieselbe Regel wie
 * bei einem internen Domain-Präfix, denn ein Zweig ist ein Namensraum, kein Satz.
 */
const BRANCH_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;

/**
 * Normalisiert einen Zweig-Namen: Kleinschreibung, getrimmt.
 * @param {unknown} value
 * @returns {string|null}
 */
export const normalizeBranch = (value) => {
    if (typeof value !== 'string') return null;
    const raw = value.trim().toLowerCase();
    return raw || null;
};

/**
 * @param {unknown} value
 * @returns {boolean} `true`, wenn der Zweig-Name zulässig ist
 */
export const isValidBranch = (value) => {
    const branch = normalizeBranch(value);
    return branch !== null && BRANCH_RE.test(branch);
};

/**
 * Schlüssel, die **niemals** in einem Zweig stehen dürfen.
 *
 * Die Werte eines Zweigs landen über `orgContext` ungefiltert in `window.env` —
 * `createEnvEndpoint` wendet seine Secret-Filterung
 * (`extractSafeFrontendConfig`) nur auf `appSecrets` an, nicht auf den
 * Overlay. Für die Backend-Adressierung ist das beabsichtigt (ein Overlay ist
 * keine Vault-Quelle), es verschiebt die Grenze aber: die Prüfung muss hier
 * passieren.
 *
 * Deshalb dieselben Muster wie im Filter dort (`secret`, `password`, `token`,
 * `key`, `cert`) — doppelt gehalten, weil es hier nicht um
 * „nicht versehentlich mitliefern" geht, sondern um „gar nicht erst annehmen".
 * Keiner der real gelesenen Schlüssel (`api`, `baseUrl`, `apiBase`, `loginApi`,
 * `otherApis`, `basePath`) trifft eines dieser Muster.
 */
const FORBIDDEN_BACKEND_KEY = /secret|password|token|key|cert/i;

/**
 * Prüft ein flaches env.js-Objekt — `environment` **oder** einen Zweig.
 *
 * Beides ist dieselbe Form und geht denselben Weg: der Broker mischt
 * `{...environment, ...branch}` und schreibt das Ergebnis nach `window.env`.
 * Deshalb gibt es hier **keine** Unterscheidung zwischen „Backend-Schlüsseln"
 * und anderen — ein Zweig darf jeden Schlüssel überschreiben, auch `title` oder
 * `icon`.
 *
 * Ein Schema gibt es bewusst nicht: das Frontend liest je App unterschiedliche
 * Schlüssel (`members-app` `api`/`baseUrl`/`apiBase`, `admin` zusätzlich
 * `loginApi`/`otherApis`), und `window.env` ist genau die Schnittstelle, die es
 * erwartet. Ein Schema würde jede neue Frontend-App zu einer Registry-Änderung
 * machen. Geprüft wird nur, was für **alle** gilt:
 *
 *  - ein nicht-leeres, JSON-sicheres Objekt (mit `allowEmpty` darf eine
 *    Zweig-Ebene auch leer sein — „erbt alles von der App-Ebene"),
 *  - keine Secret-Schlüssel (die Werte landen ungefiltert im Browser),
 *  - die **bekannten** Meta-Schlüssel in ihrer Form, wenn sie vorkommen
 *    (`title`, `description`, `icon`, `roles`, `domain`) — sonst wäre ein
 *    Tippfehler dort erst im Frontend sichtbar.
 *
 * @param {unknown} obj
 * @param {string} [label] - für die Meldung (`Environment`, `Branch`)
 * @param {{allowEmpty?: boolean}} [options]
 * @returns {string|null} `null` = gültig
 */
export const validateEnvironmentObject = (obj, label = 'Environment', { allowEmpty = false } = {}) => {
    if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
        return `${label} must be an object of env.js keys (e.g. { "api": "member", "baseUrl": "api.commtool.org/api" }).`;
    }

    const entries = Object.entries(obj);
    if (entries.length === 0) return allowEmpty ? null : `${label} must not be empty.`;

    for (const [key, value] of entries) {
        if (!key.trim()) return `${label} must not contain an empty key.`;
        if (FORBIDDEN_BACKEND_KEY.test(key)) {
            return `${label} key "${key}" looks like a secret. ${label} is delivered to the browser — secrets belong in Vault.`;
        }
        if (value == null) return `${label} key "${key}" must have a value.`;
        // `otherApis` ist eine Map — ein Wert darf deshalb ein Objekt sein, aber
        // nur mit primitiven Einträgen. Tiefere Strukturen wären in `window.env`
        // nicht mehr adressierbar und sind mit hoher Wahrscheinlichkeit ein Fehler.
        if (typeof value === 'object' && !Array.isArray(value)) {
            if (Object.keys(value).length === 0) return `${label} key "${key}" must have a value.`;
            for (const nested of Object.values(value)) {
                if (nested && typeof nested === 'object') {
                    return `${label} key "${key}" must not nest objects deeper than one level.`;
                }
            }
        }
        const metaError = validateMetaValue(key, value);
        if (metaError) return `${label}: ${metaError}`;
    }

    return null;
};

/**
 * Die Form der **bekannten** Meta-Schlüssel — nur geprüft, wenn der Schlüssel
 * vorkommt. Unbekannte Schlüssel sind frei (siehe oben), diese fünf nicht:
 * `title` trägt die Anzeige, `description` die Kurzbeschreibung (Portal:
 * Hover/Help), `icon` die Manifest-Grafik, `roles` die Sichtbarkeit, `domain`
 * den Host-Präfix. Ein Fehler darin fiele sonst erst im Frontend auf.
 *
 * @param {string} key
 * @param {unknown} value
 * @returns {string|null}
 */
const validateMetaValue = (key, value) => {
    if (key === 'title' && (typeof value !== 'string' || !value.trim())) {
        return '"title" must be a non-empty string.';
    }
    if (key === 'description' && (typeof value !== 'string' || !value.trim())) {
        return '"description" must be a non-empty string.';
    }
    if (key === 'icon' && value !== '' && (typeof value !== 'string' || !/^https:\/\/\S+$/i.test(value.trim()))) {
        return '"icon" must be an https:// URL.';
    }
    if (key === 'roles' && (!Array.isArray(value) || value.some((role) => typeof role !== 'string' || !role.trim()))) {
        return '"roles" must be an array of non-empty strings.';
    }
    if (key === 'domain' && value !== '' && !INTERNAL_RE.test(String(value).trim().toLowerCase())) {
        return '"domain" must be a lowercase prefix (letters, digits, hyphens).';
    }
    return null;
};

/**
 * Die Meta-Schlüssel, die der Kunden-Admin im Formular sieht. Alle anderen
 * Schlüssel eines `environment`/Zweigs sind frei und werden im Formular nicht
 * als eigenes Feld geführt — sie kommen aus dem Vertrag und sind dort gepflegt.
 */
export const APP_ENV_META_FIELDS = ['title', 'description', 'icon', 'roles', 'domain'];

/** App-Schlüssel (`member.app`, `ext-my-link`) — Kleinbuchstaben, Ziffern, Punkt, Bindestrich. */
export const APP_KEY_RE = /^[a-z0-9][a-z0-9.-]*$/;

/**
 * @param {unknown} value
 * @returns {boolean}
 */
export const isValidAppKey = (value) =>
    typeof value === 'string' && APP_KEY_RE.test(value.trim());

/**
 * Zerlegt eine App-ID in **Produkt** und **Umgebung**.
 *
 * Eine App-ID ist `<produkt>.<umgebung>` (`member.test`). Das Suffix ist die
 * Umgebung und **ist** der Backend-Zweig — dieselbe Achse, nicht zwei. Der
 * Vertrag (`AppCatalog`/`AppBackendBranch`) ist dagegen je **Produkt**
 * geschlüsselt (`member`); diese Funktion ist die Naht zwischen beiden
 * Schlüsselräumen.
 *
 * Ohne Punkt ist die ganze ID das Produkt (Umgebung `null`) — so bleiben
 * CommTool-eigene Apps ohne Umgebung adressierbar.
 *
 * Zwei Punkte sind kein Fehler, aber auch keine Umgebung: `a.b.c` hat das
 * Produkt `a` und die Umgebung `b.c`, was `isValidBranch` ablehnt. Bewusst
 * **nicht** hier verboten — diese Funktion deutet, sie prüft nicht.
 *
 * @param {unknown} appId
 * @returns {{product: string, environment: string|null}|null} `null` = nicht deutbar
 */
export const splitAppId = (appId) => {
    if (typeof appId !== 'string') return null;
    const trimmed = appId.trim();
    if (!trimmed) return null;
    const dot = trimmed.indexOf('.');
    if (dot === -1) return { product: trimmed, environment: null };
    const product = trimmed.slice(0, dot);
    const environment = trimmed.slice(dot + 1);
    if (!product || !environment) return null;
    return { product, environment };
};

/**
 * Das **Produkt** einer App-ID (`member.test` → `member`) — der Schlüssel, unter
 * dem der Vertrag (`AppCatalog`) und die Zweige (`AppBackendBranch`) liegen.
 *
 * Ohne Punkt ist die ID selbst das Produkt (dann liegt sie als Ganzes im
 * Vertrag, wie `member`).
 *
 * @param {unknown} appId
 * @returns {string|null}
 */
export const productOfAppId = (appId) => {
    if (typeof appId !== 'string') return null;
    return splitAppId(appId)?.product ?? (appId.trim() || null);
};

/**
 * Normalisiert das `environment` einer App bzw. eines Zweigs.
 *
 * Heute ein Durchgriff — der Aufrufer übergibt bereits das geprüfte Objekt. Die
 * Funktion ist der Ort, an dem eine spätere Umbenennung oder ein Default
 * (etwa ein eingestreutes `NODE_ENV`) läge, ohne jeden Aufrufer anzufassen.
 *
 * @param {object} obj
 * @returns {object}
 */
export const normalizeAppEnvironment = (obj) =>
    (obj && typeof obj === 'object' && !Array.isArray(obj)) ? { ...obj } : {};

// ── ID-Helfer ─────────────────────────────────────────────────────────────────

/** `UUID-`-präfixierte Form, wie sie `U_UUID2BIN()` und die JS-Casts erwarten */
const UUID_STRING_RE = /^UUID-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;

/**
 * Prüft, ob ein Wert eine UID in der hier gültigen Form ist.
 * @param {unknown} value
 * @returns {boolean}
 */
export const isValidUid = (value) =>
    typeof value === 'string' && UUID_STRING_RE.test(value);

/**
 * Bringt eine UID in die gültige Form. Akzeptiert bereits korrekte Werte,
 * ein `Buffer(16)` (wie ihn `UIDV1()` liefert, via `HEX2uuid` umgesetzt) und
 * die rohe 36-Zeichen-Form.
 *
 * Bewusst tolerant beim Lesen, streng beim Schreiben: Werte aus `session`,
 * Vault oder einer älteren Zeile sind nicht immer schon normalisiert.
 *
 * @param {string|Buffer|null|undefined} value
 * @param {(buffer: Buffer) => string|undefined} [hexToUuid] - `HEX2uuid` aus
 *   `@commtool/sql-query`; wird übergeben statt importiert, damit diese Datei
 *   ohne DB-Abhängigkeit testbar bleibt.
 * @returns {string|null} UID in `UUID-`-Form, oder `null` wenn nicht ableitbar
 */
export const normalizeUid = (value, hexToUuid) => {
    if (value == null) return null;
    if (Buffer.isBuffer(value)) {
        if (value.length !== 16 || typeof hexToUuid !== 'function') return null;
        return hexToUuid(value) ?? null;
    }
    if (typeof value !== 'string') return null;
    if (UUID_STRING_RE.test(value)) return value;

    // Rohe 36-Zeichen-Form → präfixieren (nicht konvertieren: die Byte-Reihenfolge
    // ist dieselbe, nur die Schreibweise unterscheidet sich).
    const bare = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
    if (bare.test(value)) return `UUID-${value.toLowerCase()}`;

    return null;
};

/**
 * Liest die `appId` aus dem `Data`-Feld eines App-Objekts.
 *
 * Die `appId` (z.B. `member.app`) ist **keine** UID: sie ist der logische,
 * menschenlesbare Schlüssel aus Vault (`orgas/data/{orgId}/apps` → Map-Key) und
 * wird auch in der API (`admin`) als Map-Key verwendet. Sie liegt deshalb als
 * Feld in `Data` und nicht im Primärschlüssel (§6.1 der Planung).
 *
 * @param {unknown} data - bereits geparstes `Data`-Objekt
 * @returns {string|null}
 */
export const appIdFromData = (data) => {
    if (!data || typeof data !== 'object') return null;
    const appId = /** @type {{ appId?: unknown }} */ (data).appId;
    return typeof appId === 'string' && appId.length > 0 ? appId : null;
};

/**
 * Baut die Objekt-Titel-Felder aus `appId` und Anzeigetitel.
 *
 * `Title`/`Display` tragen den **Anzeigetitel**, `SortName` dessen
 * Kleinschreibung — die App-ID liegt in `Data.appId`. Den Schlüssel in `Title`
 * zu legen wäre verlockend (er wäre dann indexiert), würde aber Anzeigename und
 * Identität vermischen: ein umbenannter Titel hätte den Lookup zerbrochen.
 *
 * @param {string} appId
 * @param {string|undefined|null} title
 * @returns {{ title: string, display: string, sortName: string }}
 */
export const appTitles = (appId, title) => {
    const display = (typeof title === 'string' && title.trim()) || appId;
    return { title: display, display, sortName: display.toLowerCase() };
};