import os
import pathlib
import re
import shlex
from dataclasses import dataclass

try:
    import tomllib
except ModuleNotFoundError:  # pragma: no cover - exercised on Python 3.10.
    import tomli as tomllib

import click
import pathspec

from langgraph_cli._ignore import (
    _build_dockerignore_negation_hints,
    _build_ignore_spec,
    _is_always_excluded,
)
from langgraph_cli.schemas import Config


@dataclass(frozen=True, slots=True)
class UvLockSourceEntry:
    name: str
    value: object
    declared_root: pathlib.Path
    pyproject_path: pathlib.Path


@dataclass(slots=True)
class UvLockPackage:
    name: str
    normalized_name: str
    root: pathlib.Path
    pyproject_path: pathlib.Path
    raw_dependency_specs: object
    raw_uv_tool: object
    # Set after validation:
    package_enabled: bool = False
    dependency_names: tuple[str, ...] = ()
    workspace_dependencies: tuple[str, ...] = ()


@dataclass(frozen=True, slots=True)
class UvLockWorkspace:
    raw_root_source_entries: object
    packages_by_name: dict[str, UvLockPackage]
    packages_by_root: dict[pathlib.Path, UvLockPackage]


@dataclass(frozen=True, slots=True)
class UvLockPlan:
    project_root: pathlib.Path
    pyproject_path: pathlib.Path
    uv_lock_path: pathlib.Path
    target: UvLockPackage
    target_root: pathlib.Path
    install_order: tuple[UvLockPackage, ...]
    container_roots: dict[pathlib.Path, pathlib.PurePosixPath]
    working_dir: str
    all_workspace_roots: frozenset[pathlib.Path] = frozenset()


@dataclass(slots=True)
class DockerBuildPlan:
    lines: list[str]

    def add_blank(self) -> None:
        self.lines.append("")

    def add_raw(self, line: str) -> None:
        self.lines.append(line)

    def add_instruction(self, opcode: str, value: str | None = None) -> None:
        if value:
            self.lines.append(f"{opcode} {value}")
        else:
            self.lines.append(opcode)

    def extend_nonempty(self, items: list[str]) -> None:
        self.lines.extend(item for item in items if item)

    def render(self) -> str:
        return os.linesep.join(self.lines)


def _normalize_package_name(name: str) -> str:
    return re.sub(r"[-_.]+", "-", name).lower()


def _parse_dependency_name(
    dependency: str, *, package_name: str, pyproject_path: pathlib.Path
) -> str:
    match = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", dependency)
    if not match:
        raise click.UsageError(
            "source.kind 'uv' only supports PEP 508 dependency strings "
            f"with an explicit package name. Could not parse dependency "
            f"{dependency!r} in {pyproject_path} for package '{package_name}'."
        )
    return _normalize_package_name(match.group(1))


def _load_pyproject(pyproject_path: pathlib.Path) -> dict:
    with pyproject_path.open("rb") as pyproject_file:
        return tomllib.load(pyproject_file)


def _get_dependency_names(
    dependency_specs: object, *, package_name: str, pyproject_path: pathlib.Path
) -> tuple[str, ...]:
    if dependency_specs is None:
        dependency_specs = []
    if not isinstance(dependency_specs, list) or not all(
        isinstance(spec, str) for spec in dependency_specs
    ):
        raise click.UsageError(
            "source.kind 'uv' requires [project].dependencies to be a "
            f"list of strings in {pyproject_path}."
        )
    return tuple(
        _parse_dependency_name(
            spec,
            package_name=package_name,
            pyproject_path=pyproject_path,
        )
        for spec in dependency_specs
    )


