#!/usr/bin/env python3
"""
Rewrite anon_hang.dzn (+ flat variant) so anonymised identifiers
carry **role-appropriate** human-readable names from the
financial / bookkeeping domain.

Per-role vocabulary pools:

  Component   — PascalCase, one name           ("Application")
  Namespace   — snake_case, segments           ("bookkeeping.core.ledger_module")
  Interfaces  — I-prefixed PascalCase          ("IAccount", "ILedger", "IJournal", …)
  Ports       — camelCase nouns                ("account", "ledger", "journal", …)
  Enum types  — PascalCase nouns               ("State", "Phase", "Status", "Result", …)
  Enum values — PascalCase                     ("Pending", "Posted", "Settled", …)
  Events      — camelCase verbs                ("post", "submit", "approve", "reverse", …)
  Local types / fields — kept as `_NNN` unless
    they collide with a previously-renamed
    word (then suffixed). Those are noise for
    discussion purposes.

Idempotent on rerun: handles both the original anonymised form and
the after-first-pass form. The token regex matches any identifier
starting with `_NNN`, `[A-Z][A-Za-z]+` (PascalCase), or
`[a-z][A-Za-z]+` (camelCase) so a partial rename can be polished.

Roles are detected from regex-based structural patterns over the
DZN grammar — light parsing, not a full AST:

  ^component NAME                     → NAME is the component
  ^namespace A.B.C                    → segments are namespace parts
  ^\\s*(provides|requires) [injected ] T P; → T is interface, P is port
  ^\\s*enum NAME \\{                    → NAME is enum-type
  …{ V1, V2, … } inside enum block    → Vi are enum values
  ^\\s*on PORT.EVENT(                  → EVENT is event name on PORT
"""

import os
import re
import sys
import collections

HERE = os.path.dirname(os.path.abspath(__file__))
SOURCES = ["anon_hang.dzn", "anon_hang_flat.dzn"]

# Dezyne keywords + built-ins that must NEVER be renamed.
DZN_KEYWORDS = {
    "behavior", "blocking", "bool", "component", "defer", "else", "enum",
    "extern", "false", "if", "illegal", "import", "in", "inevitable",
    "injected", "interface", "namespace", "on", "optional", "otherwise",
    "out", "provides", "reply", "requires", "return", "subint", "system",
    "true", "void", "string", "guard", "tag",
}

# Fixed structural names — top of file.
FIXED = {
    "component":  "Application",
    "namespace":  ["bookkeeping", "core", "ledger_module"],
}

# Role-specific vocabulary. Plenty for the top 30-40 of each role;
# the rest get suffixed numerics ("IAccount2", "post3", etc.).
VOCAB = {
    "interface": [
        "Account", "Ledger", "Journal", "Posting", "Transaction",
        "Receipt", "Invoice", "Payment", "Statement", "Audit",
        "Voucher", "Settlement", "Reconciliation", "Reversal",
        "Adjustment", "Asset", "Liability", "Equity", "Revenue",
        "Expense", "Cashflow", "Budget", "Forecast", "Capital",
        "Reserve", "Dividend", "Interest", "Principal", "Tax",
        "Fee", "Discount", "Loan", "Deposit", "Transfer", "Refund",
        "Allocation", "Notification", "Approval", "Trace", "Log",
    ],
    "port": [
        "account", "ledger", "journal", "posting", "transaction",
        "receipt", "invoice", "payment", "statement", "audit",
        "voucher", "settlement", "reconciliation", "reversal",
        "adjustment", "asset", "liability", "equity", "revenue",
        "expense", "cashflow", "budget", "forecast", "capital",
        "reserve", "dividend", "interest", "principal", "tax",
        "fee", "discount", "loan", "deposit", "transfer", "refund",
        "allocation", "notification", "approval", "trace", "log",
    ],
    "enum_type": [
        "State", "Phase", "Status", "Mode", "Stage", "Step",
    ],
    "enum_value": [
        "Idle", "Drafted", "Submitted", "Validated", "Approved",
        "Posted", "Settled", "Cleared", "Reversed", "Cancelled",
        "Pending", "Active", "Closed", "Suspended", "Rejected",
        "Refunded", "Held", "Released", "Locked", "Open",
        "Booked", "Reconciled", "Audited", "Adjusted", "Archived",
    ],
    "event": [
        "post", "submit", "validate", "approve", "reject",
        "commit", "rollback", "reverse", "cancel", "settle",
        "clear", "release", "hold", "open", "close", "suspend",
        "resume", "draft", "audit", "reconcile", "adjust",
        "transfer", "deposit", "withdraw", "refund", "issue",
        "endorse", "notify", "acknowledge", "dispute", "inquire",
        "respond", "forward", "retry", "drop", "queue", "process",
        "schedule", "begin", "finalise", "archive", "restore",
        "lock", "unlock", "tag", "trace", "log", "ready",
        "request", "resolve", "approve_ext", "post_ext",
    ],
}

