"""Map source test files to their roles and Helix-QA target paths."""
from __future__ import annotations
import re
from collections import defaultdict
from pathlib import Path


_SKIP_DIRS = {
    "node_modules", "dist", ".venv", "__pycache__", ".git",
    "build", "coverage", ".next", "out", ".nyc_output",
    "test-results", "playwright-report", "allure-results", "allure-report",
}

# Fix 1: removed *constants* — config/enum files are not locators
_LOCATOR_RE = [
    re.compile(r".*[Ll]ocators?\.(js|ts)$"),
    re.compile(r".*[Ss]electors?\.(js|ts)$"),
    re.compile(r".*\.locators?\.(js|ts)$"),
]

_PAGE_OBJ_RE = [
    re.compile(r".*[Pp]age\.(js|ts)$"),
    re.compile(r".*\.page\.(js|ts)$"),
    re.compile(r".*[Pp]age[Oo]bject\.(js|ts)$"),
    re.compile(r".*[Pp][Oo]\.(js|ts)$"),
]

_STEP_DEF_RE = [
    re.compile(r".*\.steps\.(js|ts)$"),
    re.compile(r".*[Ss]teps?\.(js|ts)$"),
    re.compile(r".*(step[_-]?definitions?|stepDefinitions?)/.*\.(js|ts)$", re.IGNORECASE),
]

_FEATURE_RE = [re.compile(r".*\.feature$")]

_CONFIG_RE = {
    "config_playwright": [re.compile(r"playwright\.config\.(js|ts)$")],
    "config_cucumber":   [re.compile(r"cucumber\.(js|cjs|mjs)$")],
    "config_tsconfig":   [re.compile(r"tsconfig(\..+)?\.json$")],
    "config_package":    [re.compile(r"package\.json$")],
}

# ── Stale-name detection ───────────────────────────────────────────────────
# Patterns that consistently mark source files as junk in real-world repos:
#   "foo copy.ts", "foo copy 2.json"      — macOS Finder duplicates
#   "foo.bak" / ".orig" / ".tmp" / ".swp" — editor / merge / vim backups
#   "*.unity"                             — cross-project leak (Unity project)
#   "_old.*", "*-old.*", "*.old"          — explicit "outdated" suffix
#
# Filename must match anchored at word boundaries — substring matches alone
# would false-positive on legitimate names ("copywriter.ts", "Goldfinger.ts").
import re as _re
_STALE_NAME_PATTERNS = [
    _re.compile(r"(^|[\s_-])copy(\s+\d+)?\.[A-Za-z0-9]+$", _re.IGNORECASE),
    _re.compile(r"\.(bak|orig|tmp|swp|swo|swn|old|unity)$",  _re.IGNORECASE),
    _re.compile(r"(^|[\s_-])(old|backup)\.[A-Za-z0-9]+$",   _re.IGNORECASE),
    _re.compile(r"^_?old[_-]",                              _re.IGNORECASE),
]
def _is_stale_artefact(name: str) -> bool:
    return any(p.search(name) for p in _STALE_NAME_PATTERNS)


# ── Helix-writer boilerplate infra subdirs ─────────────────────────────────
# These four subtrees under src/utils/ are owned by agent_helix_writer's
# boilerplate — it writes them on every scaffold run. Source files that live
# in any of them must NOT be re-routed under src/helper/, otherwise the same
# module ends up at two paths (the helper-side copy goes stale immediately
# because all canonical imports use @utils/*). Kept as a tuple so it can be
# passed straight to str.startswith().
_HELIX_WRITER_UTILS_PREFIXES = (
    "src/utils/ai-assistant/",
    "src/utils/helpers/",
    "src/utils/locators/",
    "src/utils/storage-state/",
)

