"""
Shared utilities for map parsing, validation, and DAG analysis.

Bundled copy of .claude/skills/lib/map_utils.py for standalone PyPI distribution.
Uses spoq_server.epic_utils for task-level aggregation when resolving epics.
"""

import re
from pathlib import Path
from collections import defaultdict
from typing import Any


# =============================================================================
# Path Resolution
# =============================================================================

def resolve_map_file(map_path: Path) -> Path | None:
    """Resolve the map markdown file from either storage format.

    Supports two layouts:
    - Directory format: ``map_path/MAP.md``
    - Single-file format: ``map_path`` itself is a ``.md`` file

    Returns the resolved :class:`Path`, or *None* when no file is found.
    """
    if map_path.is_file() and map_path.suffix.lower() == ".md":
        return map_path
    map_md = map_path / "MAP.md"
    if map_md.exists():
        return map_md
    return None


# =============================================================================
# MAP.md Parsing
# =============================================================================

def parse_map_md(map_path: Path) -> dict[str, Any]:
    """Extract metadata from a map markdown file.

    *map_path* may be a directory containing ``MAP.md`` **or** a standalone
    ``.md`` file.
    """
    map_md = resolve_map_file(map_path)
    if map_md is None:
        return {"error": f"Map file not found at {map_path} (expected MAP.md inside a directory or a standalone .md file)"}

    content = map_md.read_text()

    title_match = re.search(r'^#\s+(?:Map:\s*)?(.+)$', content, re.MULTILINE)
    title = title_match.group(1).strip() if title_match else map_path.name

    vision_match = re.search(
        r'##\s*Vision\s*\n(.*?)(?=\n##|\Z)', content, re.DOTALL | re.IGNORECASE
    )
    vision = vision_match.group(1).strip() if vision_match else ""

    dispatch_match = re.search(
        r'##\s*Dispatch Strategy\s*\n(.*?)(?=\n##|\Z)', content, re.DOTALL | re.IGNORECASE
    )
    dispatch_strategy = dispatch_match.group(1).strip() if dispatch_match else ""

    criteria_match = re.search(
        r'##\s*Success Criteria\s*\n(.*?)(?=\n##|\Z)', content, re.DOTALL | re.IGNORECASE
    )
    success_criteria = criteria_match.group(1).strip() if criteria_match else ""

    epics = _parse_epic_entries(content)

    return {
        "title": title,
        "vision": vision,
        "epics": epics,
        "dispatch_strategy": dispatch_strategy,
        "success_criteria": success_criteria,
        "raw_length": len(content),
    }


def _parse_epic_entries(content: str) -> list[dict[str, Any]]:
    """Parse ### subsections under ## Epics into structured dicts."""
    epics_section = re.search(
        r'##\s*Epics\s*\n(.*?)(?=\n##[^#]|\Z)', content, re.DOTALL | re.IGNORECASE
    )
    if not epics_section:
        return []

    section_text = epics_section.group(1)
    epic_blocks = re.split(r'\n###\s+', section_text)
    epics = []

    for block in epic_blocks:
        block = block.strip()
        if not block:
            continue

        lines = block.split('\n', 1)
        slug = lines[0].strip().lstrip('#').strip()
        body = lines[1] if len(lines) > 1 else ""

        status_match = re.search(r'\*\*Status:\*\*\s*(.+)', body, re.IGNORECASE)
        hours_match = re.search(r'\*\*Estimated Hours:\*\*\s*~?(\d+(?:\.\d+)?)', body, re.IGNORECASE)
        deps_match = re.search(r'\*\*Dependencies:\*\*\s*(.+)', body, re.IGNORECASE)
        summary_match = re.search(r'\*\*Summary:\*\*\s*(.+)', body, re.IGNORECASE)

        deps_raw = deps_match.group(1).strip() if deps_match else "none"
        if deps_raw.lower() == "none":
            dependencies = []
        else:
            deps_raw = deps_raw.strip("[]")
            dependencies = [d.strip() for d in deps_raw.split(",") if d.strip()]

        epics.append({
            "slug": slug,
            "status": status_match.group(1).strip() if status_match else "pending",
            "estimated_hours": float(hours_match.group(1)) if hours_match else 0.0,
            "dependencies": dependencies,
            "summary": summary_match.group(1).strip() if summary_match else "",
        })

    return epics


