# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========

import logging
import posixpath

from mirage.commands.cli.builtin.git.types import SYMLINK
from mirage.ops.types import LinkView
from mirage.runtime.types import DispatchFn
from mirage.types import LINK_TARGET_KEY, FileStat, FileType, PathSpec
from mirage.utils.errors import MISS_ERRORS

logger = logging.getLogger(__name__)


async def read_file(dispatch: DispatchFn, path: str) -> bytes:
    """Read one virtual path through the workspace dispatcher.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path.
    """
    data, _ = await dispatch("read", PathSpec.from_str_path(path))
    return data if isinstance(data, bytes) else bytes(data)


async def entry_bytes(dispatch: DispatchFn, path: str,
                      info: FileStat) -> bytes:
    """The bytes git stores for one working-tree entry.

    A symlink's blob is its target string, not what the target holds, so
    reading through the link would stage a second copy of the target
    under mode 100644 and then report the entry modified forever after
    (the staged blob and the bytes behind the link never match). The
    target is namespace state, which is why it arrives on the stat
    rather than from a read.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path.
        info (FileStat): what the walk saw at that path, lstat-style.
    """
    if info.type is FileType.SYMLINK:
        target = info.extra.get(LINK_TARGET_KEY)
        if isinstance(target, str):
            return target.encode()
    return await read_file(dispatch, path)


async def restore_entry(dispatch: DispatchFn,
                        path: str,
                        mode: int,
                        blob: bytes,
                        links: LinkView | None = None) -> None:
    """Materialize one tree entry into the working tree.

    A 120000 entry is a symlink whose blob is the target string, so it
    is restored through the namespace rather than written as content:
    writing the blob would leave a regular file spelling the target.

    Whatever is already there goes first, whichever kind it is, because
    git replaces a tree entry rather than merging with it. Two of the
    four combinations are the ones that corrupt state: writing a regular
    blob at a path the namespace holds a link for follows the link and
    lands the content in the file it points at, damaging a path no
    branch touched while the link stays; and linking over a regular file
    leaves that file behind the link, ready to reappear when the link
    goes. The fourth, a link over a link, is the retarget a checkout
    does when a branch moves where a link points: symlink(2) does not
    overwrite, so the old name is removed rather than replaced in place.
    The check is a namespace lookup, so the ordinary file-for-file case
    costs nothing.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path to materialize at.
        mode (int): the tree entry's mode.
        blob (bytes): the entry's blob content.
        links (LinkView | None): the name plane's link facts, None when
            no namespace is wired.
    """
    linked = links is not None and links.stat_at(path) is not None
    if mode == SYMLINK:
        await remove_file(dispatch, path)
        await dispatch("symlink",
                       PathSpec.from_str_path(path),
                       target=blob.decode("utf-8", errors="replace"))
        return
    if linked:
        await remove_file(dispatch, path)
    await write_file(dispatch, path, blob)


async def read_range(dispatch: DispatchFn, path: str, offset: int,
                     size: int) -> bytes:
    """Read a byte range of one virtual path.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path.
        offset (int): first byte to read.
        size (int): how many bytes to read.
    """
    data, _ = await dispatch("read",
                             PathSpec.from_str_path(path),
                             offset=offset,
                             size=size)
    return data if isinstance(data, bytes) else bytes(data)


async def file_size(dispatch: DispatchFn, path: str) -> int | None:
    """A path's byte length, or None when the backend does not know it.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path.
    """
    stat, _ = await dispatch("stat", PathSpec.from_str_path(path))
    return getattr(stat, "size", None)


async def read_optional(dispatch: DispatchFn, path: str) -> bytes | None:
    """Read a path that a repository may legitimately not have.

    ``packed-refs`` and ``HEAD``-adjacent files are absent in perfectly
    valid repositories, so a miss is an answer rather than an error.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path.
    """
    try:
        return await read_file(dispatch, path)
    except MISS_ERRORS:
        return None


async def read_names(dispatch: DispatchFn, path: str) -> list[str]:
    """List a directory, empty when it does not exist.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path of the directory.
    """
    try:
        entries, _ = await dispatch("readdir", PathSpec.from_str_path(path))
    except MISS_ERRORS:
        return []
    return list(entries or [])


async def ensure_dir(dispatch: DispatchFn, path: str) -> None:
    """Create a directory and every missing directory above it.

    Written out rather than delegated to ``mkdir -p`` because the
    parents flag is a per-backend capability: the ops factory only wires
    ``parents=True`` for backends that declare it, so a plain ``mkdir``
    of ``objects/ab`` fails on the rest.

    Existence is probed with a point stat, which on a prefix store misses
    a directory that has no object of its own. That false negative is
    harmless here and the reason this does not need the two-channel
    stat: on such a store a directory is the set of keys under it, so
    creating one again costs a no-op rather than an error.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path of the directory.
    """
    missing: list[str] = []
    current = path.rstrip("/")
    while current and current != "/":
        try:
            await dispatch("stat", PathSpec.from_str_path(current))
            break
        except MISS_ERRORS:
            missing.append(current)
            current = posixpath.dirname(current)
    for target in reversed(missing):
        await dispatch("mkdir", PathSpec.from_str_path(target))


async def exists(dispatch: DispatchFn, path: str) -> bool:
    """Whether a point lookup finds anything at a path.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path.
    """
    try:
        await dispatch("stat", PathSpec.from_str_path(path))
    except MISS_ERRORS:
        return False
    return True


async def write_file(dispatch: DispatchFn, path: str, data: bytes) -> None:
    """Write one virtual path, creating the directories above it.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path.
        data (bytes): the whole contents.
    """
    await ensure_dir(dispatch, posixpath.dirname(path))
    await dispatch("write", PathSpec.from_str_path(path), data=data)


async def write_once(dispatch: DispatchFn, path: str, data: bytes) -> None:
    """Write a path only if nothing is there yet.

    For content-addressed files, which is every object in the database:
    a path that exists already holds exactly these bytes, because its
    name is a hash of them. Skipping the write is therefore not an
    optimisation but a requirement, since git writes loose objects
    read-only (0444) and rewriting one fails with EACCES. Re-staging an
    unchanged file hits that on the first try.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path.
        data (bytes): the whole contents.
    """
    if await exists(dispatch, path):
        return
    await write_file(dispatch, path, data)


async def remove_file(dispatch: DispatchFn, path: str) -> None:
    """Delete one virtual path, tolerating one that is already gone.

    A miss is an answer rather than an error for every caller here:
    unstaging a path deletes whatever ref or lock may or may not exist,
    and a checkout removes files the other branch does not have.

    Args:
        dispatch (DispatchFn): workspace op dispatcher.
        path (str): absolute virtual path.
    """
    try:
        await dispatch("unlink", PathSpec.from_str_path(path))
    except MISS_ERRORS as exc:
        logger.debug("nothing to remove at %s: %s", path, exc)
