#Melder module and OOP DSL for game-like agent interaction
# Modes: create (full building tools) <-> game (play-only tools)

from __future__ import annotations
import re
from pathlib import Path

FILES = "abcdefgh"
DIRS = {
    "n": (0, 1), "s": (0, -1), "e": (1, 0), "w": (-1, 0),
    "ne": (1, 1), "nw": (-1, 1), "se": (1, -1), "sw": (-1, -1),
}
history: list[str] = []

BUILD_TOOLS = {"proto", "spawn", "cmd", "save", "load"}
BUILD_CMDS  = {"proto", "spawn", "cmd", "reset", "save", "load"}

def reset() -> str:
    """Clear history, emptying the world."""
    global history
    history = []
    return "history cleared"

def _rf(square: str) -> tuple[int, int]:
    square = square.strip().lower()
    if len(square) != 2 or square[0] not in FILES or square[1] not in "12345678":
        raise ValueError(f"invalid square {square!r}")
    return FILES.index(square[0]), int(square[1]) - 1

def _fr(pos: tuple[int, int]) -> str:
    return f"{FILES[pos[0]]}{pos[1] + 1}"

def _valid(f: int, r: int) -> bool:
    return 0 <= f < 8 and 0 <= r < 8

def _parse_fields(raw: str) -> dict:
    """Parse key=value pairs. Values may be quoted with " or ' to contain spaces."""
    result = {}
    token = re.compile(
        r'''(\w+)=(?:"([^"]*)"|'([^']*)'|(\S+))'''
    )
    for m in token.finditer(raw):
        key = m.group(1)
        val = m.group(2) if m.group(2) is not None else (
              m.group(3) if m.group(3) is not None else m.group(4))
        result[key] = val
    return result

def _eval(expr: str, ctx: dict) -> str:
    safe = {"__builtins__": {}, "int": int, "max": max, "min": min,
            "str": str, "len": len, "abs": abs}
    try:
        return str(eval(expr, safe, ctx))
    except Exception:
        return expr

def _sub(text: str, args: list, ctx: dict | None = None) -> str:
    """Substitute $0/$1... and {expr} in text.

    Outside braces $N is replaced by the raw argument string.
    Inside braces $N becomes the bound name _N so that
    {step(pos,$0)} works when the argument is a direction like n.
    """
    ctx = dict(ctx or {})
    for i, a in enumerate(args):
        ctx[f"_{i}"] = a
    def replacer(m: re.Match) -> str:
        expr = m.group(1)
        for i in range(len(args)):
            expr = expr.replace(f"${i}", f"_{i}")
        return _eval(expr, ctx)
    text = re.sub(r"\{([^}]+)\}", replacer, text)
    for i, a in enumerate(args):
        text = text.replace(f"${i}", a)
    return text

def _new_board() -> dict:
    return {"protos": {}, "ents": {}, "grid": [["."] * 8 for _ in range(8)]}

def _neighbors(pos: str) -> list[str]:
    f, r = _rf(pos)
    return [_fr((f + df, r + dr))
            for df, dr in DIRS.values() if _valid(f + df, r + dr)]

def _step(pos: str, direction: str) -> str:
    d = DIRS.get(direction.strip().lower())
    if not d:
        return ""
    try:
        f, r = _rf(pos)
    except ValueError:
        return ""
    nf, nr = f + d[0], r + d[1]
    return _fr((nf, nr)) if _valid(nf, nr) else ""

def _entities_at(b: dict, pos: tuple[int, int]) -> list:
    return b["ents"].get(pos, [])

def _top(b: dict, pos: str) -> dict | None:
    try:
        lst = _entities_at(b, _rf(pos))
        return lst[-1] if lst else None
    except (ValueError, IndexError):
        return None

def _make_ctx(b: dict, e: dict) -> dict:
    ctx = {
        "look":      lambda p: (lambda ent: ent["name"] if ent else ".")(_top(b, p)),
        "at":        lambda p: (lambda ent: ent["name"] if ent else ".")(_top(b, p)),
        "fields_of": lambda p: (lambda ent: dict(ent["fields"]) if ent else {})(_top(b, p)),
        "neighbors": _neighbors,
        "step":      _step,
        "dirs":      list(DIRS.keys()),
        "self":      e["name"],
        "pos":       _fr(e["pos"]),
    }
    ctx.update(e["fields"])
    return ctx

