"""Drag-and-drop launcher for the NC toolpath viewer.

Starts a tiny local web server (Python standard-library ``http.server`` only —
no extra installs beyond what the viewer already needs) that serves a dark
full-screen **drop zone**. Drop an ``.nc`` file — or a whole folder of them —
onto the page and it is parsed with ``nc_parser`` and rendered with the exact
same Plotly viewer as ``nc_viewer.py`` (``build_figure``), shown inline in an
iframe. Drop another at any time; pick from a list when several are dropped.

Why a server (instead of pure client-side drag-drop): the parser and the viewer
already live in Python and must not be rewritten in JavaScript. The browser only
reads the dropped file's text and POSTs it; Python does the parsing + figure
building and returns ready-to-display HTML.

Run:
  python nc_server.py            # serves on http://127.0.0.1:8765
  python nc_server.py 9000       # custom port

Then open the printed URL and drag NC files onto it. Ctrl-C to stop.

Dependencies: plotly, numpy (same as the viewer). ``nc_parser.py`` and
``nc_viewer.py`` must sit in the same folder.
"""

import os
import sys
import tempfile
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

import plotly.io as pio

from nc_parser import parse_nc
from nc_viewer import build_figure


DEFAULT_PORT = 8765


# --- the drop-zone page (served at "/") -------------------------------------
INDEX_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>wired3d visualizer</title>
<style>
  html, body { margin: 0; height: 100%; background: #0f0f1a; color: #e8e8f0;
    font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
  #wrap { display: flex; flex-direction: column; height: 100vh; }
  #bar { flex: 0 0 auto; padding: 10px 16px; display: flex; align-items: center;
    gap: 14px; border-bottom: 1px solid #222; background: #16213e; }
  #bar h1 { font-size: 15px; margin: 0; font-weight: 600; color: #fff; }
  #status { font-size: 13px; color: #9aa0b5; }
  #drop { flex: 1 1 auto; position: relative; }
  #zone { position: absolute; inset: 0; display: flex; flex-direction: column;
    align-items: center; justify-content: center; text-align: center;
    border: 2px dashed #2a3a5e; margin: 18px; border-radius: 14px;
    transition: background .15s, border-color .15s; }
  #zone.hot { background: rgba(0,191,255,.10); border-color: #00bfff; }
  #zone .big { font-size: 22px; color: #cfd3e6; }
  #zone .sub { font-size: 13px; color: #7f86a0; margin-top: 8px; }
  #zone .buttons { margin-top: 18px; display: flex; gap: 10px; }
  #zone .buttons button { background: #0d47a1; color: #fff; border: 0;
    border-radius: 8px; padding: 10px 16px; font-size: 14px; cursor: pointer; }
  #zone .buttons button:hover { background: #1565c0; }
  #picker { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 8px;
    justify-content: center; max-width: 80%; }
  #picker button { background: #16213e; color: #e8e8f0; border: 1px solid #0d47a1;
    border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer; }
  #picker button:hover { background: #1d2b4d; }
  iframe { border: 0; width: 100%; height: 100%; display: none; background: #0f0f1a; }
  a.file { color: #00bfff; text-decoration: none; cursor: pointer; }
  /* Loading overlay shown while the server parses + builds the figure. */
  #loading { position: absolute; inset: 0; display: none; flex-direction: column;
    align-items: center; justify-content: center; gap: 22px;
    background: rgba(15,15,26,.92); z-index: 5; }
  #loading.show { display: flex; }
  #loading .spinner { width: 64px; height: 64px; border-radius: 50%;
    border: 6px solid #1d2b4d; border-top-color: #00bfff;
    animation: spin 0.9s linear infinite; }
  #loading .msg { font-size: 16px; color: #cfd3e6; }
  #loading .sub2 { font-size: 12px; color: #7f86a0; }
  @keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="wrap">
  <div id="bar">
    <h1>wired3d visualizer</h1>
    <span id="status">Drag an .nc file (or a folder of them) anywhere on this page.</span>
    <span style="flex:1"></span>
    <a class="file" id="another" style="display:none">⤓ Drop another</a>
  </div>
  <div id="drop">
    <div id="zone">
      <div class="big">⬇ Drop an NC file here</div>
      <div class="sub">a single <code>.nc</code> file or a whole folder — parsed locally, nothing is uploaded anywhere</div>
      <div class="buttons">
        <button id="browseFile">📄 Choose .nc file</button>
        <button id="browseFolder">📁 Choose folder</button>
      </div>
      <input id="fileInput" type="file" accept=".nc,.NC" multiple hidden>
      <input id="dirInput" type="file" webkitdirectory directory multiple hidden>
      <div id="picker"></div>
    </div>
    <iframe id="view"></iframe>
    <div id="loading">
      <div class="spinner"></div>
      <div class="msg" id="loadingMsg">Rendering…</div>
      <div class="sub2">building the 3D toolpath — this can take a moment for large files</div>
    </div>
  </div>
