# Copyright (c) 2025-2026 Zensical and contributors

# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

from __future__ import annotations

import functools
import hashlib
import importlib
import os
import pickle
from importlib.metadata import EntryPoint, entry_points
from importlib.util import find_spec
from pathlib import Path
from typing import IO, TYPE_CHECKING, Any
from urllib.parse import urljoin, urlparse

import yaml
from click import ClickException
from deepmerge import always_merger
from tomli import load as toml_load
from yaml import Loader, YAMLError
from yaml.constructor import ConstructorError

from zensical.compat.mkdocstrings import get_mkdocstrings_extension
from zensical.extensions.autorefs import AutorefsExtension
from zensical.extensions.emoji import to_svg, twemoji
from zensical.extensions.glightbox import GlightboxExtension
from zensical.extensions.macros import MacrosExtension

if TYPE_CHECKING:
    from collections.abc import Iterator


# ----------------------------------------------------------------------------
# Globals
# ----------------------------------------------------------------------------


_CONFIG: dict[str, Any] | None = None
"""
Global configuration to pick up later for parsing Markdown.

Since MkDocs uses YAML as a configuration format, the configuration can contain
references to functions or other Python objects, for which we don't have any
representation in Rust. Thus, we just keep the configuration on the Python
side, and use it directly when needed. It's a hack but will do for now.
"""


# ----------------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------------


DEFAULT_MARKDOWN_EXTENSIONS = {
    "abbr": {},
    "admonition": {},
    "attr_list": {},
    "def_list": {},
    "footnotes": {},
    "md_in_html": {},
    "toc": {"permalink": True},
    "pymdownx.arithmatex": {"generic": True},
    "pymdownx.betterem": {},
    "pymdownx.caret": {},
    "pymdownx.details": {},
    "pymdownx.emoji": {
        "emoji_generator": to_svg,
        "emoji_index": twemoji,
    },
    "pymdownx.highlight": {
        "anchor_linenums": True,
        "line_spans": "__span",
        "pygments_lang_class": True,
    },
    "pymdownx.inlinehilite": {},
    "pymdownx.keys": {},
    "pymdownx.magiclink": {},
    "pymdownx.mark": {},
    "pymdownx.smartsymbols": {},
    "pymdownx.superfences": {
        "custom_fences": [{"name": "mermaid", "class": "mermaid"}]
    },
    "pymdownx.tabbed": {
        "alternate_style": True,
        "combine_header_slug": True,
    },
    "pymdownx.tasklist": {"custom_checkbox": True},
    "pymdownx.tilde": {},
}


# ----------------------------------------------------------------------------
# Classes
# ----------------------------------------------------------------------------


class ConfigurationError(ClickException):
    """Configuration resolution or validation failed."""


# ----------------------------------------------------------------------------
# Functions
# ----------------------------------------------------------------------------


def parse_config(path: str) -> dict:
    """Parse configuration file."""
    # Decide by extension; no need to convert to Path
    _, ext = os.path.splitext(path)
    if ext.lower() == ".toml":
        return parse_zensical_config(path)
    return parse_mkdocs_config(path)


def parse_zensical_config(path: str) -> dict:
    """Parse zensical.toml configuration file."""
    global _CONFIG  # noqa: PLW0603
    with open(path, "rb") as f:
        config = toml_load(f)
    if "project" in config:
        config = config["project"]

    # Apply defaults and return parsed configuration
    _CONFIG = _apply_defaults(config, path)
    return _CONFIG


def parse_mkdocs_config(path: str) -> dict:
    """Parse mkdocs.yml configuration file."""
    global _CONFIG  # noqa: PLW0603
    with open(path, encoding="utf-8") as f:
        config = _yaml_load(f)

    # Apply defaults and return parsed configuration
    _CONFIG = _apply_defaults(config, path)
    return _CONFIG


def get_config() -> dict:
    """Return configuration."""
    # We assume this function is only called after populating `_CONFIG`.
    return _CONFIG  # ty:ignore[invalid-return-type]


def _yaml_load(source: IO) -> dict[str, Any]:
    """Load configuration file, resolve environment variables and parent files.

    Note that INHERIT is only a bandaid that was introduced to allow for some
    degree of modularity, but with serious shortcomings. Zensical will use a
    different approach in the future, which will allow for composable and
    environment-specific configuration.
    """
    Loader.add_constructor("!ENV", _construct_env_tag)
    try:
        config = yaml.load(
            # Compatibility shim: we remap Material's extension namespace to
            # Zensical's, and the now deprecated materialx namespace as well
            source.read()
            .replace("material.extensions", "zensical.extensions")
            .replace("materialx", "zensical.extensions"),
            Loader=Loader,  # noqa: S506
        )
    except YAMLError as e:
        raise ConfigurationError(
            f"Encountered an error parsing the configuration file: {e}"
        ) from e
    if config is None:
        return {}

    # Try to resolve inherited configuration file
    if "INHERIT" in config and not isinstance(source, str):
        relpath = config.pop("INHERIT")
        abspath = os.path.normpath(
            os.path.join(os.path.dirname(source.name), relpath)
        )
        if not os.path.exists(abspath):
            raise ConfigurationError(
                f"Inherited config file '{relpath}' "
                f"doesn't exist at '{abspath}'."
            )
        with open(abspath, encoding="utf-8") as fd:
            parent = _yaml_load(fd)
        config = always_merger.merge(parent, config)

    # Return resulting configuration
    return config