def _validate_uv_lock_source_entry(
    *,
    source_name: str,
    source_value: object,
    declared_root: pathlib.Path,
    pyproject_path: pathlib.Path,
    project_root: pathlib.Path,
    packages_by_name: dict[str, UvLockPackage],
    packages_by_root: dict[pathlib.Path, UvLockPackage],
) -> set[str]:
    workspace_dependencies: set[str] = set()
    if isinstance(source_value, list):
        for item in source_value:
            workspace_dependencies.update(
                _validate_uv_lock_source_entry(
                    source_name=source_name,
                    source_value=item,
                    declared_root=declared_root,
                    pyproject_path=pyproject_path,
                    project_root=project_root,
                    packages_by_name=packages_by_name,
                    packages_by_root=packages_by_root,
                )
            )
        return workspace_dependencies

    if not isinstance(source_value, dict):
        return workspace_dependencies

    normalized_source_name = _normalize_package_name(source_name)
    package: UvLockPackage | None = None

    if source_value.get("workspace") is True:
        package = packages_by_name.get(normalized_source_name)
        if package is None:
            raise click.UsageError(
                f"'{source_name}' in {pyproject_path} is marked as "
                "`{{ workspace = true }}` but no matching workspace package was "
                f"found under project_root ({project_root}). Check that "
                f"'{source_name}' appears in [tool.uv.workspace].members."
            )
        if not _get_uv_lock_package_enabled(package):
            raise click.UsageError(
                f"'{source_name}' in {pyproject_path} is a workspace dependency "
                f"but sets `tool.uv.package = false` in {package.pyproject_path}. "
                "Workspace dependencies must be buildable packages."
            )
        workspace_dependencies.add(package.normalized_name)

    path_ref = source_value.get("path")
    if isinstance(path_ref, str):
        if (
            pathlib.Path(path_ref).is_absolute()
            or pathlib.PurePosixPath(path_ref).is_absolute()
        ):
            raise click.UsageError(
                f"'{source_name}' in {pyproject_path} uses an absolute path "
                f"({path_ref}), which is not supported. Use a relative path or "
                "`{{ workspace = true }}` instead."
            )

        resolved = (declared_root / path_ref).resolve()
        if project_root != resolved and project_root not in resolved.parents:
            raise click.UsageError(
                f"'{source_name}' in {pyproject_path} uses a path source that "
                f"resolves to {resolved}, which is outside project_root "
                f"({project_root})."
            )

        package = packages_by_root.get(resolved)
        if package is None:
            raise click.UsageError(
                f"'{source_name}' in {pyproject_path} uses a path source that "
                f"resolves to {resolved}, which is not a workspace package under "
                f"project_root ({project_root})."
            )
        if package.normalized_name != normalized_source_name:
            raise click.UsageError(
                f"'{source_name}' in {pyproject_path} points to {resolved}, which "
                f"defines package '{package.name}'. The dependency name and the "
                "workspace package name must match."
            )
        if not _get_uv_lock_package_enabled(package):
            raise click.UsageError(
                f"'{source_name}' in {pyproject_path} resolves to workspace package "
                f"'{package.name}', which sets `tool.uv.package = false` in "
                f"{package.pyproject_path}. Workspace dependencies must be "
                "buildable packages."
            )
        workspace_dependencies.add(package.normalized_name)

    for key, nested_value in source_value.items():
        if key in {"workspace", "path"}:
            continue
        workspace_dependencies.update(
            _validate_uv_lock_source_entry(
                source_name=source_name,
                source_value=nested_value,
                declared_root=declared_root,
                pyproject_path=pyproject_path,
                project_root=project_root,
                packages_by_name=packages_by_name,
                packages_by_root=packages_by_root,
            )
        )
    return workspace_dependencies


def _get_uv_lock_package_enabled(package: UvLockPackage) -> bool:
    uv_tool = package.raw_uv_tool
    if uv_tool and not isinstance(uv_tool, dict):
        raise click.UsageError(
            "source.kind 'uv' requires [tool.uv] to be a table "
            f"in {package.pyproject_path}."
        )
    package_enabled = (
        uv_tool.get("package", True) if isinstance(uv_tool, dict) else True
    )
    if not isinstance(package_enabled, bool):
        raise click.UsageError(
            "source.kind 'uv' requires [tool.uv].package to be a boolean "
            f"in {package.pyproject_path}."
        )
    return package_enabled


