import json
import locale
import os
from pathlib import Path
from typing import Iterable, List, Optional
from xml.etree import ElementTree as etree

import sadb


try:
    import gi

    gi.require_version("AppStream", "1.0")
    from gi.repository import AppStream  # type: ignore
except (ImportError, ValueError):
    AppStream = None


_CACHE_VERSION = 1


def _system_locale() -> Optional[str]:
    loc = locale.getlocale(locale.LC_MESSAGES)[0] or locale.getlocale()[0]
    return loc or None


def _locale_candidates() -> list[str]:
    loc = _system_locale()
    if not loc:
        return []
    normalized = loc.replace("_", "-")
    candidates = [normalized]
    if "." in candidates[0]:
        candidates.insert(0, candidates[0].split(".", 1)[0])
    language = candidates[0].split("-", 1)[0]
    if language not in candidates:
        candidates.append(language)
    return candidates


def _as_list(value) -> list:
    if value is None:
        return []
    if hasattr(value, "as_array"):
        return list(value.as_array())
    try:
        return list(value)
    except TypeError:
        return []


def _first_nonempty_string(*values) -> Optional[str]:
    for value in values:
        if isinstance(value, str):
            text = value.strip()
            if text:
                return text
    return None


def _xml_lang(element) -> Optional[str]:
    return element.attrib.get("{http://www.w3.org/XML/1998/namespace}lang")


def _localized_child_text(parent, tag: str) -> Optional[str]:
    children = parent.findall(tag)
    if not children:
        return None
    by_lang = {_xml_lang(child): child for child in children if child.text and child.text.strip()}
    for loc in _locale_candidates():
        if loc in by_lang:
            return by_lang[loc].text.strip()
    for loc in _locale_candidates():
        language = loc.split("-", 1)[0]
        for child_lang, child in by_lang.items():
            if child_lang and child_lang.split("-", 1)[0] == language:
                return child.text.strip()
    if None in by_lang:
        return by_lang[None].text.strip()
    first = next(iter(by_lang.values()), None)
    return first.text.strip() if first is not None else None


def _localized_desc_text(parent) -> Optional[str]:
    descriptions = parent.findall("description")
    if not descriptions:
        return None

    def desc_text(desc) -> str:
        parts = []
        for child in list(desc):
            text = " ".join("".join(child.itertext()).split())
            if text:
                parts.append(text)
        if not parts and desc.text:
            parts.append(" ".join(desc.text.split()))
        return "\n\n".join(parts).strip()

    by_lang = {_xml_lang(desc): desc for desc in descriptions if desc_text(desc)}
    for loc in _locale_candidates():
        if loc in by_lang:
            return desc_text(by_lang[loc])
    for loc in _locale_candidates():
        language = loc.split("-", 1)[0]
        for child_lang, child in by_lang.items():
            if child_lang and child_lang.split("-", 1)[0] == language:
                return desc_text(child)
    if None in by_lang:
        return desc_text(by_lang[None])
    first = next(iter(by_lang.values()), None)
    return desc_text(first) if first is not None else None


def _flatpak_appstream_paths() -> list[Path]:
    paths: list[Path] = []
    user_root = Path.home() / ".local/share/flatpak/appstream"
    system_root = Path("/var/lib/flatpak/appstream")
    for root in (user_root, system_root):
        if root.exists():
            paths.extend(sorted(root.glob("*/*/active/appstream.xml")))
    return paths


def _cache_dir() -> Path:
    cache_home = os.environ.get("XDG_CACHE_HOME")
    if cache_home:
        root = Path(cache_home)
    else:
        root = Path.home() / ".cache"
    return root / "sadb"


def _flatpak_cache_path() -> Path:
    loc = _system_locale() or "C"
    safe_locale = "".join(char if char.isalnum() or char in ("-", "_", ".") else "_" for char in loc)
    return _cache_dir() / f"flatpak-appstream-{safe_locale}.json"


def _path_signature(path: Path) -> Optional[dict]:
    try:
        stat = path.stat()
    except OSError:
        return None
    return {
        "path": str(path),
        "size": stat.st_size,
        "mtime_ns": stat.st_mtime_ns,
        "ctime_ns": stat.st_ctime_ns,
        "inode": stat.st_ino,
    }