def _find(b: dict, target: str) -> dict | None:
    """Square → topmost entity; name → first match."""
    if len(target) == 2 and target[0].lower() in FILES:
        return _top(b, target)
    for lst in b["ents"].values():
        for ent in lst:
            if ent["name"] == target:
                return ent
    return None

def _run_method(b: dict, e: dict, msg: str):
    """Run a method on a concrete entity object."""
    parts = msg.split()
    if not parts:
        return
    name, args = parts[0], parts[1:]
    body = e["fields"].get(name)
    if body is None:
        body = b["protos"].get(e["name"], {}).get("fields", {}).get(name)
    if body is None:
        return
    text = _sub(str(body), args, _make_ctx(b, e))
    if text.strip():
        _parse(b, text, actor=e)

def _update_grid(b: dict, pos: tuple[int, int]):
    lst = _entities_at(b, pos)
    b["grid"][pos[1]][pos[0]] = lst[-1]["name"] if lst else "."

def _place(b: dict, e: dict):
    pos = e["pos"]
    lst = b["ents"].setdefault(pos, [])
    for i, other in enumerate(lst):
        if other["name"] == e["name"]:
            lst[i] = e
            _update_grid(b, pos)
            return
    # notify existing dynamic residents
    if not e["static"]:
        for other in list(lst):
            if not other["static"]:
                _run_method(b, other, f"collide {e['name']} {_fr(pos)}")
    lst.append(e)
    _update_grid(b, pos)

def _remove(b: dict, e: dict):
    pos = e["pos"]
    lst = b["ents"].get(pos)
    if not lst:
        return
    try:
        lst.remove(e)
    except ValueError:
        return
    if not lst:
        del b["ents"][pos]
    _update_grid(b, pos)

def _spawn(b: dict, n: str, rf: str, fields: str = "", ctx: dict | None = None):
    if n not in b["protos"]:
        return
    p = b["protos"][n]
    ov = _parse_fields(fields)
    f = {**p["fields"], **ov}
    if ctx:
        f = {k: _sub(str(v), [], ctx) for k, v in f.items()}
    _place(b, {"name": n, "static": p["static"], "fields": f, "pos": _rf(rf)})

def _move_entity(b: dict, e: dict, to: str):
    """Move a specific entity object to a new square."""
    try:
        tpos = _rf(to)
    except (ValueError, IndexError):
        return
    _remove(b, e)
    e["pos"] = tpos
    _place(b, e)

def _parse(b: dict, text: str, ctx: dict | None = None, actor: dict | None = None):
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        parts = line.split()
        if not parts:
            continue
        cmd, *rest = parts
        if cmd in "12345678":
            r = int(cmd)
            for i, n in enumerate(rest[:8]):
                _spawn(b, n, f"{FILES[i]}{r}", ctx=ctx)
        elif cmd == "p" and len(rest) >= 2:
            name, kind = rest[0], rest[1]
            f = _parse_fields(" ".join(rest[2:]))
            b["protos"][name] = {
                "name": name,
                "static": kind.startswith("s"),
                "fields": f,
            }
        elif cmd == "s" and len(rest) >= 2:
            _spawn(b, rest[0], rest[1], " ".join(rest[2:]), ctx=ctx)
        elif cmd == "m" and rest:
            # Inside a method: "m <to>" moves the actor.
            # Raw/history use: "m <from> <to>" moves the topmost at from.
            if actor is not None:
                _move_entity(b, actor, rest[-1])
            elif len(rest) >= 2:
                top = _top(b, rest[0])
                if top:
                    _move_entity(b, top, rest[1])
        elif cmd == "send" and len(rest) >= 2:
            e = _find(b, rest[0])
            if e:
                _run_method(b, e, " ".join(rest[1:]))
        elif cmd == "sendall" and rest:
            msg = " ".join(rest)
            for lst in list(b["ents"].values()):
                for e in list(lst):
                    if not e["static"]:
                        _run_method(b, e, msg)

def _replay() -> dict:
    b = _new_board()
    _parse(b, "\n".join(history))
    return b

# ── public tools ─────────────────────────────────────────────────────────────