def _construct_env_tag(
    loader: yaml.Loader,
    node: yaml.ScalarNode | yaml.SequenceNode | yaml.MappingNode,
) -> Any:
    """Assign value of ENV variable referenced at node.

    MkDocs supports the use of !ENV to reference environment variables in YAML
    configuration files. We won't likely support this in Zensical, but for now
    we need it to build MkDocs projects. Zensical will use a different approach
    to create environment-specific configuration in the future.

    Licensed under MIT
    Copyright (c) 2020 Waylan Limberg
    Taken and adapted from
        https://github.com/waylan/pyyaml-env-tag/blob/master/yaml_env_tag.py
    """
    default = None

    # Handle !ENV <name>
    if isinstance(node, yaml.nodes.ScalarNode):
        vars = [loader.construct_scalar(node)]

    # Handle !ENV [<name>, <fallback>]
    elif isinstance(node, yaml.nodes.SequenceNode):
        child_nodes = node.value
        if len(child_nodes) > 1:
            default = loader.construct_object(child_nodes[-1])
            child_nodes = child_nodes[:-1]
        # Env Vars are resolved as string values, ignoring (implicit) types.
        vars = [loader.construct_scalar(child) for child in child_nodes]
    else:
        raise ConstructorError(
            context=f"expected a scalar or sequence node, but found {node.id}",
            context_mark=node.start_mark,
        )

    # Resolve environment variable
    for var in vars:
        if var in os.environ:
            value = os.environ[var]
            # Resolve value to Python type using YAML's implicit resolvers
            tag = loader.resolve(yaml.nodes.ScalarNode, value, (True, False))
            return loader.construct_object(yaml.nodes.ScalarNode(tag, value))

    # Otherwise return default
    return default


# ----------------------------------------------------------------------------


@functools.cache
def get_themes() -> dict[str, EntryPoint]:
    """Return available themes.

    This function adds support for using MkDocs themes with Zensical, including
    derived themes. There's currently no plan to add `zensical.*` entrypoints,
    since the theming system will undergo a major revision with the addition
    of the component system. This will make theming simpler and modular.
    """
    themes: dict[str, EntryPoint] = {}
    for entry_point in entry_points(group="mkdocs.themes"):
        themes[entry_point.name] = entry_point

    # Return themes
    return themes


def get_builtin_theme_dir() -> str:
    """Return the built-in theme directory."""
    path = os.path.dirname(os.path.abspath(__file__))
    return os.path.join(path, "templates")


def get_theme_dir(name: str) -> str:
    """Return the theme directory."""
    # Zensical's default theme is the replacement for the `material` theme, so
    # make sure to use the built-in theme when `material` is specified.
    # We also reserve the `zensical` theme name to the same effect.
    if name in ("material", "zensical"):
        return get_builtin_theme_dir()

    # Otherwise, check for installed themes - note that because of minijinja,
    # theme might need to be adjusted. This functionality is primarily intended
    # for extending the `material` theme, as some users did, and allow to move
    # overrides into an installable python package.
    themes = get_themes()
    if name not in themes:
        raise ConfigurationError(
            f"Theme '{name}' is not installed. "
            f"Available themes are: {', '.join(themes.keys())}"
        )
    return os.path.dirname(os.path.abspath(themes[name].load().__file__))


def get_custom_theme_dir(path: str, config_path: str) -> str:
    """Return the custom theme directory."""
    theme_dir = os.path.join(os.path.dirname(config_path), path)

    # Validate that custom theme directory exists
    if not os.path.isdir(theme_dir):
        raise ConfigurationError(
            f"Custom theme directory does not exist: {theme_dir}"
        )
    return theme_dir


def _yaml_load_theme_config(source: IO) -> dict[str, Any]:
    Loader.add_constructor("!ENV", _construct_env_tag)
    try:
        config = yaml.load(source, Loader=Loader)  # noqa: S506
    except YAMLError as e:
        raise ConfigurationError(
            f"Encountered an error parsing the theme configuration file: {e}"
        ) from e
    if config is None:
        return {}
    return config


def _load_theme_config(theme_dir: str) -> tuple[dict[str, Any], list[str]]:
    # Keep track of the theme directories,
    # so that we can pass them up to Minijinja.
    theme_dirs = [theme_dir]

    if (theme_config_file := Path(theme_dir, "mkdocs_theme.yml")).is_file():
        # We found a theme configuration file (mkdocs_theme.yml).
        with theme_config_file.open(encoding="utf-8") as file:
            theme_config = _yaml_load_theme_config(file)
            if extends := theme_config.pop("extends", None):
                # This theme extends another theme,
                # so we load this parent theme's configuration (recursively)
                # and merge the current theme's configuration into it.
                parent_theme_dir = get_theme_dir(extends)
                parent_config, parent_dirs = _load_theme_config(
                    parent_theme_dir
                )
                parent_config.update(theme_config)
                theme_dirs.extend(parent_dirs)
                return parent_config, theme_dirs
            # Otherwise we just return the current theme configuration.
            return theme_config, theme_dirs

    # No theme configuration.
    return {}, theme_dirs


