#!/usr/bin/env python3
"""Joomla 3.x manifest -> deployed-path mapper.

Generikus baseline-generátor komponens: egy kicsomagolt Joomla-bővítmény-
csomagot (manifest-XML + a hozzá tartozó forrásfák) a Joomla-installer
mapping-szabályai szerint másolja a megfelelő deployed docroot-útvonalakra.

Támogatott ``<extension type="...">`` gyökér-típusok: component, module,
plugin, library, template, language, file, package (pkg_ csomag, rekurzívan
feldolgozott al-bővítményekkel).

A ``folder`` attribútum (``<files folder="...">``, ``<media folder="...">``,
``<languages folder="...">``) a manifest-fájl könyvtárához relatív forrás-
alkönyvtárat jelöli; ha nincs megadva, a manifest melletti könyvtár a forrás.

Csak a Python standard library-t használja (nincs külső dependency).
"""

from __future__ import annotations

import argparse
import re
import shutil
import sys
import xml.etree.ElementTree as ET
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path


class JoomlaManifestError(Exception):
    """A manifest-feldolgozás vagy a mapping-szabályok megsértése esetén dobott hiba.

    Minden hibás/hiányzó manifest-tartalom, ismeretlen extension-típus vagy
    hiányzó forrás-fájl ezt dobja — a hívó CLI ezt explicit elkapja és
    érthető hibaüzenettel, nemnulla exit-kóddal fejezi be a futást.
    """


@dataclass(frozen=True)
class CopyOp:
    """Egyetlen másolási művelet: forrás -> cél. A src lehet fájl vagy könyvtár."""

    src: Path
    dest: Path


# ---------------------------------------------------------------------------
# XML-segédfüggvények
# ---------------------------------------------------------------------------


def _text(elem: ET.Element | None) -> str:
    """Egy elem trimmelt szöveg-tartalma, vagy üres string, ha nincs elem/szöveg."""
    if elem is None or elem.text is None:
        return ""
    return elem.text.strip()


def _slug(text: str) -> str:
    """Emberi-olvasható nevet gépi slug-gá normalizál (kisbetű, szóköz -> '_')."""
    normalized = text.strip().lower()
    return "_".join(normalized.split())


def _extension_name(root: ET.Element) -> str:
    """A bővítmény gépi-nevét adja vissza a gyökér ``element`` attribútumból,
    vagy ennek hiányában a ``<name>`` elem szövegéből (slugolva).
    """
    element_attr = root.get("element")
    if element_attr:
        return element_attr.strip()
    name_text = _text(root.find("name"))
    if not name_text:
        raise JoomlaManifestError(
            "nem határozható meg a bővítmény neve: hiányzik a <name> elem és az 'element' attribútum"
        )
    return _slug(name_text)


def _plugin_element(root: ET.Element) -> str:
    """A plugin element-slugját adja vissza.

    Elsődleges forrás: ``<files>`` blokk ``<filename plugin="...">`` vagy
    ``<file plugin="...">`` gyereke (mindkét tag-alak előfordul a valós
    csomagokban -- ez a Joomla-konvenció szerinti autoritatív érték). Ha ez
    hiányzik, a ``<name>PLG_GROUP_ELEMENT</name>`` konvenció utolsó
    '_'-tagját használja fallback-ként.
    """
    files_elem = root.find("files")
    if files_elem is not None:
        for child in files_elem:
            if child.tag not in ("filename", "file"):
                continue
            plugin_attr = child.get("plugin")
            if plugin_attr:
                return plugin_attr.strip()
    name_text = _text(root.find("name"))
    if name_text:
        return _slug(name_text.rsplit("_", 1)[-1])
    raise JoomlaManifestError(
        'nem határozható meg a plugin element-je: nincs <filename plugin="..."> / '
        '<file plugin="..."> és nincs <name>'
    )