def _get_uv_lock_source_entries(
    package: UvLockPackage,
    *,
    project_root: pathlib.Path,
    root_pyproject_path: pathlib.Path,
    raw_root_source_entries: object,
) -> tuple[UvLockSourceEntry, ...]:
    if not isinstance(raw_root_source_entries, dict):
        raise click.UsageError(
            "source.kind 'uv' requires [tool.uv.sources] to be a table "
            f"in {root_pyproject_path}."
        )

    uv_tool = package.raw_uv_tool
    package_source_entries = (
        uv_tool.get("sources", {}) if isinstance(uv_tool, dict) else {}
    )
    if not isinstance(package_source_entries, dict):
        raise click.UsageError(
            "source.kind 'uv' requires [tool.uv.sources] to be a table "
            f"in {package.pyproject_path}."
        )

    source_entries: dict[str, UvLockSourceEntry] = {
        source_name: UvLockSourceEntry(
            name=source_name,
            value=source_value,
            declared_root=project_root,
            pyproject_path=root_pyproject_path,
        )
        for source_name, source_value in raw_root_source_entries.items()
    }
    source_entries.update(
        {
            source_name: UvLockSourceEntry(
                name=source_name,
                value=source_value,
                declared_root=package.root,
                pyproject_path=package.pyproject_path,
            )
            for source_name, source_value in package_source_entries.items()
        }
    )
    return tuple(source_entries.values())


def _validate_uv_lock_package(
    package: UvLockPackage,
    *,
    project_root: pathlib.Path,
    root_pyproject_path: pathlib.Path,
    raw_root_source_entries: object,
    packages_by_name: dict[str, UvLockPackage],
    packages_by_root: dict[pathlib.Path, UvLockPackage],
) -> None:
    dependency_names = _get_dependency_names(
        package.raw_dependency_specs,
        package_name=package.name,
        pyproject_path=package.pyproject_path,
    )
    dependency_name_set = set(dependency_names)

    workspace_dependency_names: set[str] = set()
    for source_entry in _get_uv_lock_source_entries(
        package,
        project_root=project_root,
        root_pyproject_path=root_pyproject_path,
        raw_root_source_entries=raw_root_source_entries,
    ):
        if _normalize_package_name(source_entry.name) not in dependency_name_set:
            continue
        workspace_dependency_names.update(
            _validate_uv_lock_source_entry(
                source_name=source_entry.name,
                source_value=source_entry.value,
                declared_root=source_entry.declared_root,
                pyproject_path=source_entry.pyproject_path,
                project_root=project_root,
                packages_by_name=packages_by_name,
                packages_by_root=packages_by_root,
            )
        )

    package.package_enabled = _get_uv_lock_package_enabled(package)
    package.dependency_names = dependency_names
    package.workspace_dependencies = tuple(
        dependency_name
        for dependency_name in dependency_names
        if dependency_name in workspace_dependency_names
    )


def _discover_uv_lock_workspace_packages(
    project_root: pathlib.Path, pyproject_path: pathlib.Path
) -> UvLockWorkspace:
    root_data = _load_pyproject(pyproject_path)
    root_source_entries = root_data.get("tool", {}).get("uv", {}).get("sources", {})

    candidate_roots: list[pathlib.Path] = []
    root_project = root_data.get("project", {})
    if isinstance(root_project, dict) and isinstance(root_project.get("name"), str):
        candidate_roots.append(project_root)

    workspace_members = (
        root_data.get("tool", {}).get("uv", {}).get("workspace", {}).get("members", [])
    )
    if workspace_members and not isinstance(workspace_members, list):
        raise click.UsageError(
            "source.kind 'uv' requires [tool.uv.workspace].members to be a list."
        )

    for pattern in workspace_members:
        if not isinstance(pattern, str):
            raise click.UsageError(
                "source.kind 'uv' requires every [tool.uv.workspace].members "
                "entry to be a string."
            )
        for match in sorted(project_root.glob(pattern)):
            package_root = match if match.is_dir() else match.parent
            package_root = package_root.resolve()
            if (package_root / "pyproject.toml").is_file():
                candidate_roots.append(package_root)

    unique_roots: list[pathlib.Path] = []
    seen_roots: set[pathlib.Path] = set()
    for root in candidate_roots:
        if root not in seen_roots:
            unique_roots.append(root)
            seen_roots.add(root)

    packages: list[UvLockPackage] = []
    for package_root in unique_roots:
        member_pyproject_path = package_root / "pyproject.toml"
        pyproject_data = _load_pyproject(member_pyproject_path)

        project_data = pyproject_data.get("project", {})
        package_name = (
            project_data.get("name") if isinstance(project_data, dict) else None
        )
        if not isinstance(package_name, str) or not package_name.strip():
            raise click.UsageError(
                "source.kind 'uv' requires every workspace package to define "
                f"[project].name in {member_pyproject_path}."
            )

        packages.append(
            UvLockPackage(
                name=package_name,
                normalized_name=_normalize_package_name(package_name),
                root=package_root,
                pyproject_path=member_pyproject_path,
                raw_dependency_specs=project_data.get("dependencies", []),
                raw_uv_tool=pyproject_data.get("tool", {}).get("uv", {}),
            )
        )

    packages_by_name: dict[str, UvLockPackage] = {}
    packages_by_root: dict[pathlib.Path, UvLockPackage] = {}
    for package in packages:
        existing = packages_by_name.get(package.normalized_name)
        if existing is not None:
            raise click.UsageError(
                "source.kind 'uv' requires unique workspace package names, "
                f"but both {existing.pyproject_path} and {package.pyproject_path} "
                f"define '{package.name}'."
            )
        packages_by_name[package.normalized_name] = package
        packages_by_root[package.root] = package

    return UvLockWorkspace(
        raw_root_source_entries=root_source_entries,
        packages_by_name=packages_by_name,
        packages_by_root=packages_by_root,
    )