</div>
<script>
const zone = document.getElementById('zone');
const picker = document.getElementById('picker');
const view = document.getElementById('view');
const statusEl = document.getElementById('status');
const another = document.getElementById('another');
const fileInput = document.getElementById('fileInput');
const dirInput = document.getElementById('dirInput');
const loading = document.getElementById('loading');
const loadingMsg = document.getElementById('loadingMsg');

function showLoading(name) {
  loadingMsg.textContent = 'Rendering ' + name + ' …';
  loading.classList.add('show');
}
function hideLoading() { loading.classList.remove('show'); }

function showZone() {
  view.style.display = 'none';
  zone.style.display = 'flex';
  another.style.display = 'none';
  hideLoading();
}
another.addEventListener('click', showZone);

// --- drag & drop highlighting (preventDefault is required to allow a drop) ---
let dragDepth = 0;
document.addEventListener('dragenter', ev => {
  ev.preventDefault(); dragDepth++; zone.classList.add('hot');
});
document.addEventListener('dragover', ev => { ev.preventDefault(); });
document.addEventListener('dragleave', ev => {
  ev.preventDefault(); if (--dragDepth <= 0) zone.classList.remove('hot');
});

// Recurse a dropped directory entry, collecting .nc File objects.
function readEntry(entry, out) {
  return new Promise(resolve => {
    if (entry.isFile) {
      entry.file(f => {
        if (f.name.toLowerCase().endsWith('.nc')) out.push(f);
        resolve();
      }, () => resolve());          // resolve only AFTER the file callback
    } else if (entry.isDirectory) {
      const reader = entry.createReader();
      const all = [];
      const readBatch = () => reader.readEntries(ents => {
        if (!ents.length) {
          Promise.all(all.map(e => readEntry(e, out))).then(resolve);
        } else { all.push(...ents); readBatch(); }
      }, () => resolve());
      readBatch();
    } else { resolve(); }
  });
}

document.addEventListener('drop', async ev => {
  ev.preventDefault();
  dragDepth = 0; zone.classList.remove('hot');
  statusEl.textContent = 'Reading…';
  const files = [];

  const items = ev.dataTransfer.items;
  let usedEntries = false;
  if (items && items.length && items[0].webkitGetAsEntry) {
    const entries = [];
    for (const it of items) { const e = it.webkitGetAsEntry && it.webkitGetAsEntry(); if (e) entries.push(e); }
    if (entries.length) { usedEntries = true; await Promise.all(entries.map(e => readEntry(e, files))); }
  }
  if (!usedEntries) {                 // fallback for browsers without entries API
    for (const f of ev.dataTransfer.files)
      if (f.name.toLowerCase().endsWith('.nc')) files.push(f);
  }
  handleFiles(files);
});

// --- "Choose file" / "Choose folder" buttons ---
document.getElementById('browseFile').addEventListener('click', () => fileInput.click());
document.getElementById('browseFolder').addEventListener('click', () => dirInput.click());
fileInput.addEventListener('change', () => handleFiles(ncOnly(fileInput.files)));
dirInput.addEventListener('change', () => handleFiles(ncOnly(dirInput.files)));

function ncOnly(fileList) {
  return Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.nc'));
}

// Render one .nc file, or show a picker when several were given.
function handleFiles(files) {
  if (!files.length) {
    statusEl.textContent = 'No .nc files found in what you selected.';
    picker.innerHTML = '';
    return;
  }
  if (files.length === 1) { render(files[0]); return; }
  statusEl.textContent = files.length + ' NC files found — pick one:';
  picker.innerHTML = '';
  files.forEach(f => {
    const b = document.createElement('button');
    b.textContent = f.webkitRelativePath || f.name;
    b.onclick = () => render(f);
    picker.appendChild(b);
  });
}