_COMPONENT_LANG_SYS_INI = re.compile(r"(com_[a-z0-9_]+)\.sys\.ini$", re.IGNORECASE)
_COMPONENT_SCRIPTFILE = re.compile(r"script\.([a-z0-9_]+)\.php$", re.IGNORECASE)


def _component_slug(text: str) -> str:
    """Komponens-display-nevet ('OneAll Social Login') Joomla-konvenció szerint
    normalizál a végső fallback-ágon: szóköz-ELTÁVOLÍTÁS (nem aláhúzás-csere)
    + kisbetűsítés -> 'oneallsociallogin'.
    """
    return "".join(text.strip().lower().split())


def _component_name(root: ET.Element) -> str:
    """A komponens deployed-elemnevét ('com_X') vezeti le a Joomla 3.x
    installer-heurisztika prioritási sorrendjében:

    1. explicit gyökér ``element`` attribútum
    2. a fő ``<files folder="com_...">`` attribútuma, ha 'com_'-prefixű --
       ez az autoritatív forrás, mert a ``<name>`` gyakran csak emberi
       megjelenítő-cím (pl. "OneAll Social Login", nem "com_oneallsociallogin")
    3. az adminisztratív, majd a site ``<languages><language>`` fájlnév-
       mintázata ``en-GB.com_<elem>.sys.ini`` -> ``com_<elem>``
    4. ``<scriptfile>install/script.<elem>.php</scriptfile>`` -> ``com_<elem>``
    5. végső fallback: ``<name>`` Joomla-konvenció szerinti normalizálása
       (szóköz-eltávolítás, kisbetűsítés); ha az eredmény már 'com_'-prefixű
       (gépi-nevet tartalmazó manifest), változatlanul megy tovább, egyébként
       'com_' prefixet kap.
    """
    element_attr = root.get("element")
    if element_attr:
        return element_attr.strip()

    files_elem = root.find("files")
    if files_elem is not None:
        folder_attr = files_elem.get("folder", "")
        if folder_attr.lower().startswith("com_"):
            return folder_attr

    for languages_elem in (root.find("administration/languages"), root.find("languages")):
        if languages_elem is None:
            continue
        for language_elem in languages_elem.findall("language"):
            match = _COMPONENT_LANG_SYS_INI.search(_text(language_elem))
            if match:
                return match.group(1).lower()

    scriptfile_text = _text(root.find("scriptfile"))
    if scriptfile_text:
        match = _COMPONENT_SCRIPTFILE.search(scriptfile_text)
        if match:
            return f"com_{match.group(1).lower()}"

    name_text = _text(root.find("name"))
    if name_text:
        slug = _component_slug(name_text)
        return slug if slug.startswith("com_") else f"com_{slug}"

    raise JoomlaManifestError(
        "nem határozható meg a komponens deployed-neve: nincs 'element' attribútum, "
        'nincs <files folder="com_...">, nincs com_-mintázatú nyelvi fájl, '
        "nincs <scriptfile>, és nincs <name>"
    )


# ---------------------------------------------------------------------------
# <files> / <media> / <languages> blokk -> CopyOp lista
# ---------------------------------------------------------------------------


def _apply_files(files_elem: ET.Element | None, manifest_dir: Path, dest_dir: Path) -> list[CopyOp]:
    """Egy ``<files>``-szerű blokk (files/media) gyerekeit CopyOp-listává alakítja.

    Az egyedi-fájl gyerek tag-neve a valós csomagokban ``<filename>`` VAGY
    ``<file>`` lehet (mindkettőt elfogadja a Joomla-installer, csak a szöveg-
    tartalom és a ``target``/egyéb attribútumok számítanak); a ``<folder>``
    gyerek egy teljes alfát másol. A ``folder`` attribútum a forrás-
    alkönyvtárat jelöli a manifest könyvtárához képest; a ``target``
    attribútum (ha van) felülírja a cél-relatív-útvonalat.
    """
    if files_elem is None:
        return []
    folder_attr = files_elem.get("folder", "")
    src_base = manifest_dir / folder_attr if folder_attr else manifest_dir
    ops: list[CopyOp] = []
    for child in files_elem:
        if child.tag not in ("filename", "file", "folder"):
            continue
        rel_text = _text(child)
        if not rel_text:
            raise JoomlaManifestError(
                f"<{child.tag}> elem üres útvonal-szöveggel a(z) {manifest_dir} manifest-ben"
            )
        rel_path = Path(rel_text)
        target_attr = child.get("target")
        dest_rel = Path(target_attr) if target_attr else rel_path
        ops.append(CopyOp(src=src_base / rel_path, dest=dest_dir / dest_rel))
    return ops