def _source_signature(paths: list[Path]) -> list[dict]:
    return [signature for path in paths if (signature := _path_signature(path)) is not None]


def _load_flatpak_cache(paths: list[Path], component_ids: set[str]) -> Optional[dict[str, dict]]:
    cache_path = _flatpak_cache_path()
    try:
        with cache_path.open("r", encoding="utf-8") as cache_file:
            cache = json.load(cache_file)
    except (OSError, json.JSONDecodeError):
        return None

    if cache.get("version") != _CACHE_VERSION:
        return None
    if cache.get("locale") != (_system_locale() or "C"):
        return None
    if cache.get("sources") != _source_signature(paths):
        return None

    cached_ids = set(cache.get("component_ids", []))
    if not component_ids.issubset(cached_ids):
        return None

    details = cache.get("details", {})
    if not isinstance(details, dict):
        return None
    return {component_id: details[component_id] for component_id in component_ids if component_id in details}


def _write_flatpak_cache(paths: list[Path], component_ids: set[str], details: dict[str, dict]) -> None:
    cache_path = _flatpak_cache_path()
    cache = {
        "version": _CACHE_VERSION,
        "locale": _system_locale() or "C",
        "sources": _source_signature(paths),
        "component_ids": sorted(component_ids),
        "details": details,
    }
    try:
        cache_path.parent.mkdir(parents=True, exist_ok=True)
        tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp")
        with tmp_path.open("w", encoding="utf-8") as cache_file:
            json.dump(cache, cache_file, separators=(",", ":"))
        tmp_path.replace(cache_path)
    except OSError:
        return


def _xml_children_text(parent, path: str) -> list[str]:
    return [
        child.text.strip()
        for child in parent.findall(path)
        if child.text and child.text.strip()
    ]


def _xml_url(parent, url_type: str) -> Optional[str]:
    for url in parent.findall("url"):
        if url.attrib.get("type") == url_type and url.text and url.text.strip():
            return url.text.strip()
    return None


def _xml_icon(parent) -> Optional[str]:
    best = None
    best_score = -1
    for icon in parent.findall("icon"):
        text = (icon.text or "").strip()
        if not text:
            continue
        icon_type = icon.attrib.get("type")
        width = int(icon.attrib.get("width", "0") or 0)
        score = width
        if icon_type == "remote":
            score += 10000
        if score > best_score:
            best_score = score
            best = text
    return best


def _xml_screenshots(parent) -> list[str]:
    urls: list[str] = []
    for screenshot in parent.findall("screenshots/screenshot"):
        best = None
        best_score = -1
        for image in screenshot.findall("image"):
            text = (image.text or "").strip()
            if not text:
                continue
            width = int(image.attrib.get("width", "0") or 0)
            score = width
            if image.attrib.get("type") == "source":
                score += 100000
            if score > best_score:
                best_score = score
                best = text
        if best:
            urls.append(best)
    return urls


def _component_xml_to_detail(component) -> dict:
    developer = component.find("developer")
    return {
        "name": _localized_child_text(component, "name"),
        "developer_name": _localized_child_text(developer, "name") if developer is not None else None,
        "summary": _localized_child_text(component, "summary"),
        "description": _localized_desc_text(component),
        "categories": _xml_children_text(component, "categories/category"),
        "keywords": _xml_children_text(component, "keywords/keyword"),
        "mimetypes": _xml_children_text(component, "provides/mediatype"),
        "project_license": _localized_child_text(component, "project_license"),
        "homepage": _xml_url(component, "homepage"),
        "donate_url": _xml_url(component, "donation"),
        "icon": _xml_icon(component),
        "screenshots": _xml_screenshots(component),
    }


def _component_id_aliases(component_id: str) -> set[str]:
    aliases = {component_id}
    if component_id.endswith(".desktop"):
        aliases.add(component_id[:-8])
    else:
        aliases.add(f"{component_id}.desktop")
    return aliases


