import os
import shutil
import signal
import time
from collections import Counter
from functools import cached_property
from controllers.base import CONTROLLERS, Controller
from controllers.stored import forget_folder
from engine import attic
from engine.record import Record
from engine.seats import terminal_of
from engine.sessions import Sessions, alive
from engine.stop import ask_session
from resources import types
from resources.base import ENVIRONMENT, SYSTEM, UNTITLED, Refused, check_title
from resources.types import EnvironmentKind
from engine import runtime
from engine.paths import ROUTED, environment_home, environments
from engine.wording import plural
from controllers.facts import Facts
from controllers.messages import Messages
from controllers.questions import Questions
from controllers.reminders import Reminders
from controllers.todos import Todos
from controllers.works import Works
from controllers.marks import action


SWEPT = ("message", "comment", "reaction", "notification", "notice", "nudge")
KEPT = ("agent", "feature", "environment")
SEED = "feature"


def seeded(folder) -> bool:
    return not any(row.parent.name != SEED for row in folder.glob("*/[0-9]*.md"))


def kind_of(given: str) -> EnvironmentKind:
    try:
        return EnvironmentKind(given)
    except ValueError as error:
        raise Refused(f"an environment is one of {', '.join(EnvironmentKind)}, not {given!r}") from error


class Environments(Controller):
    OPEN_BEFORE_REMOVING = (Todos, Facts, Reminders, Messages, Questions)
    PICKED_UP = (Works, Todos, Questions, Messages)
    resource = types.Environment

    def working_under(self) -> list[str]:
        """The environments launched from this one, and those launched from them in turn: where its helpers, tickets and their helpers work."""
        launched: dict[str, list[str]] = {}
        for row in self.rows.every():
            launched.setdefault(row.launched_from, []).append(row.title)
        found, waiting = [], [self.record.env]
        while waiting:
            for name in launched.get(waiting.pop(0), []):
                if name not in found and name != self.record.env:
                    found.append(name)
                    waiting.append(name)
        return found

    @action
    def update(self, n: int, title: str | None = None, **data):
        if title is not None:
            raise Refused("rename an environment with journal environment rename")
        if "kind" in data:
            data["kind"] = kind_of(data["kind"])
        return super().update(n, **data)

    def _seat(self, name: str, session: str):
        row = self.rows.by_title(name) or self.create(name, kind=EnvironmentKind.MAIN)
        return self.update(row.n, holder=session)

    @action
    def unused(self, name: str, hint: str = "") -> str:
        if name in ROUTED:
            raise Refused(f"{name!r} is a word the viewer's own addresses use; choose another name")
        if self.rows.by_title(name):
            raise Refused(f"environment {name!r} exists{hint}")
        return name

    @action
    def vacant(self, title: str, mine: str = "") -> None:
        holder = self._sessions.holder(title)
        if holder and holder != mine:
            found = self.rows.by_title(title)
            ending = f"journal environment stop {found.n} ends its agent" if found else "its agent ends"
            self._refuse(f"environment {title!r} is held by session {holder}; {ending} first")

    @action
    def stop(self, n: int):
        env = self.peek(n)
        holder = self._sessions.holder(env.title)
        if not holder:
            self._refuse(f"no agent holds environment {env.title!r}")
        terminal = terminal_of(self.record.root, holder)
        if terminal:
            ask_session(self.record.root, terminal)
            return self._stopping(env.n, session=holder)
        running = self._sessions.read(holder)
        if not running.pid or not alive(running.pid):
            self._refuse(f"the agent in {env.title} runs outside the journal's terminals and its process is not found; end it where it runs")
        os.kill(running.pid, signal.SIGTERM)
        return self._stopping(env.n, session=holder)

    @action
    def create(self, title: str, abstract: str = "", brief: str = "", **data):
        if title.strip() in ("", UNTITLED):
            self._refuse("an environment needs a name")
        name = self.unused(check_title(title), ": switch to it")
        home = environment_home(self.record.root, name)
        made = super().create(name, abstract, brief, **{**data, "kind": self._kind_made(data.get("kind"), data.get("owner", ""))})
        home.mkdir(parents=True, exist_ok=True)
        runtime.forget_rename(self.record.root, name)
        return made

    def _kind_made(self, kind: str | None, owner: str) -> EnvironmentKind:
        """The kind a new environment is made with: the one named, else the one its owner gives it, else main."""
        if kind is not None:
            return kind_of(kind)
        return EnvironmentKind.owned_by(owner)

    @cached_property
    def _sessions(self) -> Sessions:
        return Sessions(self.record.root)

    def _record_of(self, env) -> Record:
        return Record(self.record.root, env.title)

    def _bind_agent(self, session: str, env: str) -> None:
        for each in self._sessions.agent(session):
            self._sessions.bind(each, env)

    def _bound_session(self) -> str:
        if not self.session:
            raise Refused("no session to bind: say which with --session")
        return self.session

    @action
    def switch(self, n: int, project: bool = False, move: str = "", back: bool = False):
        bound = self._bound_session()
        who = move or bound
        if back:
            was = self._sessions.read(who).before
            if not was:
                raise Refused("this session came from nowhere: no environment to go back to")
            return self.switch(self.find(was).n, move=who)
        env = self.peek(n)
        holder = self._sessions.holder(env.title)
        if holder and holder not in self._sessions.agent(who):
            self._refuse(f"environment {env.title!r} is taken by session {holder}: claim it with a reason, or work another")
        before = self._sessions.environment(who)
        self._bind_agent(who, env.title)
        if before and before != env.title:
            self._sessions.write(who, before=before)
        if project:
            runtime.set_env(self.record.root, env.title)
        return self.update(n, holder=who)

    @action
    def complete(self, n: int, how: str = "", yes: bool = False, **data):
        env = self.load(n)
        record = self._record_of(env)
        held = {c.resource.type: len(c(record, actor=SYSTEM).rows.standing()) for c in self.OPEN_BEFORE_REMOVING}
        self.vacant(env.title)
        kept = ", ".join(f"{v} open {k}s" for k, v in held.items() if v)
        if kept and not yes:
            self._refuse(f"environment {env.title!r} holds {kept}; --yes removes it anyway (its record goes to the attic)")
        if record.home.is_dir():
            attic.pack(record.home, f"{env.title}-{int(time.time())}")
        forget_folder(record.home)
        record.numbers.forget(env.title)
        self.force_delete(n)
        return f"environment {env.title!r} removed; its record is packed in attic/ — journal environment unarchive {env.title} brings it back"

    @action
    def sweep(self, n: int, yes: bool = False):
        env = self.load(n)
        record = self._record_of(env)
        chosen = [(rows, row["n"]) for rows in self._sweepable(record) for row in rows.rows.summaries()
                  if rows.type in SWEPT or row["completed"] or row["deleted"]]
        counted = Counter(rows.type for rows, _ in chosen)
        summary = ", ".join(plural(count, kind) for kind, count in sorted(counted.items())) or "nothing"
        if not yes:
            return f"a sweep of {env.title!r} packs {summary} into the attic and keeps its facts, rules, reminders, docs and open rows; --yes sweeps"
        if not chosen:
            return f"environment {env.title!r} has nothing to sweep"
        stamp = int(time.time())
        stage = environments(self.record.root) / f".swept-{env.title}-{stamp}"
        for rows, number in chosen:
            kept = stage / rows.type
            kept.mkdir(parents=True, exist_ok=True)
            (kept / rows.path(number).name).write_text(rows.rows.text(number))
            rows.rows.remove(number)
            files = rows.path(number).with_suffix("")
            if files.is_dir():
                shutil.move(str(files), kept / files.name)
        attic.pack(stage, f"{env.title}-swept-{stamp}")
        return f"swept {summary} from {env.title!r} into attic/{env.title}-swept-{stamp}{attic.SUFFIX}"

    def _sweepable(self, record) -> list:
        return [controller(record, actor=SYSTEM) for controller in CONTROLLERS.values()
                if controller.resource.scope == ENVIRONMENT and controller.resource.type not in KEPT]

    @action
    def unarchive(self, name: str):
        return self.restored(name)

    def restored(self, name: str, **fields):
        """Brings the newest archive of an environment back, as the row it had, or with the fields a caller who knows who works there gives it."""
        home = environment_home(self.record.root, name)
        archive = attic.latest(self.record.root, name)
        if not archive:
            raise Refused(f"no archived environment {name!r} in attic/")
        self.unused(name, ": rename it before bringing the archived one back")
        attic.unpack(archive, home)
        forget_folder(home)
        return self.create(name, **fields)

    @action
    def rename(self, n: int, name: str):
        env = self.load(n)
        new = self.unused(check_title(name))
        environment_home(self.record.root, new)
        self.vacant(env.title, self.session)
        old = self._record_of(env).home
        taken = old.with_name(new)
        if taken.is_dir() and not seeded(taken):
            self._refuse(f"a folder for {new!r} already holds rows at {taken}; remove that environment first or choose another name")
        if taken.is_dir():
            attic.pack(taken, f"{new}-seed-{int(time.time())}")
        if old.is_dir():
            old.rename(taken)
        forget_folder(old)
        forget_folder(taken)
        for scope in (env.title, new):
            self.record.numbers.forget(scope)
        self._sessions.rebind(env.title, new)
        return super().update(n, title=new)

    @action
    def hand(self, n: int, machine: str) -> str:
        env = self.load(n)
        lease = self._record_of(env).hand_over(ENVIRONMENT, machine)
        return f"environment {env.title!r} is written by machine {machine} from now on (handover {lease.epoch}); a write from any other machine is refused"

    @action
    def pickup(self, n: int) -> dict:
        env = self.load(n)
        record = self._record_of(env)
        self._bound_session()
        return {"environment": env.title, "holder": self._sessions.holder(env.title),
                **{f"open {c.resource.type}s": [f"{r.n} {r.title}" for r in c(record, actor=SYSTEM).rows.standing()][:10] for c in self.PICKED_UP},
                "facts": [f"{r.n} {r.title}" for r in Facts(record, actor=SYSTEM).rows.standing()][:10]}

    @action
    def claim(self, n: int, why: str):
        env = self.peek(n)
        session = self._bound_session()
        holder = self._sessions.holder(env.title)
        self._sessions.take(env.title, session, why)
        self._bind_agent(session, env.title)
        return self.update(n, holder=session, claimed={"from": holder, "why": why})

    @action
    def leave(self, n: int):
        env = self.peek(n)
        session = self._bound_session()
        if self._sessions.environment(session) != env.title:
            self._refuse(f"session {session} does not hold environment {env.title!r}")
        self._sessions.unbind(session)
        return self.update(n, holder="")

    @action
    def grant(self, n: int, off: bool = False):
        env = self.peek(n)
        return self._sessions.grant(self._bound_session(), env.title, on=not off)