# ── Helpers / utilities ─────────────────────────────────────────────────────
# Anything under a helper/helpers/utils/util directory or matching .helper / .util suffix.
# Includes .json/.txt data files inside the helper tree so test data migrates too.
_HELPER_RE = [
    re.compile(r"(?:^|.*[\\/])(helpers?|utils?)[\\/].*\.(js|ts|json|txt)$"),
    re.compile(r".*\.helper\.(js|ts)$"),
    re.compile(r".*\.util\.(js|ts)$"),
    re.compile(r".*\.utils\.(js|ts)$"),
]

# ── API clients (commonly under pages/.../api/ in older layouts) ────────────
# These behave like helpers — pure utility/page classes — so we route them under src/helper/api/
_API_CLIENT_RE = [
    re.compile(r"(?:^|.*[\\/])api[\\/][^\\/]+\.api\.(js|ts)$"),
    re.compile(r".*\.api\.(js|ts)$"),
]

# ── Hooks (Cucumber + Playwright fixtures) ─────────────────────────────────
# pageFixture, Before/After hooks, World definitions
_HOOK_RE = [
    re.compile(r"(?:^|.*[\\/])hooks?[\\/].*\.(js|ts)$"),
    re.compile(r".*pageFixture\.(js|ts)$", re.IGNORECASE),
    re.compile(r".*[\\/](world|Before|After)\.(js|ts)$"),
]

# ── Fixtures (Playwright fixtures, test data) ──────────────────────────────
_FIXTURE_RE = [
    re.compile(r"(?:^|.*[\\/])fixtures?[\\/].*\.(js|ts|json)$"),
    re.compile(r".*\.fixture\.(js|ts)$"),
]

# ── Cucumber support files (World, custom params) ──────────────────────────
_SUPPORT_RE = [
    re.compile(r".*[\\/]support[\\/].*\.(js|ts)$"),
]

# ── Docker / containerisation ──────────────────────────────────────────────
_DOCKER_RE = [
    re.compile(r"(.*[\\/])?Dockerfile(\.[A-Za-z0-9_-]+)?$"),
    re.compile(r"(.*[\\/])?docker-compose([.-][A-Za-z0-9_-]+)?\.(ya?ml)$"),
    re.compile(r"(.*[\\/])?\.dockerignore$"),
]

# ── Environment files ──────────────────────────────────────────────────────
_ENV_RE = [
    re.compile(r"(.*[\\/])?\.env(\.[A-Za-z0-9_-]+)?$"),
    re.compile(r"(.*[\\/])?\.env\.example$"),
]

# ── CI / CD pipelines ──────────────────────────────────────────────────────
_CI_RE = [
    re.compile(r"\.github[\\/]workflows[\\/].*\.(ya?ml)$"),
    re.compile(r"\.gitlab-ci\.(ya?ml)$"),
    re.compile(r"azure-pipelines(\.[A-Za-z0-9_-]+)?\.(ya?ml)$"),
    re.compile(r"bitbucket-pipelines\.(ya?ml)$"),
    re.compile(r"\.circleci[\\/]config\.(ya?ml)$"),
]

# ── Scripts (build / utility) ──────────────────────────────────────────────
# Match scripts/foo.sh and bare run_qa.sh at the project root alike.
_SCRIPT_RE = [
    re.compile(r"(^|.*[\\/])scripts?[\\/].*\.(sh|ts|js|py)$"),
    re.compile(r"^[^\\/]+\.sh$"),  # any shell script at the project root
]

# ── Playwright spec files (non-BDD source) ────────────────────────────────
# `tests/**/*.spec.js` / `*.spec.ts` / `*.test.js` / `*.test.ts` from a
# conventional Playwright project. These get split into a generated Gherkin
# feature file plus a Cucumber step definition file, so the migrated project
# is BDD-shaped even when the source was not.
_PLAYWRIGHT_SPEC_RE = [
    re.compile(r"(^|.*[\\/])tests?[\\/].*\.(spec|test)\.(js|ts)$"),
    re.compile(r"^[^\\/]+\.(spec|test)\.(js|ts)$"),  # spec file at the project root
]

