# Copyright 2026-present, the HuggingFace Inc. team.
#
# 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.
"""Tests for the centralized HF URI parser ('huggingface_hub.utils._hf_uris')."""

import pytest

from huggingface_hub import constants
from huggingface_hub.errors import HfUriError
from huggingface_hub.utils import HfMount, HfUri, parse_hf_mount, parse_hf_uri


# ---------------------------------------------------------------------------
# HfUri success cases: (uri, expected_HfUri, expected_roundtrip)
# ---------------------------------------------------------------------------
URI_SUCCESS_CASES: list[tuple[str, HfUri, str]] = [
    # --- Models ----------------------------------------------------------------
    # Namespaced model (implicit type prefix)
    (
        "hf://my-org/my-model",
        HfUri(type="model", id="my-org/my-model"),
        "hf://models/my-org/my-model",
    ),
    # Namespaced model (explicit type prefix)
    (
        "hf://models/my-org/my-model",
        HfUri(type="model", id="my-org/my-model"),
        "hf://models/my-org/my-model",
    ),
    # Model with path inside the repo
    (
        "hf://models/my-org/my-model/config.json",
        HfUri(type="model", id="my-org/my-model", path_in_repo="config.json"),
        "hf://models/my-org/my-model/config.json",
    ),
    # Model with explicit revision
    (
        "hf://my-org/my-model@v1.0",
        HfUri(type="model", id="my-org/my-model", revision="v1.0"),
        "hf://models/my-org/my-model@v1.0",
    ),
    # Model with revision and path
    (
        "hf://models/my-org/my-model@dev/sub/file.bin",
        HfUri(type="model", id="my-org/my-model", revision="dev", path_in_repo="sub/file.bin"),
        "hf://models/my-org/my-model@dev/sub/file.bin",
    ),
    # --- Datasets --------------------------------------------------------------
    (
        "hf://datasets/my-org/my-dataset",
        HfUri(type="dataset", id="my-org/my-dataset"),
        "hf://datasets/my-org/my-dataset",
    ),
    # Dataset with revision and path
    (
        "hf://datasets/my-org/my-dataset@v1/train.csv",
        HfUri(type="dataset", id="my-org/my-dataset", revision="v1", path_in_repo="train.csv"),
        "hf://datasets/my-org/my-dataset@v1/train.csv",
    ),
    # --- Spaces ----------------------------------------------------------------
    (
        "hf://spaces/user/my-space",
        HfUri(type="space", id="user/my-space"),
        "hf://spaces/user/my-space",
    ),
    (
        "hf://spaces/user/my-space@main",
        HfUri(type="space", id="user/my-space", revision="main"),
        "hf://spaces/user/my-space@main",
    ),
    # --- Kernels ---------------------------------------------------------------
    (
        "hf://kernels/my-org/my-kernel",
        HfUri(type="kernel", id="my-org/my-kernel"),
        "hf://kernels/my-org/my-kernel",
    ),
    # --- Buckets ---------------------------------------------------------------
    (
        "hf://buckets/my-org/my-bucket",
        HfUri(type="bucket", id="my-org/my-bucket"),
        "hf://buckets/my-org/my-bucket",
    ),
    (
        "hf://buckets/my-org/my-bucket/sub/path",
        HfUri(type="bucket", id="my-org/my-bucket", path_in_repo="sub/path"),
        "hf://buckets/my-org/my-bucket/sub/path",
    ),
    # Trailing slashes are tolerated and stripped.
    (
        "hf://buckets/my-org/my-bucket/",
        HfUri(type="bucket", id="my-org/my-bucket"),
        "hf://buckets/my-org/my-bucket",
    ),
    # --- Special revisions (refs/pr/N, refs/convert/parquet) -------------------
    # Special PR revision: must be matched eagerly even though it contains '/'
    (
        "hf://datasets/foo/bar@refs/pr/10/file.csv",
        HfUri(type="dataset", id="foo/bar", revision="refs/pr/10", path_in_repo="file.csv"),
        "hf://datasets/foo/bar@refs/pr/10/file.csv",
    ),
    # Special convert revision
    (
        "hf://datasets/foo/bar@refs/convert/parquet/data.parquet",
        HfUri(
            type="dataset",
            id="foo/bar",
            revision="refs/convert/parquet",
            path_in_repo="data.parquet",
        ),
        "hf://datasets/foo/bar@refs/convert/parquet/data.parquet",
    ),
    # Convert ref name with hyphen (and dot) — must not be split at the hyphen.
    (
        "hf://datasets/foo/bar@refs/convert/parquet-v2/data.parquet",
        HfUri(
            type="dataset",
            id="foo/bar",
            revision="refs/convert/parquet-v2",
            path_in_repo="data.parquet",
        ),
        "hf://datasets/foo/bar@refs/convert/parquet-v2/data.parquet",
    ),
    (
        "hf://datasets/foo/bar@refs/convert/duckdb.v1/data.db",
        HfUri(
            type="dataset",
            id="foo/bar",
            revision="refs/convert/duckdb.v1",
            path_in_repo="data.db",
        ),
        "hf://datasets/foo/bar@refs/convert/duckdb.v1/data.db",
    ),
    # URL-encoded special revision
    (
        "hf://datasets/foo/bar@refs%2Fpr%2F10/file.csv",
        HfUri(type="dataset", id="foo/bar", revision="refs/pr/10", path_in_repo="file.csv"),
        "hf://datasets/foo/bar@refs/pr/10/file.csv",
    ),
    # Branch name containing '/' (e.g. 'feature/foo'). Must be URL-encoded so that the
    # parser does not split it at the first '/' and treat the rest as a path-in-repo.
    (
        "hf://my-org/my-model@feature%2Ffoo/config.json",
        HfUri(type="model", id="my-org/my-model", revision="feature/foo", path_in_repo="config.json"),
        "hf://models/my-org/my-model@feature%2Ffoo/config.json",
    ),
    # Special revision with no path after it
    (
        "hf://my-org/my-model@refs/pr/3",
        HfUri(type="model", id="my-org/my-model", revision="refs/pr/3"),
        "hf://models/my-org/my-model@refs/pr/3",
    ),
    # --- '@' in filenames (literal, not a revision separator) -----------------
    # Bucket with '@' in filename
    (
        "hf://buckets/my-org/my-bucket/file@1.txt",
        HfUri(type="bucket", id="my-org/my-bucket", path_in_repo="file@1.txt"),
        "hf://buckets/my-org/my-bucket/file@1.txt",
    ),
    # Bucket with '@' in nested path
    (
        "hf://buckets/my-org/my-bucket/sub/data@v2.bin",
        HfUri(type="bucket", id="my-org/my-bucket", path_in_repo="sub/data@v2.bin"),
        "hf://buckets/my-org/my-bucket/sub/data@v2.bin",
    ),
    # Repo with '@' in filename (no revision)
    (
        "hf://models/my-org/my-model/file@1.txt",
        HfUri(type="model", id="my-org/my-model", path_in_repo="file@1.txt"),
        "hf://models/my-org/my-model/file@1.txt",
    ),
    # Repo with '@' in nested path (no revision, implicit model type)
    (
        "hf://my-org/my-model/sub/data@v2.bin",
        HfUri(type="model", id="my-org/my-model", path_in_repo="sub/data@v2.bin"),
        "hf://models/my-org/my-model/sub/data@v2.bin",
    ),
    # Repo with explicit revision AND '@' in filename
    (
        "hf://models/my-org/my-model@main/file@1.txt",
        HfUri(type="model", id="my-org/my-model", revision="main", path_in_repo="file@1.txt"),
        "hf://models/my-org/my-model@main/file@1.txt",
    ),
    # '@' right after namespace/name is still a revision separator
    (
        "hf://a/b/c@v1",
        HfUri(type="model", id="a/b", path_in_repo="c@v1"),
        "hf://models/a/b/c@v1",
    ),
]


