import json
import re
from dataclasses import dataclass, field, asdict, fields, is_dataclass, replace
from enum import Enum
from types import SimpleNamespace
from typing import Callable, ClassVar

from resources.text import paragraphs

TITLE_MAX = 80
ABSTRACT_MAX = 200
VIEW_ONLY = ("kept",)
REQUESTED = "requested"
REVISED = "revised"
COMMISSIONED = "commissioned"
STARTED = "started"
PLAN_WAITS, CHECKPOINT, FINISHED, STUCK = "plan_waits", "checkpoint", "finished", "stuck"
ESCALATED, PAUSED, RESUMED = "escalated", "paused", "resumed"
CREATED = "created"
ACTIONS = (CREATED, "updated", "deleted", "linked", "commented", "completed", "reopened", "stamped", "reported", "heard", "raised", "dispatched", "returned", "fired", REQUESTED, REVISED, COMMISSIONED, STARTED, PLAN_WAITS, CHECKPOINT, FINISHED, STUCK, ESCALATED, PAUSED, RESUMED)
RAISED = "raised"
SMALL, WIDE, DOCUMENT = "small", "wide", "document"
VIEWS = (SMALL, WIDE, DOCUMENT)
USER, AGENT, SYSTEM, PLUGIN = "user", "agent", "system", "plugin"
OPEN, CLOSED, EVERY = "open", "closed", "every"
UPDATES = "updates"
OPENED, COMPLETED, CLEARED = "opened", "completed", "cleared"
WHOM = "whom"
KEYWORDS = "keywords"
KEYWORDS_IN = "keywords_in"
PART_OF = "part_of"
OWNER = "plugin"
ENVIRONMENT, PROJECT = "environment", "project"
OWNER_ID = "owner"   # the id of the journal's owner wherever a person is named: on a login, a row's writer or a phone
WRITER = "member"   # the data key naming the person who made a row: the owner, or a member of a journal on a server
SIDEBAR, RESULTS, WORKINGS, UNLISTED = "sidebar", "results", "workings", ""
SCOPES = (ENVIRONMENT, PROJECT)
ACTORS = (USER, AGENT, SYSTEM, PLUGIN)


def names(*columns: str) -> SimpleNamespace:
    return SimpleNamespace(**{c: c for c in columns})


class Field:
    def __init__(self, spec=None, default=None, name: str = "", required: bool = False, runs_commands: bool = False, journal_only: bool = False):
        self.spec, self.default, self.name, self.required = spec, default, name, required
        self.runs_commands, self.journal_only = runs_commands, journal_only

    def __get__(self, obj, owner=None):
        if obj is None:
            return self.name
        if self.name in obj.data:
            return obj.data[self.name]
        return obj.data.setdefault(self.name, self.default()) if callable(self.default) else self.default

    def __set__(self, obj, value) -> None:
        obj.data[self.name] = value


def declare(cls) -> None:
    if "data_fields" not in cls.__dict__:
        return
    for field_ in cls.__dict__["data_fields"]:
        setattr(cls, field_.name, field_)


@dataclass(frozen=True)
class ResourceDetails:
    title: str = ""
    abstract: str = ""
    help: str = ""

    def __post_init__(self) -> None:
        for key in ("title", "abstract", "help"):
            object.__setattr__(self, key, paragraphs(getattr(self, key)))


SECTION = names("title", "body")
TEXT_FIELDS = ("title", "abstract", "brief", "outcome")


def copied(value):
    if isinstance(value, dict):
        return {k: copied(v) for k, v in value.items()}
    if isinstance(value, list):
        return [copied(v) for v in value]
    return value


def reworded(value, words):
    if isinstance(value, str):
        return words(value)
    if isinstance(value, dict):
        return {k: reworded(v, words) for k, v in value.items()}
    if isinstance(value, list):
        return [reworded(v, words) for v in value]
    return value


