import functools
import inspect
from bisect import bisect_right
from itertools import accumulate
from contextvars import ContextVar
import shutil
import time
from dataclasses import asdict, dataclass, field
from functools import cache, partial

from engine import bus
from engine.extension import Extension
from resources.fields import Loaded
from engine.markers import plain
from engine.offline import queue
from engine.outbox import Request
from engine.paths import known_environment
from engine.record import Record
from resources.base import OWNER, PART_OF, PROJECT, SYSTEM, USER, Ref, Refused, Resource, SECTION, check_abstract, check_title
from resources.shapes import Options, check, normalize_options, typed
from controllers.discussion import TWICE_WITHIN, Discussed
from controllers.files import Files
from controllers.links import Links
from controllers import marks
from controllers.stored import IDEMPOTENCY, RowStore
from engine.wording import noun

WORDS = ("title", "abstract", "brief")
FIELD_WRITES = ("create", "update", "set")
LAST = 25
TWIN_WINDOW = 100
SEARCHABLE: dict[str, dict[int, tuple[float, str]]] = {}
SEARCHED: dict[str, tuple[list, bool]] = {}
CORPUS: dict[str, tuple] = {}
REREAD: dict[str, int] = {}
PUT_AWAY: dict[str, tuple] = {}


@dataclass(frozen=True)
class Arguments(Loaded):
    """What a call to an action names: every argument, its row, and the feature, key and value it sets."""

    values: dict = field(default_factory=dict)
    n: str = ""
    name: str = ""
    key: str = ""
    value: object = ""

    @classmethod
    def given(cls, body: dict, params: dict) -> "Arguments":
        return cls.from_json({**body, **params, "values": body})

    def writes(self) -> dict:
        """The fields the call writes, with their new values: every argument it names, and the key it sets."""
        return {**self.values, self.key: self.value} if self.key else dict(self.values)


def discarding(save):
    """A save that is refused leaves nothing of the change in memory: the row it was asked for is dropped from what is held, and read from the disk again."""
    @functools.wraps(save)
    def refusing(self, r, action, **event):
        try:
            return save(self, r, action, **event)
        except Refused:
            self.rows.discard(r.n)
            raise
    return refusing


def searchable(r: Resource) -> str:
    parts = [r.title, r.brief, r.abstract, *(f"{s[SECTION.title]} {s[SECTION.body]}" for s in r.sections),
             *(f"{name} {tags}" for name, tags in r.files.items())]
    return "\n".join(parts).lower()
COMMANDS: dict[str, dict] = {}


def networked(type_: str, name: str) -> bool:
    return bool(getattr(command_held(type_, name), "network", False))


def runs_here(type_: str, name: str) -> bool:
    return bool(getattr(command_held(type_, name), "here", False))


def command_held(type_: str, name: str):
    """The command a word runs on a type: one the type registered, or the method behind the word, however the type renames it (finish is complete)."""
    registered = COMMANDS.get(type_, {}).get(name)
    if registered:
        return registered
    controller = CONTROLLERS.get(type_)
    names = controller.resource.command_names if controller else {}
    return getattr(controller, next((method for method, word in names.items() if word == name), name), None)
SAVE_REWRITES = Extension()
SAVE_MARKS = Extension()
SAVE_CHECKS = Extension()
UNCHANGED_SINCE = "unchanged_since"


@dataclass(frozen=True)
class Sender:
    """The member a request came from, as the login page named them: their id, their role and the environments shared with them."""

    member: str
    role: str
    environments: frozenset[str]


@dataclass(frozen=True)
class Pressed:
    """The row a call writes to, and when it last changed as the person pressing it saw it."""

    type: str = ""
    n: int = 0
    updated: float = 0.0

    @classmethod
    def of(cls, type: str, n, given: dict) -> "Pressed":
        if UNCHANGED_SINCE not in given or n is None:
            return cls()
        return cls(type, int(n), float(given[UNCHANGED_SINCE]))

    def is_row(self, type: str, n: int) -> bool:
        return (self.type, self.n) == (type, n)


PRESSED: ContextVar[Pressed] = ContextVar("pressed", default=Pressed())
SENDER: ContextVar["Sender | None"] = ContextVar("sender", default=None)
HANDLERS: dict[str, list] = {}
CONTROLLERS: dict[str, type] = {}