def _apply_media(media_elem: ET.Element | None, manifest_dir: Path, media_root: Path) -> list[CopyOp]:
    """``<media destination="..." folder="...">`` blokk -> ``media/<destination>/`` alá."""
    if media_elem is None:
        return []
    destination = media_elem.get("destination")
    if not destination:
        raise JoomlaManifestError(
            f"<media> elem hiányzó 'destination' attribútummal a(z) {manifest_dir} manifest-ben"
        )
    return _apply_files(media_elem, manifest_dir, media_root / destination)


def _apply_languages(languages_elem: ET.Element | None, manifest_dir: Path, lang_root: Path) -> list[CopyOp]:
    """``<languages folder="...">`` blokk -> ``<lang_root>/<tag>/<fájlnév>``.

    A gyerek ``<language tag="...">relatív/út.ini</language>`` elemek szövege
    a forrás-relatív útvonal; a cél a tag-alkönyvtárban a fájl alap-neve
    (a Joomla-konvenció szerint a forrás már tag-alkönyvtárban van).
    """
    if languages_elem is None:
        return []
    folder_attr = languages_elem.get("folder", "")
    src_base = manifest_dir / folder_attr if folder_attr else manifest_dir
    ops: list[CopyOp] = []
    for language_elem in languages_elem.findall("language"):
        tag = language_elem.get("tag")
        if not tag:
            raise JoomlaManifestError(
                f"<language> elem hiányzó 'tag' attribútummal a(z) {manifest_dir} manifest-ben"
            )
        rel_text = _text(language_elem)
        if not rel_text:
            raise JoomlaManifestError(
                f'<language tag="{tag}"> elem üres fájl-útvonallal a(z) {manifest_dir} manifest-ben'
            )
        rel_path = Path(rel_text)
        ops.append(CopyOp(src=src_base / rel_path, dest=lang_root / tag / rel_path.name))
    return ops


# ---------------------------------------------------------------------------
# Extension-típus -> mapping-szabály
# ---------------------------------------------------------------------------