def _container_workspace_root() -> pathlib.PurePosixPath:
    return pathlib.PurePosixPath("/deps/workspace")


def _container_root_for_uv_lock_package(
    project_root: pathlib.Path, package_root: pathlib.Path
) -> pathlib.PurePosixPath:
    container_root = _container_workspace_root()
    relative_root = package_root.relative_to(project_root)
    if relative_root == pathlib.Path("."):
        return container_root
    return container_root.joinpath(*relative_root.parts)


def _uv_lock_package_copy_items(
    package: UvLockPackage,
    plan: UvLockPlan,
    ignore_spec: pathspec.PathSpec,
) -> tuple[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath], ...]:
    # Skip entries that .dockerignore / built-in exclusions would strip from
    # the build context. Emitting `ADD <path>` for a file that Docker has
    # filtered out causes the build to fail with
    # "failed to compute cache key: <path> not found".
    if package.root != plan.project_root:
        relative_root = pathlib.PurePosixPath(
            *package.root.relative_to(plan.project_root).parts
        )
        if _is_always_excluded(relative_root, is_dir=True) or ignore_spec.match_file(
            f"{relative_root.as_posix()}/"
        ):
            raise click.UsageError(
                f"Workspace member '{package.name}' at {relative_root} is "
                "excluded from the Docker build context, but uv.lock requires "
                "it to be copied into the build context. Remove the matching "
                "pattern or drop the member from [tool.uv.workspace].members."
            )
        return ((relative_root, plan.container_roots[package.root]),)

    root_container = plan.container_roots[package.root]
    workspace_member_roots = plan.all_workspace_roots - {plan.project_root}
    negated_dockerignore_hints = _build_dockerignore_negation_hints(plan.project_root)

    def iter_entries(
        current_dir: pathlib.Path,
    ) -> tuple[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath], ...]:
        entries: list[tuple[pathlib.PurePosixPath, pathlib.PurePosixPath]] = []
        for child in sorted(current_dir.iterdir(), key=lambda path: path.name):
            if child in workspace_member_roots:
                # Workspace members are copied separately if they are in the closure,
                # and excluded entirely otherwise.
                continue

            relative_child = pathlib.PurePosixPath(
                *child.relative_to(plan.project_root).parts
            )
            is_dir = child.is_dir()
            if _is_always_excluded(relative_child, is_dir=is_dir):
                continue
            ignored = ignore_spec.match_file(
                f"{relative_child.as_posix()}/" if is_dir else relative_child.as_posix()
            )
            is_workspace_parent = is_dir and any(
                child in ws_root.parents for ws_root in workspace_member_roots
            )

            if is_workspace_parent:
                entries.extend(iter_entries(child))
                continue
            if (
                is_dir
                and ignored
                and negated_dockerignore_hints.requires_dir_walk(relative_child)
            ):
                entries.extend(iter_entries(child))
                continue
            if ignored:
                continue

            entries.append(
                (relative_child, root_container.joinpath(*relative_child.parts))
            )
        return tuple(entries)

    return iter_entries(plan.project_root)


