#!/usr/bin/python3
# shards-link - Offline shard dependency linker for Fedora Crystal
# SPDX-License-Identifier: GPL-3.0-or-later

import argparse
import glob
import os
import re
import sys
from typing import List, Optional, Set

try:
    import yaml
except ImportError:
    yaml = None  # type: ignore[assignment]

__version__ = "0.2.0"


def ver_key(path: str) -> tuple[list[object], int, list[object]]:
    """Extract natural/semantic sort key from directory path adhering to SemVer/RPM precedence."""
    v = os.path.basename(path).rsplit("-", 1)[-1]
    # In RPM and SemVer, pre-releases (marked with ~ or -) sort lower than the base release
    if "~" in v:
        base, pre = v.split("~", 1)
        is_pre = 0
    elif "-" in v and not v.startswith("-"):
        base, pre = v.split("-", 1)
        is_pre = 0
    else:
        base, pre = v, ""
        is_pre = 1

    base_parts: list[object] = [int(c) if c.isdigit() else c for c in re.split(r"(\d+)", base) if c]
    pre_parts: list[object] = [int(c) if c.isdigit() else c for c in re.split(r"(\d+)", pre) if c]
    return (base_parts, is_pre, pre_parts)


def link_shard(shards_dir: str, name: str, target: str, lib_dir: str, verbose: bool = True) -> bool:
    """Link a single shard directory into lib_dir."""
    link = os.path.join(lib_dir, name)
    if os.path.islink(link) and not os.path.exists(link):
        os.unlink(link)
    if not os.path.lexists(link) and os.path.isdir(target):
        try:
            os.symlink(target, link)
            if verbose:
                print(f"shards-link: symlinked {name} -> {target}")
            return True
        except OSError:
            pass
    return False


def clean_links(lib_dir: str, verbose: bool = True) -> int:
    """Remove all symlinks from lib_dir."""
    if not os.path.isdir(lib_dir):
        return 0
    removed = 0
    for entry in os.listdir(lib_dir):
        path = os.path.join(lib_dir, entry)
        if os.path.islink(path):
            os.unlink(path)
            removed += 1
            if verbose:
                print(f"shards-link: unlinked {entry}")
    return removed


def parse_shard_yaml(filepath: str, include_dev: bool = True) -> Set[str]:
    """Parse dependency names from a shard.yml file."""
    if not os.path.isfile(filepath):
        return set()
    if yaml is not None:
        try:
            with open(filepath, "r", encoding="utf-8") as f:
                data = yaml.safe_load(f) or {}
            deps = set((data.get("dependencies") or {}).keys())
            if include_dev:
                deps |= set((data.get("development_dependencies") or {}).keys())
            return deps
        except Exception:
            pass
    # Regex fallback if PyYAML is not installed or fails
    deps = set()
    in_deps = False
    indent_level = 0
    target_sections = ("dependencies", "development_dependencies") if include_dev else ("dependencies",)
    try:
        with open(filepath, "r", encoding="utf-8") as f:
            for line in f:
                raw = line.rstrip()
                if not raw or raw.strip().startswith("#"):
                    continue
                match_sec = re.match(r"^([a-z_]+):\s*$", raw)
                if match_sec:
                    sec = match_sec.group(1)
                    in_deps = sec in target_sections
                    indent_level = 0
                    continue
                if in_deps:
                    match_dep = re.match(r"^(\s+)([a-zA-Z0-9_\-]+):\s*$", raw)
                    if match_dep:
                        indent = len(match_dep.group(1))
                        if indent_level == 0:
                            indent_level = indent
                        if indent == indent_level:
                            deps.add(match_dep.group(2))
                    elif not raw.startswith(" ") and not raw.startswith("\t"):
                        in_deps = False
    except Exception:
        pass
    return deps


