"""Inject automation attributes onto interactable HTML/JSX elements.

Scans a codebase for <button>, <input>, <select>, <textarea>, <a> elements
and injects a unique data-automation-id (or custom) attribute so Maeris can
construct reliable, stable test selectors.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from pathlib import Path


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

INTERACTABLE_TAGS: frozenset[str] = frozenset(
    {"button", "input", "select", "textarea", "a"}
)

SKIP_DIRS: frozenset[str] = frozenset(
    {
        "node_modules", ".git", "dist", "build", ".next", ".nuxt", ".turbo",
        "__pycache__", ".venv", "venv", ".cache", "coverage", "out", ".output",
        "playwright_tests", ".svelte-kit", "storybook-static", "public", "Cesium", "public/Cesium",
    }
)

SUPPORTED_EXTENSIONS: frozenset[str] = frozenset(
    {".jsx", ".tsx", ".js", ".ts", ".html", ".vue", ".svelte"}
)

_CAMEL_RE = re.compile(r"(?<=[a-z0-9])([A-Z])")
_TAG_OPEN_RE = re.compile(
    r"<(" + "|".join(INTERACTABLE_TAGS) + r")\b",
    re.IGNORECASE,
)


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class ElementPatch:
    """One element to be rewritten within a file."""

    file: Path
    start: int       # byte offset into file content (inclusive)
    end: int         # byte offset into file content (exclusive)
    original: str    # full opening-tag text as found
    patched: str     # full opening-tag text after injection
    attr_value: str  # the generated attribute value


@dataclass
class FileSummary:
    """All patches collected for one file."""

    file: Path
    total_found: int      # interactable elements found (excluding hidden inputs)
    already_tagged: int   # already carry the attribute → skipped
    patches: list[ElementPatch] = field(default_factory=list)

    @property
    def to_update(self) -> int:
        return len(self.patches)


# ---------------------------------------------------------------------------
# Helpers — text manipulation
# ---------------------------------------------------------------------------


def _stem_to_prefix(path: Path) -> str:
    """Filename stem → kebab-case prefix.  LoginForm → login-form"""
    name = _CAMEL_RE.sub(r"-\1", path.stem)
    name = re.sub(r"[^a-zA-Z0-9]+", "-", name)
    return name.lower().strip("-")


def _to_kebab(text: str, max_len: int = 32) -> str:
    """Arbitrary text → kebab-case slug."""
    text = re.sub(r"[^a-zA-Z0-9\s]+", " ", text).strip().lower()
    text = re.sub(r"\s+", "-", text)
    text = re.sub(r"-+", "-", text).strip("-")
    return text[:max_len]


def _extract_hint(tag_text: str, tag_name: str) -> str:
    """Return a short meaningful slug derived from the tag's existing attributes."""
    # Prefer the most semantic attribute first
    for attr in ("aria-label", "name", "id", "placeholder", "title", "value"):
        m = re.search(
            rf"""{re.escape(attr)}\s*=\s*(?:"([^"]*?)"|'([^']*?)')""",
            tag_text,
            re.IGNORECASE,
        )
        if m:
            slug = _to_kebab(m.group(1) or m.group(2) or "")
            if slug:
                return slug

    # For <input type="email"> → use type as hint (skip generic "text")
    if tag_name == "input":
        m = re.search(r'type\s*=\s*["\']([^"\']+)["\']', tag_text, re.IGNORECASE)
        if m and m.group(1).lower() not in ("text", "hidden"):
            return m.group(1).lower()

    return tag_name  # fallback: just the element type


def _make_unique(value: str, seen: set[str]) -> str:
    """Guarantee uniqueness by appending -2, -3, … when needed."""
    if value not in seen:
        seen.add(value)
        return value
    n = 2
    while f"{value}-{n}" in seen:
        n += 1
    unique = f"{value}-{n}"
    seen.add(unique)
    return unique


# ---------------------------------------------------------------------------
# Helpers — tag scanning
# ---------------------------------------------------------------------------


def _find_opening_tags(content: str) -> list[tuple[int, int, str]]:
    """Find all interactable opening tags in *content*.

    Returns [(start, end, tag_text), …] where end is exclusive and
    tag_text covers exactly the opening tag (from ``<`` to the closing
    ``>`` or ``/>``).  Handles multi-line tags, quoted attribute values,
    and JSX ``{expression}`` blocks (including nested braces).
    """
    results: list[tuple[int, int, str]] = []
    i = 0
    n = len(content)

    while i < n:
        m = _TAG_OPEN_RE.search(content, i)
        if not m:
            break

        start = m.start()

        # Skip closing tags like </button>
        if start > 0 and content[start + 1] == "/":
            i = start + 2
            continue

        j = m.end()
        in_quote: str | None = None

        while j < n:
            c = content[j]

            if in_quote:
                if c == in_quote:
                    in_quote = None

            elif c in ('"', "'"):
                in_quote = c

            elif c == "{":
                # JSX expression — skip forward past the matching '}'
                depth = 1
                j += 1
                while j < n and depth > 0:
                    ch = content[j]
                    if ch == "{":
                        depth += 1
                    elif ch == "}":
                        depth -= 1
                    j += 1
                continue  # j already advanced; skip the j += 1 below

            elif c == ">":
                j += 1
                break

            j += 1

        tag_text = content[start:j]
        results.append((start, j, tag_text))
        i = j

    return results


