"""Manager for module-style data-reading filters."""

from __future__ import annotations

import glob
import importlib
import os
import sys
import traceback
from types import ModuleType
from typing import Any, Iterable

from .tkfilter import tkFilter
from .tkfilterutils import (
    is_error_result,
    module_name_from_path,
    should_skip_data_file,
    split_file_path,
)


class tkFilterManager:
    """Load filters and use them to read data files.

    This class intentionally does not depend on tkApplication.  app and cparams
    can still be passed through to existing filters that expect them.

    Parameters
    ----------
    plugin_dir:
        Directory containing filter modules.
    fmask:
        File mask used when no manifest/order file is specified.
    manifest, order_file:
        Optional plain-text file that lists filter module filenames/names in
        priority order.  Empty lines and lines beginning with ``#`` are ignored.
        Inline comments after ``#`` are also ignored.

        Examples of valid lines::

            miniflex_ras2xrd.py
            smartlab_txt
            generic_xy_txt.py

    include_unlisted:
        If True and a manifest is specified, any ``plugin_dir/fmask`` modules not
        listed in the manifest are appended after the listed modules in filename
        order.  If False, only manifest-listed modules are loaded.
    """

    def __init__(
        self,
        plugin_dir: str = "filter",
        fmask: str = "*.py",
        target: str = "read_data",
        sort: bool = True,
        manifest: str | None = None,
        order_file: str | None = None,
        include_unlisted: bool = False,
        auto_load: bool = False,
        is_print: bool = False,
    ) -> None:
        self.plugin_dir = plugin_dir
        self.fmask = fmask
        self.target = target
        self.sort = sort
        self.manifest = manifest if manifest is not None else order_file
        self.include_unlisted = include_unlisted
        self.filters: list[tkFilter] = []
        self.load_errors: list[dict[str, Any]] = []

        if auto_load:
            self.load_filters(is_print=is_print)

    def __repr__(self) -> str:
        return (
            f"tkFilterManager(plugin_dir={self.plugin_dir!r}, "
            f"nfilters={len(self.filters)}, manifest={self.manifest!r})"
        )

    @property
    def modules(self) -> list[ModuleType]:
        return [f.module for f in self.filters]

    @property
    def module_names(self) -> list[str]:
        return [f.name for f in self.filters]

    def clear(self) -> None:
        self.filters.clear()
        self.load_errors.clear()

    def _ensure_plugin_dir_in_syspath(self, plugin_dir: str | None = None) -> None:
        pdir = self.plugin_dir if plugin_dir is None else plugin_dir
        if pdir and pdir not in sys.path:
            sys.path.append(pdir)

    def _normalize_manifest_module_name(self, line: str) -> str | None:
        """Convert one manifest line to an importable module name.

        The manifest is intentionally simple: one module filename or module name
        per line.  Both ``foo.py`` and ``foo`` are accepted.  Directory portions
        are ignored so that ``filter/foo.py`` also resolves to ``foo``.
        """

        # Remove inline comments and surrounding whitespace.
        s = line.split("#", 1)[0].strip()
        if not s:
            return None

        # Remove quotes to make copy/paste from shells or docs painless.
        s = s.strip('"\'')
        basename = os.path.basename(s)
        filebody, ext = os.path.splitext(basename)
        if ext.lower() == ".py":
            name = filebody
        else:
            name = basename

        if not name or name == "__init__" or name.startswith("_"):
            return None
        return name

    def read_manifest(self, manifest: str | None = None) -> list[str]:
        """Read a plain-text manifest/order file and return module names.

        Duplicate entries are removed, keeping the first occurrence.
        """

        path = self.manifest if manifest is None else manifest
        if path is None:
            return []
        if not os.path.exists(path):
            raise FileNotFoundError(f"Filter manifest/order file was not found: {path}")

        names: list[str] = []
        seen: set[str] = set()
        with open(path, "r", encoding="utf-8") as fp:
            for line in fp:
                name = self._normalize_manifest_module_name(line)
                if name is None or name in seen:
                    continue
                names.append(name)
                seen.add(name)
        return names

    def find_module_files(self, plugin_dir: str | None = None, fmask: str | None = None) -> dict[str, str]:
        """Return {module_name: path} for plugin files under plugin_dir."""

        pdir = self.plugin_dir if plugin_dir is None else plugin_dir
        mask = self.fmask if fmask is None else fmask
        files = glob.glob(os.path.join(pdir, mask))
        if self.sort:
            files.sort()

        path_by_name: dict[str, str] = {}
        for path in files:
            dirname, basename, filebody, ext = split_file_path(path)
            if basename == "__init__.py" or filebody.startswith("_") or ext.lower() != ".py":
                continue
            path_by_name.setdefault(filebody, path)
        return path_by_name

    def resolve_module_order(
        self,
        plugin_dir: str | None = None,
        fmask: str | None = None,
        manifest: str | None = None,
        include_unlisted: bool | None = None,
    ) -> list[tuple[str, str | None]]:
        """Resolve filter load order as ``[(module_name, path_or_none), ...]``."""

        pdir = self.plugin_dir if plugin_dir is None else plugin_dir
        mask = self.fmask if fmask is None else fmask
        order_path = self.manifest if manifest is None else manifest
        use_unlisted = self.include_unlisted if include_unlisted is None else include_unlisted

        path_by_name = self.find_module_files(plugin_dir=pdir, fmask=mask)

        if order_path is None:
            return [(name, path_by_name[name]) for name in sorted(path_by_name)]

        ordered_names = self.read_manifest(order_path)
        ordered: list[tuple[str, str | None]] = []
        seen: set[str] = set()
        for name in ordered_names:
            ordered.append((name, path_by_name.get(name)))
            seen.add(name)

        if use_unlisted:
            for name in sorted(path_by_name):
                if name not in seen:
                    ordered.append((name, path_by_name[name]))
                    seen.add(name)

        return ordered

    def load_filter(
        self,
        module_name: str,
        plugin_dir: str | None = None,
        path: str | None = None,
        desc: str = "",
        is_print: bool = False,
        reload: bool = False,
    ) -> tkFilter | None:
        """Load one filter by module name and return a tkFilter wrapper."""

        pdir = self.plugin_dir if plugin_dir is None else plugin_dir
        self._ensure_plugin_dir_in_syspath(pdir)

        try:
            if reload and module_name in sys.modules:
                module = importlib.reload(sys.modules[module_name])
            else:
                module = importlib.import_module(module_name)
        except Exception as exc:
            if is_print:
                print(f"Warning in tkFilterManager.load_filter(): failed to load [{module_name}] due to {exc}")
                traceback.print_exc()
            self.load_errors.append({"module_name": module_name, "path": path, "exception": exc})
            return None

        filt = tkFilter(name=module_name, module=module, path=path, target=self.target, desc=desc or module_name)
        self.filters.append(filt)
        return filt

    def load_filters(
        self,
        plugin_dir: str | None = None,
        fmask: str | None = None,
        sort: bool | None = None,
        manifest: str | None = None,
        order_file: str | None = None,
        include_unlisted: bool | None = None,
        clear: bool = True,
        is_print: bool = True,
        reload: bool = False,
    ) -> tuple[list[str], list[ModuleType]]:
        """Load filter modules.

        If ``manifest``/``order_file`` is specified, modules are loaded in that
        file's order.  Otherwise, modules are loaded from ``plugin_dir/fmask`` in
        filename order.

        Returns ``(module_names, modules)``, matching the old
        ``tkApplication.load_modules()`` style to make migration easy.
        """

        if plugin_dir is not None:
            self.plugin_dir = plugin_dir
        if fmask is not None:
            self.fmask = fmask
        if sort is not None:
            self.sort = sort
        if manifest is not None:
            self.manifest = manifest
        if order_file is not None:
            self.manifest = order_file
        if include_unlisted is not None:
            self.include_unlisted = include_unlisted
        if clear:
            self.clear()

        if is_print:
            if self.manifest:
                print(f"Search filters: manifest/order file [{self.manifest}]")
                print(f"  plugin_dir: {self.plugin_dir}")
                print(f"  include_unlisted: {self.include_unlisted}")
            else:
                print(f"Search filters: {os.path.join(self.plugin_dir, self.fmask)}")

        self._ensure_plugin_dir_in_syspath(self.plugin_dir)
        module_order = self.resolve_module_order()

        for module_name, path in module_order:
            filt = self.load_filter(
                module_name=module_name,
                plugin_dir="",  # plugin_dir is already on sys.path.
                path=path,
                desc=module_name,
                is_print=is_print,
                reload=reload,
            )
            if is_print:
                if filt is None:
                    print(f"  {module_name}: failed")
                else:
                    src = path if path is not None else "import path"
                    print(f"  {module_name}: loaded ({src})")

        return self.module_names, self.modules

    # Alias with a name close to the old tkApplication method.
    load_modules = load_filters

    def list_types(self, app: Any = None, cparams: Any = None) -> list[dict[str, Any]]:
        """Return input/output type information for loaded filters."""

        rows: list[dict[str, Any]] = []
        for filt in self.filters:
            rows.append(
                {
                    "name": filt.name,
                    "input_type": filt.get_input_type(app=app, cparams=cparams),
                    "output_type": filt.get_output_type(app=app, cparams=cparams),
                    "path": filt.path,
                }
            )
        return rows

    def find_filter(
        self,
        path: str,
        app: Any = None,
        cparams: Any = None,
        inf: Any = None,
        is_print: bool = False,
    ) -> tuple[tkFilter | None, Any]:
        """Find the first filter that accepts path."""

        for filt in self.filters:
            result = filt.check_file_type(path, inf=inf, app=app, cparams=cparams)
            if is_print:
                print(f"try [{filt.name}] for [{path}]: file_type={result}")
            if result is not None and not is_error_result(result):
                if is_print:
                    print("   type matched.")
                return filt, result
        return None, None

    def read_data(
        self,
        path: str,
        app: Any = None,
        cparams: Any = None,
        convert: bool = True,
        is_print: bool = False,
        return_filter: bool = False,
        raise_error: bool = True,
    ) -> Any:
        """Read one data file with the first matching filter.

        If return_filter=True, returns ``(inf, filter, file_type)``.  Otherwise
        returns only ``inf``.
        """

        filt, file_type = self.find_filter(path, app=app, cparams=cparams, is_print=is_print)
        if filt is None:
            if raise_error:
                raise RuntimeError(f"No filter matched [{path}]")
            return (None, None, None) if return_filter else None

        inf = filt.read_data(path, app=app, cparams=cparams, is_print=is_print, convert=convert)
        if isinstance(inf, dict):
            inf.setdefault("filter_name", filt.name)
            inf.setdefault("file_type", file_type)

        if return_filter:
            return inf, filt, file_type
        return inf

    def read_files(
        self,
        paths: str | Iterable[str],
        app: Any = None,
        cparams: Any = None,
        convert: bool = True,
        is_print: bool = False,
        raise_error: bool = False,
        skip_temporary: bool = True,
        skip_output: bool = True,
        return_filter: bool = False,
    ) -> list[Any]:
        """Read multiple files or glob patterns."""

        if isinstance(paths, str):
            file_list = glob.glob(paths)
            if not file_list and os.path.exists(paths):
                file_list = [paths]
            file_list.sort()
        else:
            file_list = list(paths)

        results: list[Any] = []
        for path in file_list:
            if should_skip_data_file(path, skip_temporary=skip_temporary, skip_output=skip_output):
                if is_print:
                    print(f"skip [{path}]")
                continue
            try:
                ret = self.read_data(
                    path,
                    app=app,
                    cparams=cparams,
                    convert=convert,
                    is_print=is_print,
                    return_filter=return_filter,
                    raise_error=raise_error,
                )
            except Exception:
                if raise_error:
                    raise
                if is_print:
                    print(f"Warning: failed to read [{path}]")
                    traceback.print_exc()
                continue
            if ret is not None:
                results.append(ret)
        return results
