from collections import UserDict
from collections.abc import Callable
from functools import reduce
import logging
from pathlib import Path
from typing import Any

from esphome import git, yaml_util
from esphome.components.substitutions import (
    ContextVars,
    ErrList,
    push_context,
    raise_first_undefined,
    resolve_include,
    resolve_substitutions_block,
    substitute,
)
from esphome.components.substitutions.jinja import has_jinja
from esphome.config_helpers import Remove, merge_config
import esphome.config_validation as cv
from esphome.const import (
    CONF_ESPHOME,
    CONF_FILE,
    CONF_FILES,
    CONF_MIN_VERSION,
    CONF_PACKAGES,
    CONF_PASSWORD,
    CONF_PATH,
    CONF_REF,
    CONF_REFRESH,
    CONF_SUBSTITUTIONS,
    CONF_URL,
    CONF_USERNAME,
    CONF_VARS,
    __version__ as ESPHOME_VERSION,
)
from esphome.core import EsphomeError

_LOGGER = logging.getLogger(__name__)

DOMAIN = CONF_PACKAGES
# Guard against infinite include chains (e.g. A includes B includes A).
MAX_INCLUDE_DEPTH = 20

PackageCallback = Callable[
    [dict | str | yaml_util.IncludeFile, ContextVars | None, yaml_util.DocumentPath],
    dict,
]


def is_remote_package(package_config: dict) -> bool:
    """Returns True if the package_config is a remote package definition."""
    return CONF_URL in package_config


def valid_package_contents(package_config: dict) -> dict:
    """Validate that a package looks like a plausible ESPHome config fragment.

    Rejects non-dict values, remote package schemas (which should have been
    handled earlier), non-string keys, and scalar values that aren't Jinja
    expressions. This is a lightweight check to catch obvious mistakes before
    full component validation runs later.
    """

    if not isinstance(package_config, dict):
        raise cv.Invalid("Package contents must be a dict")

    if is_remote_package(package_config):
        # Package contents must not contain a root `url:` key
        raise cv.Invalid("Remote package schema not expected here")

    # Validate manually since Voluptuous would regenerate dicts and lose metadata
    # such as ESPHomeDataBase
    for k, v in package_config.items():
        if not isinstance(k, str):
            raise cv.Invalid("Package content keys must be strings")
        if isinstance(v, (dict, list, Remove, yaml_util.IncludeFile)):
            continue  # e.g. script: [], psram: !remove, logger: {level: debug}, switch: !include switches.yaml
        if v is None:
            continue  # e.g. web_server:
        if isinstance(v, str) and has_jinja(v):
            # e.g: remote package shorthand:
            # package_name: github://esphome/repo/file.yaml@${ branch }, or:
            # switch: ${ expression that evals to a switch }
            continue

        raise cv.Invalid("Invalid component content in package definition")
    return package_config


def expand_file_to_files(config: dict):
    if CONF_FILE in config:
        new_config = config
        new_config[CONF_FILES] = [config[CONF_FILE]]
        del new_config[CONF_FILE]
        return new_config
    return config


def validate_yaml_filename(value):
    value = cv.string(value)

    if not value.endswith((".yaml", ".yml")):
        raise cv.Invalid("Only YAML (.yaml / .yml) files are supported.")

    return value


def validate_source_shorthand(value):
    if not isinstance(value, str):
        raise cv.Invalid("Git URL shorthand only for strings")

    git_file = git.GitFile.from_shorthand(value)

    conf = {
        CONF_URL: git_file.git_url,
        CONF_FILE: git_file.filename,
    }
    if git_file.ref:
        conf[CONF_REF] = git_file.ref

    return REMOTE_PACKAGE_SCHEMA(conf)


