"""Publish plugin code and its dependency selection through one recoverable handoff."""
from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
import shutil


def recover_plugin_publication(project: Path, row: dict, journal: Path) -> None:
    """Recover a shipped caller's row through the stdlib boot-journal owner."""
    from hermes_cli.runtime_state import _recover_plugin_publication

    _recover_plugin_publication(project, row, journal)


def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: dict,
                   *, target_digest: str | None = None, require_consent: bool = False) -> None:
    from pm.client import sync_venv
    from pm.plugin_inputs import StagedUpdate
    from pm.store import tree_digest

    if require_consent:
        from hermes_cli import plugins_cmd
        from pm.workspace import enabled_plugin_dirs

        if target.resolve() in enabled_plugin_dirs(installing=target):
            consented, reason = plugins_cmd._install_plugin_python_deps(
                plugins_cmd._read_manifest_for_install(staged), staged, plugins_cmd._console())
            if not consented:
                raise plugins_cmd.PluginOperationError(
                    f"Reinstall declined: {reason}. The installed plugin and active environment are unchanged.")

    sync_venv(explicit=True, plugins=StagedUpdate({
        "staged": str(staged.resolve()), "target": str(target.absolute()),
        "old_metadata": old_metadata, "new_metadata": new_metadata,
        "target_digest": target_digest if target_digest is not None else (tree_digest(target) if target.exists() else None),
    }))


def _refresh_declared_dependencies(target: Path, staged: Path, manifest: dict, *, interactive: bool) -> None:
    """Dependency changes carried by an update clear the same gates as an install.

    New Python requirements install packages into the shared environment: install and
    reinstall prompt for that, so an update prompts too, and an unattended one (dashboard,
    ``plugins.auto_apply`` from the gateway) is REFUSED rather than consented on the user's
    behalf. Publication replaces the whole tree, so a Node sidecar the user accepted earlier is
    rebuilt in the staged copy when its package.json/lock moved (custom pulls copy a stale
    node_modules; catalog re-pins clone without one).
    """
    from hermes_cli import plugins_cmd as pc
    from hermes_cli.runtime_state import _bytes
    from pm.plugin_declarations import read_python_declaration
    from pm.workspace import enabled_plugin_dirs, install_node_sidecar

    if (target / "node_modules").is_dir() and (staged / "package.json").is_file() and (
            not (staged / "node_modules").is_dir()
            or any(_bytes(target / name) != _bytes(staged / name) for name in ("package.json", "package-lock.json"))):
        reason = install_node_sidecar(staged, explicit=True)
        if reason:
            raise pc.PluginOperationError(
                f"Node dependencies could not be refreshed: {reason}. "
                "The installed plugin and active environment are unchanged.")
    if target.resolve() not in enabled_plugin_dirs(installing=target):
        return  # a disabled plugin's deps are admitted (and consented) by `hermes plugins enable`
    before, after = read_python_declaration(target), read_python_declaration(staged)
    added = tuple(spec for spec in after.install_requirements if spec not in before.install_requirements)
    if not added and not (after.is_member and not before.is_member):
        return
    if not interactive:
        raise pc.PluginOperationError(
            f"The update declares new Python dependencies ({', '.join(added) or 'in its pyproject.toml'}); "
            f"run `hermes plugins update {target.name}` in a terminal to review them.")
    consented, reason = pc._consent_python_deps(manifest.get("name", target.name), added, pc._console())
    if not consented:
        raise pc.PluginOperationError(
            f"Update declined: {reason}. The installed plugin and active environment are unchanged.")