# Structural path segments that carry no meaningful domain information
_STRUCTURAL = {
    "src", "test", "tests", "steps", "features", "pages", "locators",
    "utils", "helper", "helpers", "hooks", "config", "fixtures",
    "support", "types", "interfaces", "models", "spec", "specs",
}


# ---------------------------------------------------------------------------
# Kebab helpers
# ---------------------------------------------------------------------------

def _to_kebab(stem: str) -> str:
    """Convert a filename stem to kebab-case, stripping known role suffixes."""
    for suffix in ("Page", "Locators", "Selectors", "Steps", "PageObject", "PO"):
        if stem.endswith(suffix):
            stem = stem[: -len(suffix)]
    stem = re.sub(r"([A-Z])", r"-\1", stem).lstrip("-").lower()
    stem = re.sub(r"[_\s]+", "-", stem)
    stem = re.sub(r"-{2,}", "-", stem).strip("-")
    return stem or "unknown"


def _dir_to_kebab(name: str) -> str:
    """Convert a directory name to kebab-case (handles ALL_CAPS and CamelCase)."""
    if name.isupper():
        return name.lower()
    result = re.sub(r"([A-Z])", r"-\1", name).lstrip("-").lower()
    result = re.sub(r"[_\s]+", "-", result)
    result = re.sub(r"-{2,}", "-", result).strip("-")
    return result or name.lower()


# Fix 3: CamelCase-aware kebab for feature filenames
def _feature_kebab(stem: str) -> str:
    """Convert a feature filename stem to kebab-case, preserving word boundaries."""
    # Insert hyphen before each uppercase letter sequence start
    result = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", stem)
    result = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", result)
    result = result.lower()
    result = re.sub(r"[^a-z0-9]+", "-", result).strip("-")
    return result or "unknown"


# ---------------------------------------------------------------------------
# Role detection
# ---------------------------------------------------------------------------

def _detect_role(rel: str) -> str:
    # Order matters — more specific patterns first.
    for pat in _LOCATOR_RE:
        if pat.search(rel):
            return "locator"
    for pat in _PAGE_OBJ_RE:
        if pat.search(rel):
            return "page_object"
    for pat in _STEP_DEF_RE:
        if pat.search(rel):
            return "step_def"
    for pat in _FEATURE_RE:
        if pat.search(rel):
            return "feature"
    # Playwright spec files take priority over the catch-all "unknown" role
    # so the migrator can split them into feature+steps pairs.
    for pat in _PLAYWRIGHT_SPEC_RE:
        if pat.search(rel):
            return "playwright_spec"
    for role, patterns in _CONFIG_RE.items():
        for pat in patterns:
            if pat.search(rel):
                return role
    # Newly supported categories
    for pat in _HOOK_RE:
        if pat.search(rel):
            return "hook"
    for pat in _SUPPORT_RE:
        if pat.search(rel):
            return "support"
    for pat in _FIXTURE_RE:
        if pat.search(rel):
            return "fixture"
    for pat in _API_CLIENT_RE:
        if pat.search(rel):
            return "api_client"
    for pat in _HELPER_RE:
        if pat.search(rel):
            return "helper"
    for pat in _DOCKER_RE:
        if pat.search(rel):
            return "docker"
    for pat in _ENV_RE:
        if pat.search(rel):
            return "env"
    for pat in _CI_RE:
        if pat.search(rel):
            return "ci"
    for pat in _SCRIPT_RE:
        if pat.search(rel):
            return "script"
    return "unknown"


# ---------------------------------------------------------------------------
# Target path computation
# ---------------------------------------------------------------------------