# ----------------------------------------------------------------------------


def _apply_defaults(config: dict, path: str) -> dict:
    """Apply default settings in configuration.

    Note that this is loosely based on the defaults that MkDocs sets in its own
    configuration system, which we won't port for compatibility right now, as
    well as the defaults that are set in Material for MkDocs for theme- and
    extra-level settings.

    We must set all properties, as well as nested properties to `None`, or PyO3
    will refuse to convert them, as the key must definitely exist.
    """
    project_root = config["root_dir"] = os.path.dirname(path)

    if "site_name" not in config:
        raise ConfigurationError("Missing required setting: site_name")

    # Set site directory
    set_default(config, "site_dir", "site", str)
    if ".." in config.get("site_dir", ""):
        raise ConfigurationError("site_dir must not contain '..'")

    # Set docs directory
    set_default(config, "docs_dir", "docs", str)
    if ".." in config.get("docs_dir", ""):
        raise ConfigurationError("docs_dir must not contain '..'")

    # Validate that docs directory exists
    docs_dir_path = os.path.join(project_root, config["docs_dir"])
    if not os.path.isdir(docs_dir_path):
        raise ConfigurationError(
            f"Docs directory does not exist: {docs_dir_path}"
        )

    # Set defaults for core settings
    set_default(config, "site_url", None, str)
    set_default(config, "site_description", None, str)
    set_default(config, "site_author", None, str)
    set_default(config, "use_directory_urls", True, bool)
    set_default(config, "dev_addr", "localhost:8000", str)
    set_default(config, "copyright", None, str)
    set_default(config, "watch", [], list)

    # Validate watch setting
    if not all(isinstance(path, str) for path in config["watch"]):
        raise ConfigurationError("'watch' entries must be strings.")

    # Set defaults for versioning with mike
    set_default(config, "remote_branch", "gh-pages", str)
    set_default(config, "remote_name", "origin", str)

    # Set defaults for repository settings
    set_default(config, "repo_url", None, str)
    set_default(config, "repo_name", None, str)
    set_default(config, "edit_uri_template", None, str)
    set_default(config, "edit_uri", None, str)

    # Set defaults for repository name settings
    docs_dir = config.get("docs_dir")
    repo_names = {
        "github.com": "GitHub",
        "gitlab.com": "Gitlab",
        "bitbucket.org": "Bitbucket",
    }
    edit_uris = {
        "github.com": f"edit/master/{docs_dir}",
        "gitlab.com": f"edit/master/{docs_dir}",
        "bitbucket.org": f"src/default/{docs_dir}",
    }
    repo_url = config.get("repo_url")
    if repo_url:
        host = urlparse(repo_url).hostname or ""
        if not config.get("repo_name"):
            if host in repo_names:
                set_default(config, "repo_name", repo_names[host], str)
            elif host:
                config["repo_name"] = host.split(".")[0].title()
        if host in edit_uris:
            set_default(config, "edit_uri", edit_uris[host], str)

    # Remove trailing slash from edit_uri if present
    edit_uri = config.get("edit_uri")
    if isinstance(edit_uri, str) and edit_uri.endswith("/"):
        config["edit_uri"] = edit_uri.rstrip("/")

    # Set defaults for theme font settings
    theme = set_default(config, "theme", {}, dict)
    if isinstance(theme, str):
        config["theme"] = {"name": theme}

    # Set defaults for custom theme directory
    set_default(config["theme"], "custom_dir", None, str)

    # Load theme configuration
    if config["theme"].get("custom_dir"):
        theme_dir = get_custom_theme_dir(config["theme"]["custom_dir"], path)
        theme_config, theme_dirs = _load_theme_config(theme_dir)
        if (
            len(theme_dirs) == 1
            and (theme_name := config["theme"].get("name", "material"))
            is not None
        ):
            # Custom theme doesn't extend another theme,
            # but user specified a theme name in configuration,
            # so we use it as base theme.
            theme_dir = get_theme_dir(theme_name)
            base_theme_config, base_theme_dirs = _load_theme_config(theme_dir)
            theme_config = {**base_theme_config, **theme_config}
            theme_dirs = theme_dirs + base_theme_dirs
    else:
        # No custom theme directory
        theme_dir = get_theme_dir(config["theme"].get("name") or "material")
        theme_config, theme_dirs = _load_theme_config(theme_dir)

    # Store theme directories for Minijinja and merge theme configuration
    config["theme_dirs"] = theme_dirs
    config["theme"] = {**theme_config, **config["theme"]}

    theme = config["theme"]

    # Set defaults for theme name
    # (we do this after loading the theme configuration
    # so that explicitly setting the name to null
    # tells the system not to extend the Material theme)
    set_default(theme, "name", None, str)

    # Set variant and fonts for variant
    set_default(theme, "variant", "modern", str)
    if theme.get("variant") == "modern":
        font = {"text": "Inter", "code": "JetBrains Mono"}
    else:
        font = {"text": "Roboto", "code": "Roboto Mono"}

    # Ensure presence of static templates
    theme["static_templates"] = ["404.html", "sitemap.xml"]

    # Set defaults for theme settings
    set_default(theme, "language", "en", str)
    set_default(theme, "direction", None, str)
    set_default(theme, "features", [], list)
    set_default(theme, "favicon", "assets/images/favicon.png", str)
    set_default(theme, "logo", None, str)

    # Set defaults for theme font settings
    theme.setdefault("font", {})
    if isinstance(theme["font"], dict):
        set_default(theme["font"], "text", font["text"], str)
        set_default(theme["font"], "code", font["code"], str)

    # Set defaults for theme icons
    icon = set_default(theme, "icon", {}, dict)
    set_default(icon, "repo", None, str)
    set_default(icon, "annotation", None, str)
    set_default(icon, "tag", {}, dict)
    if theme.get("variant") == "modern":
        set_default(icon, "logo", "lucide/book-open", str)
        set_default(icon, "edit", "lucide/file-pen", str)
        set_default(icon, "view", "lucide/file-code-2", str)
        set_default(icon, "top", "lucide/circle-arrow-up", str)
        set_default(icon, "share", "lucide/share-2", str)
        set_default(icon, "menu", "lucide/menu", str)
        set_default(icon, "alternate", "lucide/languages", str)
        set_default(icon, "search", "lucide/search", str)
        set_default(icon, "close", "lucide/x", str)
        set_default(icon, "previous", "lucide/arrow-left", str)
        set_default(icon, "next", "lucide/arrow-right", str)
    else:
        set_default(icon, "logo", None, str)
        set_default(icon, "edit", None, str)
        set_default(icon, "view", None, str)
        set_default(icon, "top", None, str)
        set_default(icon, "share", None, str)
        set_default(icon, "menu", None, str)
        set_default(icon, "alternate", None, str)
        set_default(icon, "search", None, str)
        set_default(icon, "close", None, str)
        set_default(icon, "previous", None, str)
        set_default(icon, "next", None, str)

    # Set defaults for theme admonition icons
    admonition = set_default(icon, "admonition", {}, dict)
    if isinstance(admonition, dict):
        icon["admonition"] = {
            str(key): str(value)
            for key, value in admonition.items()
            if value is not None
        }

    # Set defaults for theme palette settings and normalize to list
    palette = theme.setdefault("palette", [])
    if isinstance(palette, dict):
        palette = [palette]
        theme["palette"] = palette

    # Set defaults for each palette entry
    for entry in palette:
        set_default(entry, "media", None, str)
        set_default(entry, "scheme", None, str)
        set_default(entry, "primary", None, str)
        set_default(entry, "accent", None, str)
        set_default(entry, "toggle", None, dict)

        # Set defaults for palette toggle
        toggle = entry.get("toggle")
        if toggle:
            set_default(toggle, "icon", None, str)
            set_default(toggle, "name", None, str)

    # Set defaults for extra settings
    if "extra" in config and not isinstance(config["extra"], dict):
        raise ConfigurationError(
            "The 'extra' setting must be a mapping/dictionary."
        )
    extra = set_default(config, "extra", {}, dict)

    if "polyfills" in extra and not isinstance(extra["polyfills"], list):
        raise ConfigurationError(
            "The 'extra.polyfills' setting must be a list."
        )
    set_default(extra, "polyfills", [], list)

    # Ensure all non-existent values are all empty strings (for now)
    config["extra"] = _convert_extra(extra)

    # Set defaults for extra files
    set_default(config, "extra_css", [], list)
    set_default(config, "extra_templates", [], list)

    # Generate navigation if not defined, and convert
    config["nav"] = _convert_nav(config.setdefault("nav", []))
    config["extra_javascript"] = _convert_extra_javascript(
        config.setdefault("extra_javascript", [])
    )

    # Initialize defaults for validation
    validation = {
        "unresolved_references": True,
        "unresolved_footnotes": True,
        "unused_definitions": True,
        "unused_footnotes": True,
        "shadowed_definitions": True,
        "shadowed_footnotes": True,
        "invalid_links": True,
        "invalid_link_anchors": True,
    }

    # Map MkDocs validation configuration to ours - note that we only support
    # validation of links right now, as navigation will change significantly
    if "validation" in config:
        if isinstance(config["validation"], bool):
            config["validation"] = dict.fromkeys(
                validation, config["validation"]
            )

        # Hoist links configuration to the top level, if present
        input = config["validation"]
        if "links" in input:
            input.update(input.pop("links"))

        # We only support a subset of MkDocs' validation settings, so we ignore
        # the ones we don't support. We also map info to warn for simplicity.
        if "not_found" in input:
            validation["invalid_links"] = input["not_found"] != "ignore"
        if "anchors" in input:
            validation["invalid_link_anchors"] = input["anchors"] != "ignore"

        # Our own keys override the ones we map from MkDocs, so we apply them
        # after mapping the MkDocs keys
        for key in validation:
            if key in input:
                validation[key] = bool(input[key])

    # Set validation
    config["validation"] = validation

    # MkDocs will also set fenced_code, which is incompatible with SuperFences,
    # the extension that Material for MkDocs generally recommends. Note that we
    # decided to set defaults that make it easy to get started with sensible
    # Markdown support, but users can override this as needed.
    markdown_extensions, mdx_configs = _convert_markdown_extensions(
        config.get("markdown_extensions", DEFAULT_MARKDOWN_EXTENSIONS)
    )
    config["markdown_extensions"] = markdown_extensions
    config["mdx_configs"] = mdx_configs

    # Now, since YAML supports using Python tags to resolve functions, we need
    # to support the same for when we load TOML. This is a bandaid, and we will
    # find a better solution, once we work on configuration management, but for
    # now this should be sufficient.
    _resolve_pymdownx_emoji(config)
    _resolve_pymdownx_superfences(config)
    _resolve_pymdownx_tabbed(config)
    _resolve_pymdownx_blocks_tab(config)
    _resolve_toc(config)

    # Ensure the table of contents title is initialized, as it's used inside
    # the template, and the table of contents extension is always defined
    config["mdx_configs"]["toc"].setdefault("title", None)
    config["mdx_configs_hash"] = _hash(mdx_configs)

    # Convert plugins configuration
    config["plugins"] = _convert_plugins(config.get("plugins", []), config)

    # Map plugins configuration to Markdown extensions
    _shim_autorefs(config)
    _shim_mkdocstrings(config, path)
    _shim_glightbox(config)
    _shim_macros(config)

    # List files along with their hashes, so we can rebuild when they change
    watched_files = (
        _list_sources(config, path)  # mkdocstrings
        | _list_snippet_files(config, path)  # pymdownx.snippets
        | _list_macros_files(config, path)  # macros
        | _list_watch_files(config, path)  # watch
    )

    # We watch theme directories by default on the Rust side,
    # so we need to  prevent duplicates from here, in case
    # users add theme directories to the watch option
    theme_files = _list_templates(config)
    watched_files -= set(theme_files)
    config["watched_files"] = sorted(watched_files)

    # Hash all templates, so we rebuild if something changes
    config["template_hash"] = _hash(theme_files)

    # Hash the entire plugins configuration.
    # This is a special case for plugins because we currently only source
    # the plugin configuration that we support in Rust,
    # which means config on other plugins doesn't contribute to the hash,
    # in turn not triggering full rebuilds.
    config["plugins_hash"] = _hash(config["plugins"])

    return config


