"""Deterministic v1 visual edits: text + simple typography styles on one mapped element."""
from __future__ import annotations

import os
import re
from typing import Any, Optional

_SKIP_DIR_NAMES = frozenset(
    {"node_modules", "dist", "build", ".git", ".nuxt", ".output", "coverage", "__pycache__"}
)
_SOURCE_SUFFIXES = (".html", ".htm", ".tsx", ".jsx", ".vue")

_STYLE_MAP = {
    "font_size": "font-size",
    "font_weight": "font-weight",
    "font_style": "font-style",
    "text_align": "text-align",
    "color": "color",
    "background_color": "background-color",
}


def _iter_source_files(repo_root: str):
    for dirpath, dirnames, filenames in os.walk(repo_root):
        dirnames[:] = [d for d in dirnames if d not in _SKIP_DIR_NAMES and not d.startswith(".")]
        for fn in filenames:
            if fn.endswith(_SOURCE_SUFFIXES):
                yield os.path.join(dirpath, fn)


def _count_element_id_occurrences(repo_root: str, element_id: str) -> tuple[int, Optional[str]]:
    """Return (total_hits, single_file_path_if_exactly_one_hit_else_None)."""
    patterns = (f'data-p2hs-id="{element_id}"', f"data-p2hs-id='{element_id}'")
    total = 0
    only_file: Optional[str] = None
    for fp in _iter_source_files(repo_root):
        try:
            raw = open(fp, encoding="utf-8", errors="ignore").read()
        except OSError:
            continue
        hits = sum(raw.count(p) for p in patterns)
        if hits:
            total += hits
            only_file = fp
            if total > 1:
                return total, None
    return total, only_file if total == 1 else None


def validate_visual_edit_mapping(repo_root: str, elements: list[dict[str, Any]]) -> Optional[str]:
    """Return error message if mapping is invalid; None if OK."""
    if not elements:
        return "No selected_elements"
    if not os.path.isdir(repo_root):
        return "Repository workspace is not available"
    for el in elements:
        eid = (el.get("element_id") or "").strip()
        if not eid:
            return "Missing element_id"
        total, path = _count_element_id_occurrences(repo_root, eid)
        if total == 0:
            return f"No source location for element_id={eid}"
        if total != 1:
            return f"Ambiguous mapping for element_id={eid} ({total} occurrences)"
        prev = el.get("previous_text")
        if prev is not None and path:
            try:
                content = open(path, encoding="utf-8", errors="ignore").read()
            except OSError:
                return f"Could not read {path}"
            if str(prev) not in content:
                return f"previous_text not found for element_id={eid}"
    return None


def _merge_inline_style(opening_tag: str, new_styles: dict[str, Any]) -> str:
    decls: dict[str, str] = {}
    m = re.search(r'\sstyle\s*=\s*["\']([^"\']*)["\']', opening_tag, re.I)
    if m:
        blob = m.group(1)
        for part in blob.split(";"):
            part = part.strip()
            if ":" in part:
                k, v = part.split(":", 1)
                decls[k.strip().lower()] = v.strip()
    for sk, sv in (new_styles or {}).items():
        if sv is None or sk not in _STYLE_MAP:
            continue
        decls[_STYLE_MAP[sk]] = str(sv).strip()
    if not decls:
        return opening_tag
    style_str = "; ".join(f"{k}: {v}" for k, v in sorted(decls.items()))
    if m:
        return opening_tag[: m.start(1)] + style_str + opening_tag[m.end(1) :]
    insert = f' style="{style_str}"'
    return opening_tag[:-1] + insert + ">"


def _find_close_tag(content: str, open_end: int, tag_name: str) -> Optional[int]:
    """First `</tag_name>` after open_end (v1 — assumes no nested same-tag ambiguity)."""
    needle = f"</{tag_name}>"
    i = content.find(needle, open_end)
    return i if i >= 0 else None


def apply_visual_element(content: str, element_id: str, el: dict[str, Any]) -> Optional[str]:
    """Return updated file content or None if mapping/patch fails."""
    patterns = (f'data-p2hs-id="{element_id}"', f"data-p2hs-id='{element_id}'")
    idx = -1
    used_pat = ""
    for p in patterns:
        idx = content.find(p)
        if idx >= 0:
            used_pat = p
            break
    if idx < 0:
        return None
    if content.count(patterns[0]) + content.count(patterns[1]) != 1:
        return None

    open_bracket = content.rfind("<", 0, idx)
    if open_bracket < 0:
        return None
    open_end = content.find(">", idx)
    if open_end < 0:
        return None
    opening = content[open_bracket : open_end + 1]
    nm = re.match(r"<\s*([a-zA-Z][\w-]*)", opening)
    if not nm:
        return None
    tag_name = nm.group(1)
    close_start = _find_close_tag(content, open_end + 1, tag_name)
    if close_start is None:
        return None
    inner = content[open_end + 1 : close_start]
    prev_text = el.get("previous_text")
    new_text = el.get("new_text")
    if prev_text is not None and str(prev_text) not in inner:
        return None
    inner2 = inner
    if prev_text is not None and new_text is not None and str(prev_text) != str(new_text):
        if str(prev_text) not in inner:
            return None
        inner2 = inner.replace(str(prev_text), str(new_text), 1)
        if inner2 == inner:
            return None
    elif prev_text is not None and str(prev_text) not in inner:
        return None
    opening2 = _merge_inline_style(opening, el.get("new_styles") or {})
    return content[:open_bracket] + opening2 + inner2 + content[close_start:]


def apply_visual_edits_to_repo(repo_root: str, elements: list[dict[str, Any]]) -> tuple[bool, str]:
    """Apply all elements; each must map to a unique single file. Returns (ok, message)."""
    err = validate_visual_edit_mapping(repo_root, elements)
    if err:
        return False, err
    per_file: dict[str, list[tuple[str, dict[str, Any]]]] = {}
    for el in elements:
        eid = (el.get("element_id") or "").strip()
        _, fp = _count_element_id_occurrences(repo_root, eid)
        assert fp
        per_file.setdefault(fp, []).append((eid, el))
    for fp, pairs in per_file.items():
        try:
            text = open(fp, encoding="utf-8", errors="ignore").read()
        except OSError as e:
            return False, str(e)
        for eid, el in pairs:
            patched = apply_visual_element(text, eid, el)
            if patched is None:
                return False, f"Could not apply patch for element_id={eid}"
            text = patched
        try:
            with open(fp, "w", encoding="utf-8", newline="") as out:
                out.write(text)
        except OSError as e:
            return False, str(e)
    return True, ""
