#!/usr/bin/env python3
"""Resolve plugin-declared account-owned top-level entries for the brand and
merge them into an account's SCHEMA.md.

A plugin declares owned top-level account entries in its PLUGIN.md frontmatter,
by kind:

    account-owned-dirs:  [{"dir": "quoting", "description": "..."}]
    account-owned-files: [{"file": "data-portal.json", "description": "..."}]

The owned-entry set for a brand is the union declared by every platform plugin
($PROJECT_DIR/plugins/*/PLUGIN.md) and every premium sub-plugin of a bundle the
brand ships ($PREMIUM_ROOT/<bundle>/plugins/*/PLUGIN.md, gated by brand.json
shipsPremiumBundles). Reads PROJECT_DIR from the environment.

A declaration reaches an account only on a brand whose payload carries the
declaring plugin (brand.json plugins.excluded drops the directory entirely), so
an entry written by platform code that runs on every brand belongs on a plugin
no brand excludes, not on the plugin its feature is named after.

The account SCHEMA.md also gains one domain-entity bucket per top-level
operator-entry node type of the brand's business ontology. That set is derived
from the brand vertical's schema reference (brand.json#vertical ->
plugins/memory/references/<vertical>.md, the '## Top-level node types' table),
so the file schema mirrors the graph ontology rather than a hardcoded list.
"""
import json, os, re, sys

MARK_START = "<!-- plugin-owned-dirs:start -->"
MARK_END = "<!-- plugin-owned-dirs:end -->"
ONT_START = "<!-- ontology-buckets:start -->"
ONT_END = "<!-- ontology-buckets:end -->"

# Base top-level names shipped in the account template plus `.quarantine`. This
# set is prune-protection only: a reconcile never *adds* a missing seed entry,
# it only shields a listed base dir from being flagged stale. Source (verbatim):
# docs/superpowers/plans/2026-06-23-account-filesystem-schema.md allowed-top-level.
FIXED_SEED = frozenset({
    "projects", "contacts", "documents", "url-get", "output", "generated",
    "extracted", "uploads", "agents", "specialists", "sites", "public",
    "cache", "secrets", "state", "logs", "tmp", "SCHEMA.md", "account.json",
    "AGENTS.md", ".claude", ".git", ".quarantine",
})

LABEL_DIR_OVERRIDES = {"Site": "customer-sites"}


def _dir_holds_files(account_dir, name):
    """True iff account_dir/<name> carries file content: a non-empty regular
    file, or a directory whose subtree contains at least one regular file.
    Absent path, empty dir, or a dir tree with no files -> False (prunable)."""
    path = os.path.join(account_dir, name)
    if not os.path.exists(path):
        return False
    if os.path.isfile(path):
        return os.path.getsize(path) > 0
    for _root, _dirs, files in os.walk(path):
        if files:
            return True
    return False


def _label_to_dir(label):
    """Map a Neo4j label to its file-schema bucket dir: kebab-case-plural,
    lowercased. camelCase word boundaries split on case. One override:
    `Site` -> `customer-sites`, so the tool-owned `sites/` published tree is
    untouched."""
    if label in LABEL_DIR_OVERRIDES:
        return LABEL_DIR_OVERRIDES[label]
    kebab = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", label).lower()
    if kebab.endswith("y") and len(kebab) > 1 and kebab[-2] not in "aeiou":
        return kebab[:-1] + "ies"
    return kebab + "s"


def _project_dir():
    pd = os.environ.get("PROJECT_DIR", "")
    if not pd:
        sys.stderr.write("account-schema-owned-dirs: PROJECT_DIR unset\n")
        sys.exit(1)
    return os.path.normpath(pd)


def _read_brand(project_dir):
    path = os.path.join(project_dir, "config", "brand.json")
    if not os.path.isfile(path):
        return "unknown", []
    try:
        with open(path) as f:
            b = json.load(f)
        return (b.get("installDir") or "unknown"), list(b.get("shipsPremiumBundles") or [])
    except Exception:
        return "unknown", []


