# -*- coding: utf-8 -*-
"""
Resource Sharing scanner for favorites collections.

Walks the QGIS Resource Sharing directory tree and yields any JSON file
matching the FilterMate convention:

    {profile}/resource_sharing/collections/<any>/filter_mate/favorites/*.fmfav.json
    {profile}/resource_sharing/collections/<any>/filter_mate/favorites/*.fmfav-pack.json

The scanner is intentionally filesystem-only so it works whether or not
the ``resource_sharing`` QGIS plugin is installed — users can drop
collections manually under that path.
"""

from __future__ import annotations

import json
import logging
import os
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional, Tuple

logger = logging.getLogger('FilterMate.FavoritesSharing.Scanner')


@dataclass(frozen=True)
class SharedFavoriteSource:
    """Metadata about where a shared favorite came from."""
    file_path: str
    collection_name: str
    collection_metadata: Dict[str, Any] = field(default_factory=dict)


@dataclass
class SharedFavorite:
    """A favorite loaded from a Resource Sharing bundle.

    The ``payload`` is the raw per-favorite dict straight out of the JSON
    (already canonical after ``FilterFavorite.from_dict`` normalization).
    ``source`` carries provenance for UI and Fork operations.
    """
    payload: Dict[str, Any]
    source: SharedFavoriteSource

    @property
    def name(self) -> str:
        return str(self.payload.get('name') or '(unnamed)')

    @property
    def description(self) -> str:
        return str(self.payload.get('description') or '')

    @property
    def schema_version(self) -> int:
        return int(self.payload.get('_schema_version') or 1)

    @property
    def author(self) -> str:
        """Author of the *collection* this favorite was published from.

        v3 schema does not carry per-favorite author; the canonical
        signal is the collection-level ``author`` (set by the publisher
        in the bundle envelope or in ``collection.json``).
        """
        meta = self.source.collection_metadata or {}
        return str(meta.get('author') or '').strip()


