#!/usr/bin/env python3
#
# Call with pytest. Requires XKB_CONFIG_ROOT to be set

import os
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Iterator

import pytest


def _xkb_config_root() -> list[Path]:
    roots: list[Path] = []

    # XKB_CONFIG_EXTRA_PATH is not mandatory
    if path := os.getenv("XKB_CONFIG_EXTRA_PATH"):
        roots.append(Path(path))
        print(f"Using XKB_CONFIG_EXTRA_PATH={path}")

    if path := os.getenv("XKB_CONFIG_ROOT"):
        xkbpath = Path(path)
        assert (xkbpath / "symbols").exists(), f"{path} is not an XKB installation"
        roots.append(xkbpath)
        print(f"Using XKB_CONFIG_ROOT={path}")
    else:
        raise ValueError("Environment variable XKB_CONFIG_ROOT must be set")

    return roots


@pytest.fixture
def xkb_config_root():
    return _xkb_config_root()


def iterate_layouts_variants(rules_xml):
    """
    Return an iterator of type (layout, variant) for each element in the XML
    file.
    """
    tree = ET.parse(rules_xml)
    root = tree.getroot()
    for layout in root.iter("layout"):
        yield layout, None

        for variant in layout.iter("variant"):
            yield layout, variant


def iterate_config_items(rules_xml):
    """
    Return an iterator of configItem elements
    """
    tree = ET.parse(rules_xml)
    root = tree.getroot()
    return root.iter("configItem")


def get_registry_files() -> Iterator[Path]:
    """
    List all registry files in <XKB_ROOT>/rules/*.xml and ensure unique name.
    """
    found: set[Path] = set()
    for xkb_root in _xkb_config_root():
        for path in xkb_root.glob("rules/*.xml"):
            if (file := path.relative_to(xkb_root)) in found:
                continue
            else:
                yield path
                found.add(file)


def pytest_generate_tests(metafunc):
    # For any test_foo function with an argument named rules_xml,
    # make it the list of <XKB_ROOT>/rules/*.xml files.
    # Use only the first XKB root.
    if "rules_xml" in metafunc.fixturenames:
        rules_xml = list(get_registry_files())
        assert rules_xml
        metafunc.parametrize("rules_xml", rules_xml)
    # for any test_foo function with an argument named layout,
    # make it a Layout wrapper class for all layout(variant) combinations
    elif "layout" in metafunc.fixturenames:
        rules_xml = list(get_registry_files())
        assert rules_xml
        layouts = []
        for f in rules_xml:
            for l, v in iterate_layouts_variants(f):
                layouts.append(Layout(f, l, v))
        metafunc.parametrize("layout", layouts)
    elif "config_item" in metafunc.fixturenames:
        rules_xml = list(get_registry_files())
        assert rules_xml
        config_items = []
        for f in rules_xml:
            for item in iterate_config_items(f):
                item = ConfigItem.from_elem(item)
                item.rulesfile = f
                config_items.append(item)
        metafunc.parametrize("config_item", config_items)


class Layout:
    """
    Wrapper class for layout/variants - both ConfigItems are available but
    the properties automatically pick the variant (if it exists) or the
    layout otherwise.
    """

    def __init__(self, rulesfile, layout, variant=None):
        self.rulesfile = rulesfile
        self.layout = ConfigItem.from_elem(layout)
        self.variant = ConfigItem.from_elem(variant) if variant is not None else None
        if variant is not None:
            self.name = f"{self.layout.name}({self.variant.name})"
        else:
            self.name = f"{self.layout.name}"

    @property
    def iso3166(self):
        if self.variant and self.variant.iso3166 is not None:
            return self.variant.iso3166 or []
        # inherit from parent
        return self.layout.iso3166 or []

    @property
    def iso639(self):
        if self.variant and self.variant.iso639 is not None:
            return self.variant.iso639 or []
        # inherit from parent
        return self.layout.iso639 or []

    @property
    def popularity(self):
        return self.variant.popularity if self.variant else self.layout.popularity

    @property
    def shortDescription(self):
        if self.variant and self.variant.shortDescription:
            return self.variant.shortDescription
        return self.layout.shortDescription


def prettyxml(element):
    return ET.tostring(element).decode("utf-8")


