#!/usr/bin/env python3
"""Generate an OpenPLC Editor v4 project from src/*.st.

    python gen_project.py --out editor-project

The Editor v4 project is a *folder*, not a file:

    project.json          meta + configuration (tasks, instances, globals)
    pous/programs/*.st     one file per PROGRAM
    pous/function-blocks/*.st
    pous/functions/*.st

There is no "import a flat .st" path in the Editor, so this script turns
the repo's ST sources into that layout.  The repo stays canonical; the
generated project is a build artefact and is git-ignored.

What it does NOT touch, because the user configures them in the GUI:
devices/configuration.json (runtime address, credentials) and build/.
Copy only project.json and pous/ over an existing project.

Schemas below were recovered from the Editor bundle
(resources/app.asar, v4.2.11), not guessed:

    scalar : {"definition": "base-type", "value": "INT"}
    array  : {"definition": "array",
              "value": "ARRAY[1..3] OF REAL",
              "data": {"baseType": {"definition": "base-type", "value": "REAL"},
                       "dimensions": [{"dimension": "1..3"}]}}
    class  : one of input|output|inOut|external|local|temp|global
"""

import argparse
import json
import re
import shutil
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src"

GLOBALS_FILE = "10_globals.st"
CONFIG_FILE = {"field": "90_config_field.st", "sim": "91_config_sim.st"}

# src file -> POU folder.  Anything not listed is not a POU.
POU_FOLDER = {
    "20_fb_pump.st": "function-blocks",
    "21_fb_duty_selector.st": "function-blocks",
    "22_fb_level_control.st": "function-blocks",
    "23_fb_headroom.st": "function-blocks",
    "30_prog_control.st": "programs",
    "50_prog_io_mux.st": "programs",
}

# POUs added only in a given mode.  Mirrors build.py's EXCLUDE.
MODE_ONLY = {
    "field": {},
    "sim": {"40_prog_simulation.st": "programs"},
}

POU_HEADER = re.compile(
    r"^\s*(FUNCTION_BLOCK|PROGRAM|FUNCTION)\s+([A-Za-z_][A-Za-z0-9_]*)",
    re.MULTILINE,
)

# NAME [AT %LOC] : TYPE [:= INIT] ;
VAR_DECL = re.compile(
    r"""^\s*(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*
        (?:AT\s+(?P<loc>%[A-Za-z]+[0-9.]+)\s*)?
        :\s*(?P<type>.+?)
        (?:\s*:=\s*(?P<init>.+?))?
        \s*;""",
    re.VERBOSE,
)

ARRAY_TYPE = re.compile(
    r"^ARRAY\s*\[\s*(?P<dims>[^\]]+?)\s*\]\s*OF\s+(?P<base>[A-Za-z_][A-Za-z0-9_]*)$",
    re.IGNORECASE,
)

COMMENT = re.compile(r"\(\*.*?\*\)", re.DOTALL)

# Declaration sections the Editor parses separately from the body.
VAR_BLOCK = re.compile(
    r"(?P<head>^[ \t]*VAR(?:_INPUT|_OUTPUT|_IN_OUT|_TEMP|_EXTERNAL)?\b[^\n]*\n)"
    r"(?P<body>.*?)"
    r"(?P<tail>^[ \t]*END_VAR)",
    re.DOTALL | re.MULTILINE | re.IGNORECASE,
)


def strip_comments(text):
    """Remove (* ... *) spans, preserving line count for error messages."""
    return COMMENT.sub(lambda m: " " * len(m.group(0).replace("\n", "")) +
                       "\n" * m.group(0).count("\n"), text)