def set_default(
    entry: dict, key: str, default: Any, data_type: type | None = None
) -> Any:
    """Set a key to a default value if it isn't set.

    Optionally cast it to the specified data type.
    """
    if key in entry and entry[key] is None:
        del entry[key]

    # Set the default value if the key is not present
    entry.setdefault(key, default)

    # Optionally cast the value to the specified data type
    if data_type is not None and entry[key] is not None:
        try:
            entry[key] = data_type(entry[key])
        except (ValueError, TypeError) as e:
            raise ValueError(
                f"Failed to cast key '{key}' to {data_type}: {e}"
            ) from e

    # Return the resulting value
    return entry[key]


def _hash(data: Any) -> int:
    """Compute a hash for the given data."""
    hash = hashlib.sha1(pickle.dumps(data))  # noqa: S324
    return int(hash.hexdigest(), 16) % (2**64)


# ----------------------------------------------------------------------------


def _resolve(symbol: str) -> Any:
    """Resolve a symbol to its corresponding Python object."""
    module_path, func_name = symbol.rsplit(".", 1)
    module = importlib.import_module(module_path)
    return getattr(module, func_name)


def _resolve_pymdownx_emoji(config: dict[str, Any]) -> None:
    # Emoji extension: resolve emoji generator and index functions
    emoji = config["mdx_configs"].get("pymdownx.emoji", {})
    if isinstance(emoji.get("emoji_generator"), str):
        emoji["emoji_generator"] = _resolve(emoji.get("emoji_generator"))
    if isinstance(emoji.get("emoji_index"), str):
        emoji["emoji_index"] = _resolve(emoji.get("emoji_index"))


