"""Pre-flight path verification for task descriptions.

Extracts file paths from task fields, checks them against the real project
file tree, and suggests fuzzy corrections for stale references.
"""

from __future__ import annotations

import difflib
import logging
import re
from pathlib import Path

from pydantic import BaseModel

from loom.graph.task import Task

logger = logging.getLogger(__name__)

# ── Regex patterns ────────────────────────────────────────────────────

# Multi-segment paths like loom/graph/store.py (requires at least one /)
_PATH_PATTERN = re.compile(
    r"(?<!\w)"                # not preceded by word char (avoid matching inside URLs)
    r"(?!https?://)"          # not a URL
    r"([a-zA-Z0-9_.][a-zA-Z0-9_./\-]*"  # starts with alphanum/underscore/dot
    r"/[a-zA-Z0-9_./\-]*"    # must have at least one slash
    r"[a-zA-Z0-9_])"         # ends with alphanum/underscore
)

# URL pattern to pre-filter text before path extraction
_URL_PATTERN = re.compile(r"https?://\S+")

# Bare filenames with known extensions like store.py, config.yaml
_KNOWN_EXTENSIONS = frozenset({
    ".py", ".yaml", ".yml", ".json", ".toml", ".sql", ".md",
    ".txt", ".sh", ".js", ".ts", ".html", ".css",
})
_FILENAME_PATTERN = re.compile(
    r"(?<!\w)"
    r"(?!https?://)"
    r"([a-zA-Z0-9_][a-zA-Z0-9_\-]*\.[a-zA-Z]{1,5})"
    r"(?!\w)"
)


# ── Data models ───────────────────────────────────────────────────────


class PathCorrection(BaseModel):
    stale_path: str
    corrected_path: str | None = None
    confidence: float = 0.0
    source: str = ""  # e.g. "title", "context.files", "context.description"


class PathCheckResult(BaseModel):
    task_id: str
    task_title: str
    corrections: list[PathCorrection] = []
    valid_paths: list[str] = []
    missing_paths: list[str] = []  # no correction found


class PathCheckReport(BaseModel):
    project_root: str
    total_tasks: int
    tasks_with_issues: int
    results: list[PathCheckResult] = []


# ── Path extraction ───────────────────────────────────────────────────


def extract_paths_from_dict(
    title: str,
    context: dict,
    done_when: str | None = None,
) -> list[tuple[str, str]]:
    """Extract file-like paths from raw field values.

    Returns (path, source_field) tuples.  Works on plain dicts so it can
    be used before Task objects are created (e.g. in the decompose pipeline).
    """
    results: list[tuple[str, str]] = []
    seen: set[str] = set()

    def _add(path: str, source: str) -> None:
        if path not in seen:
            seen.add(path)
            results.append((path, source))

    # context.files — direct list of paths
    ctx = context or {}
    if isinstance(ctx, dict):
        files = ctx.get("files")
        if isinstance(files, list):
            for f in files:
                if isinstance(f, str) and f.strip():
                    _add(f.strip(), "context.files")

    # Text fields to scan with regex
    text_fields: list[tuple[str, str]] = [
        (title or "", "title"),
        (done_when or "", "done_when"),
    ]
    if isinstance(ctx, dict):
        for key in ("description", "notes"):
            val = ctx.get(key)
            if isinstance(val, str):
                text_fields.append((val, f"context.{key}"))
            elif isinstance(val, list):
                text_fields.append((" ".join(str(v) for v in val), f"context.{key}"))

    for text, source in text_fields:
        if not text:
            continue
        # Strip URLs before extracting paths
        cleaned = _URL_PATTERN.sub("", text)
        # Multi-segment paths first (higher specificity)
        for m in _PATH_PATTERN.finditer(cleaned):
            _add(m.group(1), source)
        # Bare filenames with known extensions (skip if already seen as part of a multi-segment path)
        for m in _FILENAME_PATTERN.finditer(cleaned):
            candidate = m.group(1)
            suffix = Path(candidate).suffix
            if suffix in _KNOWN_EXTENSIONS and candidate not in seen:
                # Skip if this filename is the tail of an already-extracted multi-segment path
                if any(s.endswith("/" + candidate) for s in seen):
                    continue
                _add(candidate, source)

    return results


def extract_paths(task: Task) -> list[tuple[str, str]]:
    """Extract file-like paths from task fields.

    Returns (path, source_field) tuples.  Delegates to
    ``extract_paths_from_dict`` for the actual extraction logic.
    """
    return extract_paths_from_dict(
        task.title or "", task.context or {}, task.done_when,
    )


