"""Live GUI preview server: render an app's JSON Schema as an RJSF form.

No Node/build step — RJSF is loaded from esm.sh in the browser. This closes the
authoring loop: edit signature -> refresh -> see the form -> copy inputs.json.
The MVP uses the stock RJSF theme; high-fidelity (matching production pmview)
would import a shared RJSF widget package instead.
"""

from __future__ import annotations

import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

from ._generate import func_to_manifest, signature_to_inputs_model

_PREVIEW_HTML = """<!doctype html>
<html><head><meta charset="utf-8"><title>pmmanifest GUI preview</title>
<style>
 body{font-family:system-ui,sans-serif;max-width:760px;margin:2rem auto;padding:0 1rem;color:#333}
 pre{background:#f5f5f5;padding:1rem;border-radius:8px;overflow:auto}
 .err{color:#b00}
 .pm-field{margin:.5rem 0}
 details.pm-group{border:1px solid #e0e0e0;border-radius:4px;margin:.75rem 0}
 details.pm-group>summary{list-style:none;cursor:pointer;padding:.6rem .9rem;font-size:.9rem;font-weight:600;
   display:flex;justify-content:space-between;align-items:center}
 details.pm-group>summary::-webkit-details-marker{display:none}
 details.pm-group>summary::after{content:"\\25be";color:#888;font-weight:400}
 details.pm-group[open]>summary::after{content:"\\25b4"}
 details.pm-group>.pm-body{padding:.25rem .9rem .6rem}
</style></head>
<body>
<h2 id="title">Loading...</h2>
<p>Live RJSF preview of the app's JSON Schema. Submit to see the resulting
<code>inputs.json</code> (also validated server-side by the Pydantic model).</p>
<div id="root"></div>
<h3>Resulting inputs.json</h3>
<pre id="out">-</pre>
<script type="module">
import React from "https://esm.sh/react@18";
import { createRoot } from "https://esm.sh/react-dom@18/client";
import Form from "https://esm.sh/@rjsf/mui@5?deps=react@18,react-dom@18,@mui/material@5,@emotion/react@11,@emotion/styled@11";
import { customizeValidator } from "https://esm.sh/@rjsf/validator-ajv8@5?deps=react@18";
import { canExpand, ADDITIONAL_PROPERTY_FLAG } from "https://esm.sh/@rjsf/utils@5?deps=react@18";
import Ajv2020 from "https://esm.sh/ajv@8/dist/2020";

// Pydantic emits JSON Schema 2020-12 -> use the AJV 2020 validator (the default
// rjsf validator is draft-07 and mis-handles anyOf/oneOf here). Register the
// non-asserting "path" format and relax strict mode (e.g. the discriminator kw).
const validator = customizeValidator({
  AjvClass: Ajv2020,
  customFormats: { path: () => true },
  ajvOptionsOverrides: { strict: false },
});

// Optional[X] is "anyOf[X, null]"; RJSF renders that as a clunky multi-option
// field (and can't bind a widget). Drop null branches for display; collapse a
// sole remaining branch. oneOf (e.g. the discriminated amount) is left intact.
function simplify(s) {
  if (Array.isArray(s)) return s.map(simplify);
  if (s && typeof s === "object") {
    if (Array.isArray(s.anyOf)) {
      const nonNull = s.anyOf.filter(b => !(b && b.type === "null"));
      if (nonNull.length === 1) {
        const { anyOf, ...rest } = s;
        return simplify({ ...nonNull[0], ...rest });
      }
      s = { ...s, anyOf: nonNull };
    }
    // Discriminated oneOf: label each option with its discriminator key so the
    // RJSF selector shows "concentration"/"copies" instead of "Option 1/2".
    if (Array.isArray(s.oneOf) && s.discriminator && s.discriminator.mapping) {
      const inv = {};
      for (const [k, ref] of Object.entries(s.discriminator.mapping)) inv[ref.split("/").pop()] = k;
      s = { ...s, oneOf: s.oneOf.map(o => {
        const nm = o && o.$ref ? o.$ref.split("/").pop() : null;
        return (nm && inv[nm] && !o.title) ? { ...o, title: inv[nm] } : o;
      }) };
    }
    const out = {};
    for (const [k, v] of Object.entries(s)) out[k] = simplify(v);
    return out;
  }
  return s;
}

// Platform-managed dirs: pmview drops these from the form by name; mirror that.
const SPECIAL_DIRS = ["outdir", "scratchdir", "execdir"];
function stripSpecialDirs(s) {
  if (!s || !s.properties) return s;
  const properties = { ...s.properties };
  for (const d of SPECIAL_DIRS) delete properties[d];
  const required = (s.required || []).filter(r => !SPECIAL_DIRS.includes(r));
  return { ...s, properties, required };
}

const e = React.createElement;
const data = await (await fetch("/schema")).json();
document.getElementById("title").textContent = data.name + " - GUI preview";
const schema = simplify(stripSpecialDirs(data.schema));
const out = document.getElementById("out");
function onSubmit({ formData }) {
  out.textContent = JSON.stringify(formData, null, 2);
  fetch("/inputs", { method: "POST", body: JSON.stringify(formData) })
    .then(r => r.json())
    .then(v => { if (!v.ok) out.innerHTML += '\\n\\n<span class="err">server validation: '
                                              + v.error + "</span>"; });
}

// RJSF has no native field grouping. This is a full ObjectFieldTemplate that
// ALSO buckets properties by ui:options.group into collapsible panels. It must
// reproduce the default template's responsibilities (title + the
// additionalProperties "Add" button) since it replaces the default for EVERY
// object -- otherwise map fields like cosolvents / residue_templates render
// blank. Nested objects carry no group hints, so they render flat.
function GroupedObjectFieldTemplate(props) {
  const ui = props.uiSchema || {};
  const T = props.registry.templates;
  const ungrouped = [];
  const groups = new Map();
  for (const el of props.properties) {
    const g = ui[el.name] && ui[el.name]["ui:options"] && ui[el.name]["ui:options"].group;
    if (g) { (groups.get(g) || groups.set(g, []).get(g)).push(el); }
    else { ungrouped.push(el); }
  }
  const kids = [];
  if (props.title) {
    kids.push(e(T.TitleFieldTemplate, {
      key: "__title", id: props.idSchema.$id + "__title", title: props.title,
      required: props.required, schema: props.schema, uiSchema: props.uiSchema,
      registry: props.registry,
    }));
  }
  if (props.description) {
    kids.push(e(T.DescriptionFieldTemplate, {
      key: "__desc", id: props.idSchema.$id + "__description", description: props.description,
      schema: props.schema, uiSchema: props.uiSchema, registry: props.registry,
    }));
  }
  for (const el of ungrouped) kids.push(e("div", { key: el.name, className: "pm-field" }, el.content));
  for (const [name, els] of groups) {
    kids.push(e("details", { key: name, className: "pm-group" },
      e("summary", null, name),
      e("div", { className: "pm-body" }, els.map(x => e("div", { key: x.name, className: "pm-field" }, x.content)))));
  }
  // additionalProperties maps (cosolvents, residue_templates) need an Add button.
  if (canExpand(props.schema, props.uiSchema, props.formData)) {
    kids.push(e(T.ButtonTemplates.AddButton, {
      key: "__add", className: "object-property-expand",
      onClick: props.onAddClick(props.schema),
      disabled: props.disabled || props.readonly,
      uiSchema: props.uiSchema, registry: props.registry,
    }));
  }
  return e("div", null, kids);
}

// additionalProperties row: use a custom key label (uiSchema.ui:options.keyLabel)
// instead of RJSF's generic "<name> Key". Preserves key rename + remove.
function WrapIfAdditionalTemplate(props) {
  const { children, classNames, style, disabled, id, label, onKeyChange,
          onDropPropertyClick, readonly, schema, uiSchema, registry } = props;
  if (!(ADDITIONAL_PROPERTY_FLAG in schema)) {
    return e("div", { className: classNames, style }, children);
  }
  const RemoveButton = registry.templates.ButtonTemplates.RemoveButton;
  const keyLabel = (uiSchema && uiSchema["ui:options"] && uiSchema["ui:options"].keyLabel) || (label + " Key");
  const keyId = id + "-key";
  const inputStyle = { width: "100%", padding: ".5rem", border: "1px solid #ccc", borderRadius: "4px", font: "inherit", boxSizing: "border-box" };
  return e("div", { style: { display: "flex", gap: "1rem", alignItems: "flex-start", marginBottom: ".75rem" } },
    e("div", { style: { flex: "0 0 30%" } },
      e("label", { htmlFor: keyId, style: { display: "block", marginBottom: ".25rem" } }, keyLabel),
      e("input", { id: keyId, type: "text", defaultValue: label, disabled: disabled || readonly,
                   style: inputStyle, onBlur: ev => onKeyChange(ev.target.value) })),
    e("div", { style: { flex: "1 1 auto" } }, children),
    e("div", { style: { flex: "0 0 auto", paddingTop: "1.6rem" } },
      e(RemoveButton, { disabled: disabled || readonly, onClick: onDropPropertyClick(label), uiSchema, registry })));
}

// Render the field description as a hover "?" icon (native tooltip) instead of
// inline text, matching pmview. The short label is the field title (ui:title).
function DescriptionFieldTemplate(props) {
  // Prefer the schema's raw string (props.description may be a React node under
  // the MUI theme, which can't be used as a title attribute).
  const raw = (props.schema && props.schema.description) ||
              (typeof props.description === "string" ? props.description : "");
  if (!raw) return null;
  const text = String(raw);
  return e("span", {
    title: text, "aria-label": text,
    style: { display: "inline-flex", alignItems: "center", justifyContent: "center",
             width: "18px", height: "18px", borderRadius: "50%", border: "1px solid #999",
             color: "#666", fontSize: "12px", lineHeight: 1, cursor: "help",
             marginLeft: "6px", verticalAlign: "middle" },
  }, "?");
}

// Object / map field titles (e.g. "Extra files", "Residue templates") render as
// a small consistent label, not a large heading, to match pmview's field sizing.
function TitleFieldTemplate(props) {
  if (!props.title) return null;
  return e("div", { style: { fontSize: ".9rem", fontWeight: 600, margin: ".4rem 0 .3rem" } },
    props.title, props.required ? e("span", { style: { color: "#b00" } }, " *") : null);
}

// Render arrays (e.g. list[Path]) flat -- no MUI Paper "card" -- so they look
// like the additionalProperties maps (residue_templates / caps), not a box.
function ArrayFieldTemplate(props) {
  const T = props.registry.templates;
  const ItemTemplate = T.ArrayFieldItemTemplate;
  // RJSF passes the raw field name as props.title for arrays; prefer ui:title.
  const title = (props.uiSchema && props.uiSchema["ui:title"]) || props.title;
  const kids = [];
  if (title) kids.push(e(T.TitleFieldTemplate, {
    key: "__t", id: props.idSchema.$id + "__title", title: title,
    required: props.required, schema: props.schema, uiSchema: props.uiSchema, registry: props.registry }));
  const desc = props.schema && props.schema.description;
  if (desc) kids.push(e(T.DescriptionFieldTemplate, {
    key: "__d", id: props.idSchema.$id + "__description", description: desc,
    schema: props.schema, uiSchema: props.uiSchema, registry: props.registry }));
  (props.items || []).forEach(({ key, ...itemProps }) =>
    kids.push(e(ItemTemplate, { key, ...itemProps })));
  if (props.canAdd) kids.push(e(T.ButtonTemplates.AddButton, {
    key: "__add", onClick: props.onAddClick, disabled: props.disabled || props.readonly,
    uiSchema: props.uiSchema, registry: props.registry }));
  return e("div", null, kids);
}

try {
  createRoot(document.getElementById("root")).render(
    e(Form, {
      schema, uiSchema: data.uiSchema, validator, onSubmit,
      templates: { ObjectFieldTemplate: GroupedObjectFieldTemplate, WrapIfAdditionalTemplate,
                   DescriptionFieldTemplate, TitleFieldTemplate, ArrayFieldTemplate },
    }));
} catch (err) {
  document.getElementById("root").innerHTML = '<pre class="err">' + err + "</pre>";
}
</script>
</body></html>"""