def _resolve_pymdownx_tabbed(config: dict[str, Any]) -> None:
    # Tabbed extension: resolve slugification function
    tabbed = config["mdx_configs"].get("pymdownx.tabbed", {})
    if isinstance(tabbed.get("slugify"), dict):
        object = tabbed["slugify"].get("object", "pymdownx.slugs.slugify")
        tabbed["slugify"] = _resolve(object)(
            **tabbed["slugify"].get("kwds", {})
        )


def _resolve_pymdownx_blocks_tab(config: dict[str, Any]) -> None:
    # Blocks-tab extension: resolve slugification function
    blocks_tab = config["mdx_configs"].get("pymdownx.blocks.tab", {})
    if isinstance(blocks_tab.get("slugify"), dict):
        object = blocks_tab["slugify"].get("object", "pymdownx.slugs.slugify")
        blocks_tab["slugify"] = _resolve(object)(
            **blocks_tab["slugify"].get("kwds", {})
        )


def _resolve_pymdownx_superfences(config: dict[str, Any]) -> None:
    # Superfences extension: resolve format and validator functions
    superfences = config["mdx_configs"].get("pymdownx.superfences", {})
    for fence in superfences.get("custom_fences", []):
        if isinstance(fence.get("format"), str):
            fence["format"] = _resolve(fence.get("format"))
        elif isinstance(fence.get("format"), dict):
            object = fence["format"].get(
                "object", "pymdownx.superfences.fence_code_format"
            )
            fence["format"] = _resolve(object)(
                **fence["format"].get("kwds", {})
            )
        if isinstance(fence.get("validator"), str):
            fence["validator"] = _resolve(fence.get("validator"))
        elif isinstance(fence.get("validator"), dict):
            object = fence["validator"].get("object")
            callable_object = (
                _resolve(object) if object else lambda *args, **kwargs: True
            )
            fence["validator"] = callable_object(
                **fence["validator"].get("kwds", {})
            )