@dataclass(frozen=True)
class Ref:
    type: str
    n: int
    env: str = ""

    @classmethod
    def parse(cls, ref: "str | Ref") -> "Ref":
        env, _, row = str(ref).rpartition("/")
        kind, _, n = row.partition(":")
        if not kind or not n.isdigit():
            raise Refused(f"{ref!r} is not a row: write it as type:number, like todo:785, or environment/type:number for a row of another environment")
        return cls(kind, int(n), env)

    @classmethod
    def numbers_of(cls, kind: str, refs: "list[str]") -> list[int]:
        return [cls.parse(ref).n for ref in refs if ref.startswith(f"{kind}:")]

    @property
    def spoken(self) -> str:
        return f"{self.type} {self.n}"

    def __str__(self) -> str:
        return f"{self.env}/{self.type}:{self.n}" if self.env else f"{self.type}:{self.n}"


def as_dict(r) -> dict:
    return {**asdict(r), "type": r.type, "ref": r.ref}


@dataclass
class Event:
    id: int
    at: float
    type: str          # the resource type
    n: int
    action: str        # one of ACTIONS
    actor: str    # one of ACTORS
    data: dict = field(default_factory=dict)
    pid: int = 0
    handled: bool = False
    env: str = ""

    def to_json(self) -> dict:
        return dict(vars(self))


class Pruned(Enum):
    ANY = ""
    SEEN = "seen"
    CLOSED = "closed"

    def admits(self, r: "Resource") -> bool:
        return {Pruned.ANY: True, Pruned.SEEN: USER in r.seen, Pruned.CLOSED: bool(r.completed)}[self]


@dataclass(frozen=True)
class Counter:
    """A total a type keeps over its rows: what one row adds to it, in as many columns as the total has, adjusted by the change of one row and saved with the index."""

    name: str
    weigh: Callable[[dict], tuple[int, ...]]
    width: int


def overview_weight(row: dict) -> tuple[int, int, int]:
    """What one row adds to a type's overview: itself, whether it is open, and whether it is open and unread by the user."""
    if row["deleted"] or row.get("hidden"):
        return (0, 0, 0)
    opened = not row["completed"]
    return (1, int(opened), int(opened and USER not in (row.get("seen") or [])))


def listable_weight(hidden_listed: bool) -> Callable[[dict], tuple[int, int]]:
    """What one row adds to the rows a listing can show: itself, and whether it is open."""
    def weigh(row: dict) -> tuple[int, int]:
        listable = not row["deleted"] and (hidden_listed or not row.get("hidden")) and row["updated"] > 0
        return int(listable), int(listable and not row["completed"])
    return weigh


def plain(value):
    """What json writes for a value it has no word for: a dataclass becomes its fields, as it did when every row was copied whole before it was written."""
    if is_dataclass(value) and not isinstance(value, type):
        return asdict(value)
    raise TypeError(f"{type(value).__name__} is not JSON serializable")