# ---------------------------------------------------------------------------
# HfUri failure cases: (uri, error_substring)
# ---------------------------------------------------------------------------
URI_FAILURE_CASES: list[tuple[str, str]] = [
    # Missing protocol (and not a recognized URL either)
    ("gpt2", "Must start with 'hf://'"),
    ("hf:/gpt2", "Must start with 'hf://'"),
    ("/local/path/file.txt", "Must start with 'hf://'"),
    # Empty body after protocol
    ("hf://", "Empty body"),
    # Empty repo id (just a type prefix)
    ("hf://datasets", "Missing identifier"),
    ("hf://datasets/", "Missing repository id"),
    ("hf://models/", "Missing repository id"),
    ("hf://buckets", "Missing identifier"),
    ("hf://buckets/", "Bucket id must be 'namespace/name'"),
    # Singular type forms are forbidden
    ("hf://dataset/foo/bar", "Type prefix must be plural"),
    ("hf://model/my-org/my-model", "Type prefix must be plural"),
    ("hf://space/user/my-space", "Type prefix must be plural"),
    ("hf://bucket/org/b", "Type prefix must be plural"),
    # Canonical repos (no namespace) are forbidden
    ("hf://gpt2", "Repository id must be 'namespace/name'"),
    ("hf://models/gpt2", "Repository id must be 'namespace/name'"),
    ("hf://datasets/squad", "Repository id must be 'namespace/name'"),
    ("hf://gpt2@v1", "Repository id must be 'namespace/name'"),
    ("hf://gpt2@v1/config.json", "Repository id must be 'namespace/name'"),
    # Buckets must always have namespace/name
    ("hf://buckets/single-segment", "Bucket id must be 'namespace/name'"),
    # Buckets cannot have a revision
    ("hf://buckets/org/b@v1", "do not support a revision"),
    ("hf://buckets/org/b@v1/path", "do not support a revision"),
    # Empty revision
    ("hf://my-org/my-model@", "Empty revision"),
    ("hf://datasets/foo/bar@/file", "Empty revision"),
    # Empty repo id before '@'
    ("hf://@v1/file", "Missing repository id"),
    # Invalid repo id chars (validated by validate_repo_id)
    ("hf://datasets/foo/.invalid", "Repo id must use alphanumeric"),
    ("hf://models/foo--bar/baz", "Cannot have -- or .."),
    # Empty path segments (adjacent slashes)
    ("hf://models/org/m//sub", "empty segments"),
    ("hf://buckets/org/b//sub", "empty segments"),
    ("hf://models/org/m/sub//dir", "empty segments"),
    ("hf://models/org/m@main//file.txt", "empty segments"),
    ("hf://datasets/foo/bar@refs/pr/10//file.csv", "empty segments"),
]