def _helix_target(
    path: Path,
    role: str,
    rel: str | None = None,
    preserve_tree: bool = False,
) -> str | None:
    stem = path.stem
    rel  = rel or path.as_posix()

    # ── Role-grouped containers honoured when preserve_tree=True ────────────
    # Each role maps to (target_dir, container_keywords, filename_builder).
    # The container search finds the source subdirectory that owns this role
    # (e.g. "features" for .feature files) and preserves everything beneath
    # it. When no container matches, the layout falls back to flat — same as
    # preserve_tree=False — so flat sources keep their flat output.
    if role == "locator":
        stem = re.sub(r"\.locators?$", "", stem, flags=re.IGNORECASE)
        filename = f"{_to_kebab(stem)}.locators.ts"
        if preserve_tree:
            sub = _preserve_role_subtree(rel, ("locators", "selectors"))
            if sub:
                return f"src/locators/{sub}/{filename}"
        return f"src/locators/{filename}"

    if role == "page_object":
        stem = re.sub(r"\.page$", "", stem, flags=re.IGNORECASE)
        filename = f"{_to_kebab(stem)}.page.ts"
        if preserve_tree:
            sub = _preserve_role_subtree(rel, ("pages", "pageobjects", "page-objects", "po"))
            if sub:
                return f"src/pages/{sub}/{filename}"
        return f"src/pages/{filename}"

    if role == "step_def":
        stem = re.sub(r"\.steps?$", "", stem, flags=re.IGNORECASE)
        filename = f"{_to_kebab(stem)}.steps.ts"
        if preserve_tree:
            sub = _preserve_role_subtree(rel, ("steps", "stepdefinitions", "step_definitions", "step-definitions"))
            if sub:
                return f"src/test/steps/{sub}/{filename}"
        return f"src/test/steps/{filename}"

    if role == "playwright_spec":
        # Strip .spec/.test suffixes from the stem; the generated steps file
        # is written here, and the companion .feature file is derived from
        # this path during migration (see _migrate.py).
        stem = re.sub(r"\.(spec|test)$", "", stem, flags=re.IGNORECASE)
        filename = f"{_to_kebab(stem)}.steps.ts"
        if preserve_tree:
            sub = _preserve_role_subtree(rel, ("specs", "spec", "tests", "test", "e2e"))
            if sub:
                return f"src/test/steps/{sub}/{filename}"
        return f"src/test/steps/{filename}"

    if role == "feature":
        filename = f"{_feature_kebab(path.stem)}.feature"
        if preserve_tree:
            sub = _preserve_role_subtree(rel, ("features",))
            if sub:
                return f"src/test/features/{sub}/{filename}"
        return f"src/test/features/{filename}"

    if role == "config_playwright":
        return "playwright.config.ts"

    if role == "config_cucumber":
        # Emitted as CJS .js at root (not TS under src/) — cucumber-js can't
        # load a .ts config without prior ts-node bootstrap. See
        # config_merger.merge_cucumber_config.
        return "config/cucumber.js"

    if role == "config_package":
        return "package.json"

    if role == "config_tsconfig":
        return "tsconfig.json"

    # ── Helper: preserve sub-tree under src/helper/ ──────────────────────────
    if role == "helper":
        # Source files that live under one of the helix-writer's boilerplate
        # infra subdirs are re-emitted there by agent_helix_writer. Re-routing
        # them under src/helper/ produces two copies of the same module at
        # different paths (helper-side stale, utils-side canonical) — see
        # MIGRATION-REPORT.md "Post-migration cleanup". Preserve the original
        # src/utils/ path so the writer either keeps the source customisation
        # (file exists) or overwrites it with boilerplate (force_scaffold).
        if rel.startswith(_HELIX_WRITER_UTILS_PREFIXES):
            return rel
        return f"src/helper/{_preserve_under(rel, ('helper', 'helpers', 'utils', 'util'))}"

    # ── API client: flatten to src/helper/api/ ───────────────────────────────
    if role == "api_client":
        return f"src/helper/api/{path.name}"

    # ── Hook: flat under src/hooks/ ──────────────────────────────────────────
    if role == "hook":
        return f"src/hooks/{path.name}"

    # ── Fixture: preserve sub-tree under src/fixtures/ ───────────────────────
    if role == "fixture":
        return f"src/fixtures/{_preserve_under(rel, ('fixture', 'fixtures'))}"

    # ── Support (Cucumber): flat under src/test/support/ ─────────────────────
    if role == "support":
        return f"src/test/support/{path.name}"

    # ── Docker / env / scripts / CI: keep at original location relative to root
    if role == "docker":
        return path.name if "/" not in rel else rel  # keep at root, or preserve dir
    if role == "env":
        # If env file sits at the project root, target the project root.
        # Otherwise preserve its nested location — many frameworks load
        # `.env.<app>` from a fixed sub-path (e.g. src/helper/environment/).
        if "/" not in rel:
            return path.name
        return rel
    if role == "ci":
        return rel  # preserve `.github/workflows/...` etc.
    if role == "script":
        # Preserve location: scripts/foo.sh stays under scripts/, but root-level
        # run_qa.sh stays at root.
        return rel

    # ── Catch-all: any file not matched by a specific role is raw-copied to
    # the same relative path it had in the source project. This keeps READMEs,
    # docs (.md), license files, .gitignore, .editorconfig, vrt-baselines,
    # arbitrary JSON data, etc. — anything the user dropped in the source —
    # available in the migrated project.
    if role == "unknown":
        return rel

    return None