def _resolve_toc(config: dict[str, Any]) -> None:
    # Table of contents extension: resolve slugification function
    toc = config["mdx_configs"]["toc"]
    if isinstance(toc.get("slugify"), dict):
        object = toc["slugify"].get("object", "pymdownx.slugs.slugify")
        toc["slugify"] = _resolve(object)(**toc["slugify"].get("kwds", {}))


# ----------------------------------------------------------------------------


def _shim_autorefs(config: dict[str, Any]) -> None:
    # Map autorefs plugin configuration to the extension configuration
    if "autorefs" in config["plugins"]:
        plugin = config["plugins"]["autorefs"]["config"]
        if plugin.get("enabled", True):
            config["markdown_extensions"].append(AutorefsExtension.name)
    elif "mkdocstrings" in config["plugins"]:
        # mkdocstrings enables autorefs itself
        plugin = config["plugins"]["mkdocstrings"]["config"]
        if plugin.get("enabled", True):
            config["markdown_extensions"].append(AutorefsExtension.name)


def _shim_mkdocstrings(config: dict[str, Any], path: str) -> None:
    # Map mkdocstrings plugin configuration to the extension configuration
    if "mkdocstrings" in config["plugins"]:
        mkdocstrings_config = config["plugins"]["mkdocstrings"]["config"]
        if mkdocstrings_config.get("enabled", True):
            if not find_spec("mkdocstrings"):
                raise ConfigurationError(
                    "mkdocstrings plugin is enabled, but mkdocstrings is not "
                    "installed. Please install mkdocstrings or disable the "
                    "plugin."
                )
            mkdocstrings = get_mkdocstrings_extension(config, path)
            config["markdown_extensions"].append(mkdocstrings)


def _shim_glightbox(config: dict[str, Any]) -> None:
    # Map glightbox plugin configuration to the extension configuration
    if "glightbox" in config["plugins"]:
        plugin = config["plugins"]["glightbox"]["config"]
        config["markdown_extensions"].append(GlightboxExtension.name)
        config["mdx_configs"][GlightboxExtension.name] = plugin


def _shim_macros(config: dict[str, Any]) -> None:
    # Map macros plugin configuration to the extension configuration
    if "macros" in config["plugins"]:
        plugin = config["plugins"]["macros"]["config"]
        config["markdown_extensions"].append(MacrosExtension.name)
        config["mdx_configs"][MacrosExtension.name] = plugin


# ----------------------------------------------------------------------------


def _ignore_directory(dirpath: str) -> bool:
    """Determine whether to ignore a folder based on its name."""
    return dirpath == "__pycache__" or dirpath.startswith(".venv")


def _list_py_modules(path: Path) -> Iterator[Path]:
    """List Python modules in a directory, recursively."""
    for root, dirs, files in os.walk(path, topdown=True, followlinks=True):
        dirs[:] = [dir for dir in dirs if not _ignore_directory(dir)]
        for relfile in files:
            if os.path.splitext(relfile)[1] in {
                ".py",
                ".pyc",
                ".pyo",
                ".pyd",
                ".pyi",
                ".so",
            }:
                yield Path(root, relfile)


def _list_sources(config: dict, config_file: str) -> set[tuple[str, int]]:
    """List all absolute links to source files for mkdocstrings."""
    python_paths = (
        config["plugins"]
        .get("mkdocstrings", {})
        .get("config", {})
        .get("handlers", {})
        .get("python", {})
        .get("paths", ())
    )
    files_with_hash = set()
    root = Path(config_file).parent.resolve()
    for python_path in python_paths:
        path = root.joinpath(python_path).resolve()
        if path.is_dir() and path.is_relative_to(root) and path != root:
            for py_module in _list_py_modules(path):
                files_with_hash.add(
                    (str(py_module), int(os.path.getmtime(py_module)))
                )
    return files_with_hash