# Pre-existing partial renames left in the file from a frequency-
# only pass. Treat them as candidate renameable identifiers
# alongside `_NNN` so we can promote them into role-correct names.
PARTIAL_RENAMES = set(VOCAB["interface"]) | set(VOCAB["port"]) \
    | set(VOCAB["enum_type"]) | set(VOCAB["enum_value"]) \
    | set(VOCAB["event"]) | {
        # Words from the previous-pass vocabulary that don't appear
        # in role pools above but are nonetheless candidates.
        "Bookkeeper", "Cashier", "Drafted", "Validated", "Approved",
        "Cancelled", "Refunded", "Reversed", "Annual", "Quarterly",
        "Monthly", "Daily", "Profit", "Loss", "Income", "Inflow",
        "Outflow", "Allocate", "Disburse", "Levy", "Toll", "Tariff",
        "Charge", "Surcharge", "Rebate", "Subsidy", "Grant",
        "Mortgage", "Remit", "Disclose", "Notice", "Confirm",
        "Petition", "Backout", "Drop", "Dequeue", "Skip", "Mark",
        "Reschedule",
    }

# An identifier is "renameable" if it starts with `_` (i.e. `_NNN`),
# OR it's in PARTIAL_RENAMES (left over from a prior pass). Keywords
# are always excluded.
IDENT_RE = re.compile(r"\b[_A-Za-z][_A-Za-z0-9]*\b")
ANON_RE = re.compile(r"^_[0-9]+$")
QUOTED_STRING_RE = re.compile(r'\$[^$]*\$|"[^"]*"|\$[^$]+\$')


def is_renameable(tok: str) -> bool:
    if tok in DZN_KEYWORDS:
        return False
    if ANON_RE.match(tok):
        return True
    if tok in PARTIAL_RENAMES:
        return True
    return False


def alloc_name(pool: list, used: set, fallback_prefix: str) -> str:
    """Pick the next name from `pool` not in `used`; add a numeric
    suffix once `pool` is exhausted. Always returns a fresh string;
    `used` is updated in place."""
    for name in pool:
        if name not in used:
            used.add(name)
            return name
    # Exhausted: suffix-extend the original pool.
    i = 2
    while True:
        for name in pool:
            cand = f"{name}{i}"
            if cand not in used:
                used.add(cand)
                return cand
        i += 1