def symlink_dependencies(
    shards_dir: str,
    project_dir: str = ".",
    lib_dir_name: str = "lib",
    verbose: bool = True,
) -> None:
    """Resolve and symlink all dependencies for the project."""
    if not os.path.isdir(shards_dir):
        return

    lib_dir = os.path.join(project_dir, lib_dir_name)
    os.makedirs(lib_dir, exist_ok=True)

    linked_names: Set[str] = set()
    shards_dir_real = os.path.realpath(shards_dir)

    # 1. Pinned versions from shard.lock
    lock_file = os.path.join(project_dir, "shard.lock")
    if os.path.isfile(lock_file) and yaml is not None:
        try:
            with open(lock_file, "r", encoding="utf-8") as f:
                data = yaml.safe_load(f) or {}
            shards = data.get("shards", {}) or {}
            for name, info in shards.items():
                if not isinstance(name, str) or not re.match(r"^[a-zA-Z0-9_\-]+$", name):
                    continue
                if isinstance(info, dict) and "version" in info:
                    version = str(info["version"])
                    if not re.match(r"^[a-zA-Z0-9_\-\.\+~]+$", version):
                        continue
                    target = os.path.join(shards_dir, f"{name}-{version}")
                    target_real = os.path.realpath(target)
                    if not target_real.startswith(shards_dir_real + os.sep) and target_real != shards_dir_real:
                        continue
                    link = os.path.join(lib_dir, name)
                    # Heal broken or stale symlink
                    if os.path.islink(link):
                        if not os.path.exists(link) or os.readlink(link) != target:
                            os.unlink(link)
                    if not os.path.lexists(link) and os.path.isdir(target):
                        os.symlink(target, link)
                        linked_names.add(name)
                        if verbose:
                            print(f"shards-link: locked {name} -> {target}")
        except Exception:
            pass

    # 2. Constraints from shard.yml (direct and transitive)
    root_shard_yaml = os.path.join(project_dir, "shard.yml")
    pending_deps = list(parse_shard_yaml(root_shard_yaml, include_dev=True))
    processed_deps: Set[str] = set()

    while pending_deps:
        name = pending_deps.pop(0)
        if not isinstance(name, str) or not re.match(r"^[a-zA-Z0-9_\-]+$", name):
            continue
        if name in processed_deps:
            continue
        processed_deps.add(name)

        link = os.path.join(lib_dir, name)
        matches = sorted(
            glob.glob(os.path.join(shards_dir, f"{name}-[0-9]*")),
            key=ver_key,
        )
        if matches:
            best_match = matches[-1]
            best_match_real = os.path.realpath(best_match)
            if best_match_real.startswith(shards_dir_real + os.sep):
                # Heal broken or stale symlink
                if os.path.islink(link):
                    try:
                        if not os.path.exists(link) or os.readlink(link) != best_match:
                            os.unlink(link)
                    except OSError:
                        pass
                if not os.path.lexists(link):
                    if link_shard(shards_dir, name, best_match, lib_dir, verbose):
                        linked_names.add(name)

        # Transitive discovery: inspect dependencies of the linked shard (production only)
        target_shard_yaml = os.path.join(lib_dir, name, "shard.yml")
        if os.path.isfile(target_shard_yaml):
            for subdep in parse_shard_yaml(target_shard_yaml, include_dev=False):
                if subdep not in processed_deps and subdep not in pending_deps:
                    pending_deps.append(subdep)


def main() -> None:
    parser = argparse.ArgumentParser(
        description="shards-link: Offline shard dependency linker for Fedora Crystal",
        prog="shards-link",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"%(prog)s {__version__}",
    )
    parser.add_argument(
        "shards_dir_pos",
        nargs="?",
        default=None,
        metavar="SHARDS_DIR",
        help="Path to system shards directory (positional fallback)",
    )
    parser.add_argument(
        "--shards-dir",
        "-s",
        dest="shards_dir",
        default="/usr/share/crystal/shards",
        help="Path to system shards directory (default: /usr/share/crystal/shards)",
    )
    parser.add_argument(
        "--path",
        "-p",
        dest="project_dir",
        default=".",
        help="Path to Crystal project directory containing shard.yml/shard.lock (default: .)",
    )
    parser.add_argument(
        "--lib-dir",
        "-l",
        dest="lib_dir",
        default="lib",
        help="Name or path of destination library directory (default: lib)",
    )
    parser.add_argument(
        "--clean",
        "-c",
        action="store_true",
        help="Remove all symlinks from the lib directory",
    )
    group = parser.add_mutually_exclusive_group()
    group.add_argument(
        "--verbose",
        "-v",
        action="store_true",
        default=True,
        help="Print verbose symlink actions (default)",
    )
    group.add_argument(
        "--quiet",
        "-q",
        action="store_true",
        help="Suppress informational output",
    )

    args = parser.parse_args()

    shards_dir = args.shards_dir_pos or args.shards_dir
    verbose = not args.quiet

    if args.clean:
        clean_links(os.path.join(args.project_dir, args.lib_dir), verbose=verbose)
        return

    symlink_dependencies(
        shards_dir=shards_dir,
        project_dir=args.project_dir,
        lib_dir_name=args.lib_dir,
        verbose=verbose,
    )


if __name__ == "__main__":
    main()
