#!/usr/bin/python3
# -*- coding: utf-8 -*-

from __future__ import annotations

import json
import re
import tarfile
from pathlib import Path
from typing import Callable

from sbo_create.models import PROG, AppContext, colorize

try:
    import tomllib
except ImportError:
    try:
        import tomli as tomllib  # type: ignore[no-redef]
    except ImportError:
        tomllib = None  # type: ignore[assignment]


# ---------------------------------------------------------------------------
# Per-format parser functions (pure — no class state needed)
# ---------------------------------------------------------------------------

def _parse_pkg_info(content: str) -> list[str]:
    """Parse PKG-INFO / METADATA — Python sdist standard (PEP 566/658).

    Extracts every ``Requires-Dist:`` header, preserving version specifiers
    and environment markers.

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency strings with version specifiers and markers intact.
    """
    deps: list[str] = []
    for line in content.splitlines():
        if line.startswith('Requires-Python:'):
            ver = line.split(':', 1)[1].strip()
            if ver:
                deps.append(f'python {ver}')
        elif line.startswith('Requires-Dist:'):
            dist = line.split(':', 1)[1].strip()
            if dist:
                deps.append(dist)
    return deps


def _poetry_deps(poetry: dict[str, str]) -> list[str]:
    """Extract dependency strings from a parsed Poetry ``[tool.poetry.dependencies]`` dict.

    Includes the ``python`` key (runtime version constraint) when present.

    Args:
        poetry: Parsed ``[tool.poetry.dependencies]`` mapping.

    Returns:
        List of dependency strings; the Python version entry is labelled ``python``.
    """
    deps: list[str] = []
    for k, v in poetry.items():
        deps.append(f'{k} {v}' if isinstance(v, str) else k)
    return deps


def _parse_pyproject_toml(content: str) -> list[str]:
    """Parse pyproject.toml — Python (PEP 621 / Poetry).

    Reads ``[project] dependencies`` (PEP 621) and
    ``[tool.poetry.dependencies]`` (Poetry).  Uses ``tomllib`` / ``tomli``
    when available; falls back to regex for Python < 3.11 without ``tomli``.

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency strings with version specifiers and markers intact.
    """
    if tomllib is not None:
        try:
            data = tomllib.loads(content)
        except Exception:  # pylint: disable=broad-except
            return []
        deps: list[str] = []
        # PEP 621 — [project] requires-python and dependencies
        project = data.get('project', {})
        req_py = project.get('requires-python', '')
        if req_py:
            deps.append(f'python {req_py}')
        deps += [d.strip() for d in project.get('dependencies', []) if d.strip()]
        # Poetry — [tool.poetry.dependencies]: key = name, value = version spec
        poetry = data.get('tool', {}).get('poetry', {}).get('dependencies', {})
        deps += _poetry_deps(poetry)
        return deps

    # Regex fallback when tomllib/tomli is unavailable.
    return _parse_pyproject_toml_regex(content)


def _parse_pyproject_toml_regex(content: str) -> list[str]:
    """Regex-based fallback parser for pyproject.toml (no tomllib/tomli available).

    Args:
        content: Raw text content of the file.

    Returns:
        List of dependency strings extracted via regex.
    """
    deps: list[str] = []
    sec = re.search(r'^\[project\](.*?)(?=^\[|\Z)', content, re.MULTILINE | re.DOTALL)
    if sec:
        rp = re.search(r'requires-python\s*=\s*"([^"]*)"', sec.group(1))
        if rp:
            deps.append(f'python {rp.group(1)}')
        m = re.search(r'dependencies\s*=\s*\[([^\]]*)\]', sec.group(1), re.DOTALL)
        if m:
            for item in m.group(1).split(','):
                item = item.strip().strip('"').strip("'")
                if item:
                    deps.append(item)
    poetry_sec = re.search(r'^\[tool\.poetry\.dependencies\](.*?)(?=^\[|\Z)',
                           content, re.MULTILINE | re.DOTALL)
    if poetry_sec:
        for line in poetry_sec.group(1).splitlines():
            m2 = re.match(r'^\s*(\w[\w-]*)\s*=\s*(?:"([^"]*)")?', line)
            if m2:
                name, ver = m2.group(1), m2.group(2)
                deps.append(f'{name} {ver}' if ver else name)
    return deps