def parse_roles(text: str) -> dict:
    """Walk the text once, identify each renameable identifier's
    role from the surrounding syntax. Returns dict: token → role.
    Tokens whose role can't be inferred are left out (kept as is)."""
    roles: dict[str, str] = {}

    # Strip block comments + line comments + quoted strings before
    # role detection — we don't want a comment word colliding.
    stripped = strip_comments_and_strings(text)

    # Namespace segments.
    for m in re.finditer(r"^namespace\s+([\w.]+)", stripped, re.M):
        for seg in m.group(1).split("."):
            roles.setdefault(seg, "namespace_seg")

    # Component name.
    for m in re.finditer(r"^component\s+(\w+)", stripped, re.M):
        roles.setdefault(m.group(1), "component")

    # provides|requires [injected] TYPE PORT;
    # TYPE may be qualified ("ns.IFace"); we tag every segment as
    # interface so cross-file consistency holds. The final
    # PORT token is camelCase port.
    iface_port = re.compile(
        r"^\s*(?:provides|requires)\s+(?:external\s+|injected\s+)?"
        r"([\w.]+)\s+(\w+)\s*;",
        re.M,
    )
    for m in iface_port.finditer(stripped):
        typ, port = m.group(1), m.group(2)
        for seg in typ.split("."):
            roles.setdefault(seg, "interface")
        roles[port] = "port"  # overwrite — port role is unambiguous

    # enum TYPE { V1, V2, ... };
    enum_re = re.compile(r"enum\s+(\w+)\s*\{([^}]*)\}", re.M)
    for m in enum_re.finditer(stripped):
        typ = m.group(1)
        roles.setdefault(typ, "enum_type")
        body = m.group(2)
        for v in re.split(r"[,\s]+", body):
            v = v.strip()
            if v and is_renameable(v):
                roles.setdefault(v, "enum_value")

    # on PORT.EVENT(...) — EVENT is event name (port already
    # classified above).
    on_re = re.compile(r"\bon\s+\w+\.(\w+)\s*\(", re.M)
    for m in on_re.finditer(stripped):
        roles.setdefault(m.group(1), "event")

    # PORT.EVENT(...) generally — second segment is an event/method.
    # We use this only as a tiebreaker (setdefault).
    call_re = re.compile(r"\b\w+\.(\w+)\s*\(", re.M)
    for m in call_re.finditer(stripped):
        roles.setdefault(m.group(1), "event")

    return roles


def strip_comments_and_strings(text: str) -> str:
    """Replace `//…` line comments, `/* … */` block comments, and
    `$…$` / `"…"` strings with spaces so role-detection regexes
    don't see tokens inside them."""
    out = []
    i = 0
    while i < len(text):
        c = text[i]
        if c == "/" and i + 1 < len(text) and text[i+1] == "/":
            # line comment
            j = text.find("\n", i)
            if j == -1: j = len(text)
            out.append(" " * (j - i))
            i = j
        elif c == "/" and i + 1 < len(text) and text[i+1] == "*":
            j = text.find("*/", i + 2)
            if j == -1: j = len(text)
            else: j += 2
            out.append(" " * (j - i))
            i = j
        elif c == '"':
            j = text.find('"', i + 1)
            if j == -1: j = len(text)
            else: j += 1
            out.append(" " * (j - i))
            i = j
        elif c == "$":
            j = text.find("$", i + 1)
            if j == -1: j = len(text)
            else: j += 1
            out.append(" " * (j - i))
            i = j
        else:
            out.append(c)
            i += 1
    return "".join(out)


def build_mapping(texts: list, roles: dict) -> dict:
    """Allocate role-appropriate names. Tokens without a known role
    are left out (kept as is)."""
    mapping: dict[str, str] = {}
    used_per_role: dict[str, set] = {r: set() for r in VOCAB}
    # `component` and `namespace_seg` use FIXED.
    mapping["__component__"] = FIXED["component"]
    ns_segs = list(FIXED["namespace"])

    # Frequency: rename heavy-hitters first so they get top vocab.
    freq = collections.Counter()
    for text in texts:
        s = strip_comments_and_strings(text)
        for m in IDENT_RE.finditer(s):
            tok = m.group(0)
            if is_renameable(tok):
                freq[tok] += 1

    # Sort renameable tokens by (frequency desc, token asc) so
    # results are stable.
    ordered = sorted(freq.keys(), key=lambda t: (-freq[t], t))

    # Pre-bind component + namespace.
    for tok, role in roles.items():
        if role == "component":
            mapping[tok] = FIXED["component"]
        elif role == "namespace_seg":
            # Cycle through ns_segs in declaration order — the seg
            # iteration order from `roles` reflects source order
            # because we used `setdefault` in parse_roles.
            if ns_segs:
                mapping[tok] = ns_segs.pop(0)

    # Allocate the rest.
    for tok in ordered:
        if tok in mapping: continue
        role = roles.get(tok)
        if role in VOCAB:
            mapping[tok] = alloc_name(VOCAB[role], used_per_role[role], role)
        # else: unknown role — leave as is.
    return mapping


