#!/usr/bin/env python3
"""Générateur de la config app mobile OTO Enterprise OS (Sprint 5 · Mobile).

Livrable Mobile de la roadmap Sprint 5 (l.56-57 « Rebuild Expo 54 + submit App
Store #32 + Play Store »). Le worker ne BUILD ni ne SUBMIT (VPS/stores · #8) : il
produit la CONFIG versionnable de l'app compagnon, surface mobile des 5 portails
rôle ERPNext.

Transforme (rbac_50_roles.json + mobile_spec.json + portails_spec.json + seo_spec.json)
en :
  - app_config.json      -> objet Expo `app.config` (thème dark+doré #4, locales seo,
                            identifiants de store `null` · a_confirmer)
  - eas_build.json       -> objet `eas.json` (profils build/submit, credentials `null`)
  - role_navigation.json -> 1 onglet par portail, gated par la surface RBAC exacte
  - store_listing.json   -> fiche store FR/EN/ES (sous-titre, descriptions, mots-clés)
                            dérivée : portails/langues injectés depuis les sources (#6)
  - MANIFEST.json        -> traçabilité (comptes, résumé par portail, marque, a_confirmer)

Sous-commandes :
  build    [-o OUT]   -> écrit les 5 fichiers ci-dessus
  validate [-o OUT]   -> (re)génère en mémoire, valide vs mobile.schema.json + invariants

Sortie déterministe (tri stable, aucun horodatage) -> diffable + re-générable.
Anti-invention (#6) : onglets = portails métier ; rôles = surface RBAC EXACTE du
portail (mêmes valeurs que les Has Role des Workspaces) ; identifiants de store/build =
`null` (a_confirmer) — jamais fabriqués ; marque/devises/langues sourcées.
Ce worker n'écrit JAMAIS sur le VPS ni ne soumet aux stores (#8).
"""

from __future__ import annotations

import argparse
import json
import os
import sys

_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)

from mobilelib import builder, deps  # noqa: E402

_DEFAULT_OUT = os.path.join(_HERE, "out")


def _eprint(*args) -> None:
    print(*args, file=sys.stderr)


def _write_json(path: str, data) -> None:
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)
        fh.write("\n")


def _build_bundle() -> dict:
    contract = deps.load_contract()
    spec = deps.load_spec()
    portails_spec = deps.load_portails_spec()
    langs, default_lang = deps.load_langs()
    return builder.build_bundle(contract, spec, portails_spec, langs, default_lang)


def _resolve_path(root: dict, dotted: str):
    """Descend `root` selon un chemin pointé (ex. 'app_config.expo.ios.bundleIdentifier').

    Retourne (trouvé: bool, valeur). `trouvé=False` si un segment n'existe pas.
    """
    cur = root
    for seg in dotted.split("."):
        if not isinstance(cur, dict) or seg not in cur:
            return False, None
        cur = cur[seg]
    return True, cur


