# 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 hashlib
import importlib
import os
import pickle
from typing import IO, Any
from urllib.parse import urlparse

import yaml
from click import ClickException
from deepmerge import always_merger
from yaml import BaseLoader, Loader, YAMLError
from yaml.constructor import ConstructorError

from zensical.extensions.emoji import to_svg, twemoji

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib  # type: ignore[no-redef]


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


_CONFIG = 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.
"""

# ----------------------------------------------------------------------------
# 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 = tomllib.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  # type: ignore[return-value]


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


def get_custom_theme_dir(config: dict) -> str | None:
    """Return the custom theme directory."""
    path = os.path.dirname(os.path.abspath(__file__))
    if config["theme"].get("custom_dir"):
        return os.path.join(path, config["theme"].get("custom_dir"))

    # Otherwise, return no path
    return None


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.
    """
    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 '..'")

    # 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 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):
        theme = {"name": theme}
        config["theme"] = theme

    # 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"}

    # Resolve custom theme directory
    set_default(theme, "custom_dir", None, str)
    if theme.get("custom_dir"):
        theme["custom_dir"] = os.path.join(
            os.path.dirname(path), theme["custom_dir"]
        )

    # 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", [])
    )

    # 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",
            {
                "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": {},
            },
        )
    )
    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.
    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"))

    # Tabbed extension configuration - 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", {})
        )

    # Table of contents extension configuration - 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", {}))

    # Superfences extension configuration - resolve format function
    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", {})
            )

    # 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)

    # Set up mkdocstrings, which touches plugins and Markdown extensions
    if "mkdocstrings" in config["plugins"]:
        mkdocstrings_config = config["plugins"]["mkdocstrings"]["config"]
        if mkdocstrings_config.pop("enabled", True):
            mkdocstrings_config["markdown_extensions"] = [
                {ext: mdx_configs.get(ext, {})} for ext in markdown_extensions
            ]
            config["markdown_extensions"].append("mkdocstrings")
            config["mdx_configs"]["mkdocstrings"] = mkdocstrings_config

    # Hash all templates, so we rebuild if something changes
    config["template_hash"] = _hash(_list_templates(config))
    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 _list_templates(config: dict) -> list[tuple[str, int]]:
    """List all template files in the theme directories."""
    dirs = [get_theme_dir()]
    if "custom_dir" in config["theme"]:
        custom_dir = get_custom_theme_dir(config)
        if custom_dir is not None:
            dirs.append(custom_dir)

    # Collect file paths and their mtimes
    files_with_mtime = []
    for directory in dirs:
        for path, _, files in os.walk(directory):
            if ".icons" in path:
                continue
            for file in files:
                file_path = os.path.join(path, file)
                mtime = os.path.getsize(file_path)
                files_with_mtime.append((file_path, mtime))

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


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 _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 _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 _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_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,
                }
            )

    # 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


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


def _yaml_load(
    source: IO, loader: type[BaseLoader] | None = None
) -> 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 = loader or 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, loader)
        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
