#!/usr/bin/env python3
# Author: Tom Sapletta · https://tom.sapletta.com
# Part of the ifURI solution.

"""Host-side service control dashboard for urirun node services.

The dashboard intentionally controls only services declared in ``services.yaml``.
It gives a chat-friendly NL command surface and a small local HTTP UI without
allowing prompts to invent arbitrary URIs.
"""

from __future__ import annotations

import argparse
import html
import json
import os
import re
import socket
import sys
import time
import unicodedata
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlparse

HERE = Path(__file__).resolve().parent
DEFAULT_ENV = HERE / ".env"
DEFAULT_ENV_EXAMPLE = HERE / ".env.example"
DEFAULT_SERVICES = HERE / "services.yaml"
DEFAULT_STATE = HERE / ".state"

Transport = Callable[[str, str, dict[str, Any], float], dict[str, Any]]

ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}")

ACTION_WORDS = {
    "restart": ("restart", "przeladuj", "przeładuj", "ponownie", "zrestartuj"),
    "stop": ("stop", "zatrzymaj", "wylacz", "wyłącz", "ubij"),
    "start": ("start", "uruchom", "uruchomienie", "wlacz", "włącz", "odpal", "wystartuj"),
    "status": ("status", "sprawdz", "sprawdź", "czy dziala", "czy działa", "link", "url", "adres", "pokaz", "pokaż"),
}


class ServiceError(RuntimeError):
    """Raised when a registered service cannot be resolved or controlled."""


def load_env(path: str | Path = DEFAULT_ENV) -> dict[str, str]:
    """Load KEY=VALUE pairs from .env and overlay the current process env."""
    env_path = Path(path)
    source = env_path
    if not source.exists() and env_path.name == ".env" and DEFAULT_ENV_EXAMPLE.exists():
        source = DEFAULT_ENV_EXAMPLE
    values: dict[str, str] = {}
    if source.exists():
        for raw in source.read_text(encoding="utf-8").splitlines():
            line = raw.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            key = key.strip()
            value = value.strip().strip('"').strip("'")
            if key:
                values[key] = value
    values.update({key: str(value) for key, value in os.environ.items()})
    return values


def coerce_scalar(value: str) -> Any:
    stripped = value.strip()
    if re.fullmatch(r"-?\d+", stripped):
        return int(stripped)
    lowered = stripped.lower()
    if lowered in {"true", "false"}:
        return lowered == "true"
    if lowered in {"null", "none"}:
        return None
    return value


def expand_env(value: Any, env: dict[str, str]) -> Any:
    if isinstance(value, dict):
        return {str(key): expand_env(item, env) for key, item in value.items()}
    if isinstance(value, list):
        return [expand_env(item, env) for item in value]
    if not isinstance(value, str):
        return value

    def replace(match: re.Match[str]) -> str:
        key, fallback = match.group(1), match.group(2)
        if key in env:
            return env[key]
        if fallback is not None:
            return fallback
        raise ServiceError(f"missing environment variable: {key}")

    expanded = ENV_PATTERN.sub(replace, value)
    return coerce_scalar(expanded) if expanded != value or re.fullmatch(r"(true|false|null|none|-?\d+)", expanded.strip(), re.I) else expanded


def load_services(path: str | Path = DEFAULT_SERVICES, env_path: str | Path = DEFAULT_ENV) -> dict[str, Any]:
    services_path = Path(path)
    if not services_path.exists():
        raise ServiceError(f"services file does not exist: {services_path}")
    try:
        import yaml
    except ModuleNotFoundError as exc:  # pragma: no cover - PyYAML is available in the project venv.
        raise ServiceError("PyYAML is required to read services.yaml") from exc
    raw = yaml.safe_load(services_path.read_text(encoding="utf-8")) or {}
    data = expand_env(raw, load_env(env_path))
    services = data.get("services") if isinstance(data, dict) else None
    if not isinstance(services, list):
        raise ServiceError("services.yaml must contain a services list")
    for service in services:
        if not isinstance(service, dict) or not service.get("id"):
            raise ServiceError("each service must be an object with id")
        if not isinstance(service.get("actions"), dict):
            raise ServiceError(f"service {service.get('id')} has no actions map")
    return {"version": data.get("version", "urirun.services.v1"), "services": services}


def normalize_text(value: str) -> str:
    plain = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
    return " ".join(plain.lower().split())


def detect_action(prompt: str) -> str:
    lowered = normalize_text(prompt)
    for action, words in ACTION_WORDS.items():
        if any(normalize_text(word) in lowered for word in words):
            return action
    return "status"