def _preserve_role_subtree(rel: str, container_keywords: tuple[str, ...]) -> str:
    """
    Find the source subdirectory that owns a role (e.g. "features" for
    .feature files) and return the directory portion of the path beneath it,
    kebab-cased so the migrated tree stays consistent with the rest of the
    Helix-QA layout.

    Examples (with container_keywords=("features",)):
        src/test/features/amplify/ECommerce/checkout.feature → amplify/e-commerce
        src/test/features/login.feature                       → ""  (sibling, no subgrouping)
        tests/login.feature                                   → ""  (no container match)

    Empty string means "drop straight into the role's top-level dir" — the
    caller's flat fallback. Returned path is always slash-joined; never has
    a leading or trailing slash.
    """
    parts = Path(rel).parts
    # Walk right-to-left so the *deepest* matching container wins. This
    # handles cases like src/pages/.../helpers/foo.page.ts (rare) where the
    # outermost dir isn't the role container.
    container_lc = {k.lower() for k in container_keywords}
    for i in range(len(parts) - 1, -1, -1):
        if parts[i].lower() in container_lc:
            sub_dirs = parts[i + 1 : -1]  # exclude the filename
            return "/".join(_dir_to_kebab(p) for p in sub_dirs if p) if sub_dirs else ""
    return ""


def _preserve_under(rel: str, parents: tuple[str, ...]) -> str:
    """
    Given a relative path like 'src/helper/dashboard/validation.helper.ts' and
    parents like ('helper', 'helpers'), return the sub-path beneath the first
    matching parent — e.g. 'dashboard/validation.helper.ts'.
    Falls back to the filename if no parent matches.
    """
    parts = Path(rel).parts
    for i, p in enumerate(parts):
        if p.lower() in parents:
            sub = "/".join(parts[i + 1 :])
            return sub or parts[-1]
    return parts[-1] if parts else rel


# ---------------------------------------------------------------------------
# Fix 2: Collision disambiguation
# ---------------------------------------------------------------------------

def _pick_prefix(rel: str, target_stem: str) -> str:
    """
    Walk the source path right-to-left and return the first directory segment
    that is (a) not the target stem itself and (b) not a generic structural dir.
    Falls back to any non-matching segment if all non-structural ones match.
    """
    parts = list(Path(rel).parts[:-1])  # directory parts only

    # First pass: prefer non-structural, non-matching segments
    for p in reversed(parts):
        k = _dir_to_kebab(p)
        if k and k != target_stem and k not in _STRUCTURAL:
            return k

    # Second pass: take any non-matching segment
    for p in reversed(parts):
        k = _dir_to_kebab(p)
        if k and k != target_stem:
            return k

    return _dir_to_kebab(parts[-1]) if parts else "misc"