# ---------------------------------------------------------------------------
# HfUri direct init invalid cases: (kwargs, error_substring)
# ---------------------------------------------------------------------------
URI_DIRECT_INIT_INVALID_CASES: list[tuple[dict, str]] = [
    ({"type": "unknown", "id": "org/repo"}, "Invalid type"),
    ({"type": "model", "id": "gpt2"}, "namespace/name"),
    ({"type": "model", "id": "a/b/c"}, "namespace/name"),
    ({"type": "dataset", "id": "foo--bar/baz"}, "Cannot have -- or .."),
    ({"type": "model", "id": "org/repo", "revision": ""}, "empty string"),
    ({"type": "bucket", "id": "org/bucket", "revision": "main"}, "do not support a revision"),
    ({"type": "model", "id": "org/repo", "path_in_repo": "a//b"}, "empty segments"),
]


# ---------------------------------------------------------------------------
# URL parsing success cases: (url, expected_HfUri)
#
# These are Hugging Face *web URLs* (copy-pasted from the browser) that
# 'parse_hf_uri' normalizes to the canonical 'hf://' form.
# ---------------------------------------------------------------------------
URL_SUCCESS_CASES: list[tuple[str, HfUri]] = [
    # --- Repository landing pages ---------------------------------------------
    (
        "https://huggingface.co/my-org/my-model",
        HfUri(type="model", id="my-org/my-model"),
    ),
    (
        "https://huggingface.co/datasets/my-org/my-dataset",
        HfUri(type="dataset", id="my-org/my-dataset"),
    ),
    (
        "https://huggingface.co/spaces/user/my-space",
        HfUri(type="space", id="user/my-space"),
    ),
    (
        "https://huggingface.co/kernels/my-org/my-kernel",
        HfUri(type="kernel", id="my-org/my-kernel"),
    ),
    # Explicit 'models/' prefix is accepted too.
    (
        "https://huggingface.co/models/my-org/my-model",
        HfUri(type="model", id="my-org/my-model"),
    ),
    # Trailing slash is tolerated.
    (
        "https://huggingface.co/my-org/my-model/",
        HfUri(type="model", id="my-org/my-model"),
    ),
    # --- File viewer routes (blob / resolve / raw / blame) ---------------------
    (
        "https://huggingface.co/my-org/my-model/blob/main/config.json",
        HfUri(type="model", id="my-org/my-model", revision="main", path_in_repo="config.json"),
    ),
    (
        "https://huggingface.co/datasets/my-org/my-dataset/resolve/v1/data/train.csv",
        HfUri(type="dataset", id="my-org/my-dataset", revision="v1", path_in_repo="data/train.csv"),
    ),
    (
        "https://huggingface.co/my-org/my-model/raw/main/README.md",
        HfUri(type="model", id="my-org/my-model", revision="main", path_in_repo="README.md"),
    ),
    (
        "https://huggingface.co/spaces/user/my-space/blame/main/app.py",
        HfUri(type="space", id="user/my-space", revision="main", path_in_repo="app.py"),
    ),
    # --- Folder viewer route (tree) -------------------------------------------
    (
        "https://huggingface.co/my-org/my-model/tree/main/subdir",
        HfUri(type="model", id="my-org/my-model", revision="main", path_in_repo="subdir"),
    ),
    # '/tree/<rev>' with no path -> repo root pinned at a revision.
    (
        "https://huggingface.co/my-org/my-model/tree/v2.0",
        HfUri(type="model", id="my-org/my-model", revision="v2.0"),
    ),
    # --- Special revisions -----------------------------------------------------
    (
        "https://huggingface.co/datasets/foo/bar/blob/refs/pr/10/file.csv",
        HfUri(type="dataset", id="foo/bar", revision="refs/pr/10", path_in_repo="file.csv"),
    ),
    (
        "https://huggingface.co/datasets/foo/bar/resolve/refs/convert/parquet/data.parquet",
        HfUri(type="dataset", id="foo/bar", revision="refs/convert/parquet", path_in_repo="data.parquet"),
    ),
    # URL-encoded special revision.
    (
        "https://huggingface.co/datasets/foo/bar/blob/refs%2Fpr%2F10/file.csv",
        HfUri(type="dataset", id="foo/bar", revision="refs/pr/10", path_in_repo="file.csv"),
    ),
    # URL-encoded branch name containing '/'.
    (
        "https://huggingface.co/my-org/my-model/blob/feature%2Ffoo/config.json",
        HfUri(type="model", id="my-org/my-model", revision="feature/foo", path_in_repo="config.json"),
    ),
    # --- Percent-encoded path (browsers encode spaces, '#', ... in file names) --
    (
        "https://huggingface.co/my-org/my-model/blob/main/my%20file.txt",
        HfUri(type="model", id="my-org/my-model", revision="main", path_in_repo="my file.txt"),
    ),
    (
        "https://huggingface.co/my-org/my-model/blob/main/dir/a%23b.txt",
        HfUri(type="model", id="my-org/my-model", revision="main", path_in_repo="dir/a#b.txt"),
    ),
    # --- Buckets (no revision; '/resolve/' and '/tree/' are followed by the path) ---
    (
        "https://huggingface.co/buckets/my-org/my-bucket",
        HfUri(type="bucket", id="my-org/my-bucket"),
    ),
    (
        "https://huggingface.co/buckets/my-org/my-bucket/resolve/sub/data.bin",
        HfUri(type="bucket", id="my-org/my-bucket", path_in_repo="sub/data.bin"),
    ),
    (
        "https://huggingface.co/buckets/my-org/my-bucket/tree/sub/dir",
        HfUri(type="bucket", id="my-org/my-bucket", path_in_repo="sub/dir"),
    ),
    # Percent-encoded bucket path.
    (
        "https://huggingface.co/buckets/my-org/my-bucket/resolve/sub/my%20data.bin",
        HfUri(type="bucket", id="my-org/my-bucket", path_in_repo="sub/my data.bin"),
    ),
    # --- Query string and fragment are dropped --------------------------------
    (
        "https://huggingface.co/datasets/my-org/my-dataset/blob/main/train.csv?download=true",
        HfUri(type="dataset", id="my-org/my-dataset", revision="main", path_in_repo="train.csv"),
    ),
    (
        "https://huggingface.co/my-org/my-model/blob/main/config.json#L10",
        HfUri(type="model", id="my-org/my-model", revision="main", path_in_repo="config.json"),
    ),
    # --- Alternative hosts / scheme-less --------------------------------------
    (
        "https://hf.co/my-org/my-model",
        HfUri(type="model", id="my-org/my-model"),
    ),
    (
        "http://huggingface.co/my-org/my-model",
        HfUri(type="model", id="my-org/my-model"),
    ),
    (
        "huggingface.co/datasets/my-org/my-dataset",
        HfUri(type="dataset", id="my-org/my-dataset"),
    ),
]


