import json
import re
from dataclasses import dataclass, field, asdict, replace
from enum import Enum
from types import SimpleNamespace
from typing import 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"
ACTIONS = ("created", "updated", "deleted", "linked", "commented", "completed", "reopened", "stamped", "reported", "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"
SIDEBAR, RESULTS, WORKINGS, UNLISTED = "sidebar", "results", "workings", ""
SCOPES = (ENVIRONMENT, PROJECT)
LAZY, MEMORY = "lazy", "memory"
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):
        self.spec, self.default, self.name, self.required = spec, default, name, required

    def __set_name__(self, owner, name: str) -> None:
        self.name = name

    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")


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


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

    @classmethod
    def parse(cls, ref: "str | Ref") -> "Ref":
        kind, _, n = str(ref).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")
        return cls(kind, int(n))

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

    def __str__(self) -> str:
        return 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

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

    def __init_subclass__(cls, **kw):
        super().__init_subclass__(**kw)
        declare(cls)

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


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
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
    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
    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
    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
    loading: ClassVar[str] = MEMORY
    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
    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

    @property
    def ref(self) -> str:
        return f"{self.type}:{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.body: words(s[SECTION.body])} for s in self.sections]

    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 = {k: v for k, v in asdict(self).items() if k not in ("sections", "brief")}
        head["type"] = self.type
        out = ["---", json.dumps(head, indent=2), "---", 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
from typing import ClassVar

import json

from resources.base import Field, Refused, declare, names

TEXT, NUMBER, FLAG, LIST = "text", "number", "flag", "list"
KINDS = {TEXT: str, NUMBER: (int, float), FLAG: bool, LIST: list}


def typed(value):
    if not isinstance(value, str):
        return value
    try:
        return json.loads(value) if value[:1] in "[{" or value in ("true", "false") or value.lstrip("-").replace(".", "", 1).isdigit() else value
    except ValueError:
        return value


LEVELS = {"low": 50, "default": 100, "high": 150, "critical": 200}


def level_named(priority) -> str | None:
    return next((name for name, n in LEVELS.items() if str(n) == str(priority)), None)


def priority_level(value) -> int:
    level = str(value).lower()
    if level in LEVELS:
        return LEVELS[level]
    if not level.lstrip("-").isdigit():
        raise Refused(f"a priority is a number or one of {', '.join(LEVELS)}")
    return int(level)


def rows(**columns: str) -> dict:
    return {"rows": columns}



def check(name: str, spec, value):
    if isinstance(spec, dict):
        if not isinstance(value, list) or not all(isinstance(v, dict) for v in value):
            raise Refused(f"{name} is a list of rows")
        for row in value:
            for column in (column for column in spec["rows"] if column in row):
                check(f"{name}.{column}", spec["rows"][column], row[column])
        return value
    if spec == LIST and isinstance(value, str):
        value = [word.strip() for word in value.split(",") if word.strip()]
    if spec == NUMBER and isinstance(value, str) and value.lower() in LEVELS:
        value = priority_level(value)
    if not isinstance(value, KINDS[spec]) or isinstance(value, bool) and spec != FLAG:
        raise Refused(f"{name} is a {spec}")
    return value


def normalize_options(value):
    if not isinstance(value, list):
        return value
    normalized = []
    for option in value:
        if not isinstance(option, dict):
            normalized.append(option)
            continue
        raw = option
        option = {k: v for k, v in raw.items() if k not in ("label", "value")}
        title = option.get("title") or raw.get("label")
        if not title:
            raise Refused("options.title is required")
        option["title"] = title
        if "code" not in option and "value" in raw:
            option["code"] = raw["value"]
        normalized.append(option)
    return normalized


class Shape:
    fields: ClassVar[dict] = {}
    required: ClassVar[list[str]] = []
    labels: ClassVar[dict] = {}

    def __init_subclass__(cls, **kw):
        super().__init_subclass__(**kw)
        declare(cls)
        cls.fields = {k: v.spec for base in reversed(cls.__mro__) for k, v in vars(base).items() if isinstance(v, Field) and v.spec}
        cls.required = [k for base in reversed(cls.__mro__) for k, v in vars(base).items() if isinstance(v, Field) and v.required]
        cls.labels = {k: v for base in reversed(cls.__mro__) for k, v in vars(base).get("labels", {}).items()}


OPTION = names("title", "description", "code")


class Options(Shape):
    data_fields: ClassVar[list[Field]] = [
        Field(rows(title=TEXT, description=TEXT, code=TEXT), list, name="options"),
        Field(NUMBER, name="pick"),
        Field(NUMBER, 0, name="chosen"),
    ]

    def chosen_for(self, answer: str) -> int:
        titles = [option["title"] if isinstance(option, dict) else str(option) for option in self.options]
        return titles.index(answer.strip()) + 1 if answer.strip() in titles else 0


class Reasoned(Shape):
    data_fields: ClassVar[list[Field]] = [
        Field(LIST, list, name="keywords", required=True),
        Field(TEXT, "both", name="keywords_in"),
    ]
    labels = {"brief": "Reasoning", "outcome": "Why struck"}




class Ranked(Shape):
    data_fields: ClassVar[list[Field]] = [
        Field(NUMBER, name="priority"),
    ]
    labels = {"priority": "Priority"}


class Placed(Shape):
    data_fields: ClassVar[list[Field]] = [
        Field(NUMBER, 0.0, name="rank"),
    ]

    @property
    def position(self) -> float:
        return float(self.rank or self.n)


def rank_before(rows: list, before: int) -> float:
    at = next((i for i, row in enumerate(rows) if row.n == before), None)
    if at is None:
        raise Refused(f"#{before} is not in the same column")
    return (rows[at - 1].position + rows[at].position) / 2 if at else rows[at].position / 2


CHANGE = names("path", "added", "removed", "created")
COMMIT = names("sha", "subject")


class Traced(Shape):
    data_fields: ClassVar[list[Field]] = [
        Field(rows(path=TEXT, added=NUMBER, removed=NUMBER, created=FLAG), list, name="changed"),
        Field(rows(sha=TEXT, subject=TEXT), list, name="commits"),
    ]
import struct
from pathlib import Path


def png(head: bytes) -> tuple[int, int] | None:
    return struct.unpack(">II", head[16:24]) if head[:8] == b"\x89PNG\r\n\x1a\n" and head[12:16] == b"IHDR" else None


def gif(head: bytes) -> tuple[int, int] | None:
    return struct.unpack("<HH", head[6:10]) if head[:6] in (b"GIF87a", b"GIF89a") else None


def webp(head: bytes) -> tuple[int, int] | None:
    if head[:4] != b"RIFF" or head[8:12] != b"WEBP":
        return None
    kind = head[12:16]
    if kind == b"VP8X":
        return int.from_bytes(head[24:27], "little") + 1, int.from_bytes(head[27:30], "little") + 1
    if kind == b"VP8L":
        bits = int.from_bytes(head[21:25], "little")
        return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
    if kind == b"VP8 ":
        return struct.unpack("<HH", head[26:30])[0] & 0x3FFF, struct.unpack("<HH", head[28:32])[0] & 0x3FFF
    return None


def jpeg(data: bytes) -> tuple[int, int] | None:
    if data[:2] != b"\xff\xd8":
        return None
    i = 2
    while i + 9 < len(data):
        if data[i] != 0xFF:
            i += 1
            continue
        marker = data[i + 1]
        if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
            i += 2
            continue
        length = struct.unpack(">H", data[i + 2:i + 4])[0]
        if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
            height, width = struct.unpack(">HH", data[i + 5:i + 9])
            return width, height
        i += 2 + length
    return None


def dimensions(path: Path) -> tuple[int, int] | None:
    try:
        with path.open("rb") as f:
            head = f.read(65536)
    except OSError:
        return None
    for read in (png, gif, webp, jpeg):
        got = read(head)
        if got and all(got):
            return int(got[0]), int(got[1])
    return None
src/resources/types.py:21:    deduplicates = True
src/resources/types.py:26:    editors = {USER: (USER, SYSTEM), AGENT: (AGENT, SYSTEM)}
src/resources/types.py:169:    lists_completed_unread = True
src/resources/types.py:190:    lists_completed_unread = True
src/resources/types.py:269:    deduplicates = True
src/resources/types.py:270:    editors = {USER: (USER, SYSTEM), AGENT: (AGENT, SYSTEM)}
src/resources/types.py:385:    pruned_when = Pruned.SEEN
src/resources/types.py:401:    pruned_when = Pruned.CLOSED
src/resources/types.py:530:    pruned_when = Pruned.CLOSED
src/resources/base.py:160:    lists_completed_unread: ClassVar[bool] = False
src/resources/base.py:179:    editors: ClassVar[dict] = {}              # who may change the words of a row written by whom: {USER: (USER,)}; unnamed authors are open to all
src/resources/base.py:181:    deduplicates: ClassVar[bool] = False
src/resources/base.py:183:    pruned_when: ClassVar[Pruned] = Pruned.ANY
src/features/journal_laws/resource.py:21:    deduplicates = False
src/features/plans/resource.py:51:    lists_completed_unread = True
src/controllers/described.py:15:    return {name: {"title": c.details.title, "abstract": c.details.abstract, "help": c.details.help, "view": c.view, "in_sidebar": c.in_sidebar, "listed_under": c.listed_under, "listed_as_cards": c.listed_as_cards, "created_in_viewer": c.created_in_viewer, "scope": c.scope, "icon": c.icon, "needs_attention": c.needs_attention, "lists_completed_unread": c.lists_completed_unread, "cleared_by": c.cleared_by, "filters": tabs(c), "nested": c.nested, "takes_comments": c.takes_comments, "closed_first": c.closed_first, "notified": list(c.notified), "typed_as_title": c.typed_as_title, "start_as_count": c.start_as_count, "fields": c.fields, "shown_fields": list(c.shown_fields), "fixed_fields": list(c.fixed_fields), "choices": c.choices, "labels": c.labels,