def _resolve_uv_lock_container_path(
    host_path: pathlib.Path, plan: UvLockPlan
) -> pathlib.PurePosixPath | None:
    for package_root in sorted(
        plan.container_roots, key=lambda path: len(path.parts), reverse=True
    ):
        if host_path == package_root or package_root in host_path.parents:
            # Guard against the workspace root matching paths inside
            # unrelated workspace members that are not in the closure.
            if plan.all_workspace_roots and _path_in_unrelated_member(
                host_path, package_root, plan
            ):
                continue
            relative_path = host_path.relative_to(package_root)
            container_root = plan.container_roots[package_root]
            if relative_path == pathlib.Path("."):
                return container_root
            return container_root.joinpath(*relative_path.parts)
    return None


def _path_in_unrelated_member(
    host_path: pathlib.Path,
    matched_root: pathlib.Path,
    plan: UvLockPlan,
) -> bool:
    """Return True if host_path is inside a workspace member NOT in the closure.

    When matched_root is the project root (workspace root), it is a parent of
    every file in the workspace.  We need to reject paths that actually belong
    to a sibling workspace member that was not selected for deployment.
    """
    for ws_root in plan.all_workspace_roots:
        if ws_root == matched_root:
            continue
        # ws_root is more specific than matched_root
        if ws_root in matched_root.parents:
            continue
        if host_path == ws_root or ws_root in host_path.parents:
            # host_path is inside this workspace member
            if ws_root not in plan.container_roots:
                return True
    return False


def _infer_uv_lock_target_package(
    *,
    config_root: pathlib.Path,
    project_root: pathlib.Path,
    source: dict,
    packages_by_name: dict[str, UvLockPackage],
    packages_by_root: dict[pathlib.Path, UvLockPackage],
) -> UvLockPackage:
    package_name = source.get("package")
    if package_name is not None:
        if not isinstance(package_name, str) or not package_name.strip():
            raise click.UsageError("`source.package` must be a non-empty string.")
        target = packages_by_name.get(_normalize_package_name(package_name))
        if target is None:
            available_packages = ", ".join(
                sorted(package.name for package in packages_by_name.values())
            )
            raise click.UsageError(
                "Could not find source.package "
                f"'{package_name}' in the uv project at {project_root}. "
                "It must match a [project].name from one of the discovered "
                f"packages. Available packages: {available_packages or '(none)'}."
            )
        return target

    containing_packages = sorted(
        (
            package
            for package_root, package in packages_by_root.items()
            if config_root == package_root or package_root in config_root.parents
        ),
        key=lambda package: len(package.root.parts),
        reverse=True,
    )
    if containing_packages:
        target = containing_packages[0]
        if (
            target.root != project_root
            or len(packages_by_name) == 1
            or config_root == project_root
        ):
            return target

    if len(packages_by_name) == 1:
        return next(iter(packages_by_name.values()))

    available_packages = ", ".join(
        sorted(package.name for package in packages_by_name.values())
    )
    raise click.UsageError(
        "source.package is required because source.root resolves to a uv "
        "workspace with multiple packages and no unique target package could be "
        f"inferred from langgraph.json at {config_root}. Available packages: "
        f"{available_packages or '(none)'}. "
        "Move langgraph.json into the target package or set source.package."
    )


