import json
import re
from pathlib import Path

from providers.payload import DISPLAYED, EVENTS
from features.base import REGISTRY
from engine.upgrades import newer
from resources.base import ACTIONS, Refused
from resources.types import TYPES
from engine.gates import CANCELABLE
from features.plugins.declared import ASYNC, SYNC, Manifest

MANIFEST = Path(".journal-plugin") / "plugin.json"
KEYS = ("name", "version", "title", "description", "journal", "requires", "env", "setup", "services", "on", "refuse", "reads", "refuse_seconds", "refuse_socket", "chat", "pages", "dashboards", "settings", "skills", "command", "load", "installed", "events", "cancels", "fits", "hooks")
NAME = re.compile(r"[a-z0-9][a-z0-9-]{1,31}$")
WORD = re.compile(r"[a-z][a-z0-9_-]*$")
PATTERNS = {"*", *TYPES, *ACTIONS, *(f"{t}.{a}" for t in TYPES for a in ACTIONS), "hook.*", *(f"hook.{e}" for e in (*EVENTS, DISPLAYED))}
STEP = ("name", "run", "cwd")
SERVICE = ("run", "cwd", "env", "port", "ready", "restart", "grace", "show", "when")
PAGE = ("name", "title", "icon", "service", "path", "status")
DASHBOARD = ("name", "title", "icon")
TONES = ("", "warn", "good")
SETTING = ("title", "default", "help", "env", "type", "options", "group", "when", "detail", "parent")
KINDS = ("text", "textarea", "list", "number", "flag", "options", "command", "secret")
RESTARTS = ("always", "on-failure", "never")
FITS = {"languages": '["PHP", "Python"]', "files": '["composer.json", "*.csproj"]'}


def read(folder: Path, version: str = "") -> Manifest:
    path = Path(folder) / MANIFEST
    try:
        given = json.loads(path.read_text())
    except OSError as error:
        raise Refused(f"no {MANIFEST.as_posix()} in {folder}: not a journal plugin") from error
    except ValueError as error:
        raise Refused(f"plugin.json is not JSON: {error}") from error
    if not isinstance(given, dict):
        raise Refused("plugin.json holds one object, with a name and what the plugin listens to")
    wanted = given.get("journal")
    if wanted and version and newer(str(wanted), version):
        raise Refused(f"{given.get('name') or 'this plugin'} needs journal {wanted} or newer; this is {version}: upgrade the journal")
    unknown_keys(given, KEYS, "plugin.json")
    name = given.get("name")
    if not isinstance(name, str) or not NAME.fullmatch(name):
        raise Refused(f"plugin.json: name must be 2-32 lowercase letters, digits or dashes, got {name!r}")
    taken = {**{n: n for n in REGISTRY},
             **{old if isinstance(old, str) else old[0]: n for n, cls in REGISTRY.items() for old in cls.aliases}}
    if name in taken:
        raise Refused(f"plugin.json: name {name!r} is a built-in feature"
                      + (f", which {taken[name]} used to be called" if taken[name] != name else ""))
    checked = {key: given[key] for key in KEYS if key in given}
    checked["requires"] = shaped(given.get("requires") or {}, "requires", ("check", "hint"), ("check",))
    checked["env"] = texts(given.get("env") or {}, "env")
    checked["setup"] = steps(given.get("setup") or [])
    checked["services"] = services(given.get("services") or {})
    checked["on"] = handlers(given.get("on") or {})
    checked["hooks"] = hooks(given.get("hooks") or {})
    if given.get("refuse_socket") and given["refuse_socket"] not in checked["services"]:
        raise Refused(f"plugin.json: refuse_socket names one of its services, not {given['refuse_socket']!r}")
    checked["chat"] = chat(given.get("chat") or [])
    checked["pages"] = pages(name, given.get("pages") or [], checked["services"])
    checked["dashboards"] = dashboards(given.get("dashboards") or [])
    checked["settings"] = typed(shaped(given.get("settings") or {}, "settings", SETTING, ()))
    checked["events"] = shaped(given.get("events") or {}, "events", ("title", "tone", "card"), ("title",))
    for event, fields in checked["events"].items():
        if "card" in fields:
            shaped({event: fields["card"]}, "events.card", ("label", "color", "icon", "collapsed"), ())
        if fields.get("tone", "") not in TONES:
            raise Refused(f"plugin.json: events.{event}.tone is one of {', '.join(t for t in TONES if t)}")
    checked["load"] = loads(given.get("load") or {}, checked["events"])
    if "cancels" in checked:
        if not isinstance(checked["cancels"], dict) or any(event not in CANCELABLE for event in checked["cancels"]):
            raise Refused(f"plugin.json: cancels names events that can be cancelled: {', '.join(CANCELABLE)}")
        checked["cancels"] = {event: command(f"cancels.{event}", run) for event, run in checked["cancels"].items()}
    if "refuse" in checked:
        checked["refuse"] = command("refuse", checked["refuse"])
    checked["fits"] = fits(given.get("fits") or {})
    checked["reads"] = bool(given.get("reads"))
    if "installed" in checked and not isinstance(checked["installed"], str):
        raise Refused("plugin.json: installed is one command, run right after the plugin is installed or upgraded; its answer fills the settings")
    if "skills" in checked and (not isinstance(checked["skills"], str) or checked["skills"].startswith("/") or ".." in checked["skills"].split("/")):
        raise Refused("plugin.json: skills is a folder inside the plugin, holding one folder per skill with its SKILL.md")
    if "command" in checked and (not isinstance(checked["command"], str) or checked["command"].startswith("/") or ".." in checked["command"].split("/")):
        raise Refused("plugin.json: command is the plugin's own executable, a path inside the plugin, which the agent runs inside a repository to use the plugin")
    if "refuse_seconds" in checked and not isinstance(checked["refuse_seconds"], (int, float)):
        raise Refused("plugin.json: refuse_seconds is a number of seconds")
    return Manifest.of(checked)