class ConfigItem:
    def __init__(self, name, shortDescription=None, description=None):
        self.name = name
        self.shortDescription = shortDescription
        self.description = description
        self.iso639 = None
        self.iso3166 = None
        self.popularity = None

    @classmethod
    def _fetch_subelement(cls, parent, name):
        sub_element = parent.findall(name)
        if sub_element is not None and len(sub_element) == 1:
            return sub_element[0]
        else:
            return None

    @classmethod
    def _fetch_subelement_text(cls, parent, name):
        sub_element = parent.findall(name)
        return [e.text for e in sub_element]

    @classmethod
    def _fetch_text(cls, parent, name):
        sub_element = cls._fetch_subelement(parent, name)
        if sub_element is None:
            return None
        return sub_element.text

    @classmethod
    def from_elem(cls, elem):
        try:
            ci_element = (
                elem
                if elem.tag == "configItem"
                else cls._fetch_subelement(elem, "configItem")
            )
            name = cls._fetch_text(ci_element, "name")
            assert name is not None
            # shortDescription and description are optional
            sdesc = cls._fetch_text(ci_element, "shortDescription")
            desc = cls._fetch_text(ci_element, "description")
            ci = ConfigItem(name, sdesc, desc)
            ci.popularity = ci_element.attrib.get("popularity")

            langlist = cls._fetch_subelement(ci_element, "languageList")
            if langlist is not None:
                ci.iso639 = cls._fetch_subelement_text(langlist, "iso639Id")

            countrylist = cls._fetch_subelement(ci_element, "countryList")
            if countrylist is not None:
                ci.iso3166 = cls._fetch_subelement_text(countrylist, "iso3166Id")

            return ci
        except AssertionError as e:
            endl = "\n"  # f{} cannot contain backslashes
            e.args = (f"\nFor element {prettyxml(elem)}\n{endl.join(e.args)}",)
            raise


def test_duplicate_layouts(rules_xml):
    tree = ET.parse(rules_xml)
    root = tree.getroot()
    layouts = {}
    for layout in root.iter("layout"):
        ci = ConfigItem.from_elem(layout)
        assert ci.name not in layouts, f"Duplicate layout {ci.name}"
        layouts[ci.name] = True

        variants = {}
        for variant in layout.iter("variant"):
            vci = ConfigItem.from_elem(variant)
            assert vci.name not in variants, (
                f"{rules_xml}: duplicate variant {ci.name}({vci.name}):\n{prettyxml(variant)}"
            )
            variants[vci.name] = True


def test_duplicate_models(rules_xml):
    tree = ET.parse(rules_xml)
    root = tree.getroot()
    models = {}
    for model in root.iter("model"):
        ci = ConfigItem.from_elem(model)
        assert ci.name not in models, f"Duplicate model {ci.name}"
        models[ci.name] = True


def test_exotic(config_item):
    """All items in extras should be marked exotic"""
    if config_item.rulesfile.stem.endswith("extras"):
        assert config_item.popularity == "exotic", (
            f"{config_item.rulesfile}: item {config_item.name} does not have popularity exotic"
        )
    else:
        assert config_item.popularity != "exotic", (
            f"{config_item.rulesfile}: item {config_item.name} has popularity exotic"
        )


def test_short_description(layout):
    assert layout.shortDescription, (
        f"{layout.rulesfile}: layout {layout.name} missing shortDescription"
    )


def test_iso3166(layout):
    """Typically layouts should specify at least one country code"""
    pycountry = pytest.importorskip("pycountry")
    country_codes = [c.alpha_2 for c in pycountry.countries]
    expected_without_country = [
        "ancient",  # from before current-day countries
        "apl",
        "bqn",  # programming
        "brai",  # Braille not specific to any country
        "custom",
        "epo",  # Esperanto not native to any country
        "trans",  # international
    ]

    for code in layout.iso3166:
        assert code in country_codes, (
            f'{layout.rulesfile}: unknown country code "{code}" in {layout.name}'
        )

    assert layout.iso3166 or layout.layout.name in expected_without_country, (
        f"{layout.rulesfile}: layout {layout.name} has no countries associated"
    )


def test_iso639(layout):
    """Typically layouts should should specify at least one language code"""
    pycountry = pytest.importorskip("pycountry")

    # A list of languages not in pycountry, so we need to special-case them
    special_langs = [
        "ber",  # Berber languages (collective), https://iso639-3.sil.org/code/ber
        "btb",  # Beti (Cameroon), https://iso639-3.sil.org/code/btb
        "fox",  # Formosan languages (collective), https://iso639-3.sil.org/code/fox
        "phi",  # Philippine languages (collective), https://iso639-3.sil.org/code/phi
        "ovd",  # Elfdalian, https://iso639-3.sil.org/code/ovd
    ]
    language_codes = [c.alpha_3 for c in pycountry.languages] + special_langs
    expected_without_language = ["brai", "custom", "trans"]

    for code in layout.iso639:
        try:
            assert code in language_codes, (
                f'{layout.rulesfile}: unknown language code "{code}" in {layout.name}'
            )
        except AssertionError as e:
            # needs pycountry 23.12.7, this code can be removed when our CI has pycountry new enough
            if code in ["pzh", "szy", "uon"]:
                pytest.skip("pycountry too old, skipping")
            else:
                raise e

    assert layout.iso639 or layout.layout.name in expected_without_language, (
        f"{layout.rulesfile}: layout {layout.name} has no languages associated"
    )