def checked_field(fields: dict, key: str, value):
    if key not in fields:
        return value
    return check(key, fields[key], normalize_options(value) if key == Options.options else value)


@cache
def actions(controller: type) -> tuple[str, ...]:
    return tuple(sorted(name for name, f in inspect.getmembers(controller, inspect.isfunction) if getattr(f, "action", False)))


def word_names(controller: type) -> set[str]:
    return {*actions(controller), *COMMANDS.get(controller.resource.type, {})}


def word_function(controller: type, name: str):
    return COMMANDS.get(controller.resource.type, {}).get(name) or getattr(controller, name)


def word_parameters(controller: type, name: str) -> list[inspect.Parameter]:
    return list(inspect.signature(word_function(controller, name)).parameters.values())[1:]


class Controller(Files, Links, Discussed):
    resource = Resource
    actor = "user"

    def __init__(self, record: Record, actor: str | None = None, session: str = "", agent: str = "", force: str = ""):
        self.record = record
        self.rows = RowStore(record, self.resource, self._ordered, self._visible, self._also, self._damaged)
        self.session = session
        self.agent = agent
        self.force = force
        self.forced: list[str] = []
        if actor:
            self.actor = actor

    def _refuse(self, why: str) -> None:
        if not self.force:
            raise Refused(why)
        self.forced.append(why)

    @property
    def type(self) -> str:
        return self.resource.type

    def _note_force(self, r: Resource) -> None:
        if not self.forced:
            return
        r.data["forced"] = [*(r.data.get("forced") or []),
                            {"why": self.force, "past": list(self.forced), "who": self.actor, "at": time.time()}]
        self.forced = []

    def _row_on_disk(self, r: Resource) -> Resource:
        """The row as the disk holds it, to compare a row about to be saved with: the held row, or a fresh read when the row being saved is the held one itself, changed in place."""
        held = self.rows.peek(r.n)
        return self.rows.reparsed(r.n) if held is r else held

    def _guarded(self, r: Resource, action: str) -> None:
        allowed = self.resource.editors.get(r.author)
        if allowed is None or self.actor in allowed or not self.rows.exists(r.n):
            return
        stored = self._row_on_disk(r)
        parts = [section[SECTION.title] for section in stored.sections]
        rewritten = any(getattr(stored, f) != getattr(r, f) for f in WORDS) or [section[SECTION.title] for section in r.sections][:len(parts)] != parts
        if action == "deleted" or rewritten:
            self._refuse(f"{self.type} {r.n} was written by the {stored.seen[0]}: answer it with journal {self.type} {self.resource.answer_command} {r.n} \"<text>\" instead of changing it")

    def _shipped(self, r: Resource, action: str) -> None:
        if self.actor == SYSTEM or not self.rows.exists(r.n):
            return
        stored = self._row_on_disk(r)
        if not stored.data.get("system"):
            return
        free = ("kept", *self.resource.progress)
        kept = lambda row: {k: v for k, v in row.data.items() if k not in free}
        if action in ("deleted", "completed") or any(getattr(stored, f) != getattr(r, f) for f in WORDS) or \
                stored.sections != r.sections or kept(stored) != kept(r):
            self._refuse(f"{self.type} {r.n} ships with the journal and cannot be changed or removed")

    @discarding
    def save(self, r: Resource, action: str, **event) -> Resource:
        self._checked_and_written(r, action)
        self._emit(r.n, action, **event)
        return r

    def _checked_and_written(self, r: Resource, action: str) -> Resource:
        """Checks the row, marks it and writes it, without announcing the change."""
        self._shipped(r, action)
        self._guarded(r, action)
        for check in SAVE_CHECKS.each(self.record):
            check(self, r)
        for mark in SAVE_MARKS.each(self.record):
            mark(self, r)
        r.rewrite(plain)
        for rewriter in SAVE_REWRITES.each(self.record):
            r.rewrite(rewriter(self.record))
        self._note_force(r)
        if self.actor not in r.seen:
            r.seen.append(self.actor)
        r.updated = time.time()
        self.rows.persist(r)
        return r

    def _emit(self, n: int, action: str, **event):
        return self.record.emit(self.type, n, action, self.actor, scope=self.resource.scope, **event)

    def _retitle(self, n: int, title: str) -> Resource:
        return self.update(int(n), title=title.strip())

    def _finished(self, r: Resource) -> bool:
        return bool(r.completed)

    def _unfinished(self, n: int, ended: str) -> Resource:
        row = self.load(n)
        if row.completed:
            raise Refused(f"{self.type} {n} is {ended}")
        return row

    def _handled(self, action: str, /, **args) -> None:
        for fn in HANDLERS.get(f"{self.type}.{action}", []) + HANDLERS.get(action, []):
            fn(self, **args)

    def _plugin_twin(self, title: str, data: dict) -> dict | None:
        name = data.get(OWNER)
        if not name:
            return None
        return next((row for row in self.rows.summaries() if row.get(OWNER) == name and row["title"] == title and not row["deleted"]), None)

    def _field_choices(self, r: Resource) -> dict:
        return {}

    def _shaped(self, data: dict) -> dict:
        fields = self.resource.fields
        return {k: checked_field(fields, k, v) for k, v in data.items()}

    def _given(self, data: dict) -> Resource:
        return self.resource(data=self._shaped(data))

    def _twin(self, title: str, brief: str, about, idempotency: str = "") -> Resource | None:
        if not self.resource.deduplicates:
            return None
        since = time.time() - TWICE_WITHIN
        lately = [row["n"] for row in self.rows.summaries()[-TWIN_WINDOW:] if not row["deleted"] and row["updated"] >= since]
        recent = (self.load(n) for n in reversed(lately[-20:]))
        return next((r for r in recent if r.created >= since and r.title == title and r.brief == brief
                     and r.author == self.actor and (not about or about in r.refs)
                     and (not idempotency or r.data.get("idempotency") == idempotency)), None)

    @marks.action
    def create(self, title: str, abstract: str = "", brief: str = "", **data) -> Resource:
        if self.resource.agent_only and self.actor == USER:
            self._refuse(f"a {self.type} is made by the agent, never by you")
        key = data.get(IDEMPOTENCY, "")
        made = self.rows.by_idempotency(key) if key else None
        if made is not None:
            return made
        twin = self._twin(title, brief, data.get("about"), key)
        if twin is not None:
            return twin
        found = self._plugin_twin(title, data)
        if found is not None:
            return self.update(found["n"], abstract=abstract or None, brief=brief or None, **data)
        self._handled("create", title=title, abstract=abstract, brief=brief, **data)
        for name in self.resource.unfilled(data):
            self._refuse(f"a {self.type} needs {name}: --set {name}=\"<word>,<word>\"")
        with self.record.locked(self.resource.scope):
            n = self.rows.draw_number()
            about, supersedes = data.pop("about", None), data.pop("supersedes", 0)
            fields = self._shaped(data)
            if self.agent:
                fields.update(agent=self.agent, dispatcher=self.session)
            if self.resource.scope == PROJECT:
                fields["environment"] = self.record.env
            r = self.resource(n=n, title=check_title(title), abstract=check_abstract(abstract), brief=brief,
                              data=fields, created=time.time(), seen=[self.actor], refs=[about] if about else [])
            r = self.save(r, "created")
            return self._supersede(int(supersedes), n) if supersedes else r

    def _supersede(self, old: int, new: int) -> Resource:
        self.complete(old, how=f"superseded by {self.type} {new}")
        return self.link(new, f"{self.type}:{old}")

    def _created_with_sections(self, title: str, abstract: str, brief: str, sections: list[tuple[str, str]], **data) -> Resource:
        made = self.create(title, abstract, brief, **data)
        for heading, body in sections:
            made = self.section(made.n, heading, body)
        return made

    def _carried(self, n: int, into: "Controller", how: str) -> Resource:
        r = self.load(n)
        made = into._created_with_sections(r.title, r.abstract, r.brief, [(s[SECTION.title], s[SECTION.body]) for s in r.sections], **r.data)
        self.complete(n, how=f"{how} {into.type} {made.n}")
        return made

    def _damaged(self, path: str, error: str) -> None:
        from controllers.faults import damaged
        damaged(self.record, path, error)

    def _stopping(self, n: int, **asked) -> Resource:
        return self.update(n, stopping={**asked, "at": time.time()})

    @marks.action
    def update(self, n: int, title: str | None = None, abstract: str | None = None, brief: str | None = None, outcome: str | None = None, **data) -> Resource:
        self._handled("update", n=n, title=title, abstract=abstract, brief=brief, outcome=outcome, **data)
        with self.record.locked(self.resource.scope):
            r = self.load(n)
            if title is not None:
                r.title = check_title(title)
            if abstract is not None:
                r.abstract = check_abstract(abstract)
            if brief is not None:
                r.brief = brief
            if outcome is not None:
                r.outcome = outcome
            r.data.update(self._shaped(data))
            given = {"title": title, "abstract": abstract, "brief": brief, "outcome": outcome}
            return self.save(r, "updated", fields=[*(k for k, v in given.items() if v is not None), *data])

    @marks.action
    def stamp(self, n: int, **data) -> Resource:
        return self._changed(n, "stamped", data, quiet=True, fields=sorted(data))

    def appended(self, r: Resource, field: str, entry, keep: int, **data) -> Resource:
        return self.update(r.n, **{field: [*(r.data.get(field) or []), entry][-keep:]}, **data)

    def _changed(self, n: int, action: str, data: dict, **event) -> Resource:
        with self.record.locked(self.resource.scope):
            r = self.load(n)
            r.data.update(self._shaped(data))
            return self.save(r, action, **event)

    @marks.action
    def set(self, n: int, key: str, value: str) -> Resource:
        return self.update(n, **{key: typed(value)})

    def add_part(self, n: int, title: str, body: str) -> Resource:
        return self.section(n, title, body)

    @marks.action
    def section(self, n: int, title: str, body: str) -> Resource:
        with self.record.locked(self.resource.scope):
            r = self.load(n)
            for s in r.sections:
                if s[SECTION.title] == title:
                    s[SECTION.body] = body
                    break
            else:
                r.sections.append({SECTION.title: title, SECTION.body: body})
            self._sectioned(r)
            return self.save(r, "updated", section=title)

    def _sectioned(self, r: Resource) -> None:
        pass

    @marks.action
    def delete(self, n: int, why: str = "") -> Resource:
        self._handled("delete", n=n, why=why)
        with self.record.locked(self.resource.scope):
            r = self.load(n)
            r.deleted = time.time()
            return self.save(r, "deleted", why=why)

    @marks.action
    def complete(self, n: int, how: str = "", **data) -> Resource:
        self._handled("complete", n=n, how=how, **data)
        with self.record.locked(self.resource.scope):
            r = self.load(n)
            if r.completed:
                self._refuse(f"{self.type} {n} is already closed")
            r.completed = time.time()
            r.outcome = how
            r.data.update(self._shaped(data))
            if self.resource.lists_completed_unread and self.actor != USER:
                r.seen = [who for who in r.seen if who != USER]
            return self.save(r, "completed", how=how, **data)

    @marks.action
    def reopen(self, n: int, why: str) -> Resource:
        with self.record.locked(self.resource.scope):
            r = self.load(n)
            if r.deleted:
                self._refuse(f"{self.type} {n} is archived; restore it before reopening it")
            if not r.completed:
                self._refuse(f"{self.type} {n} is not {self.named('complete')}")
            r.completed = 0.0
            r.outcome = ""
            return self.save(r, "reopened", why=why)

    def named(self, method: str) -> str:
        return self.resource.command_names.get(method, method)

    def _runs_commands(self, word: str, arguments: Arguments) -> bool:
        """Whether calling the action with these arguments changes a field or setting that decides what runs."""
        command = COMMANDS.get(self.type, {}).get(word)
        if command:
            return command.runs_commands(self, arguments)
        written = {name: value for name, value in arguments.writes().items() if name in self.resource.command_fields}
        if not written:
            return False
        stored = self.load(arguments.n).data if arguments.n else {}
        return any(value != stored.get(name, "") for name, value in written.items())

    def method(self, name: str):
        if name in self.resource.command_names and name not in self.resource.command_names.values():
            raise Refused(f"a {self.type} calls that {self.resource.command_names[name]}")
        return self.action(name)

    def action(self, name: str):
        if name.startswith("_") or name not in {*word_names(type(self)), *self.resource.command_names.values()}:
            raise Refused(f"{self.type} has no action {name!r}")
        command = COMMANDS.get(self.type, {}).get(name)
        if command:
            return bus.commanded(self.type, name, partial(command, self))
        method = self._method_called(name)
        return bus.commanded(self.type, method, getattr(self, method))

    def _method_called(self, word: str) -> str:
        return next((method for method, alias in self.resource.command_names.items() if alias == word), word)

    def _refuse_journal_fields(self, word: str, arguments: Arguments) -> None:
        """Refuses a create, update or set from outside that writes a field only the journal sets, such as a path or a process."""
        if self._method_called(word) not in FIELD_WRITES:
            return
        written = sorted(arguments.writes().keys() & self.resource.journal_fields)
        if written:
            self._refuse(f"the journal sets {', '.join(written)} of a {self.type} itself; it is not written by hand")

    @marks.action
    def restore(self, n: int) -> Resource:
        r = self.load(n)
        r.deleted = 0.0
        return self.save(r, "updated", restored=True)

    def _prune(self) -> None:
        prunable = sorted((r for r in self.rows.every(deleted=True) if self.resource.pruned_when.admits(r)), key=lambda r: r.created, reverse=True)
        for r in prunable[self.resource.kept:]:
            self.force_delete(r.n)

    @marks.action
    def force_delete(self, n: int) -> None:
        self.load(n)
        self.rows.remove(n)
        files = self.rows.row_folder(n)
        if files.is_dir():
            shutil.rmtree(files)
        self._emit(n, "deleted", force=True)

    @marks.action
    def move(self, n: int, env: str) -> Resource:
        r = self.load(n)
        self._shipped(r, "deleted")
        self._guarded(r, "deleted")
        there = type(self)(Record(self.record.root, env), actor=self.actor)
        with there.record.locked(self.resource.scope):
            m = there.rows.draw_number()
            moved = self.resource(**{**asdict(r), "n": m})
            if any(self.folder(n).iterdir()):
                shutil.copytree(self.folder(n), there.rows.row_folder(m), dirs_exist_ok=True)
            there.save(moved, "created", moved_from=f"{self.record.env}/{n}")
        self.delete(n, why=f"moved to {env} as {self.type} {m}")
        return moved

    @marks.action
    def show(self, n: int) -> Resource:
        row = self.read(n)
        self._handled("show", row=row)
        return row

    @marks.action
    def read(self, n: int) -> Resource:
        return self.read_all([n])[0]

    @marks.action
    def read_all(self, numbers: list[int]) -> list[Resource]:
        rows = []
        changed = []
        wanted = list(dict.fromkeys(int(n) for n in numbers))
        if not self.record.holds(self.resource.scope):
            queue(self.record, self.resource.scope, Request(self.record.env, self.type, "read_all", [wanted], actor=self.actor))
            return [self.load(n) for n in wanted]
        with self.record.locked(self.resource.scope):
            for n in wanted:
                r = self.load(n)
                rows.append(r)
                if self.actor in r.seen:
                    continue
                r.seen.append(self.actor)
                r.updated = time.time()
                self.rows.persist(r)
                changed.append(r)
            if changed:
                self._emit(changed[0].n, "updated", numbers=[r.n for r in changed], seen=self.actor, by="read")
        return rows

    @marks.action
    def unread(self, actor: str | None = None) -> list[Resource]:
        who = actor or self.actor
        return [self.load(row["n"]) for row in self.rows.unread(who)]

    @marks.action
    def all(self, deleted: bool = False, completed: bool = False, last: int = LAST) -> list[Resource]:
        if not deleted and int(last) and type(self)._ordered is Controller._ordered:
            listed = [row["n"] for row in self.rows.summaries() if not row["deleted"] and (not row["completed"] or completed and not row.get(PART_OF))]
            return [self.rows.peek(n) for n in listed[-int(last):]]
        rows = self.rows.every(deleted) if completed or deleted else self.rows.standing()
        rows = rows if completed else [r for r in rows if not r.completed]
        return rows[-int(last):] if int(last) else rows

    def mark(self, r: Resource) -> str:
        return ""

    @marks.action
    def search(self, term: str, archived: bool = False) -> list[Resource]:
        want, rows = term.lower(), self.rows.summaries()
        texts = self._texts(archived, rows)
        if not want:
            hits = (row["n"] for row in reversed(rows) if (archived or not row["deleted"]) and row["n"] in texts)
            return [self.rows.peek(n) for n, _ in zip(hits, range(LAST))]
        corpus, starts, numbers = self._corpus(texts)
        gone = self._put_away(rows)
        hits, before = [], len(corpus)
        while len(hits) < LAST:
            at = corpus.rfind(want, 0, before)
            if at < 0:
                break
            found = bisect_right(starts, at) - 1
            before = starts[found]
            if archived or not gone.get(numbers[found], True):
                hits.append(numbers[found])
        return [self.rows.peek(n) for n in hits]

    def _corpus(self, texts: dict[int, tuple[float, str]]) -> tuple[str, list[int], list[int]]:
        """Every row's text in one string, a null between rows, with where each starts, so one scan in C answers a search instead of a loop over the rows; made again only when a row's text was read again."""
        folder = str(self.rows.folder())
        held = CORPUS.get(folder)
        if held and held[0] == REREAD.get(folder, 0):
            return held[1:]
        numbers = sorted(texts)
        bodies = [texts[n][1] for n in numbers]
        starts = list(accumulate((len(body) + 1 for body in bodies), initial=0))[:-1]
        CORPUS[folder] = (REREAD.get(folder, 0), "\x00".join(bodies), starts, numbers)
        return CORPUS[folder][1:]

    def _put_away(self, rows: list[dict]) -> dict[int, bool]:
        """Which rows are put away, kept for the list of summaries it was made from."""
        folder = str(self.rows.folder())
        held = PUT_AWAY.get(folder)
        if held and held[0] is rows:
            return held[1]
        PUT_AWAY[folder] = (rows, {row["n"]: row["deleted"] for row in rows})
        return PUT_AWAY[folder][1]

    def load(self, n: int | str) -> Resource:
        return self.rows.load(n)

    def peek(self, n: int | str) -> Resource:
        """The held row itself, for a caller that only reads it; a caller that changes it takes `load`, a copy."""
        return self.rows.peek(int(n))

    def path(self, n: int):
        return self.rows.path(n)

    def _ordered(self, rows: list[Resource]) -> list[Resource]:
        return rows

    def _visible(self, row: Resource) -> bool:
        return True

    def _also(self) -> list[Resource]:
        return []

    def _warm(self) -> None:
        self.rows.warm()

    def _texts(self, archived: bool = False, rows: list[dict] | None = None) -> dict[int, tuple[str, str]]:
        """Each row's searchable text beside the stamp it was read at, kept between searches and read again only for a changed row; the summaries a search already holds are passed in."""
        folder = str(self.rows.folder())
        rows = self.rows.summaries() if rows is None else rows
        kept, seen = SEARCHABLE.setdefault(folder, {}), SEARCHED.get(folder)
        if seen and seen[0] is rows and (seen[1] or not archived):
            return kept
        for row in rows:
            version = row["stamp"]
            if row["deleted"] and not archived or kept.get(row["n"], (None,))[0] == version:
                continue
            kept[row["n"]] = (version, searchable(self.rows.reparsed(row["n"])))
            REREAD[folder] = REREAD.get(folder, 0) + 1
        SEARCHED[folder] = (rows, archived or bool(seen and seen[0] is rows and seen[1]))
        return kept

    @marks.action
    def find(self, name: str) -> Resource:
        if str(name).isdigit():
            return self.load(name)
        hits = [row["n"] for row in self.rows.summaries() if not row["deleted"] and name.lower() in row["title"].lower()]
        if len(hits) != 1:
            raise Refused(f"{'no' if not hits else len(hits)} {noun(len(hits), self.type)} match {name!r}" + ("; say more of the title" if len(hits) > 1 else ""))
        return self.load(hits[0])


def controller_of(record: Record, ref: "str | Ref", actor: str = SYSTEM) -> "Controller":
    ref = Ref.parse(ref)
    if ref.type not in CONTROLLERS:
        raise Refused(f"{str(ref)!r} is not a row: write it as type:number, like todo:785")
    return CONTROLLERS[ref.type](record, actor=actor)


def row_of(record: Record, ref: "str | Ref") -> Resource:
    """The row a ref names, read live from its own environment when the ref names one."""
    ref = Ref.parse(ref)
    home = Record(record.root, ref.env) if ref.env else record
    if ref.env and not known_environment(record.root, ref.env):
        raise Refused(f"there is no environment {ref.env!r}")
    row = controller_of(home, ref).load(ref.n)
    row.home_env = ref.env
    return row


def register(*classes) -> None:
    CONTROLLERS.update({c.resource.type: c for c in classes})
