"""Issue file parsing for little-loops.

Parses issue markdown files to extract metadata like priority, ID, type, and title.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

from little_loops.cli_args import _id_matches
from little_loops.frontmatter import parse_frontmatter

if TYPE_CHECKING:
    from little_loops.config import BRConfig


logger = logging.getLogger(__name__)

# Regex pattern for issue IDs in list items
# Matches: "- FEAT-001", "- BUG-123", "* ENH-005", "- FEAT-001 (some note)"
# Also handles bold markdown: "- **ENH-1000**: description"
ISSUE_ID_PATTERN = re.compile(r"^[-*]\s+\*{0,2}([A-Z]+-\d+)", re.MULTILINE)


_NORMALIZED_RE = re.compile(r"^P[0-5]-(BUG|FEAT|ENH)-[0-9]{3,}-[a-z0-9-]+\.md$")
_ISSUE_TYPE_RE = re.compile(r"-(BUG|FEAT|ENH)-")


def is_normalized(filename: str) -> bool:
    """Check whether an issue filename conforms to naming conventions.

    Args:
        filename: The basename of the issue file (e.g. 'P2-BUG-010-my-issue.md').

    Returns:
        True if the filename matches ``^P[0-5]-(BUG|FEAT|ENH)-[0-9]{3,}-[a-z0-9-]+\\.md$``.
    """
    return bool(_NORMALIZED_RE.match(filename))


def is_formatted(issue_path: Path, templates_dir: Path | None = None) -> bool:
    """Check whether an issue file has been formatted.

    An issue is considered formatted if either:
    1. Its ## Session Log contains a ``/ll:format-issue`` entry, OR
    2. It has all required sections per its type template (structural check).

    Args:
        issue_path: Path to the issue markdown file.
        templates_dir: Optional override for the templates directory.

    Returns:
        True if the issue is formatted by either criterion, False otherwise.
        Returns False for files whose type cannot be determined or whose template
        cannot be loaded.
    """
    from little_loops.issue_template import load_issue_sections
    from little_loops.session_log import parse_session_log

    try:
        content = issue_path.read_text(encoding="utf-8")
    except Exception:
        return False

    # Criterion 1: /ll:format-issue appears in the session log
    if "/ll:format-issue" in parse_session_log(content):
        return True

    # Criterion 2: all required sections are present as ## headings
    type_match = _ISSUE_TYPE_RE.search(issue_path.name)
    if not type_match:
        return False
    issue_type = type_match.group(1)

    try:
        sections_data = load_issue_sections(issue_type, templates_dir)
    except Exception:
        return False

    required: set[str] = set()
    for name, defn in sections_data.get("common_sections", {}).items():
        if defn.get("required") is True and not defn.get("deprecated", False):
            required.add(name)
    for name, defn in sections_data.get("type_sections", {}).items():
        if defn.get("level") == "required" and not defn.get("deprecated", False):
            required.add(name)

    if not required:
        return True

    headings = {m.strip() for m in re.findall(r"^##\s+(.+)$", content, re.MULTILINE)}
    return required.issubset(headings)


def slugify(text: str) -> str:
    """Convert text to slug format for filenames.

    Args:
        text: Text to convert

    Returns:
        Lowercase slug with hyphens
    """
    text = re.sub(r"[^\w\s-]", "", text)
    text = re.sub(r"[-\s]+", "-", text)
    return text.strip("-").lower()


def get_next_issue_number(config: BRConfig, category: str | None = None) -> int:
    """Determine the next globally unique issue number.

    Scans ALL issue directories (active and completed) to find the highest
    existing number across ALL issue types (BUG, FEAT, ENH). Issue numbers
    are globally unique regardless of type.

    Args:
        config: Project configuration
        category: Unused, kept for backwards compatibility

    Returns:
        Next available issue number (globally unique across all types)
    """
    max_num = 0

    # Get all known prefixes from configuration
    all_prefixes = [cat_config.prefix for cat_config in config.issues.categories.values()]

    # Directories to scan: ALL category directories + completed + deferred
    dirs_to_scan = [config.get_completed_dir(), config.get_deferred_dir()]
    for cat_name in config.issues.categories:
        dirs_to_scan.append(config.get_issue_dir(cat_name))

    if not all_prefixes:
        return max_num + 1

    # Pre-compile a single union regex to match any known prefix
    prefix_pattern = re.compile(r"(?:" + "|".join(re.escape(p) for p in all_prefixes) + r")-(\d+)")

    for dir_path in dirs_to_scan:
        if not dir_path.exists():
            continue
        for file in dir_path.glob("*.md"):
            match = prefix_pattern.search(file.name)
            if match:
                num = int(match.group(1))
                if num > max_num:
                    max_num = num

    return max_num + 1


@dataclass
class ProductImpact:
    """Product impact assessment for an issue.

    Attributes:
        goal_alignment: ID of the strategic priority this supports
        persona_impact: ID of the persona affected
        business_value: Business value assessment (high|medium|low)
        user_benefit: Description of how this helps the target user
    """

    goal_alignment: str | None = None
    persona_impact: str | None = None
    business_value: str | None = None  # high|medium|low
    user_benefit: str | None = None

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        return {
            "goal_alignment": self.goal_alignment,
            "persona_impact": self.persona_impact,
            "business_value": self.business_value,
            "user_benefit": self.user_benefit,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any] | None) -> ProductImpact | None:
        """Create ProductImpact from dictionary.

        Args:
            data: Dictionary with product impact fields, or None

        Returns:
            ProductImpact instance or None if data is None/empty
        """
        if not data:
            return None
        return cls(
            goal_alignment=data.get("goal_alignment"),
            persona_impact=data.get("persona_impact"),
            business_value=data.get("business_value"),
            user_benefit=data.get("user_benefit"),
        )


@dataclass
class IssueInfo:
    """Parsed information from an issue file.

    Attributes:
        path: Path to the issue file
        issue_type: Type of issue (e.g., "bugs", "features")
        priority: Priority level (e.g., "P0", "P1")
        issue_id: Issue identifier (e.g., "BUG-123")
        title: Issue title from markdown header
        blocked_by: List of issue IDs that block this issue
        blocks: List of issue IDs that this issue blocks
        discovered_by: Source command/workflow that created this issue
        product_impact: Product impact assessment (optional)
        effort: Effort estimate (1=low, 2=medium, 3=high), inferred from priority if absent
        impact: Impact estimate (1=low, 2=medium, 3=high), inferred from priority if absent
        confidence_score: Readiness score (0-100) written by /ll:confidence-check, or None
        outcome_confidence: Outcome confidence (0-100) written by /ll:confidence-check, or None
        testable: Whether TDD phase should be applied; False skips TDD, None treated as testable
        session_commands: Distinct /ll:* commands found in the ## Session Log section
        session_command_counts: Per-command occurrence counts from the ## Session Log section
    """

    path: Path
    issue_type: str
    priority: str
    issue_id: str
    title: str
    blocked_by: list[str] = field(default_factory=list)
    blocks: list[str] = field(default_factory=list)
    discovered_by: str | None = None
    product_impact: ProductImpact | None = None
    effort: int | None = None
    impact: int | None = None
    confidence_score: int | None = None
    outcome_confidence: int | None = None
    testable: bool | None = None
    session_commands: list[str] = field(default_factory=list)
    session_command_counts: dict[str, int] = field(default_factory=dict)

    @property
    def priority_int(self) -> int:
        """Convert priority to integer for comparison (lower = higher priority)."""
        # Support P0-P5 priorities
        match = re.match(r"^P(\d+)$", self.priority)
        if match:
            return int(match.group(1))
        return 99  # Unknown priority sorts last

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        return {
            "path": str(self.path),
            "issue_type": self.issue_type,
            "priority": self.priority,
            "issue_id": self.issue_id,
            "title": self.title,
            "blocked_by": self.blocked_by,
            "blocks": self.blocks,
            "discovered_by": self.discovered_by,
            "product_impact": (self.product_impact.to_dict() if self.product_impact else None),
            "effort": self.effort,
            "impact": self.impact,
            "confidence_score": self.confidence_score,
            "outcome_confidence": self.outcome_confidence,
            "testable": self.testable,
            "session_commands": self.session_commands,
            "session_command_counts": self.session_command_counts,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> IssueInfo:
        """Create IssueInfo from dictionary."""
        return cls(
            path=Path(data["path"]),
            issue_type=data["issue_type"],
            priority=data["priority"],
            issue_id=data["issue_id"],
            title=data["title"],
            blocked_by=data.get("blocked_by", []),
            blocks=data.get("blocks", []),
            discovered_by=data.get("discovered_by"),
            product_impact=ProductImpact.from_dict(data.get("product_impact")),
            effort=data.get("effort"),
            impact=data.get("impact"),
            confidence_score=data.get("confidence_score"),
            outcome_confidence=data.get("outcome_confidence"),
            testable=data.get("testable"),
            session_commands=data.get("session_commands", []),
            session_command_counts=data.get("session_command_counts", {}),
        )


class IssueParser:
    """Parses issue files based on project configuration.

    Uses BRConfig to understand issue categories, prefixes, and priorities.
    """

    def __init__(self, config: BRConfig) -> None:
        """Initialize parser with project configuration.

        Args:
            config: Project configuration
        """
        self.config = config
        self._build_prefix_map()

    def _build_prefix_map(self) -> None:
        """Build mapping from issue prefixes to category names."""
        self._prefix_to_category: dict[str, str] = {}
        for category_name, category in self.config.issues.categories.items():
            self._prefix_to_category[category.prefix] = category_name

    def parse_file(self, issue_path: Path) -> IssueInfo:
        """Parse an issue file to extract metadata.

        Args:
            issue_path: Path to the issue markdown file

        Returns:
            Parsed IssueInfo
        """
        filename = issue_path.name

        # Parse priority from filename prefix (e.g., P1-BUG-123-...)
        priority = self._parse_priority(filename)

        # Parse issue type and ID from filename
        issue_type, issue_id = self._parse_type_and_id(filename, issue_path)

        # Read content once for all content-based parsing
        content = self._read_content(issue_path)

        # Parse frontmatter for discovered_by, product impact, effort, and impact
        frontmatter = parse_frontmatter(content)
        discovered_by = frontmatter.get("discovered_by")
        product_impact = self._parse_product_impact(frontmatter)
        effort_raw = frontmatter.get("effort")
        impact_raw = frontmatter.get("impact")
        effort = int(effort_raw) if effort_raw is not None and str(effort_raw).isdigit() else None
        impact = int(impact_raw) if impact_raw is not None and str(impact_raw).isdigit() else None
        confidence_raw = frontmatter.get("confidence_score")
        outcome_raw = frontmatter.get("outcome_confidence")
        confidence_score = (
            int(confidence_raw)
            if confidence_raw is not None and str(confidence_raw).isdigit()
            else None
        )
        outcome_confidence = (
            int(outcome_raw) if outcome_raw is not None and str(outcome_raw).isdigit() else None
        )
        testable_raw = frontmatter.get("testable")
        if isinstance(testable_raw, str):
            testable_value: bool | None = (
                testable_raw.lower() == "true"
                if testable_raw.lower() in ("true", "false")
                else None
            )
        else:
            testable_value = testable_raw

        # Parse title and dependencies from file content
        title = self._parse_title_from_content(content, issue_path)
        blocked_by = self._parse_blocked_by(content)
        blocks = self._parse_blocks(content)

        # Parse session commands from ## Session Log section
        from little_loops.session_log import count_session_commands, parse_session_log

        session_commands = parse_session_log(content)
        session_command_counts = count_session_commands(content)

        return IssueInfo(
            path=issue_path,
            issue_type=issue_type,
            priority=priority,
            issue_id=issue_id,
            title=title,
            blocked_by=blocked_by,
            blocks=blocks,
            discovered_by=discovered_by,
            product_impact=product_impact,
            effort=effort,
            impact=impact,
            confidence_score=confidence_score,
            outcome_confidence=outcome_confidence,
            testable=testable_value,
            session_commands=session_commands,
            session_command_counts=session_command_counts,
        )

    def _parse_priority(self, filename: str) -> str:
        """Extract priority from filename.

        Args:
            filename: Issue filename

        Returns:
            Priority string (e.g., "P1") or last priority if not found
        """
        for priority in self.config.issue_priorities:
            if filename.startswith(f"{priority}-"):
                return priority
        # Default to lowest priority if not found
        return self.config.issue_priorities[-1] if self.config.issue_priorities else "P3"

    def _get_category_for_prefix(self, prefix: str) -> str:
        """Get category name from issue prefix.

        Args:
            prefix: Issue prefix (e.g., "BUG", "FEAT")

        Returns:
            Category name (e.g., "bugs", "features"), defaults to "bugs"
        """
        return self._prefix_to_category.get(prefix, "bugs")

    def _parse_type_and_id(self, filename: str, issue_path: Path) -> tuple[str, str]:
        """Extract issue type and ID from filename.

        Args:
            filename: Issue filename
            issue_path: Full path to issue file

        Returns:
            Tuple of (issue_type, issue_id)
        """
        # Try to match known prefixes (BUG, FEAT, ENH, etc.)
        for prefix, category in self._prefix_to_category.items():
            pattern = rf"({prefix})-(\d+)"
            match = re.search(pattern, filename)
            if match:
                issue_id = f"{match.group(1)}-{match.group(2)}"
                return category, issue_id

        # Fall back to inferring from directory
        parent_name = issue_path.parent.name
        for category_name, category_config in self.config.issues.categories.items():
            if parent_name == category_config.dir:
                # Generate ID from filename
                issue_id = self._generate_id_from_filename(filename, category_config.prefix)
                return category_name, issue_id

        # Last resort: use filename as ID
        return "bugs", filename.replace(".md", "")

    def _generate_id_from_filename(self, filename: str, prefix: str) -> str:
        """Generate an issue ID from filename when not explicitly present.

        Args:
            filename: Issue filename
            prefix: Issue prefix to use

        Returns:
            Generated issue ID
        """
        # Try to extract a number from the filename
        numbers = re.findall(r"\d+", filename)
        if numbers:
            return f"{prefix}-{numbers[0]}"
        # Use next sequential number instead of hash-based fallback
        # This ensures IDs are deterministic and don't collide with existing issues
        category = self._get_category_for_prefix(prefix)
        next_num = get_next_issue_number(self.config, category)
        return f"{prefix}-{next_num:03d}"

    def _read_content(self, issue_path: Path) -> str:
        """Read file content, returning empty string on error.

        Args:
            issue_path: Path to issue file

        Returns:
            File content or empty string on error
        """
        try:
            return issue_path.read_text(encoding="utf-8")
        except Exception as e:
            logger.warning("Failed to read %s: %s", issue_path.name, e)
            return ""

    def _parse_title_from_content(self, content: str, issue_path: Path) -> str:
        """Extract title from issue file content.

        Args:
            content: Pre-read file content
            issue_path: Path to issue file (for fallback)

        Returns:
            Issue title or filename stem as fallback
        """
        if content:
            # Look for markdown header: # ISSUE-ID: Title
            match = re.search(r"^#\s+[\w-]+:\s*(.+)$", content, re.MULTILINE)
            if match:
                return match.group(1).strip()
            # Try first header of any format
            match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
            if match:
                return match.group(1).strip()
        # Fall back to filename
        return issue_path.stem

    def _parse_section_items(self, content: str, section_name: str) -> list[str]:
        """Extract issue IDs from a markdown section.

        Finds section header (## Section Name) and extracts issue IDs
        from list items until the next section or end of file.
        Skips content inside code fences.

        Args:
            content: File content to parse
            section_name: Section name to find (e.g., "Blocked By")

        Returns:
            List of issue IDs found in the section
        """
        if not content:
            return []

        # Strip code fences to avoid matching sections in examples
        content_without_code = self._strip_code_fences(content)

        # Match section header case-insensitively
        section_pattern = rf"^##\s+{re.escape(section_name)}\s*$"
        match = re.search(section_pattern, content_without_code, re.MULTILINE | re.IGNORECASE)
        if not match:
            return []

        # Get content after section header until next ## header or end
        start = match.end()
        next_section = re.search(r"^##\s+", content_without_code[start:], re.MULTILINE)
        if next_section:
            section_content = content_without_code[start : start + next_section.start()]
        else:
            section_content = content_without_code[start:]

        # Extract issue IDs from list items
        issue_ids = ISSUE_ID_PATTERN.findall(section_content)
        return issue_ids

    def _strip_code_fences(self, content: str) -> str:
        """Remove code fence blocks from content.

        Replaces content between ``` markers with empty lines to preserve
        line numbers while removing code fence content from parsing.

        Args:
            content: File content

        Returns:
            Content with code fence blocks replaced by empty lines
        """
        # Match code fences: ``` or ```language through closing ```
        result = []
        in_fence = False
        for line in content.split("\n"):
            if line.startswith("```"):
                in_fence = not in_fence
                result.append("")  # Preserve line count
            elif in_fence:
                result.append("")  # Replace fenced content with empty line
            else:
                result.append(line)
        return "\n".join(result)

    def _parse_blocked_by(self, content: str) -> list[str]:
        """Extract issue IDs from ## Blocked By section.

        Args:
            content: File content to parse

        Returns:
            List of issue IDs that block this issue
        """
        return self._parse_section_items(content, "Blocked By")

    def _parse_blocks(self, content: str) -> list[str]:
        """Extract issue IDs from ## Blocks section.

        Args:
            content: File content to parse

        Returns:
            List of issue IDs that this issue blocks
        """
        return self._parse_section_items(content, "Blocks")

    def _parse_product_impact(self, frontmatter: dict[str, Any]) -> ProductImpact | None:
        """Extract product impact from frontmatter.

        Args:
            frontmatter: Dictionary of frontmatter fields

        Returns:
            ProductImpact instance if any product fields are present, None otherwise
        """
        # Check if any product fields are present
        product_fields = ("goal_alignment", "persona_impact", "business_value", "user_benefit")
        if not any(frontmatter.get(key) for key in product_fields):
            return None

        return ProductImpact(
            goal_alignment=frontmatter.get("goal_alignment"),
            persona_impact=frontmatter.get("persona_impact"),
            business_value=frontmatter.get("business_value"),
            user_benefit=frontmatter.get("user_benefit"),
        )


def find_issues(
    config: BRConfig,
    category: str | None = None,
    skip_ids: set[str] | None = None,
    only_ids: list[str] | set[str] | None = None,
    type_prefixes: set[str] | None = None,
) -> list[IssueInfo]:
    """Find all issues matching criteria.

    Args:
        config: Project configuration
        category: Optional category to filter (e.g., "bugs")
        skip_ids: Issue IDs to skip
        only_ids: If provided, only include these issue IDs. When a list,
            results are returned in list order (input sequence preserved).
            When a set, results are sorted by priority as usual.
        type_prefixes: If provided, only include issues whose ID starts with
            one of these prefixes (e.g., {"BUG", "ENH"})

    Returns:
        List of IssueInfo sorted by priority, or in only_ids list order when
        only_ids is a list
    """
    skip_ids = skip_ids or set()
    parser = IssueParser(config)
    issues: list[IssueInfo] = []

    # Get completed and deferred directories for duplicate detection
    completed_dir = config.get_completed_dir()
    deferred_dir = config.get_deferred_dir()

    # Determine which categories to search
    if category:
        categories = [category] if category in config.issue_categories else []
    else:
        categories = config.issue_categories

    for cat in categories:
        issue_dir = config.get_issue_dir(cat)
        if not issue_dir.exists():
            continue

        for issue_file in issue_dir.glob("*.md"):
            # Pre-flight check: skip if already exists in completed or deferred directory
            completed_path = completed_dir / issue_file.name
            if completed_path.exists():
                continue
            deferred_path = deferred_dir / issue_file.name
            if deferred_path.exists():
                continue

            info = parser.parse_file(issue_file)
            # Apply skip filter
            if info.issue_id in skip_ids:
                continue
            # Apply only filter (if specified)
            if only_ids is not None and not any(_id_matches(info.issue_id, p) for p in only_ids):
                continue
            # Apply type filter (if specified)
            if type_prefixes is not None:
                prefix = info.issue_id.split("-", 1)[0]
                if prefix not in type_prefixes:
                    continue
            issues.append(info)

    # When only_ids is a list, preserve input order; otherwise sort by priority
    if isinstance(only_ids, list):
        issues.sort(
            key=lambda x: next(
                (i for i, p in enumerate(only_ids) if _id_matches(x.issue_id, p)),
                len(only_ids),
            )
        )
    else:
        issues.sort(key=lambda x: (x.priority_int, x.issue_id))
    return issues


def find_highest_priority_issue(
    config: BRConfig,
    category: str | None = None,
    skip_ids: set[str] | None = None,
    only_ids: set[str] | None = None,
    type_prefixes: set[str] | None = None,
) -> IssueInfo | None:
    """Find the highest priority issue.

    Args:
        config: Project configuration
        category: Optional category to filter
        skip_ids: Issue IDs to skip
        only_ids: If provided, only include these issue IDs
        type_prefixes: If provided, only include issues with these type prefixes

    Returns:
        Highest priority IssueInfo or None if no issues found
    """
    issues = find_issues(config, category, skip_ids, only_ids, type_prefixes)
    return issues[0] if issues else None