def _parse_setup_cfg(content: str) -> list[str]:
    """Parse setup.cfg — Python (setuptools legacy).

    Reads ``python_requires`` and the ``install_requires`` list under
    the ``[options]`` section.

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency strings with version specifiers and markers intact.
    """
    deps: list[str] = []
    in_requires = False
    for line in content.splitlines():
        stripped = line.strip()
        m_py = re.match(r'^python_requires\s*=\s*(.+)', stripped)
        if m_py:
            deps.append(f'python {m_py.group(1).strip()}')
            continue
        if stripped.startswith('install_requires'):
            in_requires = True
            continue
        if in_requires:
            if stripped and not stripped.startswith('#'):
                # Break on a new section or a plain key=value (not a version specifier).
                if stripped.startswith('[') or re.match(r'^\w[\w_.-]*\s*=(?!=)', stripped):
                    break
                deps.append(stripped)
    return deps


def _parse_requirements_txt(content: str) -> list[str]:
    """Parse requirements.txt — Python (pip).

    Skips blank lines, comments, and option flags (lines starting with ``-``).

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency strings with version specifiers and markers intact.
    """
    deps: list[str] = []
    for line in content.splitlines():
        line = line.strip()
        if not line or line.startswith('#') or line.startswith('-'):
            continue
        deps.append(line)
    return deps


def _parse_cargo_toml(content: str) -> list[str]:
    """Parse Cargo.toml — Rust (Cargo).

    Reads ``[dependencies]`` and ``[build-dependencies]`` tables.
    Requires ``tomllib`` / ``tomli``; returns an empty list when unavailable.

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency strings in ``name = "version"`` form where available.
    """
    if tomllib is None:
        return []
    try:
        data = tomllib.loads(content)
    except Exception:  # pylint: disable=broad-except
        return []
    deps: list[str] = []
    rust_ver = data.get('package', {}).get('rust-version', '')
    if rust_ver:
        deps.append(f'rust-version = "{rust_ver}"')
    for table in ('dependencies', 'build-dependencies'):
        for name, ver in data.get(table, {}).items():
            if isinstance(ver, str):
                deps.append(f'{name} = "{ver}"')
            elif isinstance(ver, dict) and 'version' in ver:
                deps.append(f'{name} = "{ver["version"]}"')
            else:
                deps.append(name)
    return deps


def _parse_meta_yml(content: str) -> list[str]:
    """Parse META.yml — Perl (CPAN).

    Minimal line-by-line YAML reader that extracts the ``requires:`` list.

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency strings; dict-format entries include the version.
    """
    deps: list[str] = []
    in_requires = False
    for line in content.splitlines():
        if re.match(r'^requires\s*:', line, re.IGNORECASE):
            in_requires = True
            continue
        if in_requires:
            # List format:  - Foo::Bar
            m_list = re.match(r'^\s+-\s+(\S+)', line)
            # Dict format:  Foo::Bar: 1.0  (CPAN v1)
            m_dict = re.match(r'^\s+([\w:]+)\s*:\s*(\S+)', line)
            if m_list:
                deps.append(m_list.group(1))
            elif m_dict:
                ver = m_dict.group(2)
                entry = f'{m_dict.group(1)} {ver}' if ver not in ('0', '0.0') else m_dict.group(1)
                deps.append(entry)
            elif line and not line[0].isspace():
                break
    return deps


def _parse_meta_json(content: str) -> list[str]:
    """Parse META.json — Perl (CPAN).

    Supports both the v1 top-level ``requires`` key and the v2
    ``prereqs → runtime/build → requires`` structure.

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency strings; non-zero version requirements are included.
    """
    try:
        data = json.loads(content)
    except Exception:  # pylint: disable=broad-except
        return []
    deps: list[str] = []
    prereqs = data.get('prereqs', {})
    for phase in ('runtime', 'build'):
        for k, v in prereqs.get(phase, {}).get('requires', {}).items():
            deps.append(f'{k} {v}' if v not in (0, '0', '0.0') else k)
    if not prereqs:
        for k, v in data.get('requires', {}).items():
            deps.append(f'{k} {v}' if v not in (0, '0', '0.0') else k)
    return deps