# ---------------------------------------------------------------------------
# URL parsing failure cases: (url, error_substring)
# ---------------------------------------------------------------------------
URL_FAILURE_CASES: list[tuple[str, str]] = [
    # Unrecognized host
    ("https://example.com/my-org/my-model", "Unrecognized host"),
    ("https://huggingface.co.evil.com/my-org/my-model", "Unrecognized host"),
    # Single-segment URLs (user/org profile, canonical repo, listing pages) are ambiguous
    ("https://huggingface.co/gpt2", "namespace"),
    ("https://huggingface.co/datasets", "namespace"),
    ("https://huggingface.co/settings", "namespace"),
    # Unsupported repo routes (not a file/folder location)
    ("https://huggingface.co/my-org/my-model/commit/abc123", "unsupported"),
    ("https://huggingface.co/my-org/my-model/commits/main", "unsupported"),
    ("https://huggingface.co/my-org/my-model/discussions", "unsupported"),
    ("https://huggingface.co/my-org/my-model/settings", "unsupported"),
    # '/media/' is not a route we map to a Hub location.
    ("https://huggingface.co/my-org/my-model/media/main/image.png", "unsupported"),
    # Collections, blog, docs, ... are not repositories
    ("https://huggingface.co/collections/my-org/my-collection-abc123", "unsupported"),
    # Canonical repo (no namespace): 'squad/blob' is read as the id, so the next segment is
    # an unrecognized route -> rejected (canonical repos are not supported anyway).
    ("https://huggingface.co/datasets/squad/blob/main/file.json", "unsupported"),
    # Unsupported bucket route
    ("https://huggingface.co/buckets/my-org/my-bucket/settings", "unsupported"),
    # Empty body
    ("https://huggingface.co/", "Missing repository or bucket identifier"),
    ("https://huggingface.co", "Missing repository or bucket identifier"),
]