def _validate_bundle(bundle: dict) -> list[str]:
    errors: list[str] = []

    schema = deps.load_json(deps.SCHEMA_PATH)
    errors.extend(deps.validate(bundle, schema))

    contract = deps.load_contract()
    spec = deps.load_spec()
    portails_spec = deps.load_portails_spec()
    langs, default_lang = deps.load_langs()

    app = bundle["app_config"]["expo"]
    eas = bundle["eas_build"]
    nav = bundle["role_navigation"]
    manifest = bundle["manifest"]
    b = deps.branding

    roles_map = deps.roles_by_portail(contract)
    labels = {p["key"]: p["label"] for p in portails_spec["portails"]}
    icons = {p["key"]: p["icon"] for p in portails_spec["portails"]}

    # Invariant 1 · onglets = portails métier RBAC, dans l'ordre de la spec.
    nav_keys = [n["portail"] for n in nav]
    if nav_keys != spec["portail_order"]:
        errors.append(f"ordre des onglets != portail_order spec: {nav_keys}.")
    if set(nav_keys) != set(contract["portails_business"]):
        errors.append("onglets != portails_business du contrat RBAC.")

    for n in nav:
        key = n["portail"]

        # Invariant 2 · rôles autorisés = surface RBAC EXACTE du portail (sécurité).
        expected_roles = sorted(roles_map.get(key, []))
        if n["roles_allowed"] != expected_roles:
            errors.append(f"[{key}] roles_allowed != rôles RBAC du portail.")
        if not expected_roles:
            errors.append(f"[{key}] portail sans rôle (surface RBAC vide ?).")
        if n["n_roles"] != len(expected_roles):
            errors.append(f"[{key}] n_roles incohérent.")

        # Invariant 3 · label + icône = portails_spec (source unique · mise en page).
        if n["label"] != labels.get(key):
            errors.append(f"[{key}] label != portails_spec.")
        if n["icon"] != icons.get(key):
            errors.append(f"[{key}] icône != portails_spec.")

        # Invariant 4 · route déterministe.
        if n["route"] != f"/{key}":
            errors.append(f"[{key}] route non déterministe: {n['route']!r}.")

    # Invariant 5 · langues = seo_spec (source unique), aucune langue inventée.
    if list(langs) != ["fr", "en", "es"] or default_lang != "fr":
        errors.append(f"langues seo inattendues: {langs}/{default_lang}.")
    if sorted(app["locales"].keys()) != sorted(langs):
        errors.append("app_config.locales != langues seo.")
    if app["extra"]["defaultLocale"] != default_lang:
        errors.append("extra.defaultLocale != langue par défaut seo.")

    # Invariant 6 · tokens de marque verbatim (CLAUDE.md #4).
    if app["backgroundColor"] != b.COLOR_BG or app["splash"]["backgroundColor"] != b.COLOR_BG:
        errors.append("backgroundColor != #4 (#0a0a12).")
    if app["primaryColor"] != b.COLOR_ACCENT:
        errors.append("primaryColor != #4 (#f0b429).")
    if app["userInterfaceStyle"] != "dark":
        errors.append("userInterfaceStyle != dark (thème luxury #4).")
    brand = manifest["brand"]
    if brand["fond"]["valeur"] != b.COLOR_BG or brand["accent"]["valeur"] != b.COLOR_ACCENT:
        errors.append("manifest.brand != CLAUDE.md #4.")

    # Invariant 7 · devises = USD + DOP (CLAUDE.md #10).
    if app["extra"]["devises"] != [b.DEVISE_PRIMAIRE, b.DEVISE_SECONDAIRE]:
        errors.append("extra.devises != [USD, DOP] (#10).")

    # Invariant 8 · Expo SDK majeur = spec (54) ; orientation portrait.
    if app["extra"]["expoSdkMajor"] != spec["app"]["expo_sdk_major"]:
        errors.append("extra.expoSdkMajor != spec (Expo 54).")
    if app["orientation"] != spec["app"]["orientation"]:
        errors.append("orientation != spec.")

    # Invariant 9 · ANTI-INVENTION : chaque champ `a_confirmer` reste `null` dans la
    # sortie (aucun identifiant de store/build/credential fabriqué · #6/#8).
    for entry in spec["a_confirmer"]:
        champ = entry["champ"]
        found, value = _resolve_path(bundle, champ)
        if not found:
            errors.append(f"a_confirmer: champ absent du bundle: {champ}.")
        elif value is not None:
            errors.append(f"a_confirmer: {champ} doit rester null (jamais fabriqué #6/#8).")
    if manifest["identifiants_statut"] != "a_confirmer":
        errors.append("manifest.identifiants_statut != a_confirmer.")

    # Invariant 10 · profils EAS = spec ; credentials submit `null`.
    if sorted(eas["build"].keys()) != sorted(spec["eas"]["build_profiles"]):
        errors.append("eas.build profils != spec.build_profiles.")

    # Invariant 11 · comptes du manifeste cohérents.
    c = manifest["counts"]
    checks = {
        "onglets": len(nav),
        "portails": len(nav),
        "roles_couverts": sum(n["n_roles"] for n in nav),
        "langues": len(langs),
        "identifiants_a_confirmer": len(spec["a_confirmer"]),
    }
    for k, v in checks.items():
        if c.get(k) != v:
            errors.append(f"counts.{k} incohérent ({c.get(k)} != {v}).")

    # Invariant 12 · fiche store : couvre EXACTEMENT les langues seo (aucune inventée).
    sl = bundle["store_listing"]
    sl_spec = spec["store_listing"]
    if sorted(sl["content"].keys()) != sorted(langs):
        errors.append("store_listing.content != langues seo (FR/EN/ES).")
    if sl["langues"] != list(langs):
        errors.append("store_listing.langues != langues seo.")
    if sl["limits"] != sl_spec["limits"]:
        errors.append("store_listing.limits != spec.store_listing.limits.")

    # Invariant 13 · chaque champ de fiche respecte sa limite de caractères store
    # (App Store / Play — voir limits_source) et n'est jamais vide.
    for lang, fields in sl["content"].items():
        for champ, limit in sl_spec["limits"].items():
            val = fields.get(champ)
            if not val:
                errors.append(f"store_listing[{lang}].{champ} vide/absent.")
            elif len(val) > limit:
                errors.append(
                    f"store_listing[{lang}].{champ} dépasse la limite store {limit} "
                    f"caractères (={len(val)})."
                )

    # Invariant 14 · ANTI-DÉRIVE : l'énumération des portails et les langues injectées
    # dans chaque description longue == la surface RÉELLE (labels role_navigation ·
    # langues seo) — la copie marketing ne peut pas mentir sur les portails/langues.
    portails_str = " · ".join(n["label"] for n in nav)
    langues_str = "/".join(lang.upper() for lang in langs)
    for lang, fields in sl["content"].items():
        full = fields.get("full_description", "")
        if portails_str not in full:
            errors.append(f"store_listing[{lang}].full_description n'injecte pas les portails réels.")
        if langues_str not in full:
            errors.append(f"store_listing[{lang}].full_description n'injecte pas les langues réelles.")
        if "{" in full or "}" in full:
            errors.append(f"store_listing[{lang}].full_description : placeholder non substitué.")

    # Invariant 15 · déterminisme : deux builds successifs identiques.
    again = _build_bundle()
    if json.dumps(again, sort_keys=True, ensure_ascii=False) != json.dumps(bundle, sort_keys=True, ensure_ascii=False):
        errors.append("build non déterministe (deux passes divergent).")

    return errors