@dataclass
class Resource:
    details: ClassVar[ResourceDetails] = ResourceDetails()
    command_names: ClassVar[dict] = {}   # what this type calls a controller method: {"complete": "done", "create": "add"}
    status_labels: ClassVar[dict] = {}    # how the bar says a command on it: {"complete": "answering"}
    formatted_data: ClassVar[dict] = {}  # data lists whose items carry words a person reads: {"cards": ("detail",)}
    event_labels: ClassVar[dict] = {}   # how an event on it reads in the viewer: {"created": "Work started"}
    view: ClassVar[str] = SMALL  # how it is read: a small inspector, a wide one, or a document page
    in_sidebar: ClassVar[bool] = True   # whether the viewer lists it and counts it
    listed_under: ClassVar[str] = WORKINGS   # where it is listed: the sidebar, or a group on the Resources page
    listed_as_cards: ClassVar[bool] = False
    icon: ClassVar[str] = "dot"  # the viewer's glyph for it
    needs_attention: ClassVar[bool] = False   # unread by the user, it waits on them
    lists_completed_unread: ClassVar[bool] = False
    cleared_by: ClassVar[str] = CLEARED     # what takes it off the user's list: opening it, completing it, or the user clearing it
    filters: ClassVar[tuple] = (OPEN, CLOSED)   # the ways its list can be narrowed, shown as the tabs above it
    created_in_viewer: ClassVar[bool] = True     # its page offers a New button
    agent_only: ClassVar[bool] = False           # the agent makes it; a user create is refused and its page has no New
    start_heading: ClassVar[str] = ""          # its heading in the start block, empty when it is not handed to a session
    start_as_count: ClassVar[bool] = False     # handed as a count, not row by row
    summary_in_dashboard: ClassVar[bool] = False  # the dashboard sends its rows without their text, and the viewer reads a row whole when it opens one
    light_in_dashboard: ClassVar[dict[str, tuple[str, ...]]] = {}  # a heavy data field and the keys of it the dashboard's rows keep
    subagent_writable: ClassVar[bool] = True         # a subagent lent the environment may write it
    takes_comments: ClassVar[bool] = True   # its inspector has a comment section
    nested: ClassVar[bool] = False      # it exists about another resource and is shown under it, never on its own
    closed_first: ClassVar[bool] = False
    listed_open: ClassVar[bool] = False    # its open rows are a working list: a listing carries every one of them
    hidden_listed: ClassVar[bool] = True
    listed_beneath: ClassVar[bool] = False   # the open rows of the environments working under this one are listed with its own, each named for its environment
    moments: ClassVar[tuple] = ("created", "completed")   # what a sequence can start on
    scope: ClassVar[str] = ENVIRONMENT   # whose it is: one environment's, or the whole project's
    notified: ClassVar[tuple] = (USER, AGENT)   # who is told of its events, besides the actor
    typed_as_title: ClassVar[bool] = False            # typed to the agent as its title, not as "type n action"
    notify_actions: ClassVar[tuple] = ()      # besides everything the user does, the system actions the agent is notified of
    addressed_to_agent: ClassVar[bool] = False
    answer_command: ClassVar[str] = "comment"      # the word that answers a row instead of changing it
    editors: ClassVar[dict] = {}              # who may change the words of a row written by whom: {USER: (USER,)}; unnamed authors are open to all
    stamped_when_notified: ClassVar[bool] = False              # the row is stamped with the moment the agent was told of it
    deduplicates: ClassVar[bool] = False
    kept: ClassVar[int] = 0     # how many prunable rows are kept, newest first; older ones are pruned; 0 keeps every row
    pruned_when: ClassVar[Pruned] = Pruned.ANY
    indexed: ClassVar[tuple] = ()
    shown_fields: ClassVar[tuple] = ()   # data fields its panel shows and edits
    fixed_fields: ClassVar[tuple] = ()   # shown fields the journal sets, which the panel shows but never edits
    choices: ClassVar[dict] = {}         # the values a shown field may take, where they are fixed
    own_folder: ClassVar[bool] = False  # each row lives in a folder of its own, beside its files
    read_whole: ClassVar[bool] = False  # its files are read whole by the agent, however long
    progress: ClassVar[tuple] = ()   # data a system row may still change as it runs
    held: ClassVar[int | None] = 10000    # parsed rows kept in memory for each folder, the least recently used going first; None keeps them all; a listing of every row that outgrows it reads all of them again
    eager: ClassVar[bool] = False       # its open rows are parsed when the server starts
    version: ClassVar[int] = 1     # changes when the way its rows are read changes; a server started after an update keeps the rows it read before only of a type whose version is the same
    data_fields: ClassVar[list[Field]] = [
        Field(default=dict, name="files"),
        Field(default=dict, name="pictures"),
        Field(default="", name="agent"),
        Field(default="", name="dispatcher"),
        Field(default="", name="plugin"),
        Field(default="", name="environment"),
    ]
    type = ""              # the type's name; its title, abstract and help are the type's own words
    home_env = ""   # the environment a row read from another one came from; "" for a row of the reader's own
    n: int = 0
    title: str = ""
    abstract: str = ""
    brief: str = ""
    sections: list = field(default_factory=list)   # [{"title": str, "body": str}]
    refs: list = field(default_factory=list)       # ["type:n"]
    seen: list = field(default_factory=list)       # the actors who have seen it: USER, AGENT
    data: dict = field(default_factory=dict)       # what a type adds: status, answer, phases …
    created: float = 0.0
    updated: float = 0.0
    deleted: float = 0.0
    completed: float = 0.0
    outcome: str = ""      # what completing it said: how a to-do was done, a question's answer, why a pin was struck

    @classmethod
    def counters(cls) -> tuple[Counter, ...]:
        """The totals a type keeps over its rows: its overview, and the rows a listing can show."""
        return (Counter("overview", overview_weight, 3), Counter("listable", listable_weight(cls.hidden_listed), 2))

    def texts(self) -> set[str]:
        """Every text a person wrote into the row: its fields and its sections' titles and bodies."""
        return {*(getattr(self, name) for name in TEXT_FIELDS), *(part[key] for part in self.sections for key in (SECTION.title, SECTION.body))}

    def rewrite_texts(self, change: Callable[[str], str]) -> None:
        for name in TEXT_FIELDS:
            setattr(self, name, change(getattr(self, name)))
        self.sections = [{**part, SECTION.title: change(part[SECTION.title]), SECTION.body: change(part[SECTION.body])} for part in self.sections]

    @property
    def ref(self) -> str:
        return str(Ref(self.type, self.n, self.home_env))

    def creation_order(self) -> tuple[float, int]:
        return self.created, self.n

    @property
    def author(self) -> str:
        return self.seen[0] if self.seen else ""

    def agent_line(self) -> str:
        return f"{self.title} — {self.brief}" if self.brief else self.title

    def start_line(self) -> str:
        return f"{self.title}  ({self.abstract})" if self.abstract else self.title

    def member_refs(self) -> list[str]:
        return []

    def rewrite(self, words) -> None:
        self.title, self.abstract, self.brief, self.outcome = words(self.title), words(self.abstract), words(self.brief), words(self.outcome)
        self.sections = [{**s, SECTION.title: words(s[SECTION.title]), SECTION.body: words(s[SECTION.body])} for s in self.sections]
        self.data = reworded(self.data, words)

    def fork(self) -> "Resource":
        return replace(self, sections=[dict(s) for s in self.sections], refs=list(self.refs), seen=list(self.seen), data=copied(self.data))

    def dump(self) -> str:
        head = {f.name: getattr(self, f.name) for f in fields(self) if f.name not in ("sections", "brief")}
        head["type"] = self.type
        out = ["---", json.dumps(head, indent=2, default=plain), "---", self.brief.strip(), ""]
        for s in self.sections:
            out += [f"## {s[SECTION.title]}", s[SECTION.body].strip(), ""]
        return "\n".join(out)

    @classmethod
    def load(cls, text: str) -> "Resource":
        _, head, body = text.split("---\n", 2)
        got = json.loads(head)
        got.pop("type", None)
        parts = re.split(r"^## (.+)$", body, flags=re.M)
        brief = parts[0].strip()
        sections = [{SECTION.title: parts[i].strip(), SECTION.body: parts[i + 1].strip()} for i in range(1, len(parts) - 1, 2)]
        return cls(brief=brief, sections=sections, **got)


