#!/usr/bin/env python3
"""Pedalboard Finder for MOD units (Dwarf, Duo, Duo X).

Browse every plugin installed on the unit, filter the list, click a plugin to
see the pedalboards that use it, click a pedalboard to load it on the unit.

    python3 pedalboard_finder.py                  # MOD over USB at 192.168.51.1
    python3 pedalboard_finder.py 192.168.1.50     # MOD at another address
    python3 pedalboard_finder.py 192.168.51.1 9000  # another local port

Then open http://localhost:8099 (it opens by itself). Standard library only.

Why a local server: the MOD's web app (mod-ui) doesn't allow pages from other
sites to read its data, so this script serves the page and forwards its
requests to the unit. It uses mod-ui's internal HTTP routes, which are not an
official API and may change between MOD OS versions.
"""
import json
import sys
import urllib.error
import urllib.request
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

DWARF = sys.argv[1] if len(sys.argv) > 1 else "192.168.51.1"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8099

PAGE = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pedalboard Finder</title>
<style>
:root{
  --bg:#141518; --panel:#1c1e22; --panel2:#24272c; --line:#30343a;
  --text:#e9e7e2; --muted:#9a9ea6; --accent:#f2a33a; --accent2:#3a2a12;
  --bad:#e5604f; --radius:10px;
}
*{box-sizing:border-box}
[hidden]{display:none !important}
html,body{margin:0;background:var(--bg);color:var(--text);
  font:14px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
header{position:sticky;top:0;z-index:5;background:rgba(20,21,24,.94);
  backdrop-filter:blur(6px);border-bottom:1px solid var(--line);padding:12px 16px}
.top{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
h1{font-size:17px;margin:0;letter-spacing:.02em}
h1 span{color:var(--accent)}
.host{color:var(--muted);font-size:12px}
.spacer{flex:1}
button.ghost{background:var(--panel2);color:var(--text);border:1px solid var(--line);
  border-radius:8px;padding:6px 12px;font:inherit;cursor:pointer}
button.ghost:hover{border-color:var(--accent)}
.progress{height:3px;background:var(--line);border-radius:2px;margin-top:10px;overflow:hidden}
.progress i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
.status{font-size:12px;color:var(--muted);margin-top:6px;min-height:1.4em}
.status.bad{color:var(--bad)}
main{display:grid;grid-template-columns:minmax(320px,420px) 1fr;gap:16px;padding:16px;
  max-width:1500px;margin:0 auto}
@media (max-width:860px){main{grid-template-columns:1fr}}
.pane{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);min-width:0}
.filters{padding:12px;border-bottom:1px solid var(--line);display:grid;gap:8px}
input[type=search],select{width:100%;background:var(--panel2);color:var(--text);
  border:1px solid var(--line);border-radius:8px;padding:8px 10px;font:inherit}
input[type=search]:focus,select:focus{outline:2px solid var(--accent);outline-offset:-1px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.row select{flex:1;min-width:120px}
label.chk{display:flex;gap:6px;align-items:center;color:var(--muted);font-size:13px;cursor:pointer}
.count-line{font-size:12px;color:var(--muted)}
#plugins{max-height:calc(100vh - 250px);overflow:auto;padding:6px}
@media (max-width:860px){#plugins{max-height:55vh}}
.plugin{display:flex;gap:10px;align-items:center;width:100%;text-align:left;
  background:none;border:1px solid transparent;border-radius:8px;padding:6px;
  color:inherit;font:inherit;cursor:pointer}
.plugin:hover{background:var(--panel2)}
.plugin.active{background:var(--accent2);border-color:var(--accent)}
.plugin .shot{width:76px;height:56px;flex:none}
.meta{min-width:0;flex:1}
.name{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.brand{color:var(--muted);font-size:12px}
.uri{color:#6f747c;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.count{flex:none;min-width:28px;text-align:center;font-size:12px;font-weight:600;
  background:var(--panel2);border-radius:20px;padding:2px 8px;color:var(--accent)}
.count.zero{color:#5d6168}
.shot{display:flex;align-items:center;justify-content:center;background:#101113;
  border-radius:6px;overflow:hidden}
.shot img{max-width:100%;max-height:100%;object-fit:contain;display:block}
.shot.empty{color:#5d6168;font-size:12px;font-weight:600;letter-spacing:.05em}
#results{padding:16px;min-height:300px}
.empty-state{color:var(--muted);text-align:center;padding:80px 20px}
.sel-head{display:flex;gap:16px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
.sel-head .shot{width:180px;height:130px;flex:none}
.sel-head h2{margin:0 0 4px;font-size:20px}
.note{color:var(--muted);margin:4px 0 14px}
.boards{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px}
.board{background:var(--panel2);border:1px solid var(--line);border-radius:var(--radius);
  overflow:hidden;transition:border-color .15s}
.board:hover{border-color:var(--accent)}
.board .shot{cursor:pointer}
.actions{display:flex;gap:6px;margin-top:8px}
.actions button{flex:1;font:inherit;font-size:12px;font-weight:600;border-radius:7px;
  padding:6px 8px;cursor:pointer;border:1px solid var(--accent)}
.actions .primary{background:var(--accent);color:#1b1206}
.actions .primary:hover{filter:brightness(1.1)}
.actions .secondary{background:transparent;color:var(--accent)}
.actions .secondary:hover{background:var(--accent2)}
.board .shot{width:100%;aspect-ratio:16/10;border-radius:0}
.board .txt{padding:10px 12px}
.btitle{font-weight:600}
.bsub{color:var(--muted);font-size:12px}
.bsub .broken{color:var(--bad)}
#toast{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);max-width:90vw;
  background:#2b2e33;border:1px solid var(--line);border-radius:8px;padding:10px 16px;
  box-shadow:0 8px 30px rgba(0,0,0,.4);opacity:0;pointer-events:none;transition:opacity .2s}
#toast.show{opacity:1}
#toast.bad{border-color:var(--bad)}
</style>
</head>
<body>
<header>
  <div class="top">
    <h1>Pedalboard <span>Finder</span></h1>
    <span class="host" id="host"></span>
    <span class="spacer"></span>
    <button class="ghost" id="rescan" type="button">Refresh</button>
  </div>
  <div class="progress"><i id="bar"></i></div>
  <div class="status" id="status"></div>
</header>
<main>
  <section class="pane">
    <div class="filters">
      <input type="search" id="q" placeholder="Filter by name, maker, category or URI" autocomplete="off">
      <div class="row">
        <select id="cat"><option value="">All categories</option></select>
        <select id="sort">
          <option value="name">Sort by name</option>
          <option value="used">Most used first</option>
        </select>
      </div>
      <div class="row">
        <label class="chk"><input type="checkbox" id="used"> Only plugins used in a pedalboard</label>
        <span class="spacer"></span>
        <span class="count-line" id="count"></span>
      </div>
    </div>
    <div id="plugins"></div>
  </section>
  <section class="pane" id="results">
    <div class="empty-state">Pick a plugin to see the pedalboards that use it.</div>
  </section>
</main>
<div id="toast"></div>
<script>
const DWARF = __DWARF__;
const api = p => "/dwarf" + p;
const enc = encodeURIComponent;
const $ = s => document.querySelector(s);
function el(tag, cls, text){
  const e = document.createElement(tag);
  if (cls) e.className = cls;
  if (text != null) e.textContent = text;
  return e;
}

let plugins = [], boards = [], index = new Map();
let scanned = 0, scanning = false, scanId = 0, selected = null;

async function getJSON(p){
  const r = await fetch(api(p));
  if (!r.ok) throw new Error((await r.text()) || ("HTTP " + r.status));
  return r.json();
}
function status(msg, bad){ const s = $("#status"); s.textContent = msg; s.classList.toggle("bad", !!bad); }
let toastTimer;
function toast(msg, bad){
  const t = $("#toast"); t.textContent = msg; t.classList.toggle("bad", !!bad); t.classList.add("show");
  clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), bad ? 7000 : 4000);
}
const byName = (a, b) => (a.name || a.uri).localeCompare(b.name || b.uri, undefined, {sensitivity: "base"});
const usage = p => (index.get(p.uri) || []).length;
function initials(s){
  return (s || "?").split(/[\s\-_:]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join("").toUpperCase();
}
const pluginImg = (uri, kind) => api("/effect/image/" + kind + ".png?uri=" + enc(uri));
const boardImg = (bundle, kind) => api("/pedalboard/image/" + kind + ".png?bundlepath=" + enc(bundle));

// An image that tries each source in turn, then shows a text placeholder.
function picture(srcs, fallbackText){
  const wrap = el("div", "shot");
  const img = el("img");
  img.loading = "lazy"; img.decoding = "async"; img.alt = "";
  let i = 0;
  img.onerror = () => {
    i++;
    if (i < srcs.length) img.src = srcs[i];
    else { img.remove(); wrap.classList.add("empty"); wrap.textContent = fallbackText; }
  };
  img.src = srcs[0];
  wrap.append(img);
  return wrap;
}

async function init(){
  $("#host").textContent = "MOD at " + DWARF;
  status("Connecting to the MOD…");
  try {
    [plugins, boards] = await Promise.all([getJSON("/effect/list"), getJSON("/pedalboard/list")]);
  } catch (e) {
    status("Couldn't reach the MOD at " + DWARF + ". Is it connected and switched on? (" + e.message + ")", true);
    return;
  }
  plugins.sort(byName);
  const cats = new Set();
  plugins.forEach(p => (p.category || []).forEach(c => cats.add(c)));
  const sel = $("#cat");
  [...cats].sort().forEach(c => sel.append(new Option(c, c)));
  buildPluginRows();
  applyFilter();
  scan();
}

function buildPluginRows(){
  const list = $("#plugins");
  list.textContent = "";
  for (const p of plugins) {
    const row = el("button", "plugin");
    row.type = "button";
    row.append(picture([pluginImg(p.uri, "screenshot"), pluginImg(p.uri, "thumbnail")], initials(p.name)));
    const meta = el("div", "meta");
    meta.append(el("div", "name", p.name || p.label || p.uri));
    const sub = [p.brand, (p.category || []).join(", ")].filter(Boolean).join(" · ");
    meta.append(el("div", "brand", sub));
    meta.append(el("div", "uri", p.uri));
    const badge = el("span", "count", "…");
    badge.title = "Pedalboards using this plugin";
    row.append(meta, badge);
    row.onclick = () => select(p);
    p._row = row; p._badge = badge;
    p._search = [p.name, p.label, p.brand, p.uri, ...(p.category || [])].join(" ").toLowerCase();
    list.append(row);
  }
}

function applyFilter(){
  const words = $("#q").value.trim().toLowerCase().split(/\s+/).filter(Boolean);
  const cat = $("#cat").value, usedOnly = $("#used").checked, sort = $("#sort").value;
  const ordered = plugins.slice();
  if (sort === "used") ordered.sort((a, b) => usage(b) - usage(a) || byName(a, b));
  const list = $("#plugins");
  let shown = 0;
  for (const p of ordered) {
    const ok = words.every(w => p._search.includes(w))
      && (!cat || (p.category || []).includes(cat))
      && (!usedOnly || usage(p) > 0);
    p._row.hidden = !ok;
    if (ok) shown++;
    list.append(p._row);
  }
  $("#count").textContent = shown + " of " + plugins.length + " plugins";
  let none = $("#none");
  if (!none) { none = el("div", "empty-state", "No plugins match."); none.id = "none"; list.append(none); }
  none.hidden = shown > 0;
}

function updateBadges(){
  for (const p of plugins) {
    const n = usage(p);
    p._badge.textContent = (n || !scanning) ? String(n) : "…";
    p._badge.classList.toggle("zero", n === 0);
  }
}

// Read every pedalboard once and build an index: plugin URI -> pedalboards.
async function scan(){
  const myId = ++scanId;
  index = new Map(); scanned = 0; scanning = true;
  updateBadges(); progress();
  const queue = boards.slice();
  const failed = [];
  const worker = async () => {
    while (queue.length && myId === scanId) {
      const b = queue.shift();
      try {
        const info = await getJSON("/pedalboard/info/?bundlepath=" + enc(b.bundle));
        if (myId !== scanId) return;
        const counts = new Map();
        for (const pl of (info.plugins || [])) counts.set(pl.uri, (counts.get(pl.uri) || 0) + 1);
        for (const [uri, n] of counts) {
          if (!index.has(uri)) index.set(uri, []);
          index.get(uri).push({board: b, count: n});
        }
      } catch (e) { failed.push(b.title || b.bundle); }
      scanned++;
      refreshSoon();
    }
  };
  await Promise.all([worker(), worker()]);   // two at a time: gentle on the unit
  if (myId !== scanId) return;
  scanning = false;
  refresh();
  if (failed.length) status("Read " + (boards.length - failed.length) + " of " + boards.length +
    " pedalboards. Couldn't read: " + failed.join(", "), true);
}

let refreshQueued = false;
function refreshSoon(){
  if (refreshQueued) return;
  refreshQueued = true;
  setTimeout(() => { refreshQueued = false; refresh(); }, 250);
}
function refresh(){
  progress(); updateBadges();
  if ($("#used").checked || $("#sort").value === "used") applyFilter();
  renderSelected();
}
function progress(){
  const total = boards.length || 1;
  $("#bar").style.width = (scanning ? (100 * scanned / total) : 100) + "%";
  status(scanning
    ? "Reading pedalboards… " + scanned + " of " + boards.length
    : plugins.length + " plugins and " + boards.length + " pedalboards on the MOD.");
}

function select(p){
  if (selected) selected._row.classList.remove("active");
  selected = p;
  p._row.classList.add("active");
  $("#results")._for = null;
  renderSelected();
  if (window.matchMedia("(max-width: 860px)").matches)
    $("#results").scrollIntoView({behavior: "smooth", block: "start"});
}

function renderSelected(){
  const pane = $("#results");
  if (!selected) return;
  if (pane._for !== selected.uri) {
    pane._for = selected.uri; pane._n = 0;
    pane.textContent = "";
    const head = el("div", "sel-head");
    head.append(picture([pluginImg(selected.uri, "screenshot"), pluginImg(selected.uri, "thumbnail")],
      initials(selected.name)));
    const t = el("div");
    t.append(el("h2", null, selected.name || selected.uri));
    t.append(el("div", "brand", [selected.brand, (selected.category || []).join(", ")].filter(Boolean).join(" · ")));
    t.append(el("div", "uri", selected.uri));
    head.append(t);
    const note = el("p", "note"); note.id = "note";
    const grid = el("div", "boards"); grid.id = "boards";
    pane.append(head, note, grid);
  }
  const hits = index.get(selected.uri) || [];
  const grid = $("#boards");
  for (const h of hits.slice(pane._n)) grid.append(boardCard(h));
  pane._n = hits.length;
  const n = hits.length;
  $("#note").textContent = scanning
    ? "Found in " + n + " pedalboard" + (n === 1 ? "" : "s") + " so far. Still reading (" + scanned + " of " + boards.length + ")…"
    : n ? "Used in " + n + " pedalboard" + (n === 1 ? "" : "s") + ". Click one to load it on the MOD."
        : "Not used in any pedalboard on this MOD.";
}

function boardCard(h){
  const b = h.board;
  const card = el("div", "board");
  const pic = picture([boardImg(b.bundle, "screenshot"), boardImg(b.bundle, "thumbnail")], "No picture yet");
  pic.title = "Load on the MOD (replaces the current pedalboard)";
  pic.onclick = () => openBoard(b, "replace");
  card.append(pic);
  const txt = el("div", "txt");
  txt.append(el("div", "btitle", b.title || b.bundle));
  const sub = el("div", "bsub", h.count > 1 ? h.count + " instances" : "1 instance");
  if (b.factory) sub.append(" · factory");
  if (b.broken) { sub.append(" · "); sub.append(el("span", "broken", "missing plugins")); }
  txt.append(sub);
  const actions = el("div", "actions");
  const load = el("button", "primary", "Load");
  load.type = "button"; load.title = "Replace the pedalboard playing now with this one";
  load.onclick = () => openBoard(b, "replace");
  const add = el("button", "secondary", "Add to current");
  add.type = "button"; add.title = "Add this pedalboard's plugins to the one playing now";
  add.onclick = () => openBoard(b, "add");
  actions.append(load, add);
  txt.append(actions);
  card.append(txt);
  return card;
}

// "replace" does what MOD's own interface does: clear the pedalboard (/reset),
// then load it. "add" skips the clear, so the plugins land on the current pedalboard.
async function openBoard(b, mode){
  const name = b.title || b.bundle;
  const q = "“" + name + "”";
  const question = mode === "add"
    ? "Add " + q + " to the pedalboard playing now?\n\n" +
      "• Its plugins and connections are added next to the current ones.\n" +
      "• If both pedalboards have a plugin with the same instance name, they can clash and one may not load.\n" +
      "• Afterwards the MOD treats the result as " + q + ": use Save As, not Save, or you overwrite " + q + "."
    : "Load " + q + " on the MOD?\n\n" +
      "It replaces the pedalboard playing now; unsaved changes to that one are lost.";
  if (!confirm(question)) return;
  const tab = window.open("about:blank", "_blank");   // opened now so popup blockers allow it
  toast((mode === "add" ? "Adding " : "Loading ") + name + "…");
  try {
    if (mode !== "add") {
      const r = await fetch(api("/reset"), {cache: "no-store"});
      const ok = await r.json().catch(() => false);
      if (!r.ok || ok !== true) throw new Error("the MOD couldn't clear the current pedalboard");
    }
    const r = await fetch(api("/pedalboard/load_bundle/"), {
      method: "POST",
      headers: {"Content-Type": "application/x-www-form-urlencoded"},
      body: new URLSearchParams({bundlepath: b.bundle, isDefault: "0"})
    });
    const res = await r.json().catch(() => ({}));
    if (!r.ok || res.ok === false) throw new Error(res.error || ("HTTP " + r.status));
    const url = "http://" + DWARF + "/";
    const done = (mode === "add" ? "Added " : "Loaded ") + name + ".";
    if (tab) { tab.location.href = url; toast(done + " The MOD opened in a new tab."); }
    else location.href = url;
  } catch (e) {
    if (tab) tab.close();
    toast("Couldn't " + (mode === "add" ? "add " : "load ") + name + ": " + e.message, true);
  }
}

$("#q").addEventListener("input", applyFilter);
$("#cat").addEventListener("change", applyFilter);
$("#sort").addEventListener("change", applyFilter);
$("#used").addEventListener("change", applyFilter);
$("#rescan").addEventListener("click", async () => {
  scanId++;
  try { boards = await getJSON("/pedalboard/list"); } catch (e) { status("Couldn't refresh: " + e.message, true); return; }
  if (selected) $("#results")._for = null;
  scan();
});
document.addEventListener("keydown", e => {
  if (e.key === "/" && document.activeElement !== $("#q")) { e.preventDefault(); $("#q").focus(); }
});
init();
</script>
</body>
</html>
"""


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_GET(self):
        self.route()

    def do_POST(self):
        self.route()

    def route(self):
        if self.path in ("/", "/index.html"):
            body = PAGE.replace("__DWARF__", json.dumps(DWARF)).encode("utf-8")
            self.reply(200, "text/html; charset=utf-8", body)
        elif self.path.startswith("/dwarf/"):
            self.proxy(self.path[len("/dwarf"):])
        else:
            self.reply(404, "text/plain; charset=utf-8", b"not found")

    def proxy(self, path):
        data = None
        if self.command == "POST":
            data = self.rfile.read(int(self.headers.get("Content-Length") or 0))
        req = urllib.request.Request(f"http://{DWARF}{path}", data=data, method=self.command)
        if self.headers.get("Content-Type"):
            req.add_header("Content-Type", self.headers["Content-Type"])
        try:
            with urllib.request.urlopen(req, timeout=60) as r:
                self.reply(r.status, r.headers.get("Content-Type", "application/octet-stream"), r.read())
        except urllib.error.HTTPError as e:
            self.reply(e.code, e.headers.get("Content-Type", "text/plain"), e.read())
        except Exception as e:
            self.reply(502, "text/plain; charset=utf-8",
                       f"Can't reach the MOD at {DWARF}: {e}".encode("utf-8"))

    def reply(self, code, ctype, body):
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        # Images can be cached for a while; data must always be fresh.
        self.send_header("Cache-Control", "max-age=600" if ctype.startswith("image/") else "no-store")
        self.end_headers()
        self.wfile.write(body)


if __name__ == "__main__":
    server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
    url = f"http://localhost:{PORT}"
    print(f"Pedalboard Finder: {url}  (MOD at {DWARF}). Ctrl+C to stop.")
    try:
        webbrowser.open(url)
    except Exception:
        pass
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nStopped.")