# ── Fuzzy matching ────────────────────────────────────────────────────

_MIN_CONFIDENCE = 0.5


def find_best_match(
    stale_path: str,
    real_paths: list[str],
    real_dirs: set[str],
) -> tuple[str | None, float]:
    """Find the best match for a stale path among real project paths.

    Returns (best_match, confidence) or (None, 0.0).
    """
    stale_lower = stale_path.lower()
    stale_parts = Path(stale_lower).parts
    stale_stem = Path(stale_lower).stem
    stale_ext = Path(stale_lower).suffix

    # Check if it's a directory reference
    if not stale_ext and stale_path in real_dirs:
        return (stale_path, 1.0)

    best_path: str | None = None
    best_score = 0.0

    for rp in real_paths:
        rp_lower = rp.lower()
        rp_parts = Path(rp_lower).parts
        rp_stem = Path(rp_lower).stem
        rp_ext = Path(rp_lower).suffix

        # Base similarity
        base = difflib.SequenceMatcher(None, stale_lower, rp_lower).ratio()

        # Boost: same stem (e.g. "store" matches "store")
        if stale_stem and rp_stem and stale_stem == rp_stem:
            base += 0.15

        # Boost: shared stem prefix (e.g. "orchestrat" in both)
        elif stale_stem and rp_stem:
            prefix_len = len(
                stale_stem[: next(
                    (i for i, (a, b) in enumerate(zip(stale_stem, rp_stem)) if a != b),
                    min(len(stale_stem), len(rp_stem)),
                )]
            )
            if prefix_len >= 4:
                base += 0.1

        # Boost: same extension
        if stale_ext and rp_ext and stale_ext == rp_ext:
            base += 0.05

        # Boost: shared directory components
        shared_dirs = set(stale_parts[:-1]) & set(rp_parts[:-1])
        if shared_dirs:
            base += 0.05 * min(len(shared_dirs), 3)

        if base > best_score:
            best_score = base
            best_path = rp

    # Cap at 0.99 (1.0 reserved for exact matches)
    best_score = min(best_score, 0.99)

    if best_score >= _MIN_CONFIDENCE:
        return (best_path, round(best_score, 3))
    return (None, 0.0)


# ── Main check function ──────────────────────────────────────────────


def check_paths(tasks: list[Task], project_root: str | Path) -> PathCheckReport:
    """Check all extracted paths against the real project file tree.

    Uses scanner.walk_project_files() to get the actual file list.
    """
    from loom.scanner import walk_project_files

    root = Path(project_root).resolve()
    try:
        file_list = walk_project_files(root)
    except ValueError:
        logger.warning("Invalid project root: %s", root)
        return PathCheckReport(
            project_root=str(root),
            total_tasks=len(tasks),
            tasks_with_issues=0,
        )

    real_paths_posix = [str(p) for p in file_list]  # relative posix-style
    real_set = set(real_paths_posix)

    # Build set of real directory prefixes
    real_dirs: set[str] = set()
    for p in real_paths_posix:
        parts = Path(p).parts
        for i in range(1, len(parts)):
            real_dirs.add(str(Path(*parts[:i])))

    results: list[PathCheckResult] = []
    issues_count = 0

    for task in tasks:
        extracted = extract_paths(task)
        if not extracted:
            continue

        result = PathCheckResult(task_id=task.id, task_title=task.title)

        for path, source in extracted:
            # Normalise path separators
            normalised = path.replace("\\", "/").strip("/")

            # Exact match
            if normalised in real_set:
                result.valid_paths.append(normalised)
                continue

            # Bare filename — check if any real path ends with it
            if "/" not in normalised:
                matches = [rp for rp in real_paths_posix if rp.endswith("/" + normalised) or rp == normalised]
                if len(matches) == 1:
                    result.valid_paths.append(matches[0])
                    continue
                elif len(matches) > 1:
                    # Ambiguous — treat as valid (multiple matches)
                    result.valid_paths.append(normalised)
                    continue

            # Directory reference check
            if normalised in real_dirs:
                result.valid_paths.append(normalised)
                continue

            # Fuzzy match
            best, confidence = find_best_match(normalised, real_paths_posix, real_dirs)
            if best:
                result.corrections.append(PathCorrection(
                    stale_path=normalised,
                    corrected_path=best,
                    confidence=confidence,
                    source=source,
                ))
            else:
                result.missing_paths.append(normalised)

        if result.corrections or result.missing_paths:
            issues_count += 1
        results.append(result)

    return PathCheckReport(
        project_root=str(root),
        total_tasks=len(tasks),
        tasks_with_issues=issues_count,
        results=results,
    )