def rewrite(text: str, mapping: dict) -> str:
    """Substitute identifiers globally. Skips:
       - `#file` directive on line 1 (path).
       - `import <path>;` lines — the imported paths refer to other
         .dzn files on disk; renaming the path tokens would break
         import resolution. The identifier names imported are
         consistent across files because the rename is applied
         globally to all listed SOURCES, so we don't need to
         re-export the rename through import paths.
       - identifiers inside comments and strings.
    """
    import_re = re.compile(r"^\s*import\s+")
    out = []
    for line in text.splitlines(keepends=True):
        if line.startswith("#file ") or import_re.match(line):
            out.append(line)
            continue
        out.append(rewrite_line(line, mapping))
    return "".join(out)


def rewrite_line(line: str, mapping: dict) -> str:
    """Inside one line, substitute identifiers but leave comments
    and `$…$` / `"…"` strings untouched."""
    pieces = []
    i = 0
    while i < len(line):
        c = line[i]
        if c == "/" and i + 1 < len(line) and line[i+1] == "/":
            pieces.append(line[i:])
            return "".join(pieces)
        elif c == "/" and i + 1 < len(line) and line[i+1] == "*":
            j = line.find("*/", i + 2)
            if j == -1:
                pieces.append(line[i:]); return "".join(pieces)
            pieces.append(line[i:j+2]); i = j + 2
        elif c == '"':
            j = line.find('"', i + 1)
            if j == -1:
                pieces.append(line[i:]); return "".join(pieces)
            pieces.append(line[i:j+1]); i = j + 1
        elif c == "$":
            j = line.find("$", i + 1)
            if j == -1:
                pieces.append(line[i:]); return "".join(pieces)
            pieces.append(line[i:j+1]); i = j + 1
        else:
            # Greedy identifier match.
            m = IDENT_RE.match(line, i)
            if m and i == m.start():
                tok = m.group(0)
                pieces.append(mapping.get(tok, tok))
                i = m.end()
            else:
                pieces.append(c); i += 1
    return "".join(pieces)


def main():
    paths = [os.path.join(HERE, n) for n in SOURCES]
    texts = [open(p, encoding="utf-8").read() for p in paths]

    # Roles inferred from the union of both files — both share the
    # same anon namespace, so we want consistent renames.
    combined = "\n\n".join(texts)
    roles = parse_roles(combined)
    mapping = build_mapping(texts, roles)

    # Print a per-role summary.
    by_role: dict = collections.defaultdict(list)
    for tok, name in mapping.items():
        if tok.startswith("__"): continue
        role = roles.get(tok, "(unknown)")
        by_role[role].append((tok, name))
    for role in ("component", "namespace_seg", "interface", "port",
                 "enum_type", "enum_value", "event", "(unknown)"):
        items = by_role.get(role, [])
        if not items: continue
        print(f"\n[{role}] {len(items)} renames", file=sys.stderr)
        for tok, name in sorted(items)[:20]:
            print(f"  {tok:>14}  →  {name}", file=sys.stderr)
        if len(items) > 20:
            print(f"  … +{len(items) - 20} more", file=sys.stderr)

    for p, text in zip(paths, texts):
        new = rewrite(text, mapping)
        with open(p, "w", encoding="utf-8") as f:
            f.write(new)
        print(f"\nwrote {p}", file=sys.stderr)


if __name__ == "__main__":
    main()