# ---------------------------------------------------------------------------
# 'HfUri.to_url' cases: (HfUri, expected_url_path)
#
# 'expected_url_path' is everything after the endpoint; the endpoint is prepended
# at test time so the cases stay independent of staging vs production.
# ---------------------------------------------------------------------------
TO_URL_CASES: list[tuple[HfUri, str]] = [
    # Repository landing pages
    (
        HfUri(type="model", id="my-org/my-model"),
        "/my-org/my-model",
    ),
    (
        HfUri(type="dataset", id="my-org/my-dataset"),
        "/datasets/my-org/my-dataset",
    ),
    (
        HfUri(type="space", id="user/my-space"),
        "/spaces/user/my-space",
    ),
    (
        HfUri(type="kernel", id="my-org/my-kernel"),
        "/kernels/my-org/my-kernel",
    ),
    # Revision only -> folder viewer at that revision
    (
        HfUri(type="model", id="my-org/my-model", revision="v1.0"),
        "/my-org/my-model/tree/v1.0",
    ),
    # File with explicit revision -> blob
    (
        HfUri(type="dataset", id="my-org/my-dataset", revision="v1", path_in_repo="train.csv"),
        "/datasets/my-org/my-dataset/blob/v1/train.csv",
    ),
    # File without revision -> blob at 'main'
    (
        HfUri(type="model", id="my-org/my-model", path_in_repo="config.json"),
        "/my-org/my-model/blob/main/config.json",
    ),
    # Special ref is kept verbatim
    (
        HfUri(type="dataset", id="foo/bar", revision="refs/pr/10", path_in_repo="file.csv"),
        "/datasets/foo/bar/blob/refs/pr/10/file.csv",
    ),
    # Branch name with '/' is URL-encoded so it stays a single segment
    (
        HfUri(type="model", id="my-org/my-model", revision="feature/foo", path_in_repo="config.json"),
        "/my-org/my-model/blob/feature%2Ffoo/config.json",
    ),
    # Path with special characters is percent-encoded ('/' kept as the separator)
    (
        HfUri(type="model", id="my-org/my-model", revision="main", path_in_repo="dir/my file#1.txt"),
        "/my-org/my-model/blob/main/dir/my%20file%231.txt",
    ),
    # Buckets: landing page and tree route (no revision)
    (
        HfUri(type="bucket", id="my-org/my-bucket"),
        "/buckets/my-org/my-bucket",
    ),
    (
        HfUri(type="bucket", id="my-org/my-bucket", path_in_repo="sub/data.bin"),
        "/buckets/my-org/my-bucket/tree/sub/data.bin",
    ),
]


