# SPDX-License-Identifier: LGPL-2.1-or-later
# SPDX-FileCopyrightText: 2025 FreeCAD Project Association
# SPDX-FileNotice: Part of the AddonManager.

################################################################################
#                                                                              #
#   This addon is free software: you can redistribute it and/or modify         #
#   it under the terms of the GNU Lesser General Public License as             #
#   published by the Free Software Foundation, either version 2.1              #
#   of the License, or (at your option) any later version.                     #
#                                                                              #
#   This addon is distributed in the hope that it will be useful,              #
#   but WITHOUT ANY WARRANTY; without even the implied warranty                #
#   of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.                    #
#   See the GNU Lesser General Public License for more details.                #
#                                                                              #
#   You should have received a copy of the GNU Lesser General Public           #
#   License along with this addon. If not, see https://www.gnu.org/licenses    #
#                                                                              #
################################################################################

"""The Addon Catalog is the main list of all Addons along with their various
sources and compatible versions. Added in FreeCAD 1.1 to replace .gitmodules."""

import base64
import datetime
import os
import xml.etree.ElementTree
from dataclasses import dataclass
import json
from hashlib import sha256
from typing import Any, Dict, List, Optional

from addonmanager_metadata import Version, MetadataReader
from Addon import Addon

import addonmanager_freecad_interface as fci


INVALID_TIME = datetime.datetime.fromtimestamp(0, tz=datetime.timezone.utc).astimezone()


@dataclass
class CatalogEntryMetadata:
    """All contents of the metadata are the text contents of the file listed. The icon data is
    base64-encoded (even though it was probably an SVG, technically other formats are supported)."""

    package_xml: str = ""
    requirements_txt: str = ""
    metadata_txt: str = ""
    icon_data: str = ""

    @staticmethod
    def from_dict(data: Dict[str, Any]) -> "CatalogEntryMetadata":
        """Create CatalogEntryMetadata from a data dictionary"""
        md = CatalogEntryMetadata()
        if "package_xml" in data:
            md.package_xml = data["package_xml"]
        if "requirements_txt" in data:
            md.requirements_txt = data["requirements_txt"]
        if "metadata_txt" in data:
            md.metadata_txt = data["metadata_txt"]
        if "icon_data" in data:
            md.icon_data = data["icon_data"]
        return md


