"""Key patterns, mapping-row marker rules and text helpers shared by the scripts.

Every script that scans keys, names output files or orders languages imports
this module, so the scanner, the gate, the CMS sheet and the overlay agree on
what a wired key is, which rows are component-owned or dynamic, which files are
source, how a screen name becomes a file slug and which order languages take.
The key accessor type (`--key-type`, default `L10n`) and the language list
(`--langs`, default the languages present in the data) are inputs, never a
built-in list of one app's.
"""
import argparse
import os
import re

EXTENSIONS = {".swift", ".kt", ".kts", ".xml", ".ts", ".tsx", ".js", ".jsx", ".vue"}
SKIP_DIRS = {".git", "Generated"}
FALLBACK_LANG = "en"
RTL_LANGS = {"ar", "fa", "he", "ur"}

WORD = "A-Za-z0-9_"
IDENT = rf"[A-Za-z_][{WORD}]*"
DEFAULT_KEY_TYPES = ("L10n",)
KEY_TYPE_NAME = re.compile(rf"^{IDENT}$")
LITERAL_KEY = re.compile(rf"\"([{WORD}.]+)\"\.localized\b")
WEB_T = re.compile(r"(?<![\w$])t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
WEB_DOLLAR_T = re.compile(r"\$t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
INTERPOLATION = re.compile(r"\\\(|\$\{|\$[A-Za-z_]")

TRAILING_MARKER = re.compile(r"\s*\([^()]*\)\s*$")
COMPONENT_MARKER = re.compile(r"\([^)]*(owned by|bile\u015fen|component)[^)]*\)", re.IGNORECASE)
PLACEHOLDER = re.compile(r"<[^<>]+>")
DYNAMIC_WORDS = ("dynamic", "dinamik")
RUNTIME_NOTE = "enumerate at runtime"

XML_INVALID = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f]")

ACCESSOR_EXTENSIONS = {".swift", ".kt", ".kts"}
COMMON_ACCESSORS = (
    "L10n", "LocalizationKey", "LocalizationKeys",
    "LocalizedStringKey", "LocalizedStrings", "Localizable", "Localization", "Strings", "Loc",
)
ACCESSOR_CHAIN = re.compile(rf"(?<![{WORD}.])([A-Z][{WORD}]*)((?:\.{IDENT}){{2,}})")


def strip_accessor(chain):
    segments = chain.strip(".").split(".")
    while segments and segments[-1].startswith("localized"):
        segments.pop()
    return ".".join(segments)


def key_types(values, fallback=DEFAULT_KEY_TYPES):
    """The accessor type names from repeated or comma-separated --key-type values.

    Raises ValueError on a name that is not an identifier.
    """
    names = [part.strip() for value in values or [] for part in str(value).split(",") if part.strip()]
    for name in names:
        if not KEY_TYPE_NAME.match(name):
            raise ValueError(f"--key-type expects an identifier, got {name!r}")
    return tuple(dict.fromkeys(names)) or tuple(fallback)


def key_type_alternation(types):
    return "|".join(re.escape(name) for name in sorted(types, key=len, reverse=True))


def key_chain_pattern(types):
    return re.compile(rf"(?:{key_type_alternation(types)})((?:\.{IDENT})+)")


def lsk_keys(text, types=DEFAULT_KEY_TYPES):
    for hit in key_chain_pattern(types).finditer(text):
        key = strip_accessor(hit.group(1))
        if key.count(".") >= 1:
            yield key


def accessor_roots(texts, types=DEFAULT_KEY_TYPES):
    """Roots of key-like member chains that are not one of `types`, with their counts.

    A chain counts when its root is a common accessor name or when it ends in
    `.localized`, and it keeps two or more segments once that suffix is dropped.
    """
    counts = {}
    for text in texts:
        for hit in ACCESSOR_CHAIN.finditer(text):
            root, members = hit.group(1), hit.group(2)
            if root in types:
                continue
            segments = members.strip(".").split(".")
            localized = segments[-1].startswith("localized")
            if strip_accessor(members).count(".") < 1:
                continue
            if root in COMMON_ACCESSORS or localized:
                counts[root] = counts.get(root, 0) + 1
    return dict(sorted(counts.items(), key=lambda item: (-item[1], item[0])))


def key_type_warning(texts, types=DEFAULT_KEY_TYPES, mapping_hint=False):
    """A warning when no chain of `types` is found but chains of another accessor are.

    `texts` are the Swift and Kotlin sources read. Returns None when the
    configured type matched at least once or nothing resembles an accessor.
    """
    texts = list(texts)
    if any(True for text in texts for _ in lsk_keys(text, types)):
        return None
    roots = accessor_roots(texts, types)
    if not roots:
        return None
    seen = ", ".join(f"{root} ({count})" for root, count in list(roots.items())[:5])
    where = "--key-type <Name> (or \"keyType\" in the mapping)" if mapping_hint else "--key-type <Name>"
    return (
        f"WARNING: no key chain of {', '.join(types)} was found, but the code has accessor chains rooted at "
        f"{seen}. Set {where} to the project's key accessor type; until then its keys are "
        f"missed or counted as weak."
    )


def order_langs(langs):
    """en first when present, then the rest sorted; en alone when nothing is given."""
    unique = list(dict.fromkeys(lang for lang in langs if lang))
    rest = sorted(lang for lang in unique if lang != FALLBACK_LANG)
    ordered = ([FALLBACK_LANG] if FALLBACK_LANG in unique else []) + rest
    return ordered or [FALLBACK_LANG]


def parse_langs(value, present=()):
    """A comma list in the order given, or for 'all' every language in `present`."""
    text = str(value or "all").strip()
    if text == "all":
        return order_langs(present)
    return [part.strip() for part in text.split(",") if part.strip()]


def reject_token_flag(cli, opts, env_hint):
    """Exit 2 when a credential was passed on argv, which other processes can read."""
    if getattr(opts, "token", None) is not None:
        cli.error(f"--token is not accepted, because other processes can read argv; {env_hint}")


def add_token_flag(cli):
    cli.add_argument("--token", help=argparse.SUPPRESS)


def is_interpolated(literal):
    return bool(INTERPOLATION.search(literal))


def normalize_key(key):
    return ".".join(seg[:1].upper() + seg[1:] for seg in key.split("."))


def source_files(root):
    if os.path.isfile(root):
        yield root
        return
    for current, dirs, files in os.walk(root):
        relative = os.path.relpath(current, root)
        parts = [] if relative == os.curdir else relative.split(os.sep)
        if SKIP_DIRS.intersection(parts):
            dirs[:] = []
            continue
        for name in sorted(files):
            if os.path.splitext(name)[1] in EXTENSIONS:
                yield os.path.join(current, name)


def raw_key(row):
    return str(row.get("newKey") or "").strip()


def clean_key(new_key):
    text = str(new_key or "").strip()
    if text.startswith("(dynamic"):
        return text
    return TRAILING_MARKER.sub("", text)


def is_component(row):
    return bool(COMPONENT_MARKER.search(raw_key(row)))


def key_is_dynamic(key):
    return key.startswith("(dynamic") or bool(PLACEHOLDER.search(key))


def is_dynamic(row, key):
    element = str(row.get("element") or "").lower()
    note = str(row.get("note") or "").lower()
    return key_is_dynamic(key) or any(word in element for word in DYNAMIC_WORDS) or RUNTIME_NOTE in note


def xml_text(value):
    return XML_INVALID.sub("", str(value))


def slug(text):
    value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
    return value or "screen"