def _read_vertical(project_dir):
    """Return the brand's default vertical (the schema file basename, e.g.
    'schema-construction'), or None. Read from config/brand.json#vertical."""
    path = os.path.join(project_dir, "config", "brand.json")
    if not os.path.isfile(path):
        return None
    try:
        with open(path) as f:
            v = json.load(f).get("vertical")
        v = (v or "").strip()
        return v or None
    except Exception:
        return None


def _parse_plugin_md(path):
    """Return (name, [ {name, kind, description} ]) from a PLUGIN.md, or (None, []).
    Two frontmatter keys, one list: `account-owned-dirs` yields kind='dir',
    `account-owned-files` yields kind='file'. A key whose value is not a JSON
    array of objects is skipped WHOLE: entries are staged and committed only once
    every element parses, so a bad element cannot half-merge a declaration and
    leave the rest reporting as strays. The other key and every other plugin
    still merge, because one hand-edited manifest must never empty the set. A
    name carrying a path separator is dropped: a fence entry has to match one
    readdir name, and the region tells a bucket from a file by the trailing slash
    it adds itself, so a declared `foo/` would render as a bucket that matches
    nothing."""
    try:
        with open(path) as f:
            text = f.read()
    except Exception:
        return None, []
    m = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
    if not m:
        return None, []
    fm = m.group(1)
    name = None
    nm = re.search(r"^name:\s*(.+)$", fm, re.MULTILINE)
    if nm:
        name = nm.group(1).strip().strip('"').strip("'")
    owned = []
    for key, field, kind in (("account-owned-dirs", "dir", "dir"),
                             ("account-owned-files", "file", "file")):
        om = re.search(r"^" + key + r":\s*(.+)$", fm, re.MULTILINE)
        if not om:
            continue
        staged = []
        try:
            for e in json.loads(om.group(1).strip()):
                n = (e.get(field) or "").strip()
                if n and "/" not in n:
                    staged.append({"name": n, "kind": kind,
                                   "description": (e.get("description") or "").strip()})
        except Exception:
            continue
        owned.extend(staged)
    return name, owned


def _plugin_md_paths(project_dir):
    paths = []
    plugins_root = os.path.join(project_dir, "plugins")
    if os.path.isdir(plugins_root):
        for entry in sorted(os.listdir(plugins_root)):
            p = os.path.join(plugins_root, entry, "PLUGIN.md")
            if os.path.isfile(p):
                paths.append(p)
    _brand, ships = _read_brand(project_dir)
    premium_root = os.path.join(os.path.dirname(project_dir), "premium-plugins")
    for bundle in ships:
        bundle_dir = os.path.join(premium_root, bundle)
        # Bundle-root manifest (shape b): premium-plugins/<bundle>/PLUGIN.md.
        root_md = os.path.join(bundle_dir, "PLUGIN.md")
        if os.path.isfile(root_md):
            paths.append(root_md)
        # Sub-plugin manifests (shape a): premium-plugins/<bundle>/plugins/*/PLUGIN.md.
        subs = os.path.join(bundle_dir, "plugins")
        if not os.path.isdir(subs):
            continue
        for entry in sorted(os.listdir(subs)):
            p = os.path.join(subs, entry, "PLUGIN.md")
            if os.path.isfile(p):
                paths.append(p)
    return paths


def resolve(project_dir):
    """Union of owned-entry declarations. First declarer of a name wins its
    owner label + description, whichever kind it declared."""
    seen = {}
    order = []
    for path in _plugin_md_paths(project_dir):
        name, owned = _parse_plugin_md(path)
        for e in owned:
            if e["name"] in seen:
                continue
            seen[e["name"]] = {"name": e["name"], "kind": e["kind"],
                               "description": e["description"], "plugin": name or "unknown"}
            order.append(e["name"])
    return [seen[n] for n in order]