def _parse_cmake(content: str) -> list[str]:
    """Parse CMakeLists.txt — C/C++ (CMake).

    Extracts all ``find_package(...)`` calls as dependency names.

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency names with version specifiers stripped.
    """
    return re.findall(r'find_package\s*\(\s*(\w+)', content, re.IGNORECASE)


def _parse_autotools(content: str) -> list[str]:
    """Parse configure.ac — C/C++ (Autotools).

    Extracts library names from ``PKG_CHECK_MODULES`` and ``AC_CHECK_LIB``
    macro calls.

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency names with version specifiers stripped.
    """
    deps: list[str] = []
    for m in re.finditer(r'PKG_CHECK_MODULES\s*\(\s*\w+\s*,\s*([^)]+)\)', content):
        for token in re.split(r'[\s,]+', m.group(1)):
            token = re.split(r'[>=<]', token)[0].strip()
            if token:
                deps.append(token)
    for m in re.finditer(r'AC_CHECK_LIB\s*\(\s*(\w+)', content):
        deps.append(m.group(1))
    return deps


def _parse_meson(content: str) -> list[str]:
    """Parse meson.build — C/C++ (Meson).

    Extracts all ``dependency('...')`` call arguments as dependency names.

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency names with version specifiers stripped.
    """
    return re.findall(r"dependency\s*\(\s*'([^']+)'", content)


def _parse_package_yaml(content: str) -> list[str]:
    """Parse package.yaml — Haskell (hpack / Stack).

    Reads the top-level ``dependencies:`` list, preserving version constraints.
    Each entry may be a plain string (``- text >= 1.2``) or a bare name.

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency strings with version constraints intact.
    """
    deps: list[str] = []
    in_deps = False
    for line in content.splitlines():
        if re.match(r'^dependencies\s*:', line):
            in_deps = True
            continue
        if in_deps:
            m = re.match(r'^\s+-\s+(.+)', line)
            if m:
                dep = m.group(1).strip()
                if dep:
                    deps.append(dep)
            elif line and not line[0].isspace():
                break
    return deps


def _parse_gemfile(content: str) -> list[str]:
    """Parse Gemfile — Ruby (Bundler).

    Extracts gem names and version constraints from ``gem 'name', '~> 1.0'``
    / ``gem "name"`` declarations.

    Args:
        content: Raw text content of the metadata file.

    Returns:
        List of dependency strings; version constraints are appended when present.
    """
    deps: list[str] = []
    for m in re.finditer(r"""^\s*ruby\s+['"]([^'"]+)['"]""", content, re.MULTILINE):
        deps.append(f'ruby {m.group(1)}')
    for m in re.finditer(r"""^\s*gem\s+['"]([^'"]+)['"](.*)""", content, re.MULTILINE):
        name = m.group(1)
        versions = re.findall(r"""['"]([^'"]+)['"]""", m.group(2))
        deps.append(f'{name} ({", ".join(versions)})' if versions else name)
    return deps


# ---------------------------------------------------------------------------
# Single source of truth: filename → parser (order defines scan priority).
# _METADATA_FILES is derived from _PARSERS so the two are always in sync.
# ---------------------------------------------------------------------------

_PARSERS: dict[str, Callable[[str], list[str]]] = {
    'PKG-INFO': _parse_pkg_info,
    'pyproject.toml': _parse_pyproject_toml,
    'setup.cfg': _parse_setup_cfg,
    'requirements.txt': _parse_requirements_txt,
    'Cargo.toml': _parse_cargo_toml,
    'META.yml': _parse_meta_yml,
    'META.json': _parse_meta_json,
    'package.yaml': _parse_package_yaml,
    'Gemfile': _parse_gemfile,
    'CMakeLists.txt': _parse_cmake,
    'configure.ac': _parse_autotools,
    'meson.build': _parse_meson,
}

_METADATA_FILES: tuple[str, ...] = tuple(_PARSERS)

# Maps --format choice to the set of filenames handled by that ecosystem.
_FORMAT_MAP: dict[str, tuple[str, ...]] = {
    'python': ('PKG-INFO', 'pyproject.toml', 'setup.cfg', 'requirements.txt'),
    'rust': ('Cargo.toml',),
    'perl': ('META.yml', 'META.json'),
    'haskell': ('package.yaml',),
    'ruby': ('Gemfile',),
    'cmake': ('CMakeLists.txt',),
    'autotools': ('configure.ac',),
    'meson': ('meson.build',),
}