REMOTE_PACKAGE_SCHEMA = cv.All(
    cv.Schema(
        {
            cv.Required(CONF_URL): cv.url,
            cv.Optional(CONF_PATH): cv.string,
            cv.Optional(CONF_USERNAME): cv.string,
            cv.Optional(CONF_PASSWORD): cv.string,
            cv.Exclusive(CONF_FILE, CONF_FILES): validate_yaml_filename,
            cv.Exclusive(CONF_FILES, CONF_FILES): cv.All(
                cv.ensure_list(
                    cv.Any(
                        validate_yaml_filename,
                        cv.Schema(
                            {
                                cv.Required(CONF_PATH): validate_yaml_filename,
                                cv.Optional(CONF_VARS, default={}): cv.Schema(
                                    {cv.string: object}
                                ),
                            }
                        ),
                    )
                ),
                cv.Length(min=1),
            ),
            cv.Optional(CONF_REF): cv.git_ref,
            cv.Optional(CONF_REFRESH, default="1d"): cv.All(
                cv.string, cv.source_refresh
            ),
        }
    ),
    cv.has_at_least_one_key(CONF_FILE, CONF_FILES),
    expand_file_to_files,
)

PACKAGE_SCHEMA = cv.Any(  # A package definition is either:
    validate_source_shorthand,  # A git URL shorthand string that expands to a remote package schema, or
    REMOTE_PACKAGE_SCHEMA,  # a valid remote package schema, or
    yaml_util.IncludeFile,  # isinstance check — passes IncludeFile objects through unchanged, or:
    valid_package_contents,  # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}}
    # which will have to be fully validated later as per each component's schema.
)

CONFIG_SCHEMA = cv.Any(  # under `packages:` we can have either:
    cv.Schema(
        {
            str: PACKAGE_SCHEMA,  # a named dict of package definitions, or
        }
    ),
    [PACKAGE_SCHEMA],  # a list of package definitions
)


def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
    """Clone/update a git repo and load the YAML files listed in the package definition.

    Returns ``{"packages": {<filename>: <loaded_yaml>, ...}}`` so the caller
    can recurse into the loaded packages. Each loaded YAML node is tagged
    with any ``vars:`` from the file entry via :func:`yaml_util.add_context`.

    If loading fails after cloning, attempts a revert and retry in case
    a prior cached checkout is stale.
    """
    repo_root, revert = git.clone_or_update(
        url=config[CONF_URL],
        ref=config.get(CONF_REF),
        refresh=config[CONF_REFRESH],
        domain=DOMAIN,
        username=config.get(CONF_USERNAME),
        password=config.get(CONF_PASSWORD),
    )
    files: list[dict[str, Any]] = []

    # ``repo_root`` is the directory containing ``.git`` and must be passed
    # to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a
    # subdirectory via the user's CONF_PATH and is used for file lookups.
    repo_dir = repo_root
    if base_path := config.get(CONF_PATH):
        repo_dir = repo_dir / base_path

    for file in config[CONF_FILES]:
        if isinstance(file, str):
            files.append({CONF_PATH: file, CONF_VARS: {}})
        else:
            files.append(file)

    def _load_package_yaml(yaml_file: Path, filename: str) -> dict:
        """Load a YAML file from a remote package, validating min_version."""

        def _load(path: Path) -> dict | str | None:
            try:
                return yaml_util.load_yaml(path)
            except EsphomeError as e:
                raise cv.Invalid(
                    f"{filename} is not a valid YAML file."
                    f" Please check the file contents.\n{e}"
                ) from e

        new_yaml = _load(yaml_file)
        if not isinstance(new_yaml, dict):
            # On Windows, git defaults to core.symlinks=false unless the user
            # has Developer Mode enabled or is running elevated. Files stored
            # in the repo as symlinks (tree mode 120000) are then checked out
            # as plain text files containing the symlink target path, so
            # parsing them as YAML yields a bare scalar instead of a mapping.
            # Best-effort: follow the symlink target ourselves and re-load.
            target = git.resolve_symlink_stub(repo_root, yaml_file)
            if target is not None:
                new_yaml = _load(target)
        if not isinstance(new_yaml, dict):
            raise cv.Invalid(
                f"{filename} does not contain a YAML mapping at the top level "
                f"(got {type(new_yaml).__name__}). "
                f"If this file is a git symlink in the source repository, it "
                f"may not have been materialized correctly on your platform "
                f"(this is a known issue with git on Windows without Developer "
                f"Mode enabled). Try pointing your package at the real file "
                f"path instead."
            )
        esphome_config = new_yaml.get(CONF_ESPHOME) or {}
        min_version = esphome_config.get(CONF_MIN_VERSION)
        if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse(
            ESPHOME_VERSION
        ):
            raise cv.Invalid(
                f"Current ESPHome Version is too old to use"
                f" this package: {ESPHOME_VERSION} < {min_version}"
            )
        return new_yaml

    def get_packages(files: list[dict[str, Any]]) -> dict:
        packages: dict[str, Any] = {}
        for idx, file in enumerate(files):
            filename = file[CONF_PATH]
            yaml_file: Path = repo_dir / filename
            if not yaml_file.is_file():
                raise cv.Invalid(
                    f"{filename} does not exist in repository",
                    path=[CONF_FILES, idx, CONF_PATH],
                )
            new_yaml = _load_package_yaml(yaml_file, filename)
            new_yaml = yaml_util.add_context(new_yaml, file.get(CONF_VARS))
            packages[f"{filename}{idx}"] = new_yaml
        return packages

    if revert is not None:
        # If loading fails, the cached checkout may be stale — revert and retry once.
        try:
            return {CONF_PACKAGES: get_packages(files)}
        except cv.Invalid as err:
            if not revert():
                # The pre-update content is out of reach (lock timeout, the
                # checkout moved, or the reset failed; see the log), so a
                # retry could not see it.
                raise cv.Invalid(
                    f"Failed to load packages and could not revert the cached "
                    f"checkout to retry. {err}",
                    path=err.path,
                ) from err
            # If the retry succeeds this is the only trace that the
            # refreshed upstream content was broken.
            _LOGGER.warning(
                "Loading packages failed (%s), reverted the cached checkout "
                "and retrying",
                err,
            )
        try:
            return {CONF_PACKAGES: get_packages(files)}
        except cv.Invalid as err:
            raise cv.Invalid(f"Failed to load packages. {err}", path=err.path) from err

    return {CONF_PACKAGES: get_packages(files)}