def _top_level_labels(schema_path):
    """Extract (label, owner) rows from the entity table under the
    '## Top-level node types' heading of a vertical schema file. A row is
    included iff its 'Schema.org Type' cell is a schema:-namespaced type
    (excludes transport labels such as WhatsAppGroup -> cdm:Channel). `owner`
    is the containment parent from the 'Owner' column: None when the table has
    no Owner column, '' when the column is present but the cell is blank (a root
    entity), else the parent label (a contained entity). Returns rows in table
    order. No such heading -> empty list."""
    try:
        with open(schema_path) as f:
            lines = f.read().split("\n")
    except Exception:
        return []
    start = None
    for i, ln in enumerate(lines):
        if ln.startswith("## ") and "top-level node types" in ln.lower():
            start = i
            break
    if start is None:
        return []
    hdr = None
    for j in range(start + 1, len(lines)):
        if lines[j].startswith("## "):
            return []
        if "|" in lines[j] and "Neo4j Label" in lines[j] and "Schema.org Type" in lines[j]:
            hdr = j
            break
    if hdr is None:
        return []
    cols = [c.strip() for c in lines[hdr].strip().strip("|").split("|")]
    label_i = cols.index("Neo4j Label")
    type_i = cols.index("Schema.org Type")
    owner_i = cols.index("Owner") if "Owner" in cols else None
    need = max(label_i, type_i, owner_i if owner_i is not None else 0)
    out = []
    for k in range(hdr + 2, len(lines)):  # +2 skips the header separator row
        row = lines[k]
        if row.startswith("## "):
            break
        if "|" not in row or not row.strip():
            break
        cells = [c.strip() for c in row.strip().strip("|").split("|")]
        if len(cells) <= need:
            continue
        label = cells[label_i].strip("`").strip()
        stype = cells[type_i].strip("`").strip()
        if not (label and stype.startswith("schema:")):
            continue
        owner = None if owner_i is None else cells[owner_i].strip("`").strip()
        out.append((label, owner))
    return out


def resolve_domain_buckets(project_dir):
    """Domain operator-entity buckets derived from the brand's vertical
    ontology. Returns [{dir, label, owner}] in table order, deduped by dir.
    `owner` is None (the vertical table has no Owner column), '' (a root entity
    that owns a top-level bucket), or the parent label (a contained entity that
    gets no root bucket, its records living under the parent's folder). Empty
    when the brand declares no vertical or the vertical file has no top-level
    section."""
    vertical = _read_vertical(project_dir)
    if not vertical:
        return []
    schema_path = os.path.join(project_dir, "plugins", "memory", "references", vertical + ".md")
    seen = set()
    out = []
    for label, owner in _top_level_labels(schema_path):
        d = _label_to_dir(label)
        if d in seen:
            continue
        seen.add(d)
        out.append({"dir": d, "label": label, "owner": owner})
    return out


def _is_root(owner):
    """A domain bucket is a root (own top-level dir) when its Owner is blank
    ('') or the vertical declares no Owner column at all (None). A non-empty
    owner names the containing parent, so the entity nests and emits no root."""
    return owner is None or owner == ""


def _allowed_block(text):
    """Return (list_of_entries, start_idx, end_idx) for the fenced
    allowed-top-level block. end_idx is the closing-fence line index."""
    lines = text.split("\n")
    start = None
    for i, ln in enumerate(lines):
        if ln.strip() == "```allowed-top-level":
            start = i
            break
    if start is None:
        return None, None, None
    for j in range(start + 1, len(lines)):
        if lines[j].strip() == "```":
            return [l for l in lines[start + 1:j] if l.strip()], start, j
    return None, None, None


def _strip_region(text, start=MARK_START, end=MARK_END):
    if start not in text:
        return text
    pre = text.split(start, 1)[0]
    post = ""
    if end in text:
        post = text.split(end, 1)[1]
    return (pre.rstrip("\n") + "\n" + post.lstrip("\n")).rstrip("\n") + "\n"