def serve(func, *, port: int = 8000, function: str | None = None, name: str | None = None):
    """Serve a live RJSF preview of ``func``'s generated JSON Schema on ``port``.

    ``GET /schema`` returns ``{name, schema, uiSchema}``; ``GET /`` serves the
    page; ``POST /inputs`` validates submitted form data with the synthesized
    Pydantic model (proving coercion + PMPath flexibility).
    """
    entry = func_to_manifest(func, function=function)
    app_name = name or getattr(func, "__name__", "app")
    payload = {"name": app_name, "schema": entry["inputSchema"], "uiSchema": entry["uiSchema"]}
    model, _ = signature_to_inputs_model(func)

    class _Handler(BaseHTTPRequestHandler):
        def log_message(self, *_args):
            pass

        def _send(self, code, body, ctype):
            data = body.encode() if isinstance(body, str) else body
            self.send_response(code)
            self.send_header("Content-Type", ctype)
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        def do_GET(self):
            if self.path == "/":
                self._send(200, _PREVIEW_HTML, "text/html")
            elif self.path == "/schema":
                self._send(200, json.dumps(payload), "application/json")
            else:
                self._send(404, "not found", "text/plain")

        def do_POST(self):
            n = int(self.headers.get("Content-Length", 0))
            raw = self.rfile.read(n).decode() or "{}"
            try:
                model.model_validate(json.loads(raw))
                self._send(200, json.dumps({"ok": True}), "application/json")
            except Exception as ex:  # noqa: BLE001 - surface any validation error
                self._send(
                    200,
                    json.dumps({"ok": False, "error": str(ex).splitlines()[0]}),
                    "application/json",
                )

    print(f"pmmanifest: serving GUI preview on http://127.0.0.1:{port}  (Ctrl-C to stop)")
    ThreadingHTTPServer(("127.0.0.1", port), _Handler).serve_forever()