def proto(name: str, kind: str, fields: str = "") -> dict:
    """Define a prototype that instances can be spawned from.

    kind: 's' for static (ignored by tick, useful for terrain/walls) or
          'd' for dynamic (receives tick and other broadcast messages).

    fields: space-separated key=value pairs. Quote a value to give it spaces:
              proto("B", "d", 'dir=n tick="m {step(pos,dir)}"')
              proto("P", "d", 'fire="s B {step(pos,$0)} dir=$0"')

            Every key becomes an ordinary field on instances. When send or
            tick looks up a field name, the field's text is substituted and
            executed as DSL. $0/$1... are the arguments after the field name;
            {self} {pos} {step(pos,dir)} {look(p)} {at(p)} {fields_of(p)}
            {neighbors(p)} and any field on the entity itself are also
            available inside {...}.

            Inside a running field, 'm <square>' moves this entity. Stacking
            is allowed and the move always succeeds. Landing on other dynamic
            entities sends each of them 'collide <newcomer> <square>'.
    """
    history.append(f"p {name} {kind} {fields}".rstrip())
    return {"text": f"proto {name} registered"}

def spawn(name: str, pos: str, fields: str = "") -> dict:
    """Place an instance of a prototype on a square (a1–h8).

    Optional fields (key=value) override the prototype defaults for this
    instance only. Spawning the same name onto a square that already holds
    that name quietly updates its fields instead of adding a duplicate.
    Multiple different entities may share one square; the newest is topmost
    and is what the grid displays. When a dynamic entity lands on a square
    that already has other dynamic entities, each of those residents is sent
    a 'collide <newcomer> <square>' message.
    """
    history.append(f"s {name} {pos} {fields}".rstrip())
    return {"text": f"spawned {name} at {pos}"}

def send(target: str, message: str) -> dict:
    """Send a message to one entity.

    target may be a square (a1–h8) or a prototype name. A square selects the
    topmost entity there; a name selects the first entity with that name.
    The first word of message is a field name on that entity; the rest are
    arguments. The field's text is substituted ($0, {expr}, entity fields)
    and executed as DSL. Unknown fields are ignored.
    """
    history.append(f"send {target} {message}")
    return {"text": f"sent to {target}: {message}"}

def tick(message: str = "tick") -> dict:
    """Broadcast a message to every dynamic entity on the board.

    Default message is 'tick'. Each dynamic entity (including those sharing
    a square) has the named field looked up and executed independently.
    Static entities are skipped. Returns the board after the broadcast.
    """
    history.append(f"sendall {message}")
    return show()

def cmd(command: str) -> dict:
    """Append one raw DSL line to history and return the resulting board.

    DSL commands (one per line):

      p <name> <s|d> [key=value ...]
          Define a prototype. 's' = static (skipped by tick), 'd' = dynamic.
          Values may be quoted: tick="m {step(pos,dir)}"

      s <name> <square> [key=value ...]
          Spawn an instance of a prototype at a1–h8. Same name on the same
          square updates fields. Different names stack (newest on top).

      m <from> <to>
          Move the topmost entity at <from> to <to>. (Inside a running field
          the form is just 'm <to>' and moves that entity.)

      send <target> <field> [args...]
          Run a field on one entity. <target> is a square (topmost) or a name
          (first match). $0/$1... and {expr} are substituted in the field text.

      sendall <field> [args...]
          Run a field on every dynamic entity independently.

      <rank> <name> <name> ...
          Spawn a whole rank at once, e.g. '3 w b . . . . b w'.

    Substitution inside field text:
      $0 $1 ...     argument from the send/tick call
      {self}        entity name
      {pos}         entity square
      {step(pos,d)} square one step in direction d (n/s/e/w/ne/...)
      {look(p)} {at(p)}   topmost name at square p, or '.'
      {fields_of(p)}      dict of fields at p
      {neighbors(p)}      list of adjacent squares
      {<fieldname>}       any field on the entity itself
    """
    history.append(command.strip())
    return {"text": show()["text"]}