def update_plugin(
    target: Path,
    *,
    catalog_entry=None,
    interactive: bool = False,
    carry_user_files: Callable[[Path], list[str]] | None = None,
) -> str:
    """Prepare a catalog re-pin or custom Git pull without changing the live tree.

    *interactive*: a terminal user is present to consent to newly declared dependencies;
    the dashboard and the gateway's auto-apply pass False and get a refusal instead.
    *carry_user_files(staged)* may merge user-owned state into the staged tree before
    manifest validation, example-file generation, dependency preparation and publication; it
    returns the carried paths so a scan block can attribute findings to them."""
    import tempfile

    from hermes_cli import plugins_cmd as pc
    from hermes_cli.plugins_cmd_catalog import refuse_if_installed_removed
    from pm.store import tree_digest

    target = target.resolve()
    metadata = pc._read_install_metadata()
    record = dict(metadata.get(target.name, {}))
    if catalog_entry is None and record.get("pinned"):
        raise pc.PluginOperationError(f"Plugin '{target.name}' is pinned; reinstall with an explicit --ref to change it.")
    source = catalog_entry.install_identifier if catalog_entry else str(record.get("source") or "")
    if not source or (catalog_entry is None and not (target / ".git").is_dir()):
        raise pc.PluginOperationError(f"Plugin '{target.name}' has no owned Git checkout; reinstall from its source.")
    feed_revision = None
    if catalog_entry is None:
        from hermes_cli.plugins_provenance import Provenance, ProvenanceClass
        from hermes_cli.plugins_updates import check_local_provenance, default_fetch, parse_feed_yml

        checked = check_local_provenance(Provenance(target.name, ProvenanceClass.GIT, target, record))
        if checked.needs_fixing:
            raise pc.PluginOperationError(checked.needs_fixing)
        if record.get("update_url"):
            feed = parse_feed_yml(default_fetch(record["update_url"]))
            proposed = (feed.get("artifacts") or {}).get("git", "")
            if pc._EXACT_COMMIT_RE.fullmatch(proposed):
                feed_revision = proposed.lower()
            elif proposed not in (source, source.split("#", 1)[0]):
                raise pc.PluginOperationError("Update feed must select a commit or the recorded Git source.")
            if feed.get("min_hermes"):
                pc._check_manifest_version({"requires_hermes": feed["min_hermes"]}, target.name)
    refuse_if_installed_removed(target.name, target)
    before = tree_digest(target)
    with tempfile.TemporaryDirectory(prefix=".update-", dir=target.parent) as directory:
        staged = Path(directory) / "plugin"
        try:
            if catalog_entry:
                git_url, subdir = pc._resolve_git_url(source)
                revision = pc._clone_plugin_repo(staged, git_url, catalog_entry.sha)
                staged = pc._resolve_subdir_within(staged, subdir) if subdir else staged
                output = f"Updated to catalog pin {revision}"
                catalog_record = {
                    "name": catalog_entry.name,
                    "repo": catalog_entry.repo,
                    "tier": catalog_entry.tier,
                    "pin": catalog_entry.sha,
                    "sha": revision,
                }
                record.update(
                    catalog=catalog_record,
                    pinned=True,
                    source=pc._canonical_source(git_url, subdir),
                )
                record.pop("catalog_name", None)
                record.pop("catalog_tier", None)
                from hermes_cli.plugins_cmd_catalog import write_catalog_sidecar_record
                write_catalog_sidecar_record(staged, catalog_record, revision)
            else:
                # Copy Git metadata and local changes. Autostash only ever touches the copy.
                shutil.copytree(target, staged, symlinks=True, ignore=shutil.ignore_patterns("__pycache__"))
                if feed_revision:
                    git = pc._resolve_git_executable()
                    status = pc._git_or_raise(git, target, "status", "--porcelain", failure_prefix="Could not inspect plugin edits: ")
                    if status.stdout.strip():
                        raise pc.PluginOperationError("Pinned feed update has local changes; save them before updating.")
                    pc._checkout_exact_revision(staged, git, feed_revision)
                    output = f"Updated to feed commit {feed_revision}"
                else:
                    ok, output = pc._git_pull_plugin_dir(staged)
                    if not ok:
                        raise pc.PluginOperationError(output)
                revision = pc._git_head_revision(staged, pc._resolve_git_executable())
            merged = carry_user_files(staged) if carry_user_files is not None else None
            manifest = pc._read_manifest_for_install(staged)
            installed_name = str(manifest.get("name") or target.name)
            if catalog_entry is None and installed_name != target.name:
                raise pc.PluginOperationError("The updated plugin changed its installed name; reinstall it explicitly.")
            try:
                new_target = pc._sanitize_plugin_name(installed_name, target.parent)
            except ValueError as exc:
                raise pc.PluginOperationError(str(exc)) from exc
            if new_target != target and new_target.exists():
                raise pc.PluginOperationError(
                    f"The updated plugin renamed itself to '{installed_name}', but that plugin already exists.")
            pc._check_manifest_version(manifest, installed_name)
            pc._scan_merged_tree(staged, source, merged, force=False)
            pc._copy_example_files(staged, pc._console())
            _refresh_declared_dependencies(target, staged, manifest, interactive=interactive)
            if tree_digest(target) != before:
                raise pc.PluginOperationError("Plugin files changed while preparing the update; retry.")
            record["revision"] = revision
            if new_target == target and tree_digest(staged) == before:
                return output
            publish_plugin(
                staged,
                new_target,
                metadata,
                {**metadata, installed_name: record},
                target_digest=before if new_target == target else None,
            )
            return output
        except pc.PluginOperationError:
            raise
        except Exception as exc:
            raise pc.PluginOperationError(f"Plugin '{target.name}' update was not published: {exc}") from exc