# ---------------------------------------------------------------------------
# HfMount success cases: (mount_str, expected_HfMount, expected_roundtrip)
# ---------------------------------------------------------------------------
MOUNT_SUCCESS_CASES: list[tuple[str, HfMount, str]] = [
    (
        "hf://my-org/my-model:/data",
        HfMount(source=HfUri(type="model", id="my-org/my-model"), mount_path="/data"),
        "hf://models/my-org/my-model:/data",
    ),
    (
        "hf://my-org/my-model:/data:ro",
        HfMount(source=HfUri(type="model", id="my-org/my-model"), mount_path="/data", read_only=True),
        "hf://models/my-org/my-model:/data:ro",
    ),
    (
        "hf://my-org/my-model:/data:rw",
        HfMount(source=HfUri(type="model", id="my-org/my-model"), mount_path="/data", read_only=False),
        "hf://models/my-org/my-model:/data:rw",
    ),
    (
        "hf://datasets/my-org/my-dataset:/mnt",
        HfMount(source=HfUri(type="dataset", id="my-org/my-dataset"), mount_path="/mnt"),
        "hf://datasets/my-org/my-dataset:/mnt",
    ),
    # Mount path with revision
    (
        "hf://datasets/my-org/my-dataset@v1:/mnt:ro",
        HfMount(
            source=HfUri(type="dataset", id="my-org/my-dataset", revision="v1"),
            mount_path="/mnt",
            read_only=True,
        ),
        "hf://datasets/my-org/my-dataset@v1:/mnt:ro",
    ),
    # Mount path with sub-path inside repo
    (
        "hf://datasets/my-org/my-dataset/train:/mnt",
        HfMount(
            source=HfUri(type="dataset", id="my-org/my-dataset", path_in_repo="train"),
            mount_path="/mnt",
        ),
        "hf://datasets/my-org/my-dataset/train:/mnt",
    ),
    # Bucket with mount path and ro/rw
    (
        "hf://buckets/my-org/my-bucket:/mnt:rw",
        HfMount(
            source=HfUri(type="bucket", id="my-org/my-bucket"),
            mount_path="/mnt",
            read_only=False,
        ),
        "hf://buckets/my-org/my-bucket:/mnt:rw",
    ),
    # Bucket with sub-path and mount
    (
        "hf://buckets/my-org/my-bucket/sub/dir:/mnt:ro",
        HfMount(
            source=HfUri(type="bucket", id="my-org/my-bucket", path_in_repo="sub/dir"),
            mount_path="/mnt",
            read_only=True,
        ),
        "hf://buckets/my-org/my-bucket/sub/dir:/mnt:ro",
    ),
    # Mount path with several path segments
    (
        "hf://my-org/my-model:/path/to/mount",
        HfMount(source=HfUri(type="model", id="my-org/my-model"), mount_path="/path/to/mount"),
        "hf://models/my-org/my-model:/path/to/mount",
    ),
]


# ---------------------------------------------------------------------------
# HfMount failure cases: (mount_str, error_substring)
# ---------------------------------------------------------------------------
MOUNT_FAILURE_CASES: list[tuple[str, str]] = [
    # Missing protocol
    ("my-org/my-model:/data", "Must start with 'hf://'"),
    # Missing mount path entirely
    ("hf://my-org/my-model", "Missing mount path"),
    # Mount path that is just '/'
    ("hf://my-org/my-model:/", "Mount path must be a non-empty absolute path"),
    # Read-only flag without a mount path
    ("hf://my-org/my-model:ro", "':ro'/':rw' suffix is only valid"),
    ("hf://my-org/my-model:rw", "':ro'/':rw' suffix is only valid"),
    # Invalid URI inside the mount (canonical repo without namespace)
    ("hf://gpt2:/data", "Repository id must be 'namespace/name'"),
    ("hf://gpt2:/data:ro", "Repository id must be 'namespace/name'"),
]


