"""
Source Patcher — automatically rewrites broken selectors in source files.

When a locator is healed at runtime the pipeline records the originating
file path, line number, old selector, and the healed replacement.  All
patches are collected during a test run and applied at session end in a
single write-per-file pass so tests are never interrupted mid-run.

Activation:
    HealerConfig(auto_patch_source=True)         # programmatic
    PH_AUTO_PATCH_SOURCE=true                    # env var (auto_config)
    pytest --ph-auto-patch-source                 # pytest CLI flag

What gets patched:
    Every Python source file containing a quoted string literal that
    exactly matches the broken selector on the line reported in the stack
    frame.  Both single-quote and double-quote delimiters are supported.

    Before:  await healing_page.click("#logout-btn", "Logout")
    After:   await healing_page.click("#logout-button", "Logout")

Safety:
    - Patches are deduped (same file + line + old selector → one patch).
    - Files are read and written once per file, not once per selector.
    - An OSError or any unexpected exception aborts patching for that file
      only; other files are still processed.
    - The original files are overwritten in-place (no backup by default).
      Set ``backup=True`` on ``SourcePatcher`` to create ``.bak`` copies.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List

logger = logging.getLogger("playwright_healer.source_patcher")


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------

@dataclass
class SourcePatch:
    """A pending modification to a single line of a source file."""

    original_selector: str   # the broken selector string (without quotes)
    healed_selector: str     # the working replacement selector (without quotes)
    file_path: str           # absolute path to the Python source file
    lineno: int              # 1-based line number
    description: str = ""   # human label from the test (for logging)
    stage: str = ""          # which healing stage found the fix


# ---------------------------------------------------------------------------
# Core patcher
# ---------------------------------------------------------------------------

class SourcePatcher:
    """
    Collects healing events that map to source lines and applies them all
    at session end so tests run without interruption.

    Parameters
    ----------
    backup:
        When True a ``.bak`` copy is written before overwriting any file.
    """

    def __init__(self, backup: bool = False) -> None:
        self._queue: List[SourcePatch] = []
        self._backup = backup

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def queue(self, patch: SourcePatch) -> None:
        """Add a patch; silently drops duplicates (same file+line+old)."""
        for p in self._queue:
            if (
                p.file_path == patch.file_path
                and p.lineno == patch.lineno
                and p.original_selector == patch.original_selector
            ):
                return
        self._queue.append(patch)

    def apply_all(self, console_logging: bool = True) -> List[SourcePatch]:
        """
        Apply every queued patch.

        Files are read once, all patches for that file applied in memory,
        then the file is written once.

        Returns the patches that were actually applied (i.e. the old
        selector string was found on the reported line).
        """
        if not self._queue:
            return []

        by_file: Dict[str, List[SourcePatch]] = {}
        for p in self._queue:
            by_file.setdefault(p.file_path, []).append(p)

        applied: List[SourcePatch] = []

        for file_path, patches in by_file.items():
            try:
                path = Path(file_path)
                if not path.exists() or not path.is_file():
                    logger.warning(
                        "Source patch skipped — file not found: %s", file_path
                    )
                    continue

                lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
                changed = False

                for p in patches:
                    idx = p.lineno - 1  # 0-based index
                    if idx < 0 or idx >= len(lines):
                        logger.warning(
                            "Source patch skipped — line %d out of range in %s",
                            p.lineno,
                            file_path,
                        )
                        continue

                    original_line = lines[idx]
                    patched_line = _replace_selector_in_line(
                        original_line, p.original_selector, p.healed_selector
                    )

                    if patched_line != original_line:
                        lines[idx] = patched_line
                        changed = True
                        applied.append(p)
                        if console_logging:
                            _print_patch(p, file_path)
                    else:
                        logger.debug(
                            "Source patch: selector %r not found literally "
                            "on line %d of %s",
                            p.original_selector,
                            p.lineno,
                            file_path,
                        )

                if changed:
                    if self._backup:
                        bak = Path(file_path).with_suffix(path.suffix + ".bak")
                        bak.write_bytes(path.read_bytes())
                        logger.debug("Backup created: %s", bak)

                    path.write_text("".join(lines), encoding="utf-8")
                    n = sum(1 for p in patches if p in applied)
                    logger.info(
                        "Source patched: %s (%d selector(s) updated)", file_path, n
                    )

            except OSError as exc:
                logger.warning(
                    "Source patch I/O error for %s: %s", file_path, exc
                )
            except Exception as exc:
                logger.warning(
                    "Source patch unexpected error for %s: %s", file_path, exc
                )

        self._queue.clear()
        return applied

    @property
    def pending(self) -> List[SourcePatch]:
        """Return a copy of all patches not yet applied."""
        return list(self._queue)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _replace_selector_in_line(line: str, old: str, new: str) -> str:
    """
    Replace the first occurrence of ``old`` as a quoted string literal in
    ``line`` with ``new``.  Tries double-quotes first, then single-quotes.

    Examples::

        '#userid'    →  '#username'
        "#userid"    →  "#username"
    """
    for quote in ('"', "'"):
        token = f"{quote}{old}{quote}"
        if token in line:
            return line.replace(token, f"{quote}{new}{quote}", 1)
    return line


def _print_patch(patch: SourcePatch, file_path: str) -> None:
    """Print a Rich-formatted (or plain-text) one-liner about the patch."""
    rel = _rel_path(file_path)
    try:
        from rich.console import Console
        _c = Console(highlight=False)
        _c.print(
            f"  [bold cyan]SOURCE PATCHED[/bold cyan]  "
            f"[dim]{rel}:{patch.lineno}[/dim]  "
            f"[red]{patch.original_selector!r}[/red] "
            f"→ [bold green]{patch.healed_selector!r}[/bold green]  "
            f"[dim]via {patch.stage}[/dim]"
        )
    except ImportError:
        print(
            f"  [SOURCE PATCHED] {rel}:{patch.lineno}  "
            f"{patch.original_selector!r} -> {patch.healed_selector!r}"
            f"  (via {patch.stage})"
        )


def _rel_path(file_path: str) -> str:
    """Return path relative to cwd, falling back to the absolute path."""
    try:
        return str(Path(file_path).relative_to(Path.cwd()))
    except ValueError:
        return file_path