# ---------------------------------------------------------------------------
# Handler class
# ---------------------------------------------------------------------------

class ScanHandler:  # pylint: disable=too-few-public-methods
    """Scan the upstream source tarball for potential SBo dependencies.

    Opens the source tarball without full extraction, searches for known
    build-system metadata files, and prints a list of suggested dependencies.
    Supported formats: Python (PKG-INFO, pyproject.toml, setup.cfg,
    requirements.txt), Rust (Cargo.toml), Perl (META.yml, META.json),
    Haskell (package.yaml), Ruby (Gemfile),
    C/C++ (CMakeLists.txt, configure.ac, meson.build).
    """

    def __init__(self, ctx: AppContext) -> None:
        self.ctx = ctx

    def scan_dependencies(self, tarball_path: str,
                          fmt: str | None = None) -> None:
        """Scan the source tarball and print detected dependency suggestions.

        Opens the tarball, locates all recognised metadata files, parses each
        one, and prints the results grouped by metadata file.  Exits with
        code 0 on success (including when no metadata is found) and code 1
        if the tarball cannot be opened.

        Args:
            tarball_path: Path to the source tarball to scan.
            fmt: Optional ecosystem filter (e.g. ``'python'``, ``'rust'``).
                 When given, only the metadata files for that ecosystem are
                 inspected.
        """
        tarball: Path = Path(tarball_path).expanduser().resolve()
        if not tarball.is_file():
            print(colorize(f"{PROG}: '{tarball}' not found.", 'red'))
            raise SystemExit(1)

        files: tuple[str, ...] = _FORMAT_MAP.get(fmt, _METADATA_FILES) if fmt else _METADATA_FILES
        print(f"Scanning {colorize(tarball.name, 'cyan')} for dependencies…\n")

        deps: dict[str, list[str]] = self._extract_deps(tarball, files)

        if not deps:
            print(colorize('No known dependency metadata found in tarball.', 'yellow'))
            raise SystemExit(0)

        # Group files that produced identical dependency sets.
        groups: dict[frozenset[str], list[str]] = {}
        for source, names in deps.items():
            key = frozenset(names)
            groups.setdefault(key, []).append(source)

        for key, sources in groups.items():
            label = ', '.join(sources)
            print(f"  {colorize(label, 'green')}:")
            for name in sorted(key):
                print(f"    {name}")
            print()

        print(colorize('Note: these are suggestions — verify against SBo before adding to REQUIRES=.', 'yellow'))
        raise SystemExit(0)

    def _extract_deps(self, tarball: Path,
                      files: tuple[str, ...]) -> dict[str, list[str]]:
        """Open the tarball and parse all recognisable metadata files.

        Iterates over ``files`` in priority order and reads each matching
        member without extracting the archive to disk.

        Args:
            tarball: Resolved path to the source tarball.
            files: Ordered sequence of filenames to look for (subset of
                   ``_METADATA_FILES`` when ``--format`` is used).

        Returns:
            Dict mapping metadata filename to list of dependency names.
            Empty if no recognisable metadata was found or all parsers
            returned no results.
        """
        deps: dict[str, list[str]] = {}
        try:
            with tarfile.open(tarball, 'r:*') as tf:
                # Prefer the file closest to the archive root when duplicates exist
                # (e.g. src/requirements.txt vs test/requirements.txt).
                members: dict[str, tarfile.TarInfo] = {}
                for m in tf.getmembers():
                    if not m.isfile():
                        continue
                    basename = Path(m.name).name
                    if basename not in files:
                        continue
                    if basename not in members or len(m.name) < len(members[basename].name):
                        members[basename] = m
                for filename in files:
                    if filename not in members:
                        continue
                    fobj = tf.extractfile(members[filename])
                    if fobj is None:
                        continue
                    content: str = fobj.read().decode('utf-8', errors='replace')
                    parser = _PARSERS.get(filename)
                    found: list[str] = parser(content) if parser else []
                    if found:
                        deps[filename] = found
        except tarfile.TarError as e:
            print(colorize(f"{PROG}: failed to open tarball: {e}", 'red'))
            raise SystemExit(1) from e
        return deps