# ── Pipeline auto-fix (no DB) ─────────────────────────────────────────


def fix_paths_in_place(
    task_defs: list,
    project_root: str | Path,
) -> list[dict]:
    """Correct stale paths in raw task definition objects in-place.

    Works on any object with ``title``, ``context``, and ``done_when``
    attributes (e.g. ``TaskDefinition``).  No database access is needed —
    this is designed for the decompose pipeline before tasks are persisted.

    Returns a list of correction records for logging/reporting.
    """
    from loom.scanner import walk_project_files

    root = Path(project_root).resolve()
    try:
        file_list = walk_project_files(root)
    except (ValueError, OSError):
        logger.warning("fix_paths_in_place: invalid project root %s", root)
        return []

    real_paths_posix = [str(p) for p in file_list]
    real_set = set(real_paths_posix)

    # Build directory set
    real_dirs: set[str] = set()
    for p in real_paths_posix:
        parts = Path(p).parts
        for i in range(1, len(parts)):
            real_dirs.add(str(Path(*parts[:i])))

    corrections: list[dict] = []

    for td in task_defs:
        ctx = td.context if isinstance(td.context, dict) else {}
        extracted = extract_paths_from_dict(td.title or "", ctx, td.done_when)
        if not extracted:
            continue

        for path, source in extracted:
            normalised = path.replace("\\", "/").strip("/")

            # Already valid
            if normalised in real_set or normalised in real_dirs:
                continue

            # Bare filename — check if unique match
            if "/" not in normalised:
                matches = [rp for rp in real_paths_posix if rp.endswith("/" + normalised) or rp == normalised]
                if len(matches) == 1:
                    continue  # unambiguous bare filename — OK
                elif len(matches) > 1:
                    continue  # ambiguous — leave alone

            best, confidence = find_best_match(normalised, real_paths_posix, real_dirs)
            if not best:
                continue

            # Apply the correction in-place
            stale = normalised
            corrected = best
            modified = False

            # Fix context.files
            if isinstance(ctx.get("files"), list):
                new_files = [corrected if f.strip("/") == stale else f for f in ctx["files"]]
                if new_files != ctx["files"]:
                    ctx["files"] = new_files
                    td.context = ctx
                    modified = True

            # Fix string fields in context
            for key in ("description", "notes"):
                val = ctx.get(key)
                if isinstance(val, str) and stale in val:
                    ctx[key] = val.replace(stale, corrected)
                    td.context = ctx
                    modified = True

            # Fix done_when
            if td.done_when and stale in td.done_when:
                td.done_when = td.done_when.replace(stale, corrected)
                modified = True

            if modified:
                corrections.append({
                    "task_title": td.title,
                    "stale_path": stale,
                    "corrected_path": corrected,
                    "confidence": confidence,
                    "source": source,
                })

    return corrections


# ── Auto-fix (DB-backed) ─────────────────────────────────────────────


async def apply_corrections(pool, redis, report: PathCheckReport) -> list[str]:
    """Replace stale paths in task context fields. Returns list of fixed task IDs."""
    from loom.graph import cache as _cache
    from loom.graph import store as _store

    fixed: list[str] = []

    for result in report.results:
        if not result.corrections:
            continue

        task = await _store.get_task(pool, result.task_id)
        ctx = dict(task.context) if task.context else {}
        modified = False

        for correction in result.corrections:
            if not correction.corrected_path:
                continue
            stale = correction.stale_path
            corrected = correction.corrected_path

            # Fix context.files
            if isinstance(ctx.get("files"), list):
                new_files = [corrected if f == stale else f for f in ctx["files"]]
                if new_files != ctx["files"]:
                    ctx["files"] = new_files
                    modified = True

            # Fix string fields in context
            for key in ("description", "notes"):
                val = ctx.get(key)
                if isinstance(val, str) and stale in val:
                    ctx[key] = val.replace(stale, corrected)
                    modified = True

        if modified:
            updated = await _store.update_task(pool, result.task_id, context=ctx)
            await _cache.sync_task(redis, updated)
            fixed.append(result.task_id)

    return fixed