# =============================================================================
# MAP.md Validation
# =============================================================================

def validate_map_md_structure(map_path: Path) -> list[str]:
    """Validate MAP.md has required sections.

    *map_path* may be a directory containing ``MAP.md`` or a standalone
    ``.md`` file.
    """
    errors = []
    map_md = resolve_map_file(map_path)

    if map_md is None:
        return ["Map file not found (expected MAP.md inside a directory or a standalone .md file)"]

    content = map_md.read_text()

    required_sections = [
        ("Vision", r"##\s*Vision"),
        ("Epics", r"##\s*Epics"),
        ("Epic Dependencies", r"##\s*Epic Dependencies"),
        ("Dispatch Strategy", r"##\s*Dispatch Strategy"),
        ("Success Criteria", r"##\s*Success Criteria"),
        ("Estimated Effort", r"##\s*Estimated Effort"),
    ]

    for name, pattern in required_sections:
        if not re.search(pattern, content, re.IGNORECASE):
            errors.append(f"MAP.md missing required section: {name}")

    epics = _parse_epic_entries(content)
    if len(epics) < 2:
        errors.append(f"MAP.md has only {len(epics)} epic(s) (minimum 2 for a map)")

    criteria_match = re.search(
        r"##\s*Success Criteria\s*\n(.*?)(?=\n##|\Z)", content, re.DOTALL
    )
    if criteria_match:
        criteria_text = criteria_match.group(1)
        checkbox_count = len(re.findall(r"- \[ \]", criteria_text))
        if checkbox_count < 3:
            errors.append(f"Success Criteria has only {checkbox_count} items (recommend 5+)")

    return errors


# =============================================================================
# Dependency Validation
# =============================================================================

def validate_map_dependencies(epics: list[dict]) -> list[str]:
    """Validate all dependency references point to existing epic slugs."""
    errors = []
    slugs = {e["slug"] for e in epics}

    for epic in epics:
        slug = epic["slug"]
        for dep in epic.get("dependencies", []):
            if dep not in slugs:
                errors.append(f"Epic '{slug}' references unknown dependency: '{dep}'")

    return errors


def detect_epic_cycles(epics: list[dict]) -> list[str]:
    """Detect dependency cycles in the inter-epic DAG using DFS."""
    errors = []
    graph = {e["slug"]: e.get("dependencies", []) for e in epics}

    WHITE, GRAY, BLACK = 0, 1, 2
    color = {slug: WHITE for slug in graph}

    def dfs(node: str, path: list[str]) -> bool:
        if color.get(node) == GRAY:
            cycle_start = path.index(node)
            cycle = path[cycle_start:] + [node]
            errors.append(f"Epic dependency cycle: {' -> '.join(cycle)}")
            return True
        if color.get(node) == BLACK:
            return False

        color[node] = GRAY
        path.append(node)

        for neighbor in graph.get(node, []):
            if neighbor in graph and dfs(neighbor, path):
                return True

        path.pop()
        color[node] = BLACK
        return False

    for slug in graph:
        if color[slug] == WHITE:
            dfs(slug, [])

    return errors


# =============================================================================
# Wave Computation
# =============================================================================

def compute_epic_waves(epics: list[dict]) -> dict[str, int]:
    """Compute execution waves for epics using topological sort."""
    dependents = defaultdict(set)
    dep_counts = {}

    for epic in epics:
        slug = epic["slug"]
        deps = set(epic.get("dependencies", []))
        dep_counts[slug] = len(deps)
        for dep in deps:
            dependents[dep].add(slug)

    waves = {}
    current_wave = 0

    while True:
        ready = [slug for slug, count in dep_counts.items()
                 if count == 0 and slug not in waves]
        if not ready:
            break

        for slug in ready:
            waves[slug] = current_wave

        for slug in ready:
            for dependent in dependents[slug]:
                if dependent in dep_counts:
                    dep_counts[dependent] -= 1

        current_wave += 1

    all_slugs = {e["slug"] for e in epics}
    unassigned = all_slugs - set(waves.keys())
    if unassigned:
        raise ValueError(f"Could not assign wave to epics (possible cycle): {unassigned}")

    return waves


def group_epic_waves(waves: dict[str, int]) -> dict[int, list[str]]:
    """Group epic slugs by wave number."""
    groups = defaultdict(list)
    for slug, wave in waves.items():
        groups[wave].append(slug)

    for wave in groups:
        groups[wave].sort()

    return dict(groups)