def sanitise_declarations(text):
    """Make VAR blocks digestible by the Editor's declaration parser.

    The Editor (4.2.11) parses each POU's declaration sections with a
    parser separate from the body compiler, and that parser rejects:

      - comment-only lines inside a VAR block, e.g. "(* process image *)"
      - a (* ... *) comment that wraps onto the following line
      - more than one declaration on a line, e.g. "A : INT;  B : INT;"

    A trailing single-line comment after a declaration is fine.  So:
    fold multi-line comments onto one line, drop comment-only lines, and
    put each declaration on its own line.  Bodies are untouched, and
    src/ keeps its original formatting — only the generated project is
    reflowed.

    Returns (text, dropped_line_count, split_count).
    """
    dropped = 0
    split = 0

    def fix_block(m):
        nonlocal dropped, split
        body = m.group("body")

        # Fold any comment spanning lines onto a single line.
        body = COMMENT.sub(lambda c: " ".join(c.group(0).split()), body)

        kept = []
        for line in body.splitlines():
            stripped = line.strip()
            if not stripped:
                kept.append(line)
                continue
            if COMMENT.sub("", stripped).strip() == "":
                dropped += 1  # comment-only line
                continue

            # One declaration per line.  Split on ';', keeping it, and
            # re-attach anything trailing the last one (a comment).
            decls = re.findall(r"[^;]+;", stripped)
            if len(decls) > 1:
                indent = line[: len(line) - len(line.lstrip())]
                trailer = stripped[sum(len(d) for d in decls):].strip()
                if trailer:
                    decls[-1] = decls[-1] + "  " + trailer
                kept.extend(indent + d.strip() for d in decls)
                split += len(decls) - 1
            else:
                kept.append(line)
        return m.group("head") + "\n".join(kept) + "\n" + m.group("tail")

    return VAR_BLOCK.sub(fix_block, text), dropped, split


def parse_globals(text):
    """Return the VAR_GLOBAL declarations as Editor globalVariables entries.

    CONSTANT blocks are flagged: the Editor's class enum has no
    'constant', so those become ordinary globals with an initial value.
    """
    code = strip_comments(text)
    entries = []
    constants = []

    for block in re.finditer(
        r"VAR_GLOBAL(?P<qual>\s+CONSTANT)?(?P<body>.*?)END_VAR",
        code,
        re.DOTALL | re.IGNORECASE,
    ):
        is_const = bool(block.group("qual"))
        for line in block.group("body").splitlines():
            if not line.strip():
                continue
            m = VAR_DECL.match(line)
            if not m:
                sys.exit(f"GEN FAILED: cannot parse global declaration:\n  {line.strip()}")
            name = m.group("name")
            raw_type = m.group("type").strip()
            init = (m.group("init") or "").strip()

            arr = ARRAY_TYPE.match(raw_type)
            if arr:
                base = arr.group("base").upper()
                dims = [d.strip() for d in arr.group("dims").split(",")]
                type_obj = {
                    "definition": "array",
                    "value": f"ARRAY[{','.join(dims)}] OF {base}",
                    "data": {
                        "baseType": {"definition": "base-type", "value": base},
                        "dimensions": [{"dimension": d} for d in dims],
                    },
                }
            else:
                type_obj = {"definition": "base-type", "value": raw_type.upper()}

            entries.append(
                {
                    "name": name,
                    "type": type_obj,
                    "class": "global",
                    "location": m.group("loc") or "",
                    "documentation": "",
                    "debug": False,
                    "initialValue": init,
                }
            )
            if is_const:
                constants.append(name)

    return entries, constants


def parse_config(text):
    """Extract tasks and program instances from the CONFIGURATION file."""
    code = strip_comments(text)

    tasks = []
    for m in re.finditer(
        r"TASK\s+(?P<name>\w+)\s*\(\s*INTERVAL\s*:=\s*(?P<interval>[^,)]+)"
        r"(?:\s*,\s*PRIORITY\s*:=\s*(?P<prio>\d+))?\s*\)",
        code,
        re.IGNORECASE,
    ):
        tasks.append(
            {
                "name": m.group("name"),
                "triggering": "Cyclic",
                "interval": m.group("interval").strip(),
                "priority": int(m.group("prio") or 0),
            }
        )

    instances = []
    for m in re.finditer(
        r"PROGRAM\s+(?P<inst>\w+)\s+WITH\s+(?P<task>\w+)\s*:\s*(?P<prog>\w+)\s*;",
        code,
        re.IGNORECASE,
    ):
        instances.append(
            {
                "name": m.group("inst"),
                "program": m.group("prog"),
                "task": m.group("task"),
            }
        )

    if not tasks:
        sys.exit(f"GEN FAILED: no TASK found in {config_file}")
    if not instances:
        sys.exit(f"GEN FAILED: no PROGRAM instance found in {config_file}")
    return tasks, instances