# ---------------------------------------------------------------------------
# HfMount direct init invalid cases: (kwargs, error_substring)
# ---------------------------------------------------------------------------
MOUNT_DIRECT_INIT_INVALID_CASES: list[tuple[dict, str]] = [
    ({"source": HfUri(type="model", id="org/repo"), "mount_path": "relative"}, "absolute path"),
    ({"source": HfUri(type="model", id="org/repo"), "mount_path": "/"}, "absolute path"),
]


# ===========================================================================
# Tests
# ===========================================================================


@pytest.mark.parametrize(("uri", "expected", "expected_roundtrip"), URI_SUCCESS_CASES)
def test_parse_hf_uri_success(uri: str, expected: HfUri, expected_roundtrip: str) -> None:
    result = parse_hf_uri(uri)
    assert result == expected
    assert result.to_uri() == expected_roundtrip
    # Re-parsing the canonical form must yield the same URI (idempotency).
    assert parse_hf_uri(expected_roundtrip) == expected


@pytest.mark.parametrize(("uri", "error_substring"), URI_FAILURE_CASES)
def test_parse_hf_uri_failure(uri: str, error_substring: str) -> None:
    with pytest.raises(HfUriError, match=error_substring) as exc_info:
        parse_hf_uri(uri)
    assert exc_info.value.uri == uri


@pytest.mark.parametrize(("kwargs", "error_substring"), URI_DIRECT_INIT_INVALID_CASES)
def test_hf_uri_direct_init_invalid(kwargs: dict, error_substring: str) -> None:
    with pytest.raises(HfUriError, match=error_substring):
        HfUri(**kwargs)


@pytest.mark.parametrize(("url", "expected"), URL_SUCCESS_CASES)
def test_parse_hf_uri_from_url_success(url: str, expected: HfUri) -> None:
    assert parse_hf_uri(url) == expected


@pytest.mark.parametrize(("url", "error_substring"), URL_FAILURE_CASES)
def test_parse_hf_uri_from_url_failure(url: str, error_substring: str) -> None:
    with pytest.raises(HfUriError, match=error_substring) as exc_info:
        parse_hf_uri(url)
    assert exc_info.value.uri == url


@pytest.mark.parametrize(("uri", "expected_path"), TO_URL_CASES)
def test_hf_uri_to_url(uri: HfUri, expected_path: str) -> None:
    expected = constants.ENDPOINT + expected_path
    assert uri.to_url() == expected
    # The rendered URL must parse back to a URI pointing at the same resource.
    reparsed = parse_hf_uri(expected)
    assert reparsed.type == uri.type
    assert reparsed.id == uri.id
    assert reparsed.path_in_repo == uri.path_in_repo
    # Revision round-trips exactly, except that a file URL without a revision defaults to 'main'.
    if uri.revision is not None:
        assert reparsed.revision == uri.revision


def test_hf_uri_to_url_custom_endpoint() -> None:
    uri = HfUri(type="dataset", id="my-org/my-dataset", revision="v1", path_in_repo="train.csv")
    assert uri.to_url(endpoint="https://hub-ci.huggingface.co") == (
        "https://hub-ci.huggingface.co/datasets/my-org/my-dataset/blob/v1/train.csv"
    )


@pytest.mark.parametrize(("mount_str", "expected", "expected_roundtrip"), MOUNT_SUCCESS_CASES)
def test_parse_hf_mount_success(mount_str: str, expected: HfMount, expected_roundtrip: str) -> None:
    result = parse_hf_mount(mount_str)
    assert result == expected
    assert result.to_uri() == expected_roundtrip
    # Re-parsing the canonical form must yield the same mount (idempotency).
    assert parse_hf_mount(expected_roundtrip) == expected


@pytest.mark.parametrize(("mount_str", "error_substring"), MOUNT_FAILURE_CASES)
def test_parse_hf_mount_failure(mount_str: str, error_substring: str) -> None:
    with pytest.raises(HfUriError, match=error_substring) as exc_info:
        parse_hf_mount(mount_str)
    assert exc_info.value.uri == mount_str


@pytest.mark.parametrize(("kwargs", "error_substring"), MOUNT_DIRECT_INIT_INVALID_CASES)
def test_hf_mount_direct_init_invalid(kwargs: dict, error_substring: str) -> None:
    with pytest.raises(HfUriError, match=error_substring):
        HfMount(**kwargs)