# =============================================================================
# Critical Path Analysis
# =============================================================================

def compute_epic_critical_path(
    epics: list[dict], waves: dict[str, int] | None = None,
) -> tuple[list[str], float]:
    """Find the critical path through the epic DAG."""
    epic_map = {e["slug"]: e for e in epics}

    if waves is None:
        waves = compute_epic_waves(epics)

    wave_groups = group_epic_waves(waves)
    longest = {}
    path_to = {}

    for wave_num in sorted(wave_groups.keys()):
        for slug in wave_groups[wave_num]:
            epic = epic_map[slug]
            duration = epic.get("estimated_hours", 0.0)
            deps = epic.get("dependencies", [])

            if not deps:
                longest[slug] = duration
                path_to[slug] = [slug]
            else:
                max_len = 0
                best_path = []
                for dep in deps:
                    if dep in longest and longest[dep] > max_len:
                        max_len = longest[dep]
                        best_path = path_to[dep]

                longest[slug] = max_len + duration
                path_to[slug] = best_path + [slug]

    if not longest:
        return [], 0.0

    max_slug = max(longest.keys(), key=lambda s: longest[s])
    return path_to[max_slug], longest[max_slug]


# =============================================================================
# Metrics
# =============================================================================

def compute_map_metrics(
    epics: list[dict], waves: dict[str, int] | None = None,
) -> dict[str, Any]:
    """Compute parallelism and effort metrics for a map."""
    if waves is None:
        waves = compute_epic_waves(epics)

    wave_groups = group_epic_waves(waves)
    total = len(waves)
    wave0_count = len(wave_groups.get(0, []))
    total_hours = sum(e.get("estimated_hours", 0.0) for e in epics)
    _, cp_duration = compute_epic_critical_path(epics, waves)
    speedup = total_hours / cp_duration if cp_duration > 0 else 1.0

    return {
        "total_epics": total,
        "wave_count": len(wave_groups),
        "max_parallelism": max((len(v) for v in wave_groups.values()), default=0),
        "wave0_count": wave0_count,
        "wave0_ratio": wave0_count / total if total > 0 else 0,
        "total_hours": total_hours,
        "critical_path_hours": cp_duration,
        "speedup": speedup,
        "waves": wave_groups,
    }


# =============================================================================
# Full Map Parsing
# =============================================================================

def parse_map(map_path: Path, epics_base: Path) -> dict[str, Any]:
    """Parse a complete map with epic resolution."""
    metadata = parse_map_md(map_path)
    if "error" in metadata:
        return metadata

    epics_data = metadata["epics"]
    resolved_epics = []

    for epic_entry in epics_data:
        slug = epic_entry["slug"]
        epic_dir = epics_base / slug

        entry = {
            "slug": slug,
            "status": epic_entry["status"],
            "estimated_hours": epic_entry["estimated_hours"],
            "dependencies": epic_entry["dependencies"],
            "summary": epic_entry["summary"],
            "resolved": False,
        }

        if epic_dir.exists() and epic_dir.is_dir():
            entry["resolved"] = True
            entry["epic_path"] = str(epic_dir)

            try:
                from spoq_server import epic_utils
                epic_data = epic_utils.parse_epic(epic_dir)
                if "error" not in epic_data:
                    entry["task_stats"] = epic_data.get("stats", {})
                    tasks = epic_data.get("tasks", [])
                    if tasks:
                        actual_hours = sum(
                            epic_utils.calculate_pert_estimate(t) for t in tasks
                        )
                        entry["actual_pert_hours"] = actual_hours
            except ImportError:
                pass

        resolved_epics.append(entry)

    waves = compute_epic_waves(epics_data)
    metrics = compute_map_metrics(epics_data, waves)
    cp_path, cp_duration = compute_epic_critical_path(epics_data, waves)

    resolved_count = sum(1 for e in resolved_epics if e["resolved"])

    return {
        "map_path": str(map_path),
        "map_name": map_path.name,
        "title": metadata["title"],
        "vision": metadata["vision"],
        "epics": resolved_epics,
        "stats": {
            "total_epics": len(resolved_epics),
            "resolved_epics": resolved_count,
            "unresolved_epics": len(resolved_epics) - resolved_count,
            "total_hours": metrics["total_hours"],
            "critical_path_hours": cp_duration,
            "critical_path": cp_path,
            "wave_count": metrics["wave_count"],
            "max_parallelism": metrics["max_parallelism"],
            "speedup": metrics["speedup"],
        },
    }