def map_component(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
    """com_X: files->components/com_X, administration/files->administrator/components/com_X,
    media->media/com_X, languages(site+admin)->language/ ill. administrator/language/.

    A manifestbe ágyazott ``<modules>``/``<plugins>`` szekciók (ha vannak --
    pl. egy komponens saját manifestje telepít egy modult/plugint is, mint
    az OneAll Social Login csomagnál) is feldolgozásra kerülnek; ez NEM a
    ``pkg_`` package-típus (annak külön al-manifestjei vannak), hanem a
    komponens EGYETLEN manifestjébe ágyazott mapping-szekció.
    """
    name = _component_name(root)
    ops = _apply_files(root.find("files"), manifest_dir, out_root / "components" / name)

    admin_elem = root.find("administration")
    if admin_elem is not None:
        ops += _apply_files(
            admin_elem.find("files"), manifest_dir, out_root / "administrator" / "components" / name
        )
        ops += _apply_languages(
            admin_elem.find("languages"), manifest_dir, out_root / "administrator" / "language"
        )

    ops += _apply_media(root.find("media"), manifest_dir, out_root / "media")
    ops += _apply_languages(root.find("languages"), manifest_dir, out_root / "language")
    ops += _apply_embedded_modules(root.find("modules"), manifest_dir, out_root)
    ops += _apply_embedded_plugins(root.find("plugins"), manifest_dir, out_root)
    return ops


def _apply_embedded_modules(
    modules_elem: ET.Element | None, manifest_dir: Path, out_root: Path
) -> list[CopyOp]:
    """Egy komponens-manifestbe ágyazott ``<modules><module module="mod_X" client="...">``
    szekció feldolgozása. A deployed-modulnév közvetlenül a ``module`` attribútumból jön
    (nem a gyakran emberi-olvasható ``<name>``-ből).
    """
    if modules_elem is None:
        return []
    ops: list[CopyOp] = []
    for module_elem in modules_elem.findall("module"):
        module_name = module_elem.get("module")
        if not module_name:
            raise JoomlaManifestError(f"beágyazott <module> hiányzó 'module' attribútummal: {manifest_dir}")
        is_admin = module_elem.get("client") == "administrator"
        base = out_root / ("administrator/modules" if is_admin else "modules") / module_name
        ops += _apply_files(module_elem.find("files"), manifest_dir, base)
        ops += _apply_media(module_elem.find("media"), manifest_dir, out_root / "media")
    return ops


def _apply_embedded_plugins(
    plugins_elem: ET.Element | None, manifest_dir: Path, out_root: Path
) -> list[CopyOp]:
    """Egy komponens-manifestbe ágyazott ``<plugins><plugin plugin="X" group="Y">``
    szekció feldolgozása. Az elem-slug és a group közvetlenül a ``plugin``/``group``
    attribútumokból jön (nem a gyakran emberi-olvasható ``<name>``-ből).
    """
    if plugins_elem is None:
        return []
    ops: list[CopyOp] = []
    for plugin_elem in plugins_elem.findall("plugin"):
        element = plugin_elem.get("plugin")
        group = plugin_elem.get("group")
        if not element or not group:
            raise JoomlaManifestError(
                f"beágyazott <plugin> hiányzó 'plugin' vagy 'group' attribútummal: {manifest_dir}"
            )
        base = out_root / "plugins" / group / element
        ops += _apply_files(plugin_elem.find("files"), manifest_dir, base)
        ops += _apply_media(plugin_elem.find("media"), manifest_dir, out_root / "media")
    return ops


def map_module(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
    """mod_X: files->modules/mod_X (vagy administrator/modules/mod_X ha client=administrator)."""
    name = _extension_name(root)
    is_admin = root.get("client") == "administrator"
    base = out_root / ("administrator/modules" if is_admin else "modules") / name
    ops = _apply_files(root.find("files"), manifest_dir, base)
    ops += _apply_media(root.find("media"), manifest_dir, out_root / "media")
    lang_root = out_root / ("administrator/language" if is_admin else "language")
    ops += _apply_languages(root.find("languages"), manifest_dir, lang_root)
    return ops


def map_plugin(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
    """plugin: files->plugins/<group>/<element>."""
    group = root.get("group")
    if not group:
        raise JoomlaManifestError(f"plugin manifest hiányzó 'group' attribútummal: {manifest_dir}")
    element = _plugin_element(root)
    base = out_root / "plugins" / group / element
    ops = _apply_files(root.find("files"), manifest_dir, base)
    ops += _apply_media(root.find("media"), manifest_dir, out_root / "media")
    ops += _apply_languages(root.find("languages"), manifest_dir, out_root / "language")
    return ops


def map_library(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
    """library: files->libraries/<name>."""
    name = _extension_name(root)
    ops = _apply_files(root.find("files"), manifest_dir, out_root / "libraries" / name)
    ops += _apply_media(root.find("media"), manifest_dir, out_root / "media")
    return ops


def map_template(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
    """template: files->templates/<name> (vagy administrator/templates/<name>)."""
    name = _extension_name(root)
    is_admin = root.get("client") == "administrator"
    base = out_root / ("administrator/templates" if is_admin else "templates") / name
    ops = _apply_files(root.find("files"), manifest_dir, base)
    ops += _apply_media(root.find("media"), manifest_dir, out_root / "media")
    return ops


def map_language(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
    """language (nyelvi csomag): files -> language/<tag> (vagy administrator/language/<tag>)."""
    tag = _text(root.find("tag")) or _slug(_text(root.find("name")))
    if not tag:
        raise JoomlaManifestError(f"language manifest hiányzó <tag> és <name> elemmel: {manifest_dir}")
    is_admin = root.get("client") == "administrator"
    lang_root = out_root / ("administrator/language" if is_admin else "language") / tag
    return _apply_files(root.find("files"), manifest_dir, lang_root)


def map_file(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
    """type=file: a <files> gyerekek target-attribútuma (vagy a relatív útvonaluk)
    közvetlenül a docroot gyökeréhez képest értelmezendő.
    """
    return _apply_files(root.find("files"), manifest_dir, out_root)


def map_package(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
    """pkg_ csomag: a <files><file type="..." id="..."> bejegyzések al-csomag
    alkönyvtárakra mutatnak; mindegyiket a saját manifestjével rekurzívan dolgozza fel.
    """
    files_elem = root.find("files")
    if files_elem is None:
        raise JoomlaManifestError(f"package manifest hiányzó <files> elemmel: {manifest_dir}")
    folder_attr = files_elem.get("folder", "")
    src_base = manifest_dir / folder_attr if folder_attr else manifest_dir

    ops: list[CopyOp] = []
    for file_elem in sorted(files_elem.findall("file"), key=lambda e: (_text(e), e.get("id", ""))):
        rel_text = _text(file_elem)
        if not rel_text:
            raise JoomlaManifestError(f"<file> al-csomag bejegyzés üres útvonal-szöveggel: {manifest_dir}")
        sub_dir = src_base / rel_text
        if not sub_dir.is_dir():
            raise JoomlaManifestError(
                f"az al-csomag könyvtára nem található: {sub_dir} (package manifest: {manifest_dir})"
            )
        sub_manifest = find_manifest(sub_dir)
        ops += build_operations(sub_manifest, out_root)
    return ops


TypeHandler = Callable[[ET.Element, Path, Path], list[CopyOp]]

TYPE_HANDLERS: dict[str, TypeHandler] = {
    "component": map_component,
    "module": map_module,
    "plugin": map_plugin,
    "library": map_library,
    "template": map_template,
    "language": map_language,
    "file": map_file,
    "package": map_package,
}


# ---------------------------------------------------------------------------
# Manifest-keresés + dispatch + végrehajtás
# ---------------------------------------------------------------------------


def _is_extension_manifest(path: Path) -> bool:
    """True, ha a path egy jólformált XML, aminek gyökér-eleme <extension>."""
    try:
        # bandit B314: stdlib ElementTree XXE-re elméletileg sebezhető; a bemenet
        # egy lokálisan kicsomagolt, adminisztrátor-szolgáltatta bővítmény-csomag
        # (nem távoli/attacker-controlled forrás), és a feladat-kontraktus
        # kifejezetten csak stdlib-et enged (defusedxml külső dependency lenne
        # -> PO-jóváhagyás szükséges, jelen scope-ban nincs) — lásd záró-riport.
        root = ET.parse(path).getroot()  # nosec B314
    except ET.ParseError:
        return False
    except OSError:
        return False
    return root.tag == "extension"


def find_manifest(directory: Path) -> Path:
    """A directory közvetlen szintjén (nem rekurzívan) megkeresi az egyetlen
    Joomla extension-manifestet (<extension> gyökér-elemű XML-fájl).

    Raises:
        JoomlaManifestError: ha egy sincs, vagy ha egynél több található
            (ez utóbbi esetben a mapping nem egyértelmű, a hívónak
            szét kell választania a csomagot).
    """
    candidates = sorted(
        (p for p in directory.glob("*.xml") if _is_extension_manifest(p)),
        key=lambda p: p.name,
    )
    if not candidates:
        raise JoomlaManifestError(f"nem található Joomla extension-manifest itt: {directory}")
    if len(candidates) > 1:
        names = ", ".join(str(c) for c in candidates)
        raise JoomlaManifestError(
            f"több lehetséges manifest is található itt: {directory} ({names}); "
            "egy csomag-könyvtárban pontosan egy gyökér-manifest várt"
        )
    return candidates[0]


def build_operations(manifest_path: Path, out_root: Path) -> list[CopyOp]:
    """A manifest-path-hoz tartozó extension teljes CopyOp-listáját építi fel
    (a saját mapping-szabálya szerint; package-típus esetén rekurzívan az
    al-bővítményekével együtt).
    """
    try:
        tree = ET.parse(manifest_path)  # nosec B314 -- lásd _is_extension_manifest WHY-kommentje
    except ET.ParseError as exc:
        raise JoomlaManifestError(f"hibás XML a manifest-ben: {manifest_path} ({exc})") from exc
    root = tree.getroot()
    if root.tag != "extension":
        raise JoomlaManifestError(f"{manifest_path}: a gyökér-elem <{root.tag}>, elvárt: <extension>")
    ext_type = root.get("type")
    if not ext_type:
        raise JoomlaManifestError(f"{manifest_path}: <extension> hiányzó 'type' attribútummal")
    handler = TYPE_HANDLERS.get(ext_type)
    if handler is None:
        supported = ", ".join(sorted(TYPE_HANDLERS))
        raise JoomlaManifestError(
            f"{manifest_path}: nem támogatott extension type '{ext_type}' (támogatott: {supported})"
        )
    return handler(root, manifest_path.parent, out_root)


def _copytree_merge(src: Path, dest: Path) -> None:
    """src könyvtárfát dest alá másolja rendezett bejárással, meglévő dest-et megtartva/bővítve."""
    dest.mkdir(parents=True, exist_ok=True)
    for entry in sorted(src.iterdir(), key=lambda p: p.name):
        target = dest / entry.name
        if entry.is_dir():
            _copytree_merge(entry, target)
        else:
            shutil.copy2(entry, target)


def execute(ops: list[CopyOp]) -> None:
    """A CopyOp-listát ténylegesen végrehajtja: fájlokat/könyvtárfákat másol.

    Raises:
        JoomlaManifestError: ha egy forrás-útvonal nem létezik.
    """
    for op in ops:
        if not op.src.exists():
            raise JoomlaManifestError(f"a forrás-útvonal nem létezik: {op.src}")
        if op.src.is_dir():
            _copytree_merge(op.src, op.dest)
        else:
            op.dest.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(op.src, op.dest)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def main(argv: list[str] | None = None) -> int:
    """CLI belépőpont: ``python3 jmap.py --package <pkg_dir> --out <docroot_dir>``."""
    parser = argparse.ArgumentParser(
        description=(
            "Joomla 3.x manifest-alapú bővítmény-deployer: kicsomagolt bővítmény-csomagot "
            "másol a megfelelő deployed docroot-útvonalakra a Joomla-installer mapping-szabályai szerint."
        )
    )
    parser.add_argument(
        "--package", required=True, type=Path, help="kicsomagolt bővítmény-csomag gyökér-könyvtára"
    )
    parser.add_argument("--out", required=True, type=Path, help="cél deployed docroot könyvtár")
    args = parser.parse_args(argv)

    package_dir: Path = args.package
    out_dir: Path = args.out

    if not package_dir.is_dir():
        parser.error(f"--package nem létező könyvtár: {package_dir}")

    try:
        manifest_path = find_manifest(package_dir)
        ops = build_operations(manifest_path, out_dir)
        execute(ops)
    except JoomlaManifestError as exc:
        print(f"hiba: {exc}", file=sys.stderr)
        return 1

    print(f"kész: {len(ops)} másolási művelet végrehajtva -> {out_dir}")
    return 0


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