def show() -> dict:
    """Render the board as an 8x8 grid (files a–h, ranks 1–8).

    Each cell shows the topmost entity on that square, or '.' if empty.
    Use look() or entities() to see stacked entities beneath the top.
    """
    b = _replay()
    lines = ["# a b c d e f g h"]
    for r in range(8):
        lines.append(f"{r + 1} {' '.join(b['grid'][r])}")
    text = "\n".join(lines)
    print(text)
    return {"text": text}

def look(pos: str) -> dict:
    """Inspect a square. Lists every entity present from bottom to top.

    For each entity shows name, static/dynamic, and all current fields.
    """
    b = _replay()
    try:
        p = _rf(pos)
    except ValueError:
        return {"text": f"{pos}: invalid"}
    lst = _entities_at(b, p)
    if not lst:
        return {"text": f"{pos}: empty"}
    lines = [f"{pos}:"]
    for e in lst:
        kind = "static" if e["static"] else "dynamic"
        fields = " ".join(f"{k}={v}" for k, v in e["fields"].items()) or "(none)"
        lines.append(f"  {e['name']} ({kind}) {fields}")
    return {"text": "\n".join(lines)}

def neighbors(pos: str) -> dict:
    """Return the on-board squares adjacent to pos (8-directional)."""
    return {"text": " ".join(_neighbors(pos))}

def step(pos: str, direction: str) -> dict:
    """Return the square one step from pos in direction (n/s/e/w/ne/nw/se/sw).

    Returns an empty string if the step would leave the board. Used heavily
    inside entity field scripts, e.g. m {step(pos,dir)}.
    """
    return {"text": _step(pos, direction)}

def entities() -> dict:
    """List every entity on the board as 'square name fields...'.

    Squares that hold multiple entities produce multiple lines.
    """
    b = _replay()
    if not b["ents"]:
        return {"text": "no entities"}
    lines = []
    for pos in sorted(b["ents"], key=lambda p: (p[1], p[0])):
        for e in b["ents"][pos]:
            fields = " ".join(f"{k}={v}" for k, v in e["fields"].items())
            lines.append(f"{_fr(pos)} {e['name']} {fields}".rstrip())
    return {"text": "\n".join(lines)}

def undo() -> dict:
    """Drop the last history line."""
    if history:
        history.pop()
    return {"text": show()["text"]}

def get_history() -> dict:
    """Full command history."""
    return {"text": "\n".join(history) if history else "(empty)"}

def save(filename: str = "world.txt") -> dict:
    """Write history to a file."""
    try:
        Path(filename).write_text(
            "\n".join(history) + ("\n" if history else ""), encoding="utf-8")
        return {"text": f"saved {len(history)} commands to {filename}"}
    except OSError as exc:
        return {"text": f"could not save {filename}: {exc}"}

def load(filename: str = "world.txt") -> dict:
    """Replace history from a file."""
    global history
    try:
        lines = Path(filename).read_text(encoding="utf-8").splitlines()
    except OSError as exc:
        return {"text": f"could not load {filename}: {exc}"}
    history = [line for line in lines if line.strip()]
    return {"text": f"loaded {len(history)} commands from {filename}"}

# ── mode switching ───────────────────────────────────────────────────────────

def _remove_from_agent(agent, tool_names: set, cmd_names: set):
    for n in tool_names:
        agent.methods.pop(n, None)
    agent.tools[:] = [t for t in agent.tools
                      if t["function"]["name"] not in tool_names]
    for n in cmd_names:
        agent.user_commands.pop(n, None)

def _restore_to_agent(agent, tool_map: dict, cmd_map: dict):
    import inspect, typing, types
    from inspect import Parameter
    for name, fn in tool_map.items():
        if name in agent.methods:
            continue
        agent.methods[name] = fn
        props, req = {}, []
        for p in inspect.signature(fn).parameters.values():
            if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
                continue
            ann = p.annotation
            origin, args = typing.get_origin(ann), typing.get_args(ann)
            if origin in (typing.Union, types.UnionType):
                args = [x for x in args if x is not type(None)]
                ann = args[0] if len(args) == 1 else str
            schema = {"type": {str: "string", int: "integer", float: "number",
                               bool: "boolean"}.get(ann, "string")}
            if p.default is not Parameter.empty:
                schema["default"] = p.default
            props[p.name] = schema
            if p.default is Parameter.empty:
                req.append(p.name)
        agent.tools.append({
            "type": "function",
            "function": {
                "name": name,
                "description": inspect.getdoc(fn) or "",
                "parameters": {"type": "object", "properties": props,
                              **({"required": req} if req else {})},
            },
        })
    agent.user_commands.update(cmd_map)