@dataclass
class AddonCatalogEntry:
    """Each entry in the catalog, storing data about a particular version of an
    Addon. Note that this class needs to be identical to the one that is used in the remote cache
    generation, so don't make changes here without ensuring that the classes are synchronized."""

    freecad_min: Optional[Version] = None
    freecad_max: Optional[Version] = None
    repository: Optional[str] = None
    git_ref: Optional[str] = None
    zip_url: Optional[str] = None
    note: Optional[str] = None
    branch_display_name: Optional[str] = None
    metadata: Optional[CatalogEntryMetadata] = None  # Generated by the cache system
    last_update_time: str = ""  # Generated by the cache system
    curated: bool = True  # Generated by the cache system
    sparse_cache: bool = False  # Generated by the cache system
    relative_cache_path: str = ""  # Generated by the cache system

    def __init__(self, raw_data: Dict[str, str]) -> None:
        """Create an AddonDictionaryEntry from the raw JSON data"""
        super().__init__()
        for key, value in raw_data.items():
            if hasattr(self, key):
                if key in ("freecad_min", "freecad_max"):
                    if isinstance(value, str):
                        value = Version(from_string=value)
                    elif isinstance(value, list):
                        value = Version(from_list=value)
                    elif value is None:
                        pass
                    elif isinstance(value, dict) and "version_as_list" in value:
                        value = Version(from_list=value["version_as_list"])
                    else:
                        raise ValueError(f"Invalid value for {key}: {value}")
                elif key == "metadata":
                    if isinstance(value, dict):
                        metadata = CatalogEntryMetadata()
                        metadata.__dict__.update(value)
                        value = metadata
                    elif isinstance(value, str):
                        value = CatalogEntryMetadata.from_dict(json.loads(value))
                elif key == "git_ref" and self.branch_display_name is None:
                    self.branch_display_name = value
                setattr(self, key, value)

    def is_compatible(self) -> bool:
        """Check whether this AddonCatalogEntry is compatible with the current version of FreeCAD"""
        if self.freecad_min is None and self.freecad_max is None:
            return True
        current_version = Version(from_list=fci.Version())
        if self.freecad_min is None:
            return current_version <= self.freecad_max
        if self.freecad_max is None:
            return current_version >= self.freecad_min
        return self.freecad_min <= current_version <= self.freecad_max

    def unique_identifier(self) -> str:
        """Return a unique identifier of the AddonCatalogEntry, guaranteed to be repeatable: when
        given the same basic information, the same ID is created. Used as the key when storing
        the metadata for a given AddonCatalogEntry."""
        sha256_hash = sha256()
        sha256_hash.update(str(self).encode("utf-8"))
        return sha256_hash.hexdigest()

    def instantiate_addon(self, addon_id: str) -> Addon:
        """Return an instantiated Addon object"""
        if AddonCatalogEntry.is_installed(addon_id):
            state = Addon.Status.UNCHECKED
        else:
            state = Addon.Status.NOT_INSTALLED
        if self.sparse_cache:
            if self.zip_url:
                url = self.zip_url
            else:
                # Technically, this should never happen, but just in case...
                raise RuntimeError(f"Sparse cache entry {addon_id} has no zip_url")
        elif self.repository:
            url = self.repository
        else:
            url = self.zip_url
        if self.git_ref:
            addon = Addon(addon_id, url, state, branch=self.git_ref)
        else:
            addon = Addon(addon_id, url, state)
        addon.relative_cache_path = self.relative_cache_path

        if self.metadata:
            try:
                self._load_addon_metadata(addon, self.metadata)
            except xml.etree.ElementTree.ParseError:
                fci.Console.PrintWarning(
                    "An invalid or corrupted package.xml file was installed "
                    f"for {addon.display_name}\n"
                )

        try:
            addon.remote_last_updated = datetime.datetime.fromisoformat(self.last_update_time)
        except ValueError:
            addon.remote_last_updated = INVALID_TIME
        if state == Addon.Status.UNCHECKED:
            try:
                package_file = os.path.join(fci.DataPaths().mod_dir, addon_id, "package.xml")
                addon.installed_metadata = MetadataReader.from_file(package_file)
            except (FileNotFoundError, xml.etree.ElementTree.ParseError, RuntimeError):
                pass  # If there was an error, just ignore it, no metadata is not fatal

            most_recent_mtime = AddonCatalogEntry.most_recent_mtime(addon_id)
            addon.updated_timestamp = most_recent_mtime.timestamp()
            if most_recent_mtime < addon.remote_last_updated:
                addon.set_status(Addon.Status.UPDATE_AVAILABLE)
            elif (
                addon.installed_metadata
                and addon.metadata
                and addon.installed_metadata.version < addon.metadata.version
            ):
                # Fallback: even if the modification time is newer, if the stated version is older
                # something strange has happened, but flag an available update
                addon.set_status(Addon.Status.UPDATE_AVAILABLE)
            else:
                addon.set_status(Addon.Status.NO_UPDATE_AVAILABLE)

        addon.branch_display_name = (
            self.branch_display_name if self.branch_display_name else self.git_ref
        )

        addon.curated = self.curated

        return addon

    @staticmethod
    def is_installed(addon_id: str) -> bool:
        """Check if the addon is installed"""
        addon_dir = os.path.join(fci.DataPaths().mod_dir, addon_id)
        return os.path.exists(addon_dir) and os.listdir(addon_dir)

    @staticmethod
    def most_recent_mtime(addon_id: str) -> datetime.datetime:
        """Get the last update time of the addon by checking the modification time of all its
        files and returning the latest one."""
        if not AddonCatalogEntry.is_installed(addon_id):
            return INVALID_TIME
        addon_dir = os.path.join(fci.DataPaths().mod_dir, addon_id)
        max_time = INVALID_TIME
        for root, dirs, files in os.walk(addon_dir):
            dirs[:] = [d for d in dirs if d != ".git"]
            for file in files:
                file_path = os.path.join(root, file)
                try:
                    file_timestamp = os.path.getmtime(file_path)
                except OSError:
                    continue
                file_time = datetime.datetime.fromtimestamp(file_timestamp).astimezone()
                if file_time > max_time:
                    max_time = file_time
        return max_time

    def _load_addon_metadata(self, addon: Addon, cem: CatalogEntryMetadata):
        if cem.package_xml:
            metadata = MetadataReader.from_bytes(cem.package_xml.encode("utf-8"))
            addon.set_metadata(metadata)
        if cem.requirements_txt:
            AddonCatalogEntry._load_requirements_txt(addon, cem.requirements_txt)
        if cem.metadata_txt:
            AddonCatalogEntry._load_metadata_txt(addon, cem.metadata_txt)
        if cem.icon_data:
            self._load_icon_data(addon, cem.icon_data)

    @staticmethod
    def _load_metadata_txt(repo: Addon, data: str):
        """Process the metadata.txt metadata file"""
        lines = data.splitlines()
        for line in lines:
            if line.startswith("workbenches="):
                workbench_dependencies = line.split("=")[1].split(",")
                for wb in workbench_dependencies:
                    wb_name = wb.strip()
                    if wb_name:
                        repo.requires.add(wb_name)

            elif line.startswith("pylibs="):
                python_dependencies = line.split("=")[1].split(",")
                for pl in python_dependencies:
                    dep = pl.strip()
                    if dep:
                        repo.python_requires.add(dep)

            elif line.startswith("optionalpylibs="):
                optional_python_dependencies = line.split("=")[1].split(",")
                for pl in optional_python_dependencies:
                    dep = pl.strip()
                    if dep:
                        repo.python_optional.add(dep)

    @staticmethod
    def _load_requirements_txt(repo: Addon, data: str):
        """Process the requirements.txt metadata file"""

        lines = data.splitlines()
        for line in lines:
            break_chars = " <>=~!+#"
            package = line
            for n, c in enumerate(line):
                if c in break_chars:
                    package = line[:n].strip()
                    break
            if package:
                repo.python_requires.add(package)

    @staticmethod
    def _load_icon_data(repo: Addon, data: str):
        """Process the icon data."""

        repo.icon_data = base64.b64decode(data)
        if not repo.icon_data:
            raise ValueError(f"Invalid icon data '{data}' in cache for addon '{repo.name}'")