def merge(project_dir, account_dir):
    brand, _ = _read_brand(project_dir)
    owned = resolve(project_dir)
    domain = resolve_domain_buckets(project_dir)
    schema_path = os.path.join(account_dir, "SCHEMA.md")
    if not os.path.isfile(schema_path):
        print(f"brand={brand} added=none domainBuckets=none removed=none keptPopulated=none")
        return
    with open(schema_path) as f:
        text = f.read()
    entries, start, end = _allowed_block(text)
    added = []
    added_domain = []
    removed = []
    kept_populated = []
    if entries is not None:
        present = set(entries)
        # Only ROOT domain buckets belong at the account root; a contained
        # entity (non-blank Owner) nests under its owner and is not a target,
        # so a pre-fix account's empty owned bucket prunes while a populated one
        # is kept-and-logged like any other stale bucket.
        target = FIXED_SEED | {e["name"] for e in owned} | {b["dir"] for b in domain if _is_root(b["owner"])}
        # Reconcile existing entries: keep in-target; keep populated strays
        # (logged); drop empty/absent strays (logged).
        new_entries = []
        for entry in entries:
            if entry in target:
                new_entries.append(entry)
            elif _dir_holds_files(account_dir, entry):
                new_entries.append(entry)
                kept_populated.append(entry)
            else:
                removed.append(entry)
        # Append newly-projected owned then domain dirs not already present.
        insert = []
        for e in owned:
            if e["name"] not in present and e["name"] not in insert:
                insert.append(e["name"])
                added.append(e["name"])
        for b in domain:
            if not _is_root(b["owner"]):
                continue  # contained entity: no root bucket, nests under its owner
            if b["dir"] not in present and b["dir"] not in insert:
                insert.append(b["dir"])
                added_domain.append(b["dir"])
        new_entries.extend(insert)
        lines = text.split("\n")
        lines[start + 1:end] = new_entries
        text = "\n".join(lines)
    # Regenerate both descriptive regions wholesale (idempotent).
    text = _strip_region(text, MARK_START, MARK_END)
    text = _strip_region(text, ONT_START, ONT_END)
    if owned:
        region = [MARK_START, "## Plugin-owned top-level entries", "",
                  "A plugin declares the account-root entries its feature owns. A",
                  "directory's internal structure belongs to that plugin's tools and is",
                  "recreated on the next write, so never reorganise it; a declared file",
                  "is written whole by its owner. Neither is operator data.", ""]
        for e in owned:
            desc = e["description"] or f"Owned by the {e['plugin']} plugin."
            # The trailing slash marks a bucket. Three readers anchor on it to
            # tell a directory from a declared file (account-schema-regions,
            # schema-exposed-dirs.mjs), so a file must never gain one.
            head = f"{e['name']}/" if e["kind"] == "dir" else e["name"]
            region.append(f"- `{head}` — {desc}")
        region.append(MARK_END)
        text = text.rstrip("\n") + "\n\n" + "\n".join(region) + "\n"
    owned_dirs = {e["name"] for e in owned if e["kind"] == "dir"}
    # A dir already claimed by a plugin (described in the plugin-owned region)
    # is not also described here, so a name collision cannot double-describe.
    described = [b for b in domain if b["dir"] not in owned_dirs]
    root_described = [b for b in described if _is_root(b["owner"])]
    nested_described = [b for b in described if not _is_root(b["owner"])]
    if root_described or nested_described:
        region = [ONT_START, "## Domain entity buckets (from the graph ontology)", "",
                  "One bucket per top-level operator-entry node type of this brand's",
                  "business ontology. File a job's artifacts under its entity folder;",
                  "the folder may hold a subtree. A contained entity has no root",
                  "bucket; its records live under the owning entity's folder.", ""]
        for b in root_described:
            region.append(f"- `{b['dir']}/` - one folder per {b['label']} record.")
        for b in nested_described:
            parent_dir = _label_to_dir(b["owner"])
            region.append(f"- `{b['label']}` records live under `{parent_dir}/` "
                          f"(owned by `{b['owner']}`), not a root bucket.")
        region.append(ONT_END)
        text = text.rstrip("\n") + "\n\n" + "\n".join(region) + "\n"
    with open(schema_path, "w") as f:
        f.write(text)
    root_dirs = [b["dir"] for b in domain if _is_root(b["owner"])]
    nested_pairs = [f"{b['label']}<-{b['owner']}" for b in domain if not _is_root(b["owner"])]
    print(f"brand={brand} added={','.join(added) if added else 'none'} "
          f"domainBuckets={','.join(added_domain) if added_domain else 'none'} "
          f"removed={','.join(removed) if removed else 'none'} "
          f"keptPopulated={','.join(kept_populated) if kept_populated else 'none'} "
          f"roots={','.join(root_dirs) if root_dirs else 'none'} "
          f"nested={','.join(nested_pairs) if nested_pairs else 'none'}")