_BUILD_TOOL_FNS: dict = {}
_BUILD_CMD_FNS: dict = {}

def enter_game_agent(agent) -> str:
    """Hide building tools."""
    global _BUILD_TOOL_FNS, _BUILD_CMD_FNS
    _BUILD_TOOL_FNS = {n: agent.methods[n] for n in BUILD_TOOLS if n in agent.methods}
    _BUILD_CMD_FNS  = {n: agent.user_commands[n] for n in BUILD_CMDS if n in agent.user_commands}
    _remove_from_agent(agent, BUILD_TOOLS, BUILD_CMDS)
    return {"text": "entered GAME mode"}

def enter_create_agent(agent) -> str:
    """Restore building tools."""
    _restore_to_agent(agent, _BUILD_TOOL_FNS, _BUILD_CMD_FNS)
    return {"text": "entered CREATE mode"}

# ── /cmd_ user commands ──────────────────────────────────────────────────────

def _print_result(result):
    print(result.get("text", result) if isinstance(result, dict) else result)

def cmd_show(agent, arg: str = ""):
    """ /show """
    _print_result(show())

def cmd_look(agent, arg: str = ""):
    """ /look e4 """
    _print_result(look(arg.strip() or "a1"))

def cmd_neighbors(agent, arg: str = ""):
    """ /neighbors e4 """
    _print_result(neighbors(arg.strip() or "e4"))

def cmd_step(agent, arg: str = ""):
    """ /step e4 n """
    parts = arg.split()
    if len(parts) != 2:
        print("usage: /step square direction")
        return
    _print_result(step(*parts))

def cmd_entities(agent, arg: str = ""):
    """List all entities."""
    _print_result(entities())

def cmd_history(agent, arg: str = ""):
    """Show history."""
    _print_result(get_history())

def cmd_undo(agent, arg: str = ""):
    """Undo last command."""
    _print_result(undo())

def cmd_reset(agent, arg: str = ""):
    """Clear the world."""
    print(reset())

def cmd_spawn(agent, arg: str = ""):
    """ /spawn name e4 [key=val ...] """
    parts = arg.split(None, 2)
    if len(parts) < 2:
        print("usage: /spawn name square [key=value ...]")
        return
    _print_result(spawn(parts[0], parts[1], parts[2] if len(parts) > 2 else ""))
    _print_result(show())

def cmd_proto(agent, arg: str = ""):
    """ /proto name s|d [key=value key="value with spaces" ...] """
    if not arg.strip():
        print("usage: /proto name s|d [key=value ...]")
        return
    _print_result(cmd("p " + arg.strip()))

def cmd_send(agent, arg: str = ""):
    """ /send e4 tick """
    parts = arg.split(None, 1)
    if len(parts) < 2:
        print("usage: /send square-or-name message...")
        return
    _print_result(send(parts[0], parts[1]))
    _print_result(show())

def cmd_tick(agent, arg: str = ""):
    """ /tick [message] """
    _print_result(tick(arg.strip() or "tick"))

def cmd_cmd(agent, arg: str = ""):
    """ /cmd <raw dsl> """
    if not arg.strip():
        print("usage: /cmd <dsl command>")
        return
    _print_result(cmd(arg))

def cmd_save(agent, arg: str = ""):
    """ /save [file] """
    _print_result(save(arg.strip() or "world.txt"))

def cmd_load(agent, arg: str = ""):
    """ /load [file] """
    _print_result(load(arg.strip() or "world.txt"))
    _print_result(show())

def cmd_game(agent, arg: str = ""):
    """Enter GAME mode."""
    _print_result(enter_game_agent(agent))

def cmd_create(agent, arg: str = ""):
    """Enter CREATE mode."""
    _print_result(enter_create_agent(agent))

if __name__ == "__main__":
    print("Melder playfield module — import with /import")
proto(".", "s")
if __name__ == "__main__":
    print(" This is a Melder module.")
    print(" It must be run from Melder agent.py (https://swiley.net/melder)")
    print(" You can enhance your agent with this module using the /import command")