def _walk_package_dict(
    packages: dict,
    callback: PackageCallback,
    context: ContextVars | None,
    path: yaml_util.DocumentPath,
) -> cv.Invalid | None:
    """Iterate a packages dict in reverse priority order, invoking callback on each entry.

    Returns ``None`` on success, or the first :class:`cv.Invalid` error if a callback fails.
    """
    for package_name, package_config in reversed(packages.items()):
        with cv.prepend_path(package_name):
            try:
                packages[package_name] = callback(
                    package_config, context, path + [package_name]
                )
            except cv.Invalid as err:
                return err
    return None


def _walk_package_list(
    packages: list,
    callback: PackageCallback,
    context: ContextVars | None,
    path: yaml_util.DocumentPath,
) -> None:
    """Iterate a packages list in reverse priority order, invoking callback on each entry."""
    for idx in reversed(range(len(packages))):
        with cv.prepend_path(idx):
            packages[idx] = callback(packages[idx], context, path + [idx])


def _walk_packages(
    config: dict,
    callback: PackageCallback,
    context: ContextVars | None = None,
    path: yaml_util.DocumentPath | None = None,
) -> dict:
    """Walks the packages structure in priority order, invoking ``callback`` on each package definition found.

    This function only iterates over the immediate ``packages:`` entries in *config*.
    If packages may contain nested ``packages:`` keys, the *callback* is responsible
    for recursing by calling ``_walk_packages`` on the returned package config.
    """
    if CONF_PACKAGES not in config:
        return config
    packages = config[CONF_PACKAGES]
    packages_path = (path or []) + [CONF_PACKAGES]

    with cv.prepend_path(CONF_PACKAGES):
        if isinstance(packages, yaml_util.IncludeFile):
            # If the packages key is an IncludeFile, resolve it first before processing.
            packages = resolve_include(
                packages, packages_path, context, strict_undefined=False
            )
        if not isinstance(packages, (dict, list)):
            raise cv.Invalid(
                f"Packages must be a key to value mapping or list, got {type(packages)} instead"
            )

        if not isinstance(packages, dict):
            _walk_package_list(packages, callback, context, packages_path)
        elif (
            result := _walk_package_dict(packages, callback, context, packages_path)
        ) is not None:
            raise result

    config[CONF_PACKAGES] = packages
    return config


