"""Parser for pip requirements.txt files.

Handles comments, blank lines, line continuations, -r includes,
option lines, editable installs, environment markers, and extras.
"""

from __future__ import annotations

import logging
import os
from pathlib import Path

from packaging.requirements import InvalidRequirement, Requirement

logger = logging.getLogger(__name__)

# Option flags that take a value argument (either as separate token or with =)
_OPTIONS_WITH_VALUE = {
    "-i",
    "--index-url",
    "--extra-index-url",
    "--find-links",
    "-f",
    "--trusted-host",
    "--no-binary",
    "--only-binary",
}

# Option flags that are standalone (no value)
_STANDALONE_OPTIONS = {
    "--prefer-binary",
    "--require-hashes",
    "--pre",
}

# All option prefixes that we should skip (lines that start with these)
_OPTION_PREFIXES = _OPTIONS_WITH_VALUE | _STANDALONE_OPTIONS


def parse_requirements(
    source: str | os.PathLike[str],
    *,
    _seen: frozenset[Path] | None = None,
) -> list[str]:
    """Parse a requirements.txt file and return a list of requirement strings.

    Handles comments, blank lines, line continuations, -r/-r includes,
    options lines, editable installs, environment markers, and extras.

    Args:
        source: Path to the requirements.txt file (str or Path-like).
        _seen: Internal set of already-visited absolute paths used to detect
               circular -r includes. Not intended for external callers.

    Returns:
        A list of requirement strings, e.g. ["django>=4.0", "celery==5.2.0"].

    Raises:
        FileNotFoundError: If the requirements file does not exist.
        ValueError: If a circular -r include is detected or a line cannot be
                    parsed as a valid PEP 508 requirement.
    """
    path = Path(source).resolve()

    if not path.exists():
        raise FileNotFoundError(f"Requirements file not found: {path}")

    if _seen is None:
        _seen = frozenset()

    if path in _seen:
        raise ValueError(f"Circular -r include detected: {path}")

    _seen = _seen | {path}

    raw_text = path.read_bytes()

    # Strip UTF-8 BOM if present
    if raw_text.startswith(b"\xef\xbb\xbf"):
        raw_text = raw_text[3:]

    text = raw_text.decode("utf-8")

    # Normalise Windows line endings
    text = text.replace("\r\n", "\n").replace("\r", "\n")

    lines = text.split("\n")

    # Join line continuations (trailing backslash)
    joined_lines: list[str] = []
    continuation_buffer: list[str] = []

    for line in lines:
        if line.endswith("\\"):
            continuation_buffer.append(line[:-1])
        else:
            if continuation_buffer:
                continuation_buffer.append(line)
                joined_lines.append("".join(continuation_buffer))
                continuation_buffer = []
            else:
                joined_lines.append(line)

    # Flush any unclosed continuation at EOF
    if continuation_buffer:
        joined_lines.append("".join(continuation_buffer))

    requirements: list[str] = []

    for raw_line in joined_lines:
        # Strip inline comments — but be careful: '#' inside markers/extras is
        # not a comment.  The pip spec says '#' starts a comment only when it
        # appears *outside* of any quoted string or environment marker
        # expression.  For our purposes we strip the first '#' that is not
        # preceded by a non-whitespace character inside parentheses/brackets.
        line = _strip_comment(raw_line).strip()

        if not line:
            continue

        # Handle -r / --requirement includes
        if line.startswith("-r ") or line.startswith("--requirement "):
            include_path_str = line.split(None, 1)[1].strip()
            include_path = (path.parent / include_path_str).resolve()
            logger.debug("Processing -r include: %s", include_path)
            sub_reqs = parse_requirements(include_path, _seen=_seen)
            requirements.extend(sub_reqs)
            continue

        # Skip editable installs
        if line.startswith("-e ") or line.startswith("--editable "):
            logger.warning("Skipping editable install (not supported by depwhy): %s", line)
            continue

        # Skip option-only lines (lines that start with a recognised flag)
        if _is_option_line(line):
            logger.debug("Skipping option line: %s", line)
            continue

        # Validate with packaging.requirements.Requirement
        try:
            req = Requirement(line)
        except InvalidRequirement as exc:
            raise ValueError(f"Invalid requirement {line!r} in {path}: {exc}") from exc

        requirements.append(str(req))

    return requirements


def _strip_comment(line: str) -> str:
    """Strip everything from the first unquoted '#' character onwards.

    This is a simplified implementation that handles the common cases in
    requirements files.  It does not attempt to parse full PEP 508 marker
    grammar; it merely skips '#' that appear inside single- or double-quoted
    strings.
    """
    in_single = False
    in_double = False

    for i, ch in enumerate(line):
        if ch == "'" and not in_double:
            in_single = not in_single
        elif ch == '"' and not in_single:
            in_double = not in_double
        elif ch == "#" and not in_single and not in_double:
            return line[:i]

    return line


def _is_option_line(line: str) -> bool:
    """Return True if the line is a pip option that should be skipped."""
    # Split on whitespace or '=' to get the first token
    token = line.split()[0] if line.split() else ""

    # Handle --flag=value syntax
    if "=" in token:
        token = token.split("=", 1)[0]

    return token in _OPTION_PREFIXES