def _plan_uv_lock_workspace(config_path: pathlib.Path, config: Config) -> UvLockPlan:
    config_root = config_path.parent.resolve()
    source = config["source"]
    root = source.get("root", ".")
    if not isinstance(root, str) or not root.strip():
        raise click.UsageError('`source.root` must be a non-empty string. Use `"."`.')
    project_root = (config_root / root).resolve()
    pyproject_path = project_root / "pyproject.toml"
    uv_lock_path = project_root / "uv.lock"

    if not uv_lock_path.exists():
        raise click.UsageError(
            f"No uv.lock found at {uv_lock_path}. Your langgraph.json sets "
            f"source.root={root!r}, which resolves to "
            f"{project_root}. Make sure this is the directory where you run "
            "`uv lock` (it should contain both pyproject.toml and uv.lock)."
        )
    if not pyproject_path.exists():
        raise click.UsageError(
            f"No pyproject.toml found at {pyproject_path}. Your langgraph.json "
            f"sets source.root={root!r}, which resolves to "
            f"{project_root}. This should be your uv workspace root."
        )

    workspace = _discover_uv_lock_workspace_packages(project_root, pyproject_path)
    packages_by_name = workspace.packages_by_name
    target = _infer_uv_lock_target_package(
        config_root=config_root,
        project_root=project_root,
        source=source,
        packages_by_name=packages_by_name,
        packages_by_root=workspace.packages_by_root,
    )
    _validate_uv_lock_package(
        target,
        project_root=project_root,
        root_pyproject_path=pyproject_path,
        raw_root_source_entries=workspace.raw_root_source_entries,
        packages_by_name=workspace.packages_by_name,
        packages_by_root=workspace.packages_by_root,
    )
    if not target.package_enabled:
        raise click.UsageError(
            f"'{target.name}' has `tool.uv.package = false` in "
            f"{target.pyproject_path}, so it cannot be deployed. Either remove "
            "that setting or point `source.package` at a different workspace "
            "member."
        )

    install_order: list[UvLockPackage] = []
    visited: set[str] = set()
    validated: set[str] = {target.normalized_name}

    def visit(package: UvLockPackage) -> None:
        if package.normalized_name in visited:
            return
        visited.add(package.normalized_name)
        if package.normalized_name not in validated:
            _validate_uv_lock_package(
                package,
                project_root=project_root,
                root_pyproject_path=pyproject_path,
                raw_root_source_entries=workspace.raw_root_source_entries,
                packages_by_name=workspace.packages_by_name,
                packages_by_root=workspace.packages_by_root,
            )
            validated.add(package.normalized_name)
        for dependency_name in package.workspace_dependencies:
            dependency = packages_by_name.get(dependency_name)
            if dependency is not None:
                visit(dependency)
        install_order.append(package)

    visit(target)

    container_roots = {
        package.root: _container_root_for_uv_lock_package(project_root, package.root)
        for package in install_order
    }

    all_workspace_roots = frozenset(
        package.root for package in packages_by_name.values()
    )

    working_dir = _resolve_uv_lock_container_path(
        config_root,
        UvLockPlan(
            project_root=project_root,
            pyproject_path=pyproject_path,
            uv_lock_path=uv_lock_path,
            target=target,
            target_root=target.root,
            install_order=tuple(install_order),
            container_roots=container_roots,
            working_dir=str(
                _container_root_for_uv_lock_package(project_root, target.root)
            ),
            all_workspace_roots=all_workspace_roots,
        ),
    )
    if working_dir is None:
        working_dir = container_roots[target.root]

    return UvLockPlan(
        project_root=project_root,
        pyproject_path=pyproject_path,
        uv_lock_path=uv_lock_path,
        target=target,
        target_root=target.root,
        install_order=tuple(install_order),
        container_roots=container_roots,
        working_dir=str(working_dir),
        all_workspace_roots=all_workspace_roots,
    )


def _rewrite_uv_lock_import_path(
    config_path: pathlib.Path,
    import_str: str,
    plan: UvLockPlan,
    *,
    label: str,
) -> str:
    module_str, _, attr_str = import_str.partition(":")
    if not module_str or not attr_str:
        raise ValueError(
            f'Import string "{import_str}" must be in format "<module>:<attribute>".'
        )

    if "/" not in module_str and "\\" not in module_str:
        return import_str

    resolved = (config_path.parent / module_str).resolve()
    if not resolved.exists():
        raise FileNotFoundError(f"Could not find {label}: {resolved}")
    if not resolved.is_file():
        raise IsADirectoryError(f"{label.capitalize()} must be a file: {resolved}")

    container_path = _resolve_uv_lock_container_path(resolved, plan)
    if container_path is None:
        copied_dirs = ", ".join(
            package.root.relative_to(plan.project_root).as_posix() or "."
            for package in plan.install_order
        )
        raise click.UsageError(
            f"{label.capitalize()} '{import_str}' resolves to {resolved}, which is "
            f"not inside the target package '{plan.target.name}' or any of its "
            f"workspace dependencies. Only these directories are copied into the "
            f"container: {copied_dirs}. If this file lives in another workspace "
            f"package, add it as a dependency of '{plan.target.name}' with "
            "`{ workspace = true }` in [tool.uv.sources]."
        )

    return f"{container_path.as_posix()}:{attr_str}"