def cmd_build(args: argparse.Namespace) -> int:
    bundle = _build_bundle()
    errors = _validate_bundle(bundle)
    if errors:
        _eprint("❌ Bundle invalide — génération refusée (anti-régression) :")
        for e in errors:
            _eprint(f"   - {e}")
        return 1

    out = os.path.abspath(args.out)
    os.makedirs(out, exist_ok=True)
    _write_json(os.path.join(out, "app_config.json"), bundle["app_config"])
    _write_json(os.path.join(out, "eas_build.json"), bundle["eas_build"])
    _write_json(os.path.join(out, "role_navigation.json"), bundle["role_navigation"])
    _write_json(os.path.join(out, "store_listing.json"), bundle["store_listing"])
    _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])

    c = bundle["manifest"]["counts"]
    print(f"✅ Config app mobile générée dans {out}")
    print(
        f"   {c['onglets']} onglets (portails) · {c['roles_couverts']} rôles couverts · "
        f"{c['langues']} langues · {c['identifiants_a_confirmer']} identifiants a_confirmer"
    )
    for m in bundle["manifest"]["portails"]:
        print(f"     - {m['label']} ({m['icon']}) : {m['nb_roles']} rôles")
    return 0


def cmd_validate(args: argparse.Namespace) -> int:
    bundle = _build_bundle()
    errors = _validate_bundle(bundle)
    if errors:
        _eprint("❌ Validation KO :")
        for e in errors:
            _eprint(f"   - {e}")
        return 1
    c = bundle["manifest"]["counts"]
    print(
        f"✅ Validation OK — {c['onglets']} onglets, {c['roles_couverts']} rôles, "
        f"schéma + 15 invariants verts (identifiants a_confirmer · fiche store dérivée)."
    )
    return 0


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(
        description="Générateur de la config app mobile OTO Enterprise OS (Expo/EAS + navigation par rôle)."
    )
    sub = p.add_subparsers(dest="cmd", required=True)

    pb = sub.add_parser("build", help="génère app_config.json / eas_build.json / role_navigation.json / MANIFEST.json")
    pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)")
    pb.set_defaults(func=cmd_build)

    pv = sub.add_parser("validate", help="valide le bundle (schéma + invariants) sans écrire")
    pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignoré (compat)")
    pv.set_defaults(func=cmd_validate)

    args = p.parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())