class AddonCatalog:
    """A catalog of addons grouped together into sets representing versions that are
    compatible with different versions of FreeCAD and/or represent different available branches
    of a given addon (e.g., a Development branch that users are presented)."""

    def __init__(self, data: Dict[str, Any]):
        self._original_data = data
        self._dictionary: Dict[str, List[AddonCatalogEntry]] = {}
        self._parse_raw_data()
        self._temp_icon_files = []

    def _parse_raw_data(self):
        self._dictionary = {}  # Clear pre-existing contents
        for key, value in self._original_data.items():
            if key in ["_meta", "$schema"]:  # Don't add the documentation objects to the tree
                continue
            self._dictionary[key] = []
            for entry in value:
                try:
                    self._dictionary[key].append(AddonCatalogEntry(entry))
                except (RuntimeError, LookupError) as e:
                    fci.Console.PrintWarning(
                        f"Failed to parse AddonCatalogEntry for {key}:\n\n {entry} \n\n"
                    )
                    fci.Console.PrintWarning(f"{e}\n")

    def get_available_addon_ids(self) -> List[str]:
        """Get a list of IDs that have at least one entry compatible with the current version of
        FreeCAD"""
        id_list = []
        for key, value in self._dictionary.items():
            for entry in value:
                if entry.is_compatible():
                    id_list.append(key)
                    break
        return id_list

    def get_all_addon_ids(self) -> List[str]:
        """Get a list of all Addon IDs, even those that have no compatible versions for the current
        version of FreeCAD."""
        id_list = []
        for key, value in self._dictionary.items():
            if len(value) == 0:
                continue
            id_list.append(key)
        return id_list

    def add_metadata_to_entry(
        self, addon_id: str, index: int, metadata: CatalogEntryMetadata
    ) -> None:
        """Adds metadata to an AddonCatalogEntry"""
        if addon_id not in self._dictionary:
            raise RuntimeError(f"Addon {addon_id} does not exist")
        if index >= len(self._dictionary[addon_id]):
            raise RuntimeError(f"Addon {addon_id} index out of range")
        self._dictionary[addon_id][index].metadata = metadata

    def get_available_branches(self, addon_id: str) -> List[str]:
        """For a given ID, get the list of available branches compatible with this version of
        FreeCAD.
        :return: A list of branch display names (or git refs, if no display name is available)"""
        if addon_id not in self._dictionary:
            return []
        result = []
        for entry in self._dictionary[addon_id]:
            if entry.is_compatible():
                result.append(
                    entry.branch_display_name if entry.branch_display_name else entry.git_ref
                )
        return result

    def get_catalog(self) -> Dict[str, List[AddonCatalogEntry]]:
        """Get access to the entire catalog without any filtering applied."""
        return self._dictionary

    def get_addon_from_id(self, addon_id: str, branch_display_name: Optional[str] = None) -> Addon:
        """Get the instantiated Addon object for the given ID and optionally branch. If no
        branch is provided, whichever branch is the "primary" branch will be returned (i.e., the
        first branch that matches). Raises a ValueError if no addon matches the request."""
        if addon_id not in self._dictionary:
            raise ValueError(f"Addon '{addon_id}' not found")
        for entry in self._dictionary[addon_id]:
            if not entry.is_compatible():
                continue
            if not branch_display_name or entry.branch_display_name == branch_display_name:
                return entry.instantiate_addon(addon_id)
        raise ValueError(
            f"Addon '{addon_id}' has no compatible branches named '{branch_display_name}'"
        )