def _update_uv_lock_graph_paths(
    config_path: pathlib.Path, config: Config, plan: UvLockPlan
) -> None:
    for graph_id, data in config["graphs"].items():
        if isinstance(data, dict):
            if "path" not in data:
                raise ValueError(
                    f"Graph '{graph_id}' must contain a 'path' key if it is a dictionary."
                )
            config["graphs"][graph_id]["path"] = _rewrite_uv_lock_import_path(
                config_path,
                data["path"],
                plan,
                label=f"graph '{graph_id}'",
            )
        elif isinstance(data, str):
            config["graphs"][graph_id] = _rewrite_uv_lock_import_path(
                config_path,
                data,
                plan,
                label=f"graph '{graph_id}'",
            )
        else:
            raise ValueError(
                f"Graph '{graph_id}' must be a string or a dictionary with a 'path' key."
            )


def _update_uv_lock_component_path(
    config_path: pathlib.Path,
    config: Config,
    plan: UvLockPlan,
    *,
    section: str,
    key: str,
    label: str,
) -> None:
    section_config = config.get(section)
    if not isinstance(section_config, dict):
        return

    path_str = section_config.get(key)
    if not isinstance(path_str, str):
        return

    section_config[key] = _rewrite_uv_lock_import_path(
        config_path,
        path_str,
        plan,
        label=label,
    )


def _update_uv_lock_ui_paths(
    config_path: pathlib.Path, config: Config, plan: UvLockPlan
) -> None:
    ui = config.get("ui")
    if not isinstance(ui, dict):
        return

    for ui_name, path_str in ui.items():
        if not isinstance(path_str, str):
            continue

        resolved = (config_path.parent / path_str).resolve()
        if not resolved.exists():
            raise FileNotFoundError(f"Could not find ui '{ui_name}': {resolved}")
        if not resolved.is_file():
            raise IsADirectoryError(f"Ui '{ui_name}' must be a file: {resolved}")

        container_path = _resolve_uv_lock_container_path(resolved, plan)
        if container_path is None:
            copied_dirs = ", ".join(
                package.root.relative_to(plan.project_root).as_posix() or "."
                for package in plan.install_order
            )
            raise click.UsageError(
                f"Ui '{ui_name}' resolves to {resolved}, which is not inside the "
                f"target package '{plan.target.name}' or any of its workspace "
                f"dependencies. Only these directories are copied into the "
                f"container: {copied_dirs}. If this file lives in another "
                f"workspace package, add it as a dependency of "
                f"'{plan.target.name}' with `{{ workspace = true }}` in "
                "[tool.uv.sources]."
            )

        ui[ui_name] = container_path.as_posix()