# =============================================================================
# Map Status Rollup
# =============================================================================

def get_map_status(map_path: Path, epics_base: Path) -> dict[str, Any]:
    """Compute per-epic status rollup from task statuses."""
    metadata = parse_map_md(map_path)
    if "error" in metadata:
        return metadata

    epics_data = metadata["epics"]
    epic_statuses = {}
    total_tasks = 0
    completed_tasks = 0

    for epic_entry in epics_data:
        slug = epic_entry["slug"]
        epic_dir = epics_base / slug

        if epic_dir.exists() and epic_dir.is_dir():
            try:
                from spoq_server import epic_utils
                tasks = epic_utils.load_tasks(epic_dir)
                valid = [t for t in tasks if "error" not in t]
                task_count = len(valid)
                done = sum(1 for t in valid if t.get("status") == "completed")
                in_prog = sum(1 for t in valid if t.get("status") == "in_progress")

                total_tasks += task_count
                completed_tasks += done

                if task_count == 0:
                    epic_status = "empty"
                elif done == task_count:
                    epic_status = "completed"
                elif in_prog > 0 or done > 0:
                    epic_status = "in_progress"
                else:
                    epic_status = "pending"

                epic_statuses[slug] = {
                    "status": epic_status,
                    "resolved": True,
                    "tasks_total": task_count,
                    "tasks_completed": done,
                    "tasks_in_progress": in_prog,
                    "tasks_pending": task_count - done - in_prog,
                    "progress": done / task_count if task_count > 0 else 0,
                }
            except ImportError:
                epic_statuses[slug] = {
                    "status": epic_entry["status"],
                    "resolved": True,
                    "note": "epic_utils not available for task aggregation",
                }
        else:
            epic_statuses[slug] = {
                "status": epic_entry["status"],
                "resolved": False,
            }

    overall_progress = completed_tasks / total_tasks if total_tasks > 0 else 0

    return {
        "map_name": map_path.name,
        "title": metadata["title"],
        "epics": epic_statuses,
        "total_tasks": total_tasks,
        "completed_tasks": completed_tasks,
        "progress": overall_progress,
    }


# =============================================================================
# Map Epic Status Update
# =============================================================================

def update_epic_status_in_map(
    map_path: Path, epic_slug: str, new_status: str,
) -> dict[str, Any]:
    """Rewrite an epic's ``**Status:**`` line inside a map markdown file.

    Locates the ``### {epic_slug}`` section and replaces the first
    ``**Status**`` line it finds within that section.

    *map_path* may be a directory containing ``MAP.md`` or a standalone
    ``.md`` file.
    """
    map_md = resolve_map_file(map_path)
    if map_md is None:
        return {"error": f"Map file not found at {map_path}"}

    lines = map_md.read_text().splitlines(keepends=True)

    in_section = False
    status_idx: int | None = None

    for i, line in enumerate(lines):
        stripped = line.strip()

        # Detect section boundaries
        if stripped.startswith("### "):
            in_section = stripped[4:].strip() == epic_slug
        elif stripped.startswith("## ") and not stripped.startswith("### "):
            in_section = False

        if in_section and re.match(r"[-\s]*\*\*Status", stripped, re.IGNORECASE):
            status_idx = i
            break

    if status_idx is None:
        return {
            "error": f"Epic '{epic_slug}' or its Status line not found in map",
        }

    old_line = lines[status_idx]

    # Preserve leading whitespace / bullet prefix
    prefix_match = re.match(r"^(\s*-\s*|\s*)", old_line)
    prefix = prefix_match.group(1) if prefix_match else ""

    # Preserve colon style: **Status:** vs **Status**:
    if "**Status:**" in old_line:
        lines[status_idx] = f"{prefix}**Status:** {new_status}\n"
    else:
        lines[status_idx] = f"{prefix}**Status**: {new_status}\n"

    map_md.write_text("".join(lines))

    return {
        "map_file": str(map_md),
        "epic_slug": epic_slug,
        "old_status": old_line.strip(),
        "new_status": new_status,
    }