def _disambiguate(mappings: list[dict]) -> list[dict]:
    """
    Detect target collisions and add a distinguishing directory prefix to
    each colliding file's target path.  Runs up to 3 passes.
    """
    for _pass in range(3):
        groups: dict[str, list[int]] = defaultdict(list)
        for i, m in enumerate(mappings):
            if m["target"]:
                groups[m["target"]].append(i)

        any_collision = False
        for target, indices in groups.items():
            if len(indices) <= 1:
                continue
            any_collision = True

            target_path     = Path(target)
            target_dir      = str(target_path.parent)
            target_filename = target_path.name       # e.g. "organization.steps.ts"
            target_stem     = target_filename.split(".")[0]  # e.g. "organization"

            for idx in indices:
                m      = mappings[idx]
                prefix = _pick_prefix(m["relative"], target_stem)
                m["target"] = f"{target_dir}/{prefix}-{target_filename}"

        if not any_collision:
            break

    return mappings


# ---------------------------------------------------------------------------
# Fix 3: Auto-grouping by name prefix
# ---------------------------------------------------------------------------
#
# Without grouping, a project with 30+ page objects ends up as a flat tree
# (`src/pages/scweb/{member-edit,member-view,members-list,group-add-edit,
# groups-list,…}.page.ts`) that's hard to navigate. The grouping pass clusters
# files that share a kebab-case first token (with trailing-s normalised, so
# `member-*` and `members-*` share the `member` group) into a feature-named
# subdirectory. Singletons stay at the parent level — we don't create a
# subfolder for a single file.
#
# Triggered roles: page_object, step_def, feature, locator.
# The pass only touches files currently sitting FLAT under their role's
# target directory; files already nested in a subdirectory (e.g. via
# `preserve_tree=True`) are left alone.

_GROUPING_ROLES: tuple[str, ...] = ("page_object", "step_def", "feature", "locator")


def _group_token(filename: str) -> str:
    """
    Extract the grouping token from a kebab-case filename.

    Examples (filename → token):
        member-edit.page.ts       → "member"
        members-list.page.ts      → "members" → normalised → "member"
        groups-aggregates.page.ts → "groups"  → normalised → "group"
        api.page.ts               → "api"     (singleton; first token is the whole stem)
        groupsAggregates.feature  → "groupsAggregates" (no dash — left unnormalised)
    """
    # Drop the file extension(s) — split on "." so multi-suffix stems like
    # "member-edit.page.ts" become "member-edit".
    stem = filename.split(".")[0]
    # The first dash-separated segment is the candidate.
    first = stem.split("-")[0] if "-" in stem else stem
    # Singularise trailing "s" for plural-form prefixes (>= 4 chars, not "ss"
    # endings like address/business) so `member-` and `members-` group
    # together. Skip when the stem has no dash — that's typically a
    # camelCase single-token filename we don't want to mangle.
    if "-" in stem and len(first) >= 4 and first.endswith("s") and not first.endswith("ss"):
        return first[:-1]
    return first