def _list_snippet_files(config: dict, config_file: str) -> set[tuple[str, int]]:
    """List files referenced in pymdownx.snippets auto_append configuration."""
    snippets_config = config["mdx_configs"].get("pymdownx.snippets", {})
    auto_append = snippets_config.get("auto_append", [])
    base_paths = snippets_config.get("base_path", ["."])

    root = Path(config_file).parent.resolve()
    files_with_mtime = set()
    for file_name in auto_append:
        for base_path in base_paths:
            candidate = root.joinpath(base_path, file_name).resolve()
            if candidate.is_file():
                mtime = int(os.path.getmtime(candidate))
                files_with_mtime.add((str(candidate), mtime))
                break

    return files_with_mtime


def _list_macros_files(config: dict, config_file: str) -> set[tuple[str, int]]:
    """List files referenced in macros plugin/extension."""
    root = Path(config_file).parent.resolve()
    macros_config = config["mdx_configs"].get(MacrosExtension.name, {})
    macros_files = []
    files_with_mtime = set()

    module = macros_config.get("module", "main")
    if (module_path := root.joinpath(module + ".py").resolve()).is_file():
        macros_files.append(module_path)

    pluglets = macros_config.get("modules", [])
    for pluglet in pluglets:
        try:
            pluglet_module = importlib.import_module(pluglet)
        except ImportError:  # noqa: PERF203
            continue
        else:
            macros_files.append(pluglet_module.__file__)

    include_yaml: list[str] | dict[str, str] = macros_config.get(
        "include_yaml", []
    )
    if isinstance(include_yaml, dict):
        include_yaml = list(include_yaml.values())
    for yaml_file in include_yaml:
        candidate = root.joinpath(yaml_file).resolve()
        if candidate.is_file():
            macros_files.append(candidate)

    for file_path in macros_files:
        mtime = int(os.path.getmtime(file_path))
        files_with_mtime.add((str(file_path), mtime))

    include_dir = macros_config.get("include_dir", None)
    if include_dir:
        candidate_dir = root.joinpath(include_dir).resolve()
        if candidate_dir.is_dir():
            for root, _, files in os.walk(candidate_dir):
                for file in files:
                    file_path = os.path.join(root, file)
                    mtime = int(os.path.getmtime(file_path))
                    files_with_mtime.add((file_path, mtime))

    return files_with_mtime


def _list_watch_files(config: dict, config_file: str) -> set[tuple[str, int]]:
    """List files for user-defined watch paths."""
    root = Path(config_file).parent.resolve()
    files_with_mtime: set[tuple[str, int]] = set()
    for watch_path in config.get("watch", []):
        path = root.joinpath(watch_path).resolve()
        if path.is_file():
            mtime = int(os.path.getmtime(path))
            files_with_mtime.add((str(path), mtime))
        elif path.is_dir():
            for dirpath, _, files in os.walk(path):
                for file in files:
                    file_path = os.path.join(dirpath, file)
                    mtime = int(os.path.getmtime(file_path))
                    files_with_mtime.add((file_path, mtime))
    return files_with_mtime


def _list_templates(config: dict) -> list[tuple[str, int]]:
    """List all template files in the theme directories."""
    # Collect file paths and their mtimes
    files_with_mtime = []
    for directory in config["theme_dirs"]:
        for root, _, files in os.walk(directory):
            if ".icons" in root:
                continue
            for file in files:
                file_path = os.path.join(root, file)
                mtime = int(os.path.getmtime(file_path))
                files_with_mtime.append((file_path, mtime))

    # Sort by file path for deterministic order
    return sorted(files_with_mtime)


# -----------------------------------------------------------------------------


def _is_index(path: str) -> bool:
    """Returns, whether the given path points to a section index."""
    return os.path.basename(path) in ("index.md", "README.md")


def _convert_nav(nav: list) -> list:
    """Convert MkDocs navigation."""
    return [_convert_nav_item(entry) for entry in nav]


def _convert_nav_item(item: str | dict | list) -> dict | list:
    """Convert MkDocs shorthand navigation structure into something manageable.

    We need to annotate each item with a title, URL, icon, and children.
    """
    if isinstance(item, str):
        return {
            "title": None,
            "url": item,
            "canonical_url": None,
            "meta": None,
            "children": [],
            "is_index": _is_index(item),
            "active": False,
        }

    # Handle Title: URL
    if isinstance(item, dict):
        for title, value in item.items():
            if isinstance(value, str):
                return {
                    "title": str(title),
                    "url": value.strip(),
                    "canonical_url": None,
                    "meta": None,
                    "children": [],
                    "is_index": _is_index(value.strip()),
                    "active": False,
                }
            if isinstance(value, list):
                return {
                    "title": str(title),
                    "url": None,
                    "canonical_url": None,
                    "meta": None,
                    "children": [_convert_nav_item(child) for child in value],
                    "is_index": False,
                    "active": False,
                }
            raise TypeError(f"Unknown nav item value type: {type(value)}")

    # Handle a list of items
    elif isinstance(item, list):
        return [_convert_nav_item(child) for child in item]

    raise TypeError(f"Unknown nav item type: {type(item)}")