def python_config_to_docker_uv_lock(
    config_path: pathlib.Path,
    config: Config,
    base_image: str,
    api_version: str | None = None,
    *,
    build_tools_to_uninstall: tuple[str, ...] | None,
) -> tuple[str, dict[str, str]]:
    from langgraph_cli.config import (
        DEFAULT_NODE_VERSION,
        _build_python_install_commands,
        _build_runtime_env_vars,
        _get_node_pm_install_cmd,
        _get_pip_cleanup_lines,
        _image_supports_uv,
        docker_tag,
    )

    if not _image_supports_uv(base_image):
        raise ValueError(
            "source.kind 'uv' requires a base image with uv support "
            "(langchain/langgraph-api >= 0.2.47)"
        )

    config_root = config_path.parent.resolve()
    install_cmd = "uv pip install --system"
    _, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands(
        config, install_cmd
    )
    plan = _plan_uv_lock_workspace(config_path, config)

    _update_uv_lock_graph_paths(config_path, config, plan)
    for section, key in [
        ("auth", "path"),
        ("encryption", "path"),
        ("checkpointer", "path"),
        ("http", "app"),
    ]:
        _update_uv_lock_component_path(
            config_path,
            config,
            plan,
            section=section,
            key=key,
            label=f"{section}.{key}",
        )
    _update_uv_lock_ui_paths(config_path, config, plan)

    additional_contexts: dict[str, str] = {}
    workspace_context_name: str | None = None
    if (
        plan.project_root != config_root
        and config_root not in plan.project_root.parents
    ):
        workspace_context_name = "uv-workspace-root"
        additional_contexts[workspace_context_name] = str(plan.project_root)

    def copy_from_project_root(
        relative_path: pathlib.PurePosixPath, destination: str
    ) -> str:
        if workspace_context_name is not None:
            source = relative_path.as_posix() or "."
            return f"COPY --from={workspace_context_name} {source} {destination}"

        source_path = plan.project_root / pathlib.Path(relative_path)
        relative_source = source_path.relative_to(config_root).as_posix()
        return f"ADD {relative_source} {destination}"

    uv_export_project_dir = "/tmp/uv_export/project"
    env_vars = _build_runtime_env_vars(config)

    image_str = docker_tag(config, base_image, api_version)
    docker_plan = DockerBuildPlan(lines=[])

    if additional_contexts:
        docker_plan.add_raw("# syntax=docker/dockerfile:1.4")
        docker_plan.add_blank()

    docker_plan.add_instruction("FROM", image_str)
    docker_plan.add_blank()
    docker_plan.extend_nonempty(config["dockerfile_lines"])
    if config["dockerfile_lines"]:
        docker_plan.add_blank()

    if (config.get("ui") or config.get("node_version")) and plan.working_dir:
        docker_plan.add_instruction("RUN", "/storage/install-node.sh")
        docker_plan.add_blank()

    if pip_config_file_str:
        docker_plan.add_raw(pip_config_file_str)
        docker_plan.add_blank()

    docker_plan.add_raw("# -- Installing dependencies from uv.lock --")
    docker_plan.add_raw(
        copy_from_project_root(
            pathlib.PurePosixPath("pyproject.toml"),
            f"{uv_export_project_dir}/pyproject.toml",
        )
    )
    docker_plan.add_raw(
        copy_from_project_root(
            pathlib.PurePosixPath("uv.lock"),
            f"{uv_export_project_dir}/uv.lock",
        )
    )
    docker_plan.add_instruction("WORKDIR", uv_export_project_dir)
    docker_plan.add_instruction(
        "RUN",
        " ".join(
            [
                "uv export",
                f"--package {shlex.quote(plan.target.name)}",
                "--frozen",
                "--no-hashes",
                "--no-emit-project",
                "--no-emit-workspace",
                "-o uv_requirements.txt",
            ]
        ),
    )
    docker_plan.add_instruction(
        "RUN", f"{global_reqs_pip_install} -r uv_requirements.txt"
    )
    docker_plan.add_instruction("RUN", "rm -rf /tmp/uv_export")
    docker_plan.add_raw("# -- End of uv.lock dependencies install --")
    docker_plan.add_blank()

    ignore_spec = _build_ignore_spec(plan.project_root, include_gitignore=False)
    for package in plan.install_order:
        package_label = package.root.relative_to(plan.project_root).as_posix() or "."
        docker_plan.add_raw(f"# -- Adding workspace package {package_label} --")
        for source, destination in _uv_lock_package_copy_items(
            package, plan, ignore_spec
        ):
            docker_plan.add_raw(copy_from_project_root(source, destination.as_posix()))
        docker_plan.add_instruction(
            "WORKDIR", plan.container_roots[package.root].as_posix()
        )
        docker_plan.add_instruction("RUN", f"{global_reqs_pip_install} --no-deps -e .")
        docker_plan.add_raw(f"# -- End of workspace package {package_label} --")
        docker_plan.add_blank()

    docker_plan.extend_nonempty(env_vars)
    if env_vars:
        docker_plan.add_blank()

    if (config.get("ui") or config.get("node_version")) and plan.working_dir:
        docker_plan.add_raw("# -- Installing JS dependencies --")
        docker_plan.add_instruction(
            "ENV", f"NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}"
        )
        docker_plan.add_instruction("WORKDIR", plan.working_dir)
        docker_plan.add_instruction(
            "RUN",
            f"{_get_node_pm_install_cmd(plan.target_root)} && "
            "tsx /api/langgraph_api/js/build.mts",
        )
        docker_plan.add_raw("# -- End of JS dependencies install --")
        docker_plan.add_blank()

    docker_plan.add_raw(
        _get_pip_cleanup_lines(
            install_cmd=install_cmd,
            to_uninstall=build_tools_to_uninstall,
            pip_installer="uv",
        )
    )
    docker_plan.add_blank()
    if plan.working_dir:
        docker_plan.add_instruction("WORKDIR", plan.working_dir)

    return docker_plan.render(), additional_contexts