def _already_has_attr(tag_text: str, attr_name: str) -> bool:
    return bool(re.search(rf"\b{re.escape(attr_name)}\s*=", tag_text, re.IGNORECASE))


def _is_hidden_input(tag_text: str, tag_name: str) -> bool:
    return tag_name == "input" and bool(
        re.search(r'type\s*=\s*["\']hidden["\']', tag_text, re.IGNORECASE)
    )


def _inject(tag_text: str, attr_name: str, attr_value: str) -> str:
    """Insert ``attr_name="attr_value"`` into *tag_text* before the closing
    ``>`` or ``/>``, correctly skipping quoted values and JSX blocks."""
    in_quote: str | None = None
    depth = 0
    last_gt = -1

    for idx, c in enumerate(tag_text):
        if in_quote:
            if c == in_quote:
                in_quote = None
        elif depth > 0:
            if c == "{":
                depth += 1
            elif c == "}":
                depth -= 1
        elif c in ('"', "'"):
            in_quote = c
        elif c == "{":
            depth += 1
        elif c == ">":
            last_gt = idx

    if last_gt == -1:
        return tag_text  # malformed — leave untouched

    attr_snippet = f'{attr_name}="{attr_value}"'
    if last_gt > 0 and tag_text[last_gt - 1] == "/":
        # Self-closing: strip trailing space before /> then insert
        return tag_text[: last_gt - 1].rstrip() + f" {attr_snippet} />"
    else:
        return tag_text[:last_gt].rstrip() + f" {attr_snippet}>"


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


def scan(target: Path, attr_name: str = "data-automation-id") -> list[FileSummary]:
    """Walk *target* and build patches for every untagged interactable element.

    IDs are guaranteed unique across the entire scan (not just per-file).
    """
    summaries: list[FileSummary] = []
    seen_values: set[str] = set()

    for path in _walk(target):
        try:
            content = path.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            continue

        tags = _find_opening_tags(content)
        if not tags:
            continue

        patches: list[ElementPatch] = []
        already_tagged = 0
        total = 0

        for start, end, tag_text in tags:
            m = re.match(r"<([a-zA-Z]+)", tag_text)
            if not m:
                continue
            tn = m.group(1).lower()

            if _is_hidden_input(tag_text, tn):
                continue

            total += 1

            if _already_has_attr(tag_text, attr_name):
                already_tagged += 1
                continue

            # Build a unique, meaningful attribute value
            prefix = _stem_to_prefix(path)
            hint = _extract_hint(tag_text, tn)
            raw = f"{prefix}-{hint}" if hint != tn else f"{prefix}-{tn}"
            if not raw.endswith(f"-{tn}"):
                raw = f"{raw}-{tn}"
            raw = re.sub(r"-+", "-", raw).strip("-")
            attr_value = _make_unique(raw, seen_values)

            patched = _inject(tag_text, attr_name, attr_value)
            patches.append(
                ElementPatch(
                    file=path,
                    start=start,
                    end=end,
                    original=tag_text,
                    patched=patched,
                    attr_value=attr_value,
                )
            )

        if total > 0:
            summaries.append(
                FileSummary(
                    file=path,
                    total_found=total,
                    already_tagged=already_tagged,
                    patches=patches,
                )
            )

    return summaries


def apply_patches(summaries: list[FileSummary]) -> int:
    """Write all patches to disk in-place.  Returns number of files modified."""
    modified = 0
    for summary in summaries:
        if not summary.patches:
            continue
        try:
            content = summary.file.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            continue

        # Apply in reverse offset order so earlier offsets stay valid
        for patch in sorted(summary.patches, key=lambda p: p.start, reverse=True):
            content = content[: patch.start] + patch.patched + content[patch.end :]

        summary.file.write_text(content, encoding="utf-8")
        modified += 1

    return modified


def _walk(target: Path):
    """Yield supported files under *target*, skipping common ignored dirs."""
    for path in sorted(target.rglob("*")):
        if path.is_file() and path.suffix in SUPPORTED_EXTENSIONS:
            if not any(part in SKIP_DIRS for part in path.parts):
                yield path