def _load_flatpak_appstream_details(component_ids: set[str]) -> dict[str, dict]:
    if not component_ids:
        return {}

    paths = _flatpak_appstream_paths()
    cached_details = _load_flatpak_cache(paths, component_ids)
    if cached_details is not None:
        return cached_details

    details: dict[str, dict] = {}
    required_ids = set(component_ids)
    required_aliases = set()
    for component_id in component_ids:
        required_aliases.update(_component_id_aliases(component_id))

    for path in paths:
        try:
            context = etree.iterparse(path, events=("end",))
        except (OSError, etree.ParseError):
            continue

        try:
            for _event, component in context:
                if component.tag != "component":
                    continue

                component_id = _first_nonempty_string(component.findtext("id"))
                if component_id is None:
                    component.clear()
                    continue
                if component_id not in required_aliases:
                    component.clear()
                    continue

                detail = _component_xml_to_detail(component)
                for alias in _component_id_aliases(component_id):
                    if alias in required_ids:
                        details[alias] = detail
                component.clear()
        except etree.ParseError:
            continue

    _write_flatpak_cache(paths, component_ids, details)
    return details


def _set_string(app: sadb.App, attr: str, value) -> None:
    text = _first_nonempty_string(value)
    if text is not None:
        setattr(app, attr, text)


def _set_list(app: sadb.App, attr: str, value) -> None:
    if value is None:
        return
    items = [str(item).strip() for item in _as_list(value) if str(item).strip()]
    if items:
        setattr(app, attr, items)


def _is_empty_value(value) -> bool:
    if value is None:
        return True
    if isinstance(value, str):
        return value.strip() == ""
    if isinstance(value, list):
        return len(value) == 0
    return False


def _has_local_field(app: sadb.App, yaml_field: str) -> bool:
    return yaml_field in getattr(app, "local_fields", set())


def _set_string_if_missing(app: sadb.App, attr: str, yaml_field: str, value) -> None:
    if _has_local_field(app, yaml_field):
        return
    local_fields = getattr(app, "local_fields", set())
    if _is_empty_value(getattr(app, attr, None)) or (attr == "app_license" and bool(local_fields)):
        _set_string(app, attr, value)


def _set_list_if_missing(app: sadb.App, attr: str, yaml_field: str, value) -> None:
    if _has_local_field(app, yaml_field):
        return
    if _is_empty_value(getattr(app, attr, None)):
        _set_list(app, attr, value)


def _flatpak_component_id(src_pkg_name: str) -> Optional[str]:
    if not src_pkg_name or not src_pkg_name.startswith("app/"):
        return None
    parts = src_pkg_name.split("/")
    if len(parts) < 2:
        return None
    return parts[1] or None


def _component_index(components: Iterable) -> dict[str, object]:
    indexed = {}
    for component in components:
        component_id = _first_nonempty_string(component.get_id())
        if component_id is None:
            continue
        indexed[component_id] = component
        if component_id.endswith(".desktop"):
            indexed[component_id[:-8]] = component
    return indexed


def _load_components():
    if AppStream is None:
        return None

    pool = AppStream.Pool()
    loc = _system_locale()
    if loc:
        pool.set_locale(loc)
    pool.set_load_std_data_locations(True)
    pool.load()
    return pool.get_components()


def _developer_name(component) -> Optional[str]:
    developer = component.get_developer()
    if developer is None:
        return None
    return _first_nonempty_string(developer.get_name())


def _mimetypes(component) -> list[str]:
    provided = component.get_provided_for_kind(AppStream.ProvidedKind.MEDIATYPE)
    if provided is None:
        return []
    return [str(item).strip() for item in provided.get_items() if str(item).strip()]


def _icon_url(component) -> Optional[str]:
    icons = _as_list(component.get_icons())
    sized = component.get_icon_by_size(128, 128)
    if sized is not None:
        icons.insert(0, sized)

    for icon in icons:
        text = _first_nonempty_string(icon.get_url(), icon.get_filename(), icon.get_name())
        if text is not None:
            return text
    return None