def service_terms(service: dict[str, Any]) -> list[str]:
    terms = [str(service.get("id", "")), str(service.get("label", ""))]
    terms.extend(str(item) for item in service.get("aliases") or [])
    return [normalize_text(term) for term in terms if term]


def choose_service(prompt: str, services: list[dict[str, Any]], explicit: str | None = None) -> dict[str, Any]:
    if explicit:
        wanted = normalize_text(explicit)
        for service in services:
            if wanted == normalize_text(str(service.get("id"))) or wanted in service_terms(service):
                return service
        raise ServiceError(f"unknown service: {explicit}")
    if len(services) == 1:
        return services[0]
    lowered = normalize_text(prompt)
    scored: list[tuple[int, dict[str, Any]]] = []
    for service in services:
        score = sum(1 for term in service_terms(service) if term and term in lowered)
        if score:
            scored.append((score, service))
    if not scored:
        raise ServiceError("no service matched the prompt")
    scored.sort(key=lambda item: (-item[0], str(item[1].get("id"))))
    return scored[0][1]


def node_run(node_url: str, uri: str, payload: dict[str, Any], timeout: float = 20.0) -> dict[str, Any]:
    body = json.dumps({"uri": uri, "payload": payload}, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(
        f"{node_url.strip().rstrip('/')}/run",
        data=body,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as response:
            return json.loads(response.read().decode("utf-8") or "{}")
    except urllib.error.HTTPError as exc:
        try:
            detail = exc.read().decode("utf-8")
        except Exception:  # noqa: BLE001
            detail = str(exc)
        raise ServiceError(f"node HTTP {exc.code}: {detail}") from exc
    except (OSError, TimeoutError, json.JSONDecodeError) as exc:
        raise ServiceError(f"node request failed: {exc}") from exc


def result_value(envelope: dict[str, Any]) -> dict[str, Any]:
    result = envelope.get("result")
    if isinstance(result, dict):
        value = result.get("value")
        if isinstance(value, dict):
            return value
        return result
    return envelope if isinstance(envelope, dict) else {"value": envelope}


def action_spec(service: dict[str, Any], action: str) -> dict[str, Any]:
    actions = service.get("actions") or {}
    spec = actions.get(action)
    if not isinstance(spec, dict) or not spec.get("uri"):
        raise ServiceError(f"service {service.get('id')} has no {action} action")
    return spec


def call_action(
    service: dict[str, Any],
    action: str,
    *,
    execute: bool = True,
    transport: Transport = node_run,
    timeout: float = 20.0,
) -> dict[str, Any]:
    if action == "restart":
        stopped = call_action(service, "stop", execute=execute, transport=transport, timeout=timeout)
        started = call_action(service, "start", execute=execute, transport=transport, timeout=timeout)
        return {"ok": bool(started.get("ok")), "service": service_public(service), "action": action, "steps": [stopped, started], "status": started.get("status")}

    spec = action_spec(service, action)
    uri = str(spec["uri"])
    payload = dict(spec.get("payload") or {})
    node_url = str(spec.get("node_url") or service.get("node_url") or "")
    if not node_url:
        raise ServiceError(f"service {service.get('id')} has no node_url")
    if not execute:
        return {"ok": True, "dryRun": True, "service": service_public(service), "action": action, "uri": uri, "payload": payload}

    envelope = transport(node_url, uri, payload, timeout)
    status = result_value(envelope)
    ok = bool(envelope.get("ok", True)) and bool(status.get("ok", True))
    response = {"ok": ok, "service": service_public(service), "action": action, "uri": uri, "payload": payload, "status": status, "raw": envelope}
    if action == "stop" and "status" in (service.get("actions") or {}):
        try:
            response["after"] = call_action(service, "status", execute=True, transport=transport, timeout=timeout).get("status")
        except ServiceError as exc:
            response["afterError"] = str(exc)
    return response


def service_public(service: dict[str, Any]) -> dict[str, Any]:
    return {
        "id": service.get("id"),
        "label": service.get("label") or service.get("id"),
        "description": service.get("description") or "",
        "public_url": service.get("public_url") or "",
        "aliases": service.get("aliases") or [],
    }


def handle_prompt(
    prompt: str,
    catalog: dict[str, Any],
    *,
    execute: bool = True,
    service_id: str | None = None,
    transport: Transport = node_run,
    timeout: float = 20.0,
) -> dict[str, Any]:
    services = catalog.get("services") or []
    service = choose_service(prompt, services, explicit=service_id)
    action = detect_action(prompt)
    return call_action(service, action, execute=execute, transport=transport, timeout=timeout)


def compact_status(status: dict[str, Any] | None) -> dict[str, Any]:
    if not isinstance(status, dict):
        return {}
    keys = ("running", "portOpen", "url", "pid", "port", "root", "indexExists", "runShExists", "php", "started", "stopped", "reason", "error")
    return {key: status.get(key) for key in keys if key in status}


def format_markdown(result: dict[str, Any]) -> str:
    service = result.get("service") or {}
    status = result.get("status") or result.get("after") or {}
    label = service.get("label") or service.get("id") or "service"
    ok = "OK" if result.get("ok") else "ERROR"
    lines = [f"{ok}: `{label}` -> `{result.get('action')}`"]
    if result.get("dryRun"):
        lines.append(f"- dry-run URI: `{result.get('uri')}`")
    for key, value in compact_status(status).items():
        if key == "url" and value:
            lines.append(f"- {key}: {value}")
        else:
            lines.append(f"- {key}: `{value}`")
    if service.get("public_url") and service.get("public_url") != status.get("url"):
        lines.append(f"- public_url: {service.get('public_url')}")
    if not result.get("ok") and status.get("error"):
        lines.append(f"- error: `{status.get('error')}`")
    return "\n".join(lines)


def list_services(catalog: dict[str, Any]) -> dict[str, Any]:
    return {"ok": True, "services": [service_public(service) for service in catalog.get("services") or []]}


def log_event(state_dir: str | Path, event: dict[str, Any]) -> None:
    path = Path(state_dir) / "service-dashboard-events.jsonl"
    path.parent.mkdir(parents=True, exist_ok=True)
    record = {"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), **event}
    with path.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")


INDEX_HTML = r"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>urirun service dashboard</title>
  <style>
    :root { color-scheme: light; --bg:#f6f8fb; --surface:#fff; --ink:#111827; --muted:#667085; --line:#d6dce7; --accent:#0f766e; --good:#15803d; --bad:#b42318; --warn:#b54708; }
    * { box-sizing: border-box; }
    body { margin:0; background:var(--bg); color:var(--ink); font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
    header { position:sticky; top:0; z-index:2; display:flex; justify-content:space-between; align-items:center; gap:12px; padding:14px 18px; border-bottom:1px solid var(--line); background:rgba(255,255,255,.95); backdrop-filter:blur(8px); }
    h1,h2,p { margin:0; }
    h1 { font-size:20px; letter-spacing:0; }
    h2 { font-size:16px; }
    main { width:min(1180px,100%); margin:0 auto; padding:16px; display:grid; gap:14px; }
    .panel, .service { background:var(--surface); border:1px solid var(--line); border-radius:8px; }
    .panel { padding:14px; display:grid; gap:10px; }
    .services { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:12px; }
    .service { padding:14px; display:grid; gap:10px; }
    .row { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
    .actions { display:flex; gap:8px; flex-wrap:wrap; }
    button,input { font:inherit; min-height:36px; border:1px solid var(--line); border-radius:6px; background:#fff; color:var(--ink); }
    button { padding:0 12px; cursor:pointer; }
    button.primary { background:var(--accent); border-color:var(--accent); color:white; }
    button.danger { color:var(--bad); }
    input { padding:0 10px; width:100%; }
    .prompt { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
    .muted { color:var(--muted); }
    .mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:12px; overflow-wrap:anywhere; white-space:pre-wrap; }
    .pill { display:inline-flex; min-height:24px; align-items:center; padding:0 8px; border-radius:999px; background:#eef2f7; color:#344054; }
    .pill.good { background:#dcfce7; color:var(--good); }
    .pill.bad { background:#fee4e2; color:var(--bad); }
    .pill.warn { background:#fef0c7; color:var(--warn); }
    pre { margin:0; padding:12px; background:#0b1220; color:#e6edf7; border-radius:8px; overflow:auto; max-height:360px; }
    @media (max-width:640px) { header { align-items:flex-start; flex-direction:column; } .prompt { grid-template-columns:1fr; } }
  </style>
</head>
<body>
  <header>
    <div>
      <h1>urirun service dashboard</h1>
      <p class="muted" id="context">Loading...</p>
    </div>
    <button class="primary" id="refresh">Refresh</button>
  </header>
  <main>
    <section class="panel">
      <h2>Chat command</h2>
      <div class="prompt">
        <input id="prompt" value="uruchom panel faktur" aria-label="Command prompt">
        <button class="primary" id="runPrompt">Run</button>
      </div>
      <div class="muted">Commands are matched only against registered services and their allowed URI actions.</div>
    </section>
    <section class="services" id="services"></section>
    <section class="panel">
      <h2>Last response</h2>
      <pre id="output">Waiting...</pre>
    </section>
  </main>
  <script>
    const $ = (id) => document.getElementById(id);
    async function api(path, options = {}) {
      const res = await fetch(path, {headers:{'Content-Type':'application/json'}, ...options});
      const data = await res.json();
      if (!res.ok || data.ok === false) throw new Error(data.error || res.statusText);
      return data;
    }
    function statusPill(status) {
      if (!status) return '<span class="pill">unknown</span>';
      if (status.running && status.portOpen) return '<span class="pill good">running</span>';
      if (status.running || status.portOpen) return '<span class="pill warn">partial</span>';
      return '<span class="pill bad">stopped</span>';
    }
    function renderService(item) {
      const status = item.status || {};
      return `<article class="service">
        <div class="row"><h2>${item.label}</h2>${statusPill(status)}</div>
        <div class="muted">${item.description || ''}</div>
        <div class="mono">${status.url || item.public_url || ''}</div>
        <div class="mono">pid=${status.pid || 0} port=${status.port || ''}</div>
        <div class="actions">
          <button data-action="status" data-id="${item.id}">Status</button>
          <button class="primary" data-action="start" data-id="${item.id}">Start</button>
          <button data-action="restart" data-id="${item.id}">Restart</button>
          <button class="danger" data-action="stop" data-id="${item.id}">Stop</button>
        </div>
      </article>`;
    }
    async function load() {
      const data = await api('/api/services?status=1');
      $('context').textContent = `${data.services.length} service(s) · ${data.config}`;
      $('services').innerHTML = data.services.map(renderService).join('');
      $('output').textContent = JSON.stringify(data, null, 2);
    }
    async function serviceAction(id, action) {
      const data = await api(`/api/services/${encodeURIComponent(id)}/${action}`, {method:'POST', body:'{}'});
      $('output').textContent = data.markdown || JSON.stringify(data, null, 2);
      await load();
    }
    async function runPrompt() {
      const data = await api('/api/chat', {method:'POST', body:JSON.stringify({prompt:$('prompt').value, execute:true})});
      $('output').textContent = data.markdown || JSON.stringify(data, null, 2);
      await load();
    }
    document.addEventListener('click', (event) => {
      const id = event.target.dataset.id;
      const action = event.target.dataset.action;
      if (id && action) serviceAction(id, action).catch((err) => alert(err.message));
    });
    $('refresh').addEventListener('click', () => load().catch((err) => alert(err.message)));
    $('runPrompt').addEventListener('click', () => runPrompt().catch((err) => alert(err.message)));
    load().catch((err) => { $('context').textContent = err.message; });
  </script>
</body>
</html>
"""


def json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict[str, Any]) -> None:
    body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
    handler.send_response(status)
    handler.send_header("Content-Type", "application/json; charset=utf-8")
    handler.send_header("Cache-Control", "no-store")
    handler.send_header("Access-Control-Allow-Origin", "*")
    handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
    handler.send_header("Access-Control-Allow-Headers", "Content-Type")
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


def html_response(handler: BaseHTTPRequestHandler) -> None:
    body = INDEX_HTML.encode("utf-8")
    handler.send_response(200)
    handler.send_header("Content-Type", "text/html; charset=utf-8")
    handler.send_header("Cache-Control", "no-store")
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


def head_html_response(handler: BaseHTTPRequestHandler) -> None:
    body = INDEX_HTML.encode("utf-8")
    handler.send_response(200)
    handler.send_header("Content-Type", "text/html; charset=utf-8")
    handler.send_header("Cache-Control", "no-store")
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()


def read_json(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
    length = int(handler.headers.get("Content-Length", "0"))
    if length <= 0:
        return {}
    return json.loads(handler.rfile.read(length).decode("utf-8") or "{}")


def create_handler(config: str | Path, env: str | Path, state_dir: str | Path = DEFAULT_STATE):
    class Handler(BaseHTTPRequestHandler):
        def do_OPTIONS(self):
            json_response(self, 200, {"ok": True})

        def do_HEAD(self):
            parsed = urlparse(self.path)
            if parsed.path in {"/", "/index.html"}:
                head_html_response(self)
                return
            self.send_response(404)
            self.send_header("Cache-Control", "no-store")
            self.end_headers()

        def do_GET(self):
            parsed = urlparse(self.path)
            try:
                if parsed.path in {"/", "/index.html"}:
                    html_response(self)
                    return
                if parsed.path == "/api/services":
                    catalog = load_services(config, env)
                    include_status = "status=1" in parsed.query
                    services = []
                    for service in catalog["services"]:
                        item = service_public(service)
                        if include_status:
                            try:
                                item["status"] = call_action(service, "status").get("status")
                            except ServiceError as exc:
                                item["status"] = {"ok": False, "error": str(exc)}
                        services.append(item)
                    json_response(self, 200, {"ok": True, "config": str(config), "services": services})
                    return
                json_response(self, 404, {"ok": False, "error": "not found"})
            except Exception as exc:  # noqa: BLE001
                json_response(self, 500, {"ok": False, "error": str(exc)})

        def do_POST(self):
            parsed = urlparse(self.path)
            parts = [part for part in parsed.path.split("/") if part]
            try:
                catalog = load_services(config, env)
                payload = read_json(self)
                if parts == ["api", "chat"]:
                    result = handle_prompt(str(payload.get("prompt") or ""), catalog, execute=bool(payload.get("execute", True)))
                    log_event(state_dir, {"kind": "chat", "prompt": payload.get("prompt"), "result": {"ok": result.get("ok"), "action": result.get("action"), "service": (result.get("service") or {}).get("id")}})
                    json_response(self, 200, {**result, "markdown": format_markdown(result)})
                    return
                if len(parts) == 4 and parts[:2] == ["api", "services"]:
                    service = choose_service("", catalog["services"], explicit=parts[2])
                    result = call_action(service, parts[3], execute=bool(payload.get("execute", True)))
                    log_event(state_dir, {"kind": "service", "service": parts[2], "action": parts[3], "ok": result.get("ok")})
                    json_response(self, 200, {**result, "markdown": format_markdown(result)})
                    return
                json_response(self, 404, {"ok": False, "error": "not found"})
            except Exception as exc:  # noqa: BLE001
                json_response(self, 400, {"ok": False, "error": str(exc)})

        def log_message(self, fmt, *args: Any):
            return

    return Handler


def serve(config: str | Path, env: str | Path, host: str, port: int) -> ThreadingHTTPServer:
    server = ThreadingHTTPServer((host, port), create_handler(config, env))
    url = f"http://{host}:{server.server_address[1]}/"
    print(json.dumps({"event": "urirun.service_dashboard.started", "url": url, "config": str(config)}), flush=True)
    return server


def parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description="Host-side urirun service dashboard")
    p.add_argument("--config", default=str(DEFAULT_SERVICES), help="service registry YAML")
    p.add_argument("--env", default=str(DEFAULT_ENV), help=".env file")
    sub = p.add_subparsers(dest="command", required=True)

    sub.add_parser("list")

    for name in ("status", "start", "stop", "restart"):
        sp = sub.add_parser(name)
        sp.add_argument("service", nargs="?", help="service id or alias")
        sp.add_argument("--dry-run", action="store_true")
        sp.add_argument("--json", action="store_true")

    chat = sub.add_parser("chat")
    chat.add_argument("prompt", nargs="+")
    chat.add_argument("--service")
    chat.add_argument("--dry-run", action="store_true")
    chat.add_argument("--json", action="store_true")

    web = sub.add_parser("serve")
    web.add_argument("--host", default=None)
    web.add_argument("--port", type=int, default=None)
    return p


def main(argv: list[str] | None = None) -> int:
    args = parser().parse_args(argv)
    catalog = load_services(args.config, args.env)
    if args.command == "list":
        print(json.dumps(list_services(catalog), ensure_ascii=False, indent=2))
        return 0
    if args.command == "chat":
        result = handle_prompt(
            " ".join(args.prompt),
            catalog,
            execute=not args.dry_run,
            service_id=args.service,
        )
        print(json.dumps(result, ensure_ascii=False, indent=2) if args.json else format_markdown(result))
        return 0 if result.get("ok") else 1
    if args.command in {"status", "start", "stop", "restart"}:
        service = choose_service(args.service or "", catalog["services"], explicit=args.service)
        result = call_action(service, args.command, execute=not args.dry_run)
        print(json.dumps(result, ensure_ascii=False, indent=2) if args.json else format_markdown(result))
        return 0 if result.get("ok") else 1
    if args.command == "serve":
        env = load_env(args.env)
        host = args.host or env.get("SERVICE_DASHBOARD_HOST", "127.0.0.1")
        port = int(args.port or env.get("SERVICE_DASHBOARD_PORT", "8196"))
        server = serve(args.config, args.env, host, port)
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            return 130
        return 0
    return 2


def default_host() -> str:
    return socket.gethostbyname(socket.gethostname())


if __name__ == "__main__":
    raise SystemExit(main())
