{ lib, pkgs, cfg, models, allPhones, intercomEntries }:

let
  hasTrunk = cfg.sipTrunks != {};

  # For HTML table cells: produces either the literal value or a @@/full/path@@
  # marker that the nginx Lua handler opens directly at request time.
  rtv = val: file: if file != null then "@@${file}@@" else val;

  # For DOT node labels (end up in SVG): never use @@ markers — graphviz may
  # split long strings at hyphens across <tspan> elements, breaking substitution
  # and corrupting the SVG. Show the key filename as a static label instead.
  rtvLabel = val: file: if file != null then baseNameOf file else val;

  # Sanitize a string for use as a DOT node identifier
  nid = prefix: s: "${prefix}_${lib.replaceStrings
    [ "-"  "+"  "*"  "@"  "."  " "  "/"  ]
    [ "_"  "p"  "s"  "at" "_"  "_"  "_"  ] s}";

  # Whether a phone gets an L2 line (same logic as asterisk.nix)
  phoneHasL2 = key:
    let phone = allPhones.${key};
    in phone.personKey != null && models.${phone.model}.hasMultiLine;

  # ── Nodes ───────────────────────────────────────────────────────────────

  trunkNodes = lib.mapAttrsToList (name: t:
    ''  ${nid "trunk" name} [label="${name}\n${rtvLabel t.host t.hostFile}" shape=box style="rounded,filled" fillcolor="#AED6F1"]''
  ) cfg.sipTrunks;

  didNodes = lib.mapAttrsToList (id: didCfg:
    let numLabel = rtvLabel didCfg.number didCfg.numberFile; in
    ''  ${nid "did" id} [label="${numLabel}\n${didCfg.displayName}" shape=box style="rounded,filled" fillcolor="#FAD7A0"]''
  ) cfg.dids;

  personNodes = lib.mapAttrsToList (key: person:
    ''  ${nid "person" key} [label="${person.displayName}\next. ${person.extension}" shape=ellipse style=filled fillcolor="#A9DFBF"]''
  ) cfg.persons;

  phoneNodes = lib.mapAttrsToList (key: phone:
    let
      modelLabel = if phone.personKey != null then
        cfg.persons.${phone.personKey}.phones.${key}.model
      else
        cfg.sharedPhones.${key}.model;
      isSoftClient = modelLabel == "sip-client";
      style = if isSoftClient then "dashed,filled" else "filled";
      lines =
        [ phone.displayName ]
        ++ lib.optional (phone.personKey == null) "ext. ${phone.extension}"
        ++ [ modelLabel ]
        ++ lib.optional (!isSoftClient) "(${key})"
        ++ lib.optional isSoftClient key;
    in
    ''  ${nid "phone" key} [label="${lib.concatStringsSep "\\n" lines}" shape=box style="${style}" fillcolor="#D5D8DC"]''
  ) allPhones;

  mailboxNodes =
    lib.optional (cfg.sharedMailbox != null)
      ''  ${nid "mbox" cfg.sharedMailbox.mailboxId} [label="${cfg.sharedMailbox.displayName}\nbox ${cfg.sharedMailbox.mailboxId}" shape=cylinder style=filled fillcolor="#D7BDE2"]''
    ++ lib.mapAttrsToList (key: person:
      lib.optionalString person.mailbox
        ''  ${nid "mbox" person.extension} [label="${person.displayName}\nbox ${person.extension}" shape=cylinder style=filled fillcolor="#D7BDE2"]''
    ) cfg.persons;

  extensionNodes = lib.mapAttrsToList (ext: extCfg:
    let icon = if extCfg.mode == "page" then "📢 " else "⚙ "; in
    ''  ${nid "ext" ext} [label="${ext}\n${extCfg.displayName}\n(${extCfg.mode})" shape=diamond style=filled fillcolor="#D6EAF8"]''
  ) cfg.extensions;

  intercomNodes = map (ic:
    ''  ${nid "ic" ic.extension} [label="${ic.extension}\n${ic.displayName}" shape=diamond style=filled fillcolor="#FDEBD0"]''
  ) intercomEntries;

  # ── Edges ───────────────────────────────────────────────────────────────

  # Trunk → DID
  trunkDidEdges = lib.mapAttrsToList (id: didCfg:
    ''  ${nid "trunk" didCfg.trunk} -> ${nid "did" id}''
  ) cfg.dids;

  # DID → phone targets (routing)
  didRoutingEdges = lib.concatLists (lib.mapAttrsToList (id: didCfg:
    let
      r   = didCfg.routing;
      lbl = label: ''label="${label}" fontsize=9'';
      edge = target: lineLabel:
        ''  ${nid "did" id} -> ${nid "phone" target} [${lbl lineLabel}]'';
    in
    if r.type == "all" then
      lib.mapAttrsToList (key: _:
        edge key (if phoneHasL2 key then "L2" else "L1")
      ) allPhones
    else if r.type == "person" then
      lib.mapAttrsToList (key: _: edge key "L1")
        cfg.persons.${r.person}.phones
    else # persons
      lib.concatMap (personKey:
        lib.mapAttrsToList (key: _: edge key "L2")
          cfg.persons.${personKey}.phones
      ) r.persons
  ) cfg.dids);

  # DID → mailbox (no-answer fallback, dashed)
  didMailboxEdges = lib.mapAttrsToList (id: didCfg:
    let
      r        = didCfg.routing;
      dashAttr = ''style=dashed color="#999999" fontsize=9 label="no answer"'';
    in
    if didCfg.mailbox == "shared" then
      ''  ${nid "did" id} -> ${nid "mbox" cfg.sharedMailbox.mailboxId} [${dashAttr}]''
    else if didCfg.mailbox == "person" then
      ''  ${nid "did" id} -> ${nid "mbox" cfg.persons.${r.person}.extension} [${dashAttr}]''
    else ""
  ) cfg.dids;

  # Person → their phones
  personPhoneEdges = lib.concatLists (lib.mapAttrsToList (key: person:
    lib.mapAttrsToList (pkey: _:
      ''  ${nid "person" key} -> ${nid "phone" pkey} [arrowhead=open]''
    ) person.phones
  ) cfg.persons);

  # Intercom edges: phone → intercom extension
  intercomEdges = map (ic:
    ''  ${nid "phone" ic.phoneKey} -> ${nid "ic" ic.extension} [style=dotted arrowhead=open label="intercom" fontsize=9]''
  ) intercomEntries;

  # Page extension → intercom-capable phones
  pagePhones = lib.filterAttrs (key: phone:
    models.${phone.model}.hasIntercom && cfg.intercomPrefix != null
  ) allPhones;

  pageEdges = lib.concatLists (lib.mapAttrsToList (ext: extCfg:
    lib.optionals (extCfg.mode == "page")
      (lib.mapAttrsToList (key: _:
        ''  ${nid "ext" ext} -> ${nid "phone" key} [style=dotted color="#5D6D7E"]''
      ) pagePhones)
  ) cfg.extensions);

  # ── Assemble DOT ────────────────────────────────────────────────────────

  dotSource = ''
    digraph voip {
      rankdir=LR;
      graph [fontname="Helvetica,Arial,sans-serif" fontsize=11 splines=ortho];
      node  [fontname="Helvetica,Arial,sans-serif" fontsize=10];
      edge  [fontname="Helvetica,Arial,sans-serif" fontsize=9];

      subgraph cluster_trunks {
        label="SIP Trunks"; style=filled; fillcolor="#EBF5FB";
    ${lib.concatStringsSep "\n" trunkNodes}
      }

      subgraph cluster_dids {
        label="DIDs (Inbound)"; style=filled; fillcolor="#FEF9E7";
    ${lib.concatStringsSep "\n" didNodes}
      }

      ${lib.optionalString (cfg.persons != {}) ''
      subgraph cluster_persons {
        label="Persons"; style=filled; fillcolor="#EAFAF1";
    ${lib.concatStringsSep "\n" personNodes}
      }
      ''}

      subgraph cluster_phones {
        label="Phones"; style=filled; fillcolor="#F2F3F4";
    ${lib.concatStringsSep "\n" phoneNodes}
      }

      ${lib.optionalString (cfg.sharedMailbox != null || lib.any (p: p.mailbox) (lib.attrValues cfg.persons)) ''
      subgraph cluster_mailboxes {
        label="Voicemail"; style=filled; fillcolor="#F5EEF8";
    ${lib.concatStringsSep "\n" mailboxNodes}
      }
      ''}

      ${lib.optionalString (cfg.extensions != {} || intercomEntries != []) ''
      subgraph cluster_extensions {
        label="Extensions"; style=filled; fillcolor="#EBF5FB";
    ${lib.concatStringsSep "\n" extensionNodes}
    ${lib.concatStringsSep "\n" intercomNodes}
      }
      ''}

      // Trunk → DID
    ${lib.concatStringsSep "\n" trunkDidEdges}

      // DID routing → phones
    ${lib.concatStringsSep "\n" didRoutingEdges}

      // No-answer → mailbox
    ${lib.concatStringsSep "\n" didMailboxEdges}

      // Person → phones
    ${lib.concatStringsSep "\n" personPhoneEdges}

      // Intercom
    ${lib.concatStringsSep "\n" intercomEdges}

      // Page
    ${lib.concatStringsSep "\n" pageEdges}
    }
  '';

  dotFile = pkgs.writeText "voip.dot" dotSource;

  svgFile = pkgs.runCommand "voip-diagram.svg" {
    nativeBuildInputs = [ pkgs.graphviz ];
  } "dot -Tsvg ${dotFile} -o $out";

  # HTML head — everything up to and including the diagram container opening tag
  htmlHead = pkgs.writeText "voip-head.html" ''
    <!DOCTYPE html>
    <html lang="de">
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>${cfg.directoryName} — VoIP</title>
      <style>
        * { box-sizing: border-box; }
        body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
        header { background: #2c3e50; color: white; padding: 1em 2em; }
        header h1 { margin: 0; font-size: 1.4em; font-weight: 500; }
        main { padding: 2em; }
        .card { background: white; border-radius: 6px; box-shadow: 0 1px 4px rgba(0,0,0,.1); padding: 1.5em; margin-bottom: 1.5em; overflow: auto; }
        .card h2 { margin: 0 0 1em; font-size: 1em; color: #555; text-transform: uppercase; letter-spacing: .05em; }
        .diagram svg { max-width: 100%; height: auto; display: block; }
        table { border-collapse: collapse; width: 100%; font-size: .9em; }
        th { text-align: left; padding: .4em .8em; background: #f0f0f0; border-bottom: 2px solid #ddd; }
        td { padding: .4em .8em; border-bottom: 1px solid #eee; }
        code { background: #f0f0f0; padding: .1em .3em; border-radius: 3px; font-size: .9em; }
      </style>
    </head>
    <body>
      <header><h1>${cfg.directoryName} — VoIP Routing</h1></header>
      <main>
        <div class="card">
          <h2>Routing Diagram</h2>
          <div class="diagram">
  '';

  # HTML tail — closes the diagram div and adds all info tables
  htmlTail = pkgs.writeText "voip-tail.html" ''
          </div>
        </div>

        ${lib.optionalString hasTrunk ''
        <div class="card">
          <h2>SIP Trunks</h2>
          <table>
            <tr><th>Name</th><th>Host</th><th>Username</th><th>Transport</th><th>Caller ID</th></tr>
            ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t:
              let callerIdCell =
                    if t.callerIdFile != null || t.callerId != "" then rtv t.callerId t.callerIdFile
                    else "<em>provider default</em>";
              in
              ''<tr><td><code>${name}</code></td><td>${rtv t.host t.hostFile}</td><td><code>${rtv t.username t.usernameFile}</code></td><td>${t.transport}</td><td>${callerIdCell}</td></tr>''
            ) cfg.sipTrunks)}
          </table>
        </div>

        <div class="card">
          <h2>DIDs (Inbound)</h2>
          <table>
            <tr><th>ID</th><th>Number</th><th>Name</th><th>Trunk</th><th>Routing</th><th>Timeout</th><th>Mailbox</th><th>Music on Hold</th></tr>
            ${lib.concatStringsSep "\n" (lib.mapAttrsToList (id: didCfg:
              let r = didCfg.routing;
                  numCell  = if didCfg.numberFile != null then "<code>@@${didCfg.numberFile}@@</code>"
                             else if didCfg.number != "" then "<code>${didCfg.number}</code>"
                             else "<em>(none)</em>";
                  routeStr = if r.type == "all" then "all phones (L2)"
                             else if r.type == "person" then "→ ${r.person} (L1)"
                             else "→ ${lib.concatStringsSep ", " r.persons} (L2)";
                  mohCell  = if didCfg.musicOnHold != null then didCfg.musicOnHold else "<em>ringback</em>";
              in
              ''<tr><td><code>${id}</code></td><td>${numCell}</td><td>${didCfg.displayName}</td><td><code>${didCfg.trunk}</code></td><td>${routeStr}</td><td>${toString didCfg.routing.timeout}s</td><td>${didCfg.mailbox}</td><td>${mohCell}</td></tr>''
            ) cfg.dids)}
          </table>
        </div>
        ''}

        <div class="card">
          <h2>Persons</h2>
          <table>
            <tr><th>Key</th><th>Name</th><th>Extension</th><th>Mailbox</th><th>Ring timeout</th><th>Greeting</th><th>Devices</th></tr>
            ${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: person:
              let
                devList = lib.concatStringsSep "<br>" (lib.mapAttrsToList (pkey: ph:
                  "<code>${pkey}</code> (${ph.model})"
                ) person.phones);
                mailboxCell = if person.mailbox then "ext. ${person.extension}" else "<em>none</em>";
                greetingCell = if person.mailboxGreeting != null then "custom" else "<em>default</em>";
              in
              ''<tr><td><code>${key}</code></td><td>${person.displayName}</td><td><code>${person.extension}</code></td><td>${mailboxCell}</td><td>${toString person.ringTimeout}s</td><td>${greetingCell}</td><td>${devList}</td></tr>''
            ) cfg.persons)}
          </table>
        </div>

        ${lib.optionalString (cfg.sharedPhones != {}) ''
        <div class="card">
          <h2>Shared Phones</h2>
          <table>
            <tr><th>Key / MAC</th><th>Name</th><th>Extension</th><th>Model</th><th>Label</th></tr>
            ${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: phone:
              ''<tr><td><code>${key}</code></td><td>${phone.displayName}</td><td><code>${phone.extension}</code></td><td>${phone.model}</td><td>${phone.label}</td></tr>''
            ) cfg.sharedPhones)}
          </table>
        </div>
        ''}

        ${lib.optionalString (cfg.sharedMailbox != null) ''
        <div class="card">
          <h2>Shared Mailbox</h2>
          <table>
            <tr><th>Name</th><th>Mailbox ID</th><th>Check extension</th><th>Greeting</th></tr>
            <tr>
              <td>${cfg.sharedMailbox.displayName}</td>
              <td><code>${cfg.sharedMailbox.mailboxId}</code></td>
              <td><code>${cfg.sharedMailbox.checkExtension}</code></td>
              <td>${if cfg.sharedMailbox.greeting != null then "custom" else "<em>default</em>"}</td>
            </tr>
          </table>
        </div>
        ''}

        ${lib.optionalString (cfg.extensions != {} || intercomEntries != []) ''
        <div class="card">
          <h2>Extensions</h2>
          <table>
            <tr><th>Extension</th><th>Name</th><th>Type</th><th>Detail</th></tr>
            ${lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg:
              let detail = if extCfg.mode == "page" then "all phones" else extCfg.app; in
              ''<tr><td><code>${ext}</code></td><td>${extCfg.displayName}</td><td>${extCfg.mode}</td><td><code>${detail}</code></td></tr>''
            ) cfg.extensions)}
            ${lib.concatStringsSep "\n" (map (ic:
              ''<tr><td><code>${ic.extension}</code></td><td>${ic.displayName}</td><td>intercom</td><td>→ <code>${ic.phoneKey}</code></td></tr>''
            ) intercomEntries)}
          </table>
        </div>
        ''}

        ${lib.optionalString (cfg.mohClasses != {}) ''
        <div class="card">
          <h2>Music on Hold</h2>
          <table>
            <tr><th>Class</th><th>Files</th><th>Sort</th></tr>
            ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: cls:
              ''<tr><td><code>${name}</code></td><td>${toString (lib.length cls.files)}</td><td>${cls.sort}</td></tr>''
            ) cfg.mohClasses)}
          </table>
        </div>
        ''}

        <div class="card">
          <h2>Codecs</h2>
          <table>
            <tr><th>Endpoint type</th><th>Preference order</th></tr>
            <tr><td>Hardware phones</td><td><code>${lib.concatStringsSep " › " cfg.codecs.hardwarePhones}</code></td></tr>
            <tr><td>Soft clients</td><td><code>${lib.concatStringsSep " › " cfg.codecs.softClients}</code></td></tr>
            <tr><td>Trunks</td><td><code>${lib.concatStringsSep " › " cfg.codecs.trunk}</code></td></tr>
          </table>
        </div>

        <div class="card">
          <h2>Server</h2>
          <table>
            <tr><th>Parameter</th><th>Value</th></tr>
            <tr><td>Address</td><td><code>${cfg.serverAddress}</code></td></tr>
            <tr><td>SIP port</td><td><code>${toString cfg.sipPort}</code></td></tr>
            <tr><td>RTP range</td><td><code>${toString cfg.rtpStart} – ${toString cfg.rtpEnd}</code></td></tr>
            <tr><td>Directory port</td><td><code>${toString cfg.directoryPort}</code></td></tr>
            ${lib.optionalString (cfg.intercomPrefix != null)
              ''<tr><td>Intercom prefix</td><td><code>${cfg.intercomPrefix}</code></td></tr>''}
          </table>
        </div>
      </main>
    </body>
    </html>
  '';

  # Web root directory: index.html with inlined SVG (head + svg + tail), plus raw .dot
  webRoot = pkgs.runCommand "voip-webroot" {} ''
    mkdir -p $out
    cat ${htmlHead} ${svgFile} ${htmlTail} > $out/index.html
    cp ${dotFile} $out/voip.dot
  '';

in { inherit webRoot svgFile dotFile; }