def _substitute_package_definition(
    package_config: dict | str,
    context_vars: ContextVars | None,
    path: yaml_util.DocumentPath | None = None,
) -> dict | str:
    """Substitute variables in a package definition string or remote package dict.

    Only substitutes strings and remote package dicts (URLs, refs, paths).
    Local package contents are left untouched — they will be substituted
    later during the main substitution pass.
    """

    def do_substitute(package_config: dict | str) -> dict | str:
        # Collect undefined-variable errors (rather than raising strict) so the
        # path walked through a remote-package dict is preserved and the user
        # sees which field (url / path / ref / ...) referenced the undefined
        # variable.
        errors: ErrList = []
        package_config = substitute(
            item=package_config,
            path=path or [],
            parent_context=context_vars or ContextVars(),
            strict_undefined=False,
            errors=errors,
        )
        raise_first_undefined(errors, "package definition")
        return package_config

    if isinstance(package_config, str):
        return do_substitute(package_config)

    if isinstance(package_config, dict) and is_remote_package(package_config):
        # Mark vars as literal to avoid substituting variables in the vars block itself, since they are meant to be
        # passed as-is to the package YAML and may contain their own substitution expressions that should not
        # be prematurely evaluated here.
        if CONF_FILES in package_config:
            for file_def in package_config[CONF_FILES]:
                if isinstance(file_def, dict) and CONF_VARS in file_def:
                    file_def[CONF_VARS] = yaml_util.make_literal(file_def[CONF_VARS])

        package_config = do_substitute(package_config)

    return package_config


def _update_substitutions_context(
    parent_context: UserDict,
    package_substitutions: dict[str, Any],
    eval_context: ContextVars | None = None,
) -> None:
    """Resolve and add new substitutions to the parent context.

    Skips keys already present (higher-priority sources win).
    String values are substituted against *eval_context* (or *parent_context*
    if not provided) so that cross-references between substitutions are
    expanded when possible. Resolved values are written into *parent_context*
    and back into *package_substitutions* so that subsequent merges into the
    consolidated ``substitutions:`` block carry the resolved value (the
    package's ``!include vars`` are no longer in scope after this function
    returns).

    *eval_context* may layer additional vars (e.g. a package's own ``!include
    vars``) on top of *parent_context* so that a package's substitutions can
    reference vars passed in by the parent file.
    """
    if eval_context is None:
        eval_context = ContextVars(parent_context)
    for key, value in package_substitutions.items():
        if key in parent_context:
            continue
        if not isinstance(value, str):
            parent_context[key] = value
            continue
        resolved = substitute(
            item=value,
            path=[CONF_SUBSTITUTIONS, key],
            parent_context=eval_context,
            strict_undefined=False,
        )
        parent_context[key] = resolved
        package_substitutions[key] = resolved