def _convert_extra(data: dict | list) -> dict | list:
    """Recursively convert None values in a dictionary/list to empty strings."""
    if isinstance(data, dict):
        # Process each key-value pair in the dictionary
        return {
            key: _convert_extra(value)
            if isinstance(value, (dict, list))
            else ("" if value is None else value)
            for key, value in data.items()
        }
    if isinstance(data, list):
        # Process each item in the list
        return [
            _convert_extra(item)
            if isinstance(item, (dict, list))
            else ("" if item is None else item)
            for item in data
        ]
    return data


def _convert_extra_javascript(value: list) -> list:
    """Ensure extra_javascript uses a structured format."""
    for i, item in enumerate(value):
        if isinstance(item, str):
            value[i] = {
                "path": item,
                "type": None,
                "async": False,
                "defer": False,
            }
        elif isinstance(item, dict):
            item.setdefault("path", "")
            item.setdefault("type", None)
            item.setdefault("async", False)
            item.setdefault("defer", False)
        else:
            raise TypeError(f"Unknown extra_javascript item type: {type(item)}")

    # Return resulting value
    return value


def _convert_markdown_extensions(value: Any) -> tuple[list[str], dict]:
    """Convert Markdown extensions to what Python Markdown expects."""
    markdown_extensions = ["toc", "tables"]
    mdx_configs: dict[str, dict[str, Any]] = {"toc": {}, "tables": {}}

    # In case of Python Markdown Extensions, we allow to omit the necessary
    # quotes around the extension names, so we need to hoist the extensions
    # configuration one level up. This is a pre-processing step before we
    # actually parse the configuration.
    if "pymdownx" in value:
        pymdownx = value.pop("pymdownx")
        for ext, conf in pymdownx.items():
            # Special case for blocks extension, which has another level of
            # nesting. This is the only extension that requires this.
            if ext == "blocks":
                for block, config in conf.items():
                    value[f"pymdownx.{ext}.{block}"] = config
            else:
                value[f"pymdownx.{ext}"] = conf

    # Same as for Python Markdown extensions, see above
    if "zensical" in value:
        zensical = value.pop("zensical")
        for ext, conf in zensical.items():
            if ext == "extensions":
                for key, config in conf.items():
                    value[f"zensical.{ext}.{key}"] = config
            else:
                value[f"zensical.{ext}"] = conf

    # Extensions can be defined as a dict
    if isinstance(value, dict):
        for ext, config in value.items():
            markdown_extensions.append(ext)
            mdx_configs[ext] = config or {}

    # Extensions can also be defined as a list
    else:
        for item in value:
            if isinstance(item, dict):
                ext, config = item.popitem()
                markdown_extensions.append(ext)
                mdx_configs[ext] = config or {}
            elif isinstance(item, str):
                markdown_extensions.append(item)

    # Return extension list and configuration, after ensuring they're unique
    return list(set(markdown_extensions)), mdx_configs


def _convert_plugins(value: Any, config: dict) -> dict:
    """Convert plugins configuration to something we can work with."""
    plugins = {}

    # Plugins can be defined as a dict
    if isinstance(value, dict):
        plugins.update(value)

    # Plugins can also be defined as a list
    else:
        for item in value:
            if isinstance(item, dict):
                name, data = item.popitem()
                plugins[name] = data
            elif isinstance(item, str):
                plugins[item] = {}

    # Define defaults for search plugin
    search = set_default(plugins, "search", {}, dict)
    set_default(search, "enabled", True, bool)
    set_default(
        search, "separator", '[\\s\\-_,:!=\\[\\]()\\\\"`/]+|\\.(?!\\d)', str
    )

    # Define defaults for offline plugin
    offline = set_default(plugins, "offline", {"enabled": False}, dict)
    set_default(offline, "enabled", True, bool)

    # Ensure correct resolution of links when viewing the site from the
    # file system by disabling directory URLs
    if offline.get("enabled"):
        config["use_directory_urls"] = False

        # Append iframe-worker to polyfills/shims
        if not any(
            "iframe-worker" in url for url in config["extra"]["polyfills"]
        ):
            script = "https://unpkg.com/iframe-worker/shim"
            config["extra"]["polyfills"].append(
                {
                    "path": script,
                    "type": "text/javascript",
                    "async": False,
                    "defer": False,
                }
            )

    # Mike sets an environment variable for us to determine whether the build
    # is triggered from mike. If it is, we read the plugin configuration, if
    # any, and mirror mike's functionality in adjusting the configuration when
    # building the site, while only allowing for our theme at the moment.
    version = os.environ.get("MIKE_DOCS_VERSION")
    if version and config.get("site_url"):
        mike = set_default(
            plugins,
            "mike",
            {
                "alias_type": "symlink",
                "redirect_template": None,
                "deploy_prefix": "",
                "canonical_version": None,
            },
            dict,
        )

        # Copied and adapted from mike's plugin implementation
        if mike["canonical_version"] is not None:
            version = mike["canonical_version"]
        config["site_url"] = urljoin(config["site_url"], version)

    # Now, add another level of indirection, by moving all plugin configuration
    # into a `config` property, making it compatible with Material for MkDocs.
    for name, data in plugins.items():
        if not isinstance(data, dict) or "config" not in data:
            plugins[name] = {"config": data}

    # Return plugins
    return plugins