def unknown_keys(given: dict, known, where: str) -> None:
    for key in given:
        if key not in known:
            raise Refused(f"{where} has unknown key {key!r}; known: {', '.join(known)}")


def fits(given) -> dict:
    if not isinstance(given, dict):
        raise Refused("plugin.json: fits is an object with languages and files, naming the projects the plugin is for")
    unknown_keys(given, FITS, "plugin.json: fits")
    for key, value in given.items():
        if not isinstance(value, list) or not value or not all(isinstance(entry, str) and entry.strip() for entry in value):
            raise Refused(f"plugin.json: fits.{key} is a list of names, such as {FITS[key]}")
    return {key: list(value) for key, value in given.items()}


def command(where: str, given) -> str | list:
    if isinstance(given, str) and given.strip():
        return given
    if isinstance(given, list) and given and all(isinstance(part, str) and part for part in given):
        return given
    raise Refused(f"plugin.json: {where} is a command, a line or a list of words, not {given!r}")


def texts(given, where: str) -> dict:
    if not isinstance(given, dict) or not all(isinstance(v, str) for v in given.values()):
        raise Refused(f"plugin.json: {where} names values, each one text")
    return dict(given)


def shaped(given, where: str, keys: tuple, needed: tuple) -> dict:
    if not isinstance(given, dict):
        raise Refused(f"plugin.json: {where} names one entry each")
    for key, value in given.items():
        if not isinstance(value, dict):
            raise Refused(f"plugin.json: {where}.{key} is an object with {', '.join(keys)}")
        unknown_keys(value, keys, f"plugin.json: {where}.{key}")
        for field in needed:
            if not value.get(field):
                raise Refused(f"plugin.json: {where}.{key} needs {field}")
    return {key: dict(value) for key, value in given.items()}


def typed(settings: dict) -> dict:
    for key, setting in settings.items():
        kind = setting.setdefault("type", "text")
        if kind not in KINDS:
            raise Refused(f"plugin.json: settings.{key} has type {kind!r}; a setting is one of {', '.join(KINDS)}")
        if kind == "options" and not (isinstance(setting.get("options"), list) and setting["options"]):
            raise Refused(f"plugin.json: settings.{key} is options, so it needs a list of options")
        when = setting.get("when") or []
        choices = when if isinstance(when, list) else [when]
        if not all(isinstance(c, dict) and all(other in settings for other in c) for c in choices):
            raise Refused(f"plugin.json: settings.{key}.when names settings and the value each must have, as in {{\"language_php\": true}}, or a list of those where any one is enough")
        setting["when"] = choices
        parent = setting.get("parent")
        if parent and (settings.get(parent) or {}).get("type") != "flag":
            raise Refused(f"plugin.json: settings.{key}.parent names the flag setting it sits under, and {parent!r} is no flag setting")
    return settings


def steps(given) -> list[dict]:
    if not isinstance(given, list):
        raise Refused("plugin.json: setup is a list of steps, run in order")
    out = []
    for i, step in enumerate(given, 1):
        if isinstance(step, (str, list)):
            step = {"run": step}
        if not isinstance(step, dict):
            raise Refused(f"plugin.json: setup step {i} is a command or an object with {', '.join(STEP)}")
        unknown_keys(step, STEP, f"plugin.json: setup step {i}")
        made = {"name": str(step.get("name") or f"step {i}"), "run": command(f"setup step {i}", step.get("run"))}
        if step.get("cwd"):
            made["cwd"] = str(step["cwd"])
        out.append(made)
    return out