class _PackageProcessor:
    """Stateful processor that resolves packages and collects substitutions.

    Packages are processed highest-priority first (later-declared before
    earlier-declared) so that their substitutions are available when
    resolving lower-priority package definitions.  For each entry:

    1. Substitute variables in remote package definitions (URLs, refs, paths).
    2. Validate against ``PACKAGE_SCHEMA`` and download remote packages.
    3. Extract ``substitutions:`` and merge into the shared context
       (higher-priority packages win on conflicts).
    4. Recurse into any nested ``packages:`` keys.

    Command-line substitutions take the highest priority and are never overridden.
    """

    def __init__(
        self,
        substitutions: UserDict,
        command_line_substitutions: dict[str, Any] | None,
    ) -> None:
        self.substitutions = substitutions
        self.parent_context = UserDict(command_line_substitutions or {})

    def resolve_package(
        self,
        package_config: dict | str | yaml_util.IncludeFile,
        context_vars: ContextVars | None,
        path: yaml_util.DocumentPath,
    ) -> dict:
        """Resolve a package definition to a concrete ``dict`` and fetch remote packages.

        The input may be a ``str`` (git shorthand or Jinja expression), a
        ``dict`` (remote or local package), or an ``IncludeFile`` whose filename
        may itself contain substitution expressions.

        The loop handles the case where loading an ``IncludeFile`` yields another
        ``IncludeFile`` (e.g. a chain of deferred includes).  Each iteration:

        1. If the current value is an ``IncludeFile``, load it — resolving any
           substitutions in its filename first.
        2. Substitute variables in the resulting value (for strings and remote
           package dicts).
        3. Validate against ``PACKAGE_SCHEMA``.  If the result is a ``dict``,
           the loop exits; otherwise another iteration is needed.

        Raises ``cv.Invalid`` if the chain has not resolved to a ``dict`` after
        ``MAX_INCLUDE_DEPTH`` iterations.
        """
        for _ in range(MAX_INCLUDE_DEPTH):
            if isinstance(package_config, yaml_util.IncludeFile):
                package_config = resolve_include(
                    package_config,
                    path,
                    context_vars or ContextVars(),
                    strict_undefined=False,
                )

            package_config = _substitute_package_definition(
                package_config, context_vars, path
            )
            package_config = PACKAGE_SCHEMA(package_config)
            if isinstance(package_config, dict):
                break
        else:
            raise cv.Invalid(
                f"Maximum include nesting depth ({MAX_INCLUDE_DEPTH}) exceeded"
            )

        if is_remote_package(package_config):
            package_config = _process_remote_package(package_config)
        return package_config

    def collect_substitutions(
        self,
        package_config: dict,
        context_vars: ContextVars | None,
    ) -> ContextVars:
        """Extract substitutions from a package and merge into the shared context.

        Returns the context updated with the package's ``!include vars`` (or
        an equivalent of *context_vars* if the package has none) so the caller
        can reuse it when recursing into nested packages. ``None`` inputs are
        normalized to an empty :class:`ContextVars`, so the result is always
        non-``None``.
        """
        # Push the package's own !include vars before evaluating its
        # substitutions so they can reference vars passed in by the parent
        # (e.g. ``vars: {my_variable: ...}`` on the include entry).
        package_context = push_context(
            package_config, context_vars if context_vars is not None else ContextVars()
        )
        if subs := package_config.pop(CONF_SUBSTITUTIONS, {}):
            # Resolve before merging so that values referencing the package's
            # ``!include vars`` are baked into the consolidated substitutions
            # block; once we return, the package vars are no longer in scope.
            # ``package_context`` is a ChainMap whose chain already terminates
            # in ``self.parent_context`` (set up by ``do_packages_pass``), so
            # ``parent_context`` mutations from ``_update_substitutions_context``
            # remain visible to evaluation reads.
            _update_substitutions_context(self.parent_context, subs, package_context)
            self.substitutions.data = merge_config(subs, self.substitutions.data)
        return package_context

    def process_package(
        self,
        package_config: dict | str,
        context_vars: ContextVars | None,
        path: yaml_util.DocumentPath,
    ) -> dict:
        """Resolve a single package and recurse into any nested packages."""
        package_config = self.resolve_package(package_config, context_vars, path)
        context_vars = self.collect_substitutions(package_config, context_vars)

        if CONF_PACKAGES not in package_config:
            return package_config

        # Push context from !include vars on the packages key (the package root
        # was already pushed in collect_substitutions above).
        context_vars = push_context(package_config[CONF_PACKAGES], context_vars)
        return _walk_packages(
            package_config,
            self.process_package,
            context_vars,
            path=path,
        )