class ResourceSharingScanner:
    """Enumerate favorites bundles installed under Resource Sharing.

    The scanner exposes two entry points:

    - :meth:`get_collections_root` — single directory where bundles live.
    - :meth:`scan` — walk the tree and return :class:`SharedFavorite`
      objects, each annotated with its source collection.

    Results are cached; call :meth:`invalidate_cache` to force a rescan
    (e.g. after a new subscription).
    """

    FAVORITE_FILE_SUFFIXES: Tuple[str, ...] = ('.fmfav.json', '.fmfav-pack.json')

    def __init__(
        self,
        collections_root: Optional[str] = None,
        extension: Optional[Any] = None,
    ):
        """
        Args:
            collections_root: Hard-coded root override (tests mostly).
            extension: Owning ``FavoritesSharingExtension`` — used to read
                config (resource_sharing_root, allowed_collections). When
                omitted the scanner falls back to standalone ENV_VARS
                lookups for backward compatibility.
        """
        self._explicit_root = collections_root
        self._extension = extension
        self._cache: Optional[List[SharedFavorite]] = None

    # ─── Path discovery ────────────────────────────────────────────────

    def get_collections_root(self) -> Optional[str]:
        """Resolve the Resource Sharing collections directory.

        Resolution order (first hit wins):
        1. explicit ``collections_root`` passed to the constructor;
        2. ``EXTENSIONS.favorites_sharing.resource_sharing_root`` in
           FilterMate config — lets users override the auto-detected
           path (e.g., point at a shared network mount);
        3. ``QgsApplication.qgisSettingsDirPath()`` + ``/resource_sharing/collections``
           (matches the resource_sharing plugin convention);
        4. ``QGIS_CUSTOM_CONFIG_PATH`` / ``QGIS_AUTH_CUSTOM_CONFIG_PATH``
           environment overrides;
        5. Platform-specific fallbacks under ``~``.

        Returns ``None`` when nothing resolves to an existing directory.
        """
        if self._explicit_root and os.path.isdir(self._explicit_root):
            return self._explicit_root

        # Config override — users can point at a custom collections dir
        configured = self._read_configured_root()
        if configured and os.path.isdir(configured):
            return configured

        candidate_roots: List[str] = []
        try:
            from qgis.core import QgsApplication
            settings_dir = QgsApplication.qgisSettingsDirPath()
            if settings_dir:
                candidate_roots.append(os.path.join(
                    settings_dir, 'resource_sharing', 'collections',
                ))
        except Exception:  # nosec B110 - best-effort candidate root discovery; falls through to next candidate
            pass

        # Environment override (set by some Resource Sharing setups)
        for env_key in ('QGIS_CUSTOM_CONFIG_PATH', 'QGIS_AUTH_CUSTOM_CONFIG_PATH'):
            raw = os.environ.get(env_key)
            if raw:
                candidate_roots.append(os.path.join(
                    raw, 'resource_sharing', 'collections',
                ))

        # Fallback: default profile path
        home = os.path.expanduser('~')
        candidate_roots.append(os.path.join(
            home, '.qgis3', 'resource_sharing', 'collections',
        ))
        candidate_roots.append(os.path.join(
            home, 'AppData', 'Roaming', 'QGIS', 'QGIS3',
            'profiles', 'default', 'resource_sharing', 'collections',
        ))

        for root in candidate_roots:
            if os.path.isdir(root):
                return root
        return None

    # ─── Scanning ──────────────────────────────────────────────────────

    def invalidate_cache(self) -> None:
        self._cache = None

    def scan(self, force_refresh: bool = False) -> List[SharedFavorite]:
        """Walk the collections tree and collect shared favorites.

        Args:
            force_refresh: Re-read the filesystem even if a cached list
                is available.

        Returns:
            List of :class:`SharedFavorite` — may be empty if no
            Resource Sharing directory exists or no collection carries
            FilterMate bundles.
        """
        if self._cache is not None and not force_refresh:
            return list(self._cache)

        root = self.get_collections_root()
        collected: List[SharedFavorite] = []
        if not root:
            logger.debug("Resource Sharing root not found — scan skipped")
            self._cache = []
            return []

        for collection_name, collection_dir in self._iter_collections(root):
            collection_meta = self._read_collection_metadata(collection_dir)
            favorites_dir = os.path.join(collection_dir, 'filter_mate', 'favorites')
            if not os.path.isdir(favorites_dir):
                continue

            for filename in sorted(os.listdir(favorites_dir)):
                lowered = filename.lower()
                if not any(lowered.endswith(suffix) for suffix in self.FAVORITE_FILE_SUFFIXES):
                    continue
                file_path = os.path.join(favorites_dir, filename)
                source = SharedFavoriteSource(
                    file_path=file_path,
                    collection_name=collection_name,
                    collection_metadata=collection_meta,
                )
                collected.extend(self._load_file(file_path, source))

        logger.info(
            "Resource Sharing scan: %d shared favorite(s) across %d file(s)",
            len(collected),
            len({f.source.file_path for f in collected}),
        )
        self._cache = list(collected)
        return collected

    # ─── Helpers ───────────────────────────────────────────────────────

    def _read_configured_root(self) -> Optional[str]:
        """Read ``EXTENSIONS.favorites_sharing.resource_sharing_root`` from
        FilterMate config — via the owning extension when available, else
        a direct ENV_VARS lookup (kept for standalone tests).
        """
        if self._extension is not None:
            try:
                return self._extension.get_resource_sharing_root()
            except Exception:  # nosec B110 - best-effort delegate lookup; falls back to direct ENV_VARS read below
                pass
        try:
            from filter_mate.config.config import ENV_VARS, _get_option_value
            cfg = (ENV_VARS.get("CONFIG_DATA", {}) or {}) \
                .get("EXTENSIONS", {}) \
                .get("favorites_sharing", {})
            value = _get_option_value(cfg.get("resource_sharing_root"), default="")
            return str(value) if value else None
        except Exception:
            return None

    def _read_allowed_collections(self) -> List[str]:
        """Read the opt-in allow-list of collection basenames from config.

        Empty list means "allow everything" — mirrors the config default.
        """
        if self._extension is not None:
            try:
                return self._extension.get_allowed_collections()
            except Exception:  # nosec B110 - best-effort delegate lookup; falls back to direct ENV_VARS read below
                pass
        try:
            from filter_mate.config.config import ENV_VARS, _get_option_value
            cfg = (ENV_VARS.get("CONFIG_DATA", {}) or {}) \
                .get("EXTENSIONS", {}) \
                .get("favorites_sharing", {})
            value = _get_option_value(cfg.get("allowed_collections"), default=[])
            if isinstance(value, list):
                return [str(v) for v in value if v]
        except Exception:  # nosec B110 - best-effort config read; falls back to "allow everything" default
            pass
        return []

    def _iter_collections(self, root: str) -> Iterable[Tuple[str, str]]:
        from .path_utils import safe_join_under

        try:
            entries = sorted(os.listdir(root))
        except OSError as e:
            logger.debug(f"Cannot list Resource Sharing root {root}: {e}")
            return []

        allowed = set(self._read_allowed_collections())
        for name in entries:
            if name.startswith('.'):
                continue
            # Reject symlinks that escape the collections root after
            # realpath resolution. ``os.path.isdir`` follows symlinks, so
            # a malicious ``allowed_name`` symlink could otherwise leak
            # an arbitrary FS subtree into the scan.
            safe = safe_join_under(root, name)
            if safe is None or not os.path.isdir(safe):
                continue
            # Opt-in allow-list: when non-empty only accept whitelisted basenames.
            if allowed and name not in allowed:
                continue
            yield name, safe

    @staticmethod
    def _read_collection_metadata(collection_dir: str) -> Dict[str, Any]:
        """Read ``collection.json`` (Resource Sharing manifest) when present.

        The Resource Sharing plugin itself uses INI-format metadata.txt but
        newer collections carry a JSON manifest. We accept either and fall
        back to an empty dict.
        """
        manifest = os.path.join(collection_dir, 'collection.json')
        if os.path.isfile(manifest):
            try:
                with open(manifest, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                if isinstance(data, dict):
                    return data
            except (ValueError, OSError) as e:
                logger.debug(f"Cannot read collection.json at {manifest}: {e}")

        return {}

    @staticmethod
    def _load_file(file_path: str, source: SharedFavoriteSource) -> List[SharedFavorite]:
        """Parse a .fmfav.json / .fmfav-pack.json file into SharedFavorite objects.

        Accepts both the envelope format (dict with ``favorites`` list,
        schema v1/v2/v3) and a bare favorite payload (dict without the
        envelope), for quick-share snippets.

        Trust boundary: bundles come from arbitrary publishers via the
        Resource Sharing plugin. We run the structural validator
        immediately after JSON parse — malformed envelopes are skipped
        with a warning so a corrupted/hostile bundle cannot reach the
        Fork path with broken invariants.
        """
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                data = json.load(f)
        except (ValueError, OSError) as e:
            logger.warning(f"Shared favorite bundle unreadable: {file_path}: {e}")
            return []

        # Validate envelope-shaped bundles. Bare-snippet form (no
        # ``favorites`` key) bypasses validation deliberately — it's
        # already covered by the per-favorite shape check below.
        if isinstance(data, dict) and 'favorites' in data:
            try:
                from .validator import validate
                ok, errors = validate(data)
            except Exception:
                logger.exception(
                    f"Validator unavailable for {file_path}; skipping bundle"
                )
                return []
            if not ok:
                logger.warning(
                    "Shared bundle %s rejected by validator: %s",
                    file_path,
                    "; ".join(errors[:3]) + (f" (+{len(errors) - 3} more)" if len(errors) > 3 else ""),
                )
                return []

        items: List[SharedFavorite] = []
        schema_version = 1

        if isinstance(data, dict) and 'favorites' in data:
            try:
                schema_version = int(data.get('schema_version') or 1)
            except (ValueError, TypeError):
                schema_version = 1
            payload_list = data.get('favorites') or []
            if not isinstance(payload_list, list):
                logger.warning(
                    f"Shared bundle {file_path}: 'favorites' is not a list — skipped"
                )
                return []
            # Merge collection metadata from file envelope into source
            file_collection = data.get('collection')
            if isinstance(file_collection, dict):
                merged = dict(source.collection_metadata)
                merged.update(file_collection)
                source = SharedFavoriteSource(
                    file_path=source.file_path,
                    collection_name=source.collection_name,
                    collection_metadata=merged,
                )
            for payload in payload_list:
                if not isinstance(payload, dict):
                    continue
                annotated = dict(payload)
                annotated['_schema_version'] = schema_version
                items.append(SharedFavorite(payload=annotated, source=source))
        elif isinstance(data, dict) and data.get('name'):
            # Bare favorite payload (snippet form)
            annotated = dict(data)
            annotated.setdefault('_schema_version', 1)
            items.append(SharedFavorite(payload=annotated, source=source))
        else:
            logger.debug(f"Shared bundle {file_path} has no favorites payload")

        return items