def reconcile(project_dir, account_dir):
    brand, _ = _read_brand(project_dir)
    owned = resolve(project_dir)
    schema_path = os.path.join(account_dir, "SCHEMA.md")
    present = set()
    if os.path.isfile(schema_path):
        with open(schema_path) as f:
            entries, _s, _e = _allowed_block(f.read())
        present = set(entries or [])
    for e in owned:
        is_present = "true" if e["name"] in present else "false"
        label = "ownedDir" if e["kind"] == "dir" else "ownedFile"
        print(f"brand={brand} plugin={e['plugin']} {label}={e['name']} present={is_present}")
    vertical = _read_vertical(project_dir)
    domain = resolve_domain_buckets(project_dir)
    if vertical and not domain:
        print(f"brand={brand} ontologyVertical={vertical} topLevelSection=absent")
    # A vertical whose top-level table carries no Owner column cannot state
    # containment; every entity falls back to root, but loudly, never silently.
    if vertical and any(b["owner"] is None for b in domain):
        print(f"brand={brand} ontologyVertical={vertical} owner-source=absent")
    for b in domain:
        if _is_root(b["owner"]):
            is_present = "true" if b["dir"] in present else "false"
            print(f"brand={brand} ontologyEntity={b['label']} bucket={b['dir']} present={is_present}")
        else:
            # Contained entity: no root bucket expected. A bucket still listed at
            # root is a pre-fix stray the standing fs-reconcile quarantines once
            # the owned bucket leaves the allowed set (unless it holds files).
            is_stray = "true" if b["dir"] in present else "false"
            print(f"brand={brand} ontologyEntity={b['label']} owner={b['owner']} "
                  f"bucket={b['dir']} expectedAtRoot=false stray={is_stray}")


def roots(project_dir):
    """Print each root domain bucket dir (blank/absent Owner) of the brand's
    vertical, one per line, in table order. Empty when no vertical is declared.
    Provision consumes this to mkdir the operator-data buckets so they exist as
    directories, not just allowed names."""
    for b in resolve_domain_buckets(project_dir):
        if _is_root(b["owner"]):
            print(b["dir"])


def main():
    if len(sys.argv) < 2:
        sys.exit("usage: account-schema-owned-dirs.py {resolve|merge|reconcile|roots} [account_dir]")
    cmd = sys.argv[1]
    pd = _project_dir()
    if cmd == "resolve":
        print(json.dumps(resolve(pd)))
    elif cmd == "merge":
        merge(pd, sys.argv[2])
    elif cmd == "reconcile":
        reconcile(pd, sys.argv[2])
    elif cmd == "roots":
        roots(pd)
    else:
        sys.exit(f"unknown command: {cmd}")


if __name__ == "__main__":
    main()