def _screenshot_urls(component) -> list[str]:
    urls = []
    for screenshot in _as_list(component.get_screenshots_all()):
        best_url = None
        best_width = -1
        for image in _as_list(screenshot.get_images_all()):
            url = _first_nonempty_string(image.get_url())
            if url is None:
                continue
            width = image.get_width() or 0
            if width > best_width:
                best_width = width
                best_url = url
        if best_url is not None:
            urls.append(best_url)
    return urls


def _enrich_app(app: sadb.App, component) -> None:
    # Keep the repo's stable name for identity, and use AppStream only for the
    # localized display name.
    _set_string(app, "appstream_name", component.get_name())

    _set_string_if_missing(app, "author", "author", _developer_name(component))
    _set_string_if_missing(app, "summary", "summary", component.get_summary())
    _set_string_if_missing(app, "description", "description", component.get_description())
    _set_list_if_missing(app, "categories", "categories", component.get_categories())
    _set_list_if_missing(app, "keywords", "keywords", component.get_keywords())
    _set_list_if_missing(app, "mimetypes", "mimetypes", _mimetypes(component))
    _set_string_if_missing(app, "app_license", "license", component.get_project_license())
    _set_string_if_missing(app, "homepage", "homepage", component.get_url(AppStream.UrlKind.HOMEPAGE))
    _set_string_if_missing(app, "donate_url", "donate_url", component.get_url(AppStream.UrlKind.DONATION))
    _set_string_if_missing(app, "icon_url", "icon_url", _icon_url(component))
    _set_list_if_missing(app, "screenshot_urls", "screenshot_urls", _screenshot_urls(component))


def _enrich_app_from_detail(app: sadb.App, detail: dict) -> None:
    _set_string(app, "appstream_name", detail.get("name"))
    _set_string_if_missing(app, "author", "author", detail.get("developer_name"))
    _set_string_if_missing(app, "summary", "summary", detail.get("summary"))
    _set_string_if_missing(app, "description", "description", detail.get("description"))
    _set_list_if_missing(app, "categories", "categories", detail.get("categories"))
    _set_list_if_missing(app, "keywords", "keywords", detail.get("keywords"))
    _set_list_if_missing(app, "mimetypes", "mimetypes", detail.get("mimetypes"))
    _set_string_if_missing(app, "app_license", "license", detail.get("project_license"))
    _set_string_if_missing(app, "homepage", "homepage", detail.get("homepage"))
    _set_string_if_missing(app, "donate_url", "donate_url", detail.get("donate_url"))
    _set_string_if_missing(app, "icon_url", "icon_url", detail.get("icon"))
    _set_list_if_missing(app, "screenshot_urls", "screenshot_urls", detail.get("screenshots"))


def enrich_apps(apps: List[sadb.App]) -> List[sadb.App]:
    """Merge localized AppStream metadata into apps already present in SaDB."""
    component_ids_by_app = {}
    flatpak_component_ids = set()

    for app in apps:
        if not app.appstream_name:
            app.appstream_name = app.name
        component_id = _flatpak_component_id(app.src_pkg_name)
        if component_id is None:
            continue
        component_ids_by_app[app.app_id] = component_id
        flatpak_component_ids.add(component_id)

    flatpak_details = _load_flatpak_appstream_details(flatpak_component_ids)
    enriched_ids = set()
    for app in apps:
        component_id = component_ids_by_app.get(app.app_id)
        if component_id is None:
            continue
        detail = flatpak_details.get(component_id)
        if detail is None:
            continue
        _enrich_app_from_detail(app, detail)
        enriched_ids.add(app.app_id)
        if not app.appstream_name:
            app.appstream_name = app.name

    unresolved_ids = set(component_ids_by_app) - enriched_ids
    if not unresolved_ids:
        return apps

    try:
        components = _load_components()
    except Exception:
        return apps

    if components is None:
        return apps

    by_id = _component_index(_as_list(components))
    for app in apps:
        if app.app_id not in unresolved_ids:
            continue
        component_id = component_ids_by_app.get(app.app_id)
        if component_id is None:
            continue
        component = by_id.get(component_id)
        if component is None:
            continue
        _enrich_app(app, component)
        if not app.appstream_name:
            app.appstream_name = app.name

    return apps