def _apply_prefix_grouping(mappings: list[dict]) -> None:
    """
    Mutate `mappings` in place: regroup flat files of selected roles into
    functionality-named subfolders based on shared name prefix.

    Two-or-more rule: a folder is created only when at least 2 files share
    the same normalised token. Singletons stay flat.
    """
    from collections import defaultdict

    for role in _GROUPING_ROLES:
        # Collect flat files of this role, indexed by their parent dir so we
        # group within each container separately (a project with multiple
        # role dirs — e.g. `src/pages/scweb/` and `src/pages/admin/` — gets
        # independent grouping per container).
        by_dir: dict[str, list[int]] = defaultdict(list)
        for i, m in enumerate(mappings):
            if m.get("role") != role or not m.get("target"):
                continue
            target = m["target"]
            # Skip files that already sit in a subdirectory under the role
            # container — only regroup files currently FLAT. The role's
            # base dir is determined by walking up the kebab path until we
            # hit a known role container (`pages`, `steps`, `features`,
            # `locators`).
            parent, _, filename = target.rpartition("/")
            if not parent:
                continue
            by_dir[parent].append(i)

        for parent_dir, indices in by_dir.items():
            if len(indices) < 2:
                continue
            # Compute groups within this parent.
            groups: dict[str, list[int]] = defaultdict(list)
            for idx in indices:
                target = mappings[idx]["target"]
                filename = target.rsplit("/", 1)[-1]
                token = _group_token(filename)
                groups[token].append(idx)
            for token, group_indices in groups.items():
                if len(group_indices) < 2:
                    continue
                for idx in group_indices:
                    old_target = mappings[idx]["target"]
                    parent, _, filename = old_target.rpartition("/")
                    mappings[idx]["target"] = f"{parent}/{token}/{filename}"


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def map_source_files(
    source_dir: str,
    preserve_tree: bool = False,
    exclude: set[str] | None = None,
) -> list[dict]:
    """
    Scan source_dir and return a list of file descriptors.

    Each entry:
        source         — absolute path
        relative       — path relative to source_dir (forward slashes)
        role           — locator | page_object | step_def | feature |
                         config_playwright | config_cucumber | unknown
        target         — Helix-relative target path, or None
        stem           — filename stem
        needs_js_to_ts — True when source is .js and target is .ts

    When `preserve_tree` is True, feature / step / page-object / locator
    files preserve their source subfolder grouping under the standard
    Helix-QA top-level dirs (src/test/features/<sub>/..., etc.). Default
    flattens — same behavior as before.

    `exclude` is a set of source-relative paths (forward-slash, same form as
    each entry's `relative` field) that the scanner skips. Used by in-place
    re-migrations to ignore files already listed in the previous run's
    manifest, so migration outputs aren't re-fed through the pipeline.
    """
    root    = Path(source_dir)
    results: list[dict] = []
    skip_set = exclude or set()

    for path in sorted(root.rglob("*")):
        if not path.is_file():
            continue
        if any(s in path.parts for s in _SKIP_DIRS):
            continue
        # Skip OS / editor cruft that adds no value to the migrated project.
        if path.name in {".DS_Store", "Thumbs.db"}:
            continue
        # Skip stale-name artefacts (Finder duplicates, editor backups,
        # files left over from other tools). These leak into source trees
        # but never carry real value — migrating them produces orphan
        # files that bitrot in the target. Real test fixtures use
        # production names, not "foo copy.json" or "config.bak".
        if _is_stale_artefact(path.name):
            continue

        rel  = path.relative_to(root).as_posix()
        if rel in skip_set:
            continue

        # No extension filter — every surviving file is migrated. Files that
        # don't match any specific role land in role="unknown" and are raw-copied
        # at their original relative path by _helix_target / _migrate.py.

        role = _detect_role(rel)
        tgt  = _helix_target(path, role, rel, preserve_tree=preserve_tree)

        results.append({
            "source":         str(path),
            "relative":       rel,
            "role":           role,
            "target":         tgt,
            "stem":           path.stem,
            "needs_js_to_ts": path.suffix == ".js" and tgt is not None and tgt.endswith(".ts"),
        })

    # Fix 2: resolve target-path collisions
    _disambiguate(results)

    # Fix 3: auto-group by name prefix. When multiple files of the same
    # role land flat in the same target dir (the default scaffold layout),
    # cluster files that share a kebab-case first token into a
    # functionality-named subfolder. Source projects with their own subtree
    # under the role container (preserve_tree=True path) are unaffected —
    # grouping only fires on files currently sitting flat.
    _apply_prefix_grouping(results)

    return results