async function render(file) {
  statusEl.textContent = 'Rendering ' + file.name + ' …';
  picker.innerHTML = '';
  showLoading(file.name);
  try {
    const text = await file.text();
    const resp = await fetch('/render?name=' + encodeURIComponent(file.name),
      { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: text });
    if (!resp.ok) {
      statusEl.textContent = 'Error: ' + (await resp.text());
      hideLoading();
      return;
    }
    const html = await resp.text();
    view.srcdoc = html;
    zone.style.display = 'none';
    view.style.display = 'block';
    another.style.display = 'inline';
    statusEl.textContent = file.name;
    // Keep the spinner up until the iframe has actually painted the viewer.
    view.onload = hideLoading;
  } catch (err) {
    statusEl.textContent = 'Error: ' + err;
    hideLoading();
  }
}
</script>
</body>
</html>
"""


def render_nc_text(nc_text: str, name: str) -> str:
    """Parse NC source text and return a full viewer HTML document.

    Inputs:
        nc_text -- raw contents of an .nc file.
        name    -- original file name (used for the title and as the part-name
                   fallback when the file has no "(Part name: ...)" comment).
    Output:
        a complete, self-contained HTML string (plotly.js from CDN), full
        viewport, identical to what ``nc_viewer.py`` writes to disk — just
        returned as a string for inline display instead of saved.
    """
    # parse_nc reads from a path; write to a temp file, parse, then clean up.
    with tempfile.NamedTemporaryFile("w", suffix=".nc", delete=False) as fh:
        fh.write(nc_text)
        tmp = fh.name
    try:
        parsed = parse_nc(tmp)
    finally:
        os.unlink(tmp)

    # Without a "(Part name: ...)" comment, parse_nc falls back to the temp
    # file's random stem — restore the real dropped name instead.
    if "(Part name:" not in nc_text:
        parsed["part_name"] = Path(name).stem

    fig = build_figure(parsed, name)
    return pio.to_html(
        fig, include_plotlyjs="cdn", full_html=True,
        default_width="100%", default_height="100vh",
        config={"responsive": True},
    )


class _Handler(BaseHTTPRequestHandler):
    """Serves the drop page (GET /) and renders posted NC text (POST /render)."""

    def _send(self, code: int, body: str, ctype: str = "text/html") -> None:
        data = body.encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", f"{ctype}; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):  # noqa: N802 (http.server naming)
        if self.path in ("/", "/index.html"):
            self._send(200, INDEX_HTML)
        else:
            self._send(404, "not found", "text/plain")

    def do_POST(self):  # noqa: N802
        if not self.path.startswith("/render"):
            self._send(404, "not found", "text/plain")
            return
        # Original filename is passed as a query param (?name=...).
        name = "dropped.nc"
        if "?" in self.path:
            from urllib.parse import parse_qs, urlparse, unquote
            q = parse_qs(urlparse(self.path).query)
            if "name" in q:
                name = unquote(q["name"][0])

        length = int(self.headers.get("Content-Length", 0))
        nc_text = self.rfile.read(length).decode("utf-8", errors="replace")
        try:
            html = render_nc_text(nc_text, name)
            self._send(200, html)
        except Exception as exc:  # noqa: BLE001 - report to the page
            self._send(500, f"Failed to render {name}: {exc}", "text/plain")

    def log_message(self, *_args):
        pass  # quiet; we print our own status line


def main(port: int = DEFAULT_PORT) -> None:
    """Start the drop-zone server and open it in the browser.

    Inputs:
        port -- TCP port to listen on (default 8765).
    Output:
        None. Runs until Ctrl-C.
    """
    ThreadingHTTPServer.allow_reuse_address = True
    try:
        server = ThreadingHTTPServer(("127.0.0.1", port), _Handler)
    except OSError as exc:
        print(f"Could not bind port {port}: {exc}")
        print(f"Another server is likely already running. Try a different port: "
              f"python nc_server.py {port + 1}")
        return
    url = f"http://127.0.0.1:{port}"
    print(f"NC Viewer drop zone running at {url}")
    print("Drag an .nc file (or a folder of them) onto the page. Ctrl-C to stop.")
    try:
        webbrowser.open(url)
    except Exception:
        pass
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nStopped.")
        server.server_close()


if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
    main(port)