def services(given) -> dict:
    if not isinstance(given, dict):
        raise Refused("plugin.json: services names one service each")
    out = {}
    for service, value in given.items():
        if not WORD.fullmatch(str(service)):
            raise Refused(f"plugin.json: service names are lowercase words; {service!r} is not")
        if not isinstance(value, dict):
            raise Refused(f"plugin.json: service {service!r} is an object with {', '.join(SERVICE)}")
        unknown_keys(value, SERVICE, f"plugin.json: service {service!r}")
        port = value.get("port")
        if port is not None and port != "auto" and not isinstance(port, int):
            raise Refused(f"plugin.json: service {service!r} takes a port number or \"auto\", not {port!r}")
        restart = str(value.get("restart") or "on-failure")
        if restart not in RESTARTS:
            raise Refused(f"plugin.json: service {service!r} restarts {', '.join(RESTARTS)}, not {restart!r}")
        when = value.get("when") or ""
        if not isinstance(when, str):
            raise Refused(f"plugin.json: service {service!r} takes \"when\" as one shell command whose exit 0 means it is needed here")
        out[service] = {**value, "run": command(f"service {service!r}", value.get("run")), "restart": restart, "when": when}
    return out


def chat(given) -> list:
    if not isinstance(given, list):
        raise Refused("plugin.json: chat is a list of {\"find\": \"<regex>\", \"as\": \"<markdown>\"}")
    out = []
    for rule in given:
        if not isinstance(rule, dict) or set(rule) - {"find", "as"} or not all(isinstance(rule.get(key), str) and rule[key] for key in ("find", "as")):
            raise Refused(f"plugin.json: each chat rule is {{\"find\": \"<regex>\", \"as\": \"<markdown>\"}}, got {rule!r}")
        try:
            re.compile(rule["find"])
        except re.error as broken:
            raise Refused(f"plugin.json: chat find {rule['find']!r} is not a pattern: {broken}") from None
        out.append({"find": rule["find"], "as": rule["as"]})
    return out


def loads(given, events: dict) -> dict:
    if not isinstance(given, dict):
        raise Refused("plugin.json: load names, for an event or a hook, the skills the agent must load when it happens")
    for pattern, skills in given.items():
        if pattern not in PATTERNS and pattern not in events:
            raise Refused(f"plugin.json: load {pattern!r} matches no event; it is a journal event, a hook.<event> or one of the plugin's own events")
        if not isinstance(skills, list) or not skills or not all(isinstance(skill, str) and skill for skill in skills):
            raise Refused(f"plugin.json: load {pattern!r} is a list of skill names")
    return dict(given)


def hooks(given) -> dict:
    if not isinstance(given, dict):
        raise Refused("plugin.json: hooks names each hook event the plugin listens to and whether it is sync or async")
    for event, how in given.items():
        if event not in EVENTS:
            raise Refused(f"plugin.json: hooks {event!r} is not a hook event: {', '.join(EVENTS)}")
        if how not in (SYNC, ASYNC):
            raise Refused(f"plugin.json: hooks.{event} is {SYNC} (the tool call waits for the answer) or {ASYNC} (it runs beside the agent), not {how!r}")
    return dict(given)


def handlers(given) -> dict:
    if not isinstance(given, dict):
        raise Refused("plugin.json: on names an event pattern for each handler")
    out = {}
    for pattern, handler in given.items():
        if pattern not in PATTERNS:
            raise Refused(f"plugin.json: on {pattern!r} matches no event; a pattern is *, a type ({', '.join(sorted(TYPES)[:3])}, …), an action ({', '.join(ACTIONS[:2])}, …), type.action, or hook.<event>")
        if isinstance(handler, dict):
            if set(handler) != {"post"} or not isinstance(handler.get("post"), str) or not handler["post"]:
                raise Refused(f"plugin.json: on {pattern!r} is a command, or {{\"post\": \"<url>\"}}")
            out[pattern] = dict(handler)
            continue
        out[pattern] = {"run": command(f"on {pattern!r}", handler)}
    return out


def pages(name: str, given, declared: dict) -> list[dict]:
    if not isinstance(given, list):
        raise Refused("plugin.json: pages is a list of pages")
    out = []
    for page in given:
        if not isinstance(page, dict):
            raise Refused(f"plugin.json: each page is an object with {', '.join(PAGE)}")
        unknown_keys(page, PAGE, "plugin.json: page")
        service = page.get("service")
        if service not in declared:
            raise Refused(f"plugin.json: page {page.get('title') or page.get('name')!r} names service {service!r}, which is not declared")
        out.append({**page, "name": str(page.get("name") or service), "title": str(page.get("title") or name.title()), "path": str(page.get("path") or "/")})
    return out


def dashboards(given) -> list[dict]:
    if not isinstance(given, list):
        raise Refused("plugin.json: dashboards is a list of dashboards")
    out = []
    for board in given:
        if not isinstance(board, dict) or not board.get("name"):
            raise Refused(f"plugin.json: each dashboard is an object with a name, and may have {', '.join(DASHBOARD[1:])}")
        unknown_keys(board, DASHBOARD, "plugin.json: dashboard")
        out.append({**board, "name": str(board["name"]), "title": str(board.get("title") or board["name"]).strip()})
    return out