declare(Resource)


class Refused(Exception):
    pass


class Stale(Refused):
    pass


class Missing(Refused):
    pass


def check_title(title: str) -> str:
    flat = " ".join((title or "").split())
    if not flat:
        raise Refused("a title is required")
    if len(flat) > TITLE_MAX:
        raise Refused(f"a title is at most {TITLE_MAX} characters; this one is {len(flat)} — the rest goes in the brief")
    if ":" in flat:
        raise Refused("a title names the thing; it does not explain it with a colon — that goes in the brief")
    return flat


UNTITLED = "untitled"


def titled(text: str) -> str:
    lines = text.splitlines()
    line = " ".join(next((l for l in lines if l.strip() and not l.startswith(">")), text).split()).replace(":", " -")
    if len(line) <= TITLE_MAX:
        return line or UNTITLED
    cut = line[:TITLE_MAX - 1]
    return f"{(cut[:cut.rindex(' ')] if ' ' in cut else cut).rstrip(' -,.;')}…"


def check_abstract(abstract: str) -> str:
    flat = " ".join((abstract or "").split())
    if len(flat) > ABSTRACT_MAX:
        raise Refused(f"an abstract is at most {ABSTRACT_MAX} characters; this one is {len(flat)} — the rest goes in the brief")
    return flat