def main():
    ap = argparse.ArgumentParser(description="Generate an OpenPLC Editor v4 project.")
    ap.add_argument("--out", default="editor-project", help="output directory")
    ap.add_argument("--name", default="wrps", help="project name")
    ap.add_argument(
        "--mode",
        choices=("field", "sim"),
        default="field",
        help="field = control only; sim = control plus PROGRAM SIMULATION",
    )
    args = ap.parse_args()

    out = (ROOT / args.out).resolve()
    config_file = CONFIG_FILE[args.mode]
    pou_folder = {**POU_FOLDER, **MODE_ONLY[args.mode]}

    missing = [n for n in [GLOBALS_FILE, config_file, *pou_folder] if not (SRC / n).is_file()]
    if missing:
        sys.exit("GEN FAILED: missing source file(s):\n" + "\n".join(f"  src/{n}" for n in missing))

    globals_entries, constants = parse_globals((SRC / GLOBALS_FILE).read_text(encoding="utf-8"))
    tasks, instances = parse_config((SRC / config_file).read_text(encoding="utf-8"))

    # --- POU files -----------------------------------------------------
    if out.exists():
        shutil.rmtree(out)
    for folder in ("programs", "function-blocks", "functions"):
        (out / "pous" / folder).mkdir(parents=True)

    pou_names = []
    total_dropped = total_split = 0
    for filename, folder in pou_folder.items():
        text = (SRC / filename).read_text(encoding="utf-8")
        text, dropped, split = sanitise_declarations(text)
        total_dropped += dropped
        total_split += split
        m = POU_HEADER.search(strip_comments(text))
        if not m:
            sys.exit(f"GEN FAILED: no POU header found in src/{filename}")
        kind, name = m.group(1).upper(), m.group(2)
        expected = "programs" if kind == "PROGRAM" else (
            "function-blocks" if kind == "FUNCTION_BLOCK" else "functions")
        if expected != folder:
            sys.exit(
                f"GEN FAILED: src/{filename} declares {kind} {name} but is mapped "
                f"to pous/{folder}/ — fix POU_FOLDER."
            )
        (out / "pous" / folder / f"{name}.st").write_text(text, encoding="utf-8")
        pou_names.append(f"{name} ({folder})")

    # Every instance must name a program we actually emitted.
    programs = {p.split(" ")[0] for p in pou_names if "(programs)" in p}
    for inst in instances:
        if inst["program"] not in programs:
            sys.exit(
                f"GEN FAILED: configuration instance '{inst['name']}' references program "
                f"'{inst['program']}', which is not among the generated programs: "
                f"{sorted(programs)}"
            )

    project = {
        "meta": {"name": args.name, "type": "plc-project"},
        "data": {
            "dataTypes": [],
            "pous": [],  # the Editor discovers POUs from pous/, verified 2026-08-13
            "configuration": {
                "resource": {
                    "tasks": tasks,
                    "instances": instances,
                    "globalVariables": globals_entries,
                }
            },
            "libraries": [],
        },
    }
    (out / "project.json").write_text(json.dumps(project, indent=2) + "\n", encoding="utf-8")

    located = sum(1 for g in globals_entries if g["location"])
    print(f"OK  {out}")
    print(f"OK  {len(pou_names)} POUs: {', '.join(pou_names)}")
    print(f"OK  {len(globals_entries)} globals ({located} located)")
    print(
        f"OK  declaration sections sanitised for the Editor parser "
        f"({total_dropped} comment-only lines dropped, {total_split} declarations "
        f"split onto their own line; src/ unchanged)"
    )
    print(f"OK  tasks: {[(t['name'], t['interval']) for t in tasks]}")
    print(f"OK  instances: {[(i['name'], i['program']) for i in instances]}")
    if constants:
        print(
            f"WARN  {len(constants)} VAR_GLOBAL CONSTANT entries emitted as ordinary "
            f"globals — the Editor's class enum has no 'constant'.\n"
            f"      They keep their initial values and no POU writes them, so behaviour "
            f"is unchanged, but they are no longer compiler-enforced read-only:\n"
            f"      {', '.join(constants)}"
        )


if __name__ == "__main__":
    main()