def do_packages_pass(
    config: dict[str, Any],
    *,
    command_line_substitutions: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Load, validate, and flatten all packages in the config.

    Returns the config with all packages loaded in-place (but not yet merged)
    and a consolidated ``substitutions:`` block restored at the front.
    """
    if CONF_PACKAGES not in config:
        return config

    with cv.prepend_path(CONF_SUBSTITUTIONS):
        substitutions = UserDict(
            resolve_substitutions_block(
                config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions
            )
        )
    processor = _PackageProcessor(substitutions, command_line_substitutions)
    _update_substitutions_context(processor.parent_context, substitutions)

    context_vars = push_context(
        config[CONF_PACKAGES], ContextVars(processor.parent_context)
    )
    _walk_packages(config, processor.process_package, context_vars)

    if substitutions:
        config[CONF_SUBSTITUTIONS] = substitutions.data

    return config


def merge_packages(config: dict) -> dict:
    """Flatten the ``packages:`` tree into the main config.

    Collects every package (including nested ones) into a flat list in
    priority order, then merges them into *config* using :func:`merge_config`.
    Higher-priority packages (declared later) override lower-priority ones.

    The ``packages:`` key is removed from the returned config.
    Must be called after :func:`do_packages_pass` has resolved all packages.
    """
    if CONF_PACKAGES not in config:
        return config

    # Build flat list of all package configs to merge in priority order:
    merge_list: list[dict] = []

    def process_package_callback(
        package_config: dict,
        context: ContextVars | None,
        path: yaml_util.DocumentPath | None = None,
    ) -> dict:
        """This will be called for each package found in the config."""
        merge_list.append(package_config)
        return _walk_packages(package_config, process_package_callback, path=path)

    _walk_packages(config, process_package_callback)
    # Merge all packages into the main config:
    config = reduce(lambda new, old: merge_config(old, new), merge_list, config)
    del config[CONF_PACKAGES]
    return config


def resolve_packages(
    config: dict[str, Any],
    *,
    command_line_substitutions: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Load and merge ``packages:`` in one call; return the flattened config.

    Convenience wrapper around :func:`do_packages_pass` followed by
    :func:`merge_packages`. External tools that want the package-
    merged dict (without going through full schema validation via
    :func:`esphome.config.read_config`) get one stable seam to call
    instead of having to chain the two functions and stay in sync
    with the pipeline order.

    Note: the full :func:`esphome.config.validate_config` pipeline
    runs two extra passes around the merge that this wrapper
    deliberately skips:

    1. :func:`esphome.components.substitutions.do_substitution_pass`
       runs BETWEEN :func:`do_packages_pass` and
       :func:`merge_packages`, so ``${var}`` placeholders inside
       package content are NOT resolved here. Callers that need
       substitution should invoke ``do_substitution_pass``
       themselves between calls, or go through the full
       ``validate_config``.
    2. :func:`esphome.config.resolve_extend_remove` runs AFTER
       :func:`merge_packages`, so top-level ``!remove`` / ``!extend``
       markers are NOT applied here. A package-contributed block
       paired with a top-level ``key: !remove`` will still appear
       in the returned dict (the marker just sits next to it).

    The wrapper exists for the "what blocks did packages
    contribute?" question — metadata callers that just need to
    see merged top-level keys. It is NOT a stand-in for
    :func:`esphome.config.validate_config` and the two passes
    above are the reasons why.

    Used by:

    - ``esphome/device-builder`` — the new WebSocket dashboard
      backend reads device metadata (api / wifi / target-platform
      flags) off the merged config so packages contribute the same
      blocks the compiler sees, not just whatever sits at the top
      of the user's YAML. See
      https://github.com/esphome/device-builder/issues/288 for the
      bug this fixes.

    Returns *config* unchanged when ``packages:`` isn't present, so
    callers can apply this unconditionally without having to peek
    at the config first.
    """
    if CONF_PACKAGES not in config:
        return config
    config = do_packages_pass(
        config, command_line_substitutions=command_line_substitutions
    )
    return merge_packages(config)
