#!/usr/bin/php -q
<?php

$NAMED_ROOT = '/usr/daemon/named';
$ZONE_BASE  = "$NAMED_ROOT/zone";

// ---------- helpers ----------

function usage() {
  echo "Usage:\n";
  echo "  namedmanage zone list\n";
  echo "  namedmanage zone add <domain> [--ns <nameserver>] [--ns-ip <ip>] [--email <soa-email>] [--ttl <seconds>]\n";
  echo "  namedmanage zone delete <domain>\n";
  echo "  namedmanage record list <domain>\n";
  echo "  namedmanage record add <domain> <type> <name> <value> [--ttl <seconds>] [--priority <n>]\n";
  echo "  namedmanage record delete <domain> <type> <name> [<value>]\n";
  echo "  namedmanage tsig list\n";
  echo "  namedmanage tsig add <name> [--algo hmac-sha256] [--key <base64>]\n";
  echo "  namedmanage tsig show <name>\n";
  echo "  namedmanage tsig delete <name>\n";
  echo "  namedmanage acl list\n";
  echo "  namedmanage acl add <name> <addr> [<addr> ...]\n";
  echo "  namedmanage acl remove <name> <addr>\n";
  echo "  namedmanage acl delete <name>\n";
  echo "  namedmanage acl show <name>\n";
  echo "  namedmanage zone allow <domain> transfer|query|update <entry> [<entry> ...]\n";
  echo "  namedmanage zone allow <domain> show\n";
  echo "  (entries: IP, CIDR, ACL name, any, none, key:keyname)\n";
  echo "  namedmanage server list\n";
  echo "  namedmanage server add <name> --ip <ip> [--tsig <keyname>]\n";
  echo "  namedmanage server delete <name>\n";
  echo "  namedmanage server show <name>\n";
  echo "  namedmanage zone secondary add <domain> <server>\n";
  echo "  namedmanage zone secondary remove <domain> <server>\n";
  echo "  namedmanage zone secondary show <domain>\n";
  echo "  namedmanage zone primary set <domain> <server>\n";
  echo "  namedmanage zone primary clear <domain>\n";
  echo "  namedmanage zone primary show <domain>\n";
  echo "  namedmanage sync\n";
  exit(1);
}

function die_err($msg) {
  fprintf(STDERR, "namedmanage: error: %s\n", $msg);
  exit(1);
}

function zone_path($domain) {
  global $ZONE_BASE;
  return "$ZONE_BASE/$domain";
}

function config_file($domain) {
  return zone_path($domain) . '/config.json';
}

function zone_file($domain) {
  return zone_path($domain) . "/$domain.zone";
}

function named_conf_fragment($domain) {
  return zone_path($domain) . '/named.conf';
}

function load_config($domain) {
  $f = config_file($domain);
  if (!file_exists($f)) die_err("zone '$domain' not found");
  $data = json_decode(file_get_contents($f), true);
  if ($data === null) die_err("could not parse config.json for '$domain'");
  return $data;
}

function save_config($domain, $data) {
  file_put_contents(config_file($domain), json_encode($data, JSON_PRETTY_PRINT) . "\n");
}

// Increment or initialise SOA serial using YYYYMMDDnn convention
function next_serial($current) {
  $today = date('Ymd');
  if (preg_match('/^(\d{8})(\d{2})$/', (string)$current, $m) && $m[1] === $today) {
    return $today . sprintf('%02d', (int)$m[2] + 1);
  }
  return $today . '01';
}

// Write a zone file from config.json contents
/**
 * Encode a mailbox as an SOA RNAME (RFC 1035 §3.3.13).
 *
 * The RNAME field is a domain name, not an address: the '@' becomes a dot, and
 * any dot inside the local part is escaped. Writing admin@example.com through
 * unchanged makes the first label literally "admin@example", so the SOA names a
 * mailbox nobody can reach — BIND loads it and dig renders it as
 * "admin\@example.com.", which is the giveaway.
 *
 * Already-encoded input (hostmaster.example.com.) passes through untouched.
 */
function soa_rname($email) {
  $email = trim($email);
  if (strpos($email, '@') !== false) {
    list($local, $domain) = explode('@', $email, 2);
    $email = str_replace('.', '\\.', $local) . '.' . $domain;
  }
  if ($email === '' || substr($email, -1) !== '.') $email .= '.';
  return $email;
}

function write_zone_file($domain, $cfg) {
  global $NAMED_ROOT;

  $ttl    = $cfg['ttl'] ?? 3600;
  $soa    = $cfg['soa'];
  $serial = $soa['serial'];

  $out  = "\$ORIGIN $domain.\n";
  $out .= "\$TTL $ttl\n";
  $out .= sprintf(
    "@ IN SOA %s %s (\n    %s ; serial\n    %d ; refresh\n    %d ; retry\n    %d ; expire\n    %d ) ; minimum\n\n",
    $soa['ns'], $soa['email'], $serial,
    $soa['refresh'], $soa['retry'], $soa['expire'], $soa['minimum']
  );

  // Record types whose value is a hostname and needs a trailing dot
  $hostnameTypes = ['NS', 'MX', 'CNAME', 'PTR', 'SRV'];

  foreach ($cfg['records'] as $rec) {
    $ttlField = isset($rec['ttl']) ? $rec['ttl'] : '';
    $name     = $rec['name'];
    $type     = strtoupper($rec['type']);
    $value    = $rec['value'];

    // Ensure hostname values are absolute (trailing dot)
    if (in_array($type, $hostnameTypes) && substr($value, -1) !== '.') {
      $value .= '.';
    }

    if ($type === 'MX') {
      $pri = isset($rec['priority']) ? $rec['priority'] : 10;
      $out .= sprintf("%-24s %s IN %-6s %d %s\n", $name, $ttlField, $type, $pri, $value);
    } else {
      $out .= sprintf("%-24s %s IN %-6s %s\n", $name, $ttlField, $type, $value);
    }
  }

  file_put_contents(zone_file($domain), $out);

  // Validate zone file
  $zf  = zone_file($domain);
  $out2 = [];
  $ret  = 0;
  exec("/usr/daemon/named/bin/named-checkzone " . escapeshellarg($domain) . " " . escapeshellarg($zf) . " 2>&1", $out2, $ret);
  if ($ret !== 0) {
    fprintf(STDERR, "namedmanage: named-checkzone warning:\n%s\n", implode("\n", $out2));
  }
}

// ---------- server helpers ----------

function server_dir() {
  global $NAMED_ROOT;
  return "$NAMED_ROOT/etc/servers";
}

function server_file($name) {
  return server_dir() . "/$name.json";
}

function server_name_valid($name) {
  return preg_match('/^[a-zA-Z0-9._-]+$/', $name);
}

function load_server($name) {
  $f = server_file($name);
  if (!file_exists($f)) die_err("server '$name' not found");
  return json_decode(file_get_contents($f), true);
}

function load_server_safe($name) {
  $f = server_file($name);
  if (!file_exists($f)) return null;
  return json_decode(file_get_contents($f), true);
}

// ---------- render helpers ----------

// Render one allow-* line from a list of entries
// Entries: IP, CIDR, ACL name, "any", "none", "key:keyname"
function render_allow_line($directive, $entries) {
  if (empty($entries)) return '';
  $parts = [];
  foreach ($entries as $e) {
    if (strncmp($e, 'key:', 4) === 0) {
      $parts[] = 'key "' . substr($e, 4) . '"';
    } else {
      $parts[] = $e;
    }
  }
  return "    $directive { " . implode('; ', $parts) . "; };\n";
}

// Write the named.conf zone stanza fragment, handling primary and secondary zone types.
function write_named_conf_fragment($domain, $cfg = null) {
  if ($cfg === null && file_exists(config_file($domain))) {
    $cfg = load_config($domain);
  }
  $allow      = $cfg['allow']    ?? [];
  $xferCfg    = $cfg['transfer'] ?? [];
  $primarySrv = $xferCfg['primary']     ?? null; // set → this zone is a secondary
  $secondaries = $xferCfg['secondaries'] ?? [];

  if ($primarySrv !== null) {
    // ---- Secondary zone ----
    $srv = load_server($primarySrv);
    $primariesEntry = $srv['ip'];
    if (!empty($srv['tsig'])) $primariesEntry .= " key \"{$srv['tsig']}\"";

    $xfer  = $allow['transfer'] ?? ['none']; // secondaries don't re-transfer by default
    $query = $allow['query']    ?? ['any'];

    $frag = "zone \"base.dom\" {\n" .
            "    type secondary;\n" .
            "    file \"/usr/daemon/named/zone/base.dom/base.dom.zone\";\n" .
            "    primaries { $primariesEntry; };\n" .
            render_allow_line('allow-transfer', $xfer) .
            render_allow_line('allow-query',    $query) .
            "};\n";
  } else {
    // ---- Primary zone ----
    // allow-transfer: explicit setting takes priority; otherwise derive from secondaries
    if (isset($allow['transfer'])) {
      $xfer = $allow['transfer'];
    } elseif (!empty($secondaries)) {
      $xfer = [];
      foreach ($secondaries as $sname) {
        $srv = load_server_safe($sname);
        if ($srv === null) continue;
        $xfer[] = !empty($srv['tsig']) ? 'key:' . $srv['tsig'] : $srv['ip'];
      }
    } else {
      $xfer = ['none'];
    }

    $query  = $allow['query']  ?? ['any'];
    $update = $allow['update'] ?? ['none'];

    $frag = "zone \"base.dom\" {\n" .
            "    type primary;\n" .
            "    file \"/usr/daemon/named/zone/base.dom/base.dom.zone\";\n" .
            render_allow_line('allow-transfer', $xfer) .
            render_allow_line('allow-query',    $query) .
            render_allow_line('allow-update',   $update);

    // also-notify secondaries so they refresh immediately
    if (!empty($secondaries)) {
      $notifyParts = [];
      foreach ($secondaries as $sname) {
        $srv = load_server_safe($sname);
        if ($srv === null) continue;
        $entry = $srv['ip'];
        if (!empty($srv['tsig'])) $entry .= " key \"{$srv['tsig']}\"";
        $notifyParts[] = $entry;
      }
      if (!empty($notifyParts)) {
        $frag .= "    also-notify { " . implode('; ', $notifyParts) . "; };\n";
      }
    }

    $frag .= "};\n";
  }

  file_put_contents(named_conf_fragment($domain), $frag);
}

function run_syncnamed() {
  $script = '/usr/daemon/named/local/bin/syncnamed';
  if (!file_exists($script)) die_err("syncnamed not found at $script");
  passthru($script);
}

// ---------- commands ----------

function cmd_zone_list() {
  global $ZONE_BASE;
  $dir = @opendir($ZONE_BASE);
  if (!$dir) { echo "No zones configured.\n"; return; }
  $zones = [];
  while (($entry = readdir($dir)) !== false) {
    if ($entry === '.' || $entry === '..') continue;
    if (is_dir("$ZONE_BASE/$entry")) $zones[] = $entry;
  }
  closedir($dir);
  sort($zones);
  if (empty($zones)) { echo "No zones configured.\n"; return; }
  foreach ($zones as $z) echo "$z\n";
}

function cmd_zone_add($domain, $opts) {
  global $ZONE_BASE;
  $path = zone_path($domain);
  if (is_dir($path)) die_err("zone '$domain' already exists");
  mkdir($path, 0755, true);

  $ns    = $opts['ns']    ?? ('ns1.' . $domain . '.');
  // soa_rname() adds the trailing dot as well as converting any '@'.
  $email = soa_rname($opts['email'] ?? ('hostmaster.' . $domain));
  // Ensure trailing dot
  if (substr($ns,    -1) !== '.') $ns    .= '.';

  $ttl    = (int)($opts['ttl'] ?? 3600);
  $serial = date('Ymd') . '01';

  $records = [['type' => 'NS', 'name' => '@', 'value' => $ns]];

  // If the NS hostname is within this zone, add a glue A record for it
  $nsHost = rtrim($ns, '.');
  $nsIp   = $opts['ns-ip'] ?? null;
  if ($nsIp) {
    // Strip the zone suffix to get the relative NS label
    $suffix = '.' . $domain;
    if (substr($nsHost, -strlen($suffix)) === $suffix) {
      $nsLabel = substr($nsHost, 0, -strlen($suffix));
    } elseif ($nsHost === $domain) {
      $nsLabel = '@';
    } else {
      $nsLabel = $nsHost; // out-of-zone, store as-is (won't be in this zone file)
    }
    if ($nsLabel !== $nsHost) { // only add if NS is in-zone
      $records[] = ['type' => 'A', 'name' => $nsLabel, 'value' => $nsIp];
    }
  }

  $cfg = [
    'ttl' => $ttl,
    'soa' => [
      'ns'      => $ns,
      'email'   => $email,
      'serial'  => $serial,
      'refresh' => 3600,
      'retry'   => 900,
      'expire'  => 604800,
      'minimum' => 300,
    ],
    'records' => $records,
  ];

  save_config($domain, $cfg);
  write_named_conf_fragment($domain);
  write_zone_file($domain, $cfg);
  printf("Zone '%s' created. NS=%s, SOA email=%s\n", $domain, $ns, $email);
  run_syncnamed();
}

function cmd_zone_delete($domain) {
  $path = zone_path($domain);
  if (!is_dir($path)) die_err("zone '$domain' not found");
  // Recursively remove zone directory
  $it = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
    RecursiveIteratorIterator::CHILD_FIRST
  );
  foreach ($it as $file) {
    $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
  }
  rmdir($path);
  printf("Zone '%s' deleted.\n", $domain);
  run_syncnamed();
}

function cmd_record_list($domain) {
  $cfg     = load_config($domain);
  $soa     = $cfg['soa'];
  $records = $cfg['records'] ?? [];

  printf("Zone: %s  TTL: %d  Serial: %s\n", $domain, $cfg['ttl'] ?? 3600, $soa['serial']);
  printf("SOA: ns=%s email=%s\n\n", $soa['ns'], $soa['email']);
  printf("%-24s %-8s %-8s %s\n", 'Name', 'TTL', 'Type', 'Value');
  printf("%s\n", str_repeat('-', 60));
  foreach ($records as $r) {
    $extra = '';
    if (strtoupper($r['type']) === 'MX') $extra = (isset($r['priority']) ? $r['priority'] . ' ' : '10 ') . $r['value'];
    else $extra = $r['value'];
    printf("%-24s %-8s %-8s %s\n", $r['name'], $r['ttl'] ?? '', strtoupper($r['type']), $extra);
  }
}

function cmd_record_add($domain, $type, $name, $value, $opts) {
  $cfg = load_config($domain);
  $rec = [
    'type'  => strtoupper($type),
    'name'  => $name,
    'value' => $value,
  ];
  if (isset($opts['ttl']))      $rec['ttl']      = (int)$opts['ttl'];
  if (isset($opts['priority'])) $rec['priority'] = (int)$opts['priority'];

  $cfg['records'][]  = $rec;
  $cfg['soa']['serial'] = next_serial($cfg['soa']['serial']);

  save_config($domain, $cfg);
  write_zone_file($domain, $cfg);
  printf("Record added to '%s': %s %s %s\n", $domain, $type, $name, $value);
  run_syncnamed();
}

function cmd_record_delete($domain, $type, $name, $value = null) {
  $cfg     = load_config($domain);
  $type    = strtoupper($type);
  $before  = count($cfg['records']);

  $cfg['records'] = array_values(array_filter($cfg['records'], function($r) use ($type, $name, $value) {
    if (strtoupper($r['type']) !== $type) return true;
    if ($r['name'] !== $name)             return true;
    if ($value !== null && $r['value'] !== $value) return true;
    return false;
  }));

  $removed = $before - count($cfg['records']);
  if ($removed === 0) die_err("no matching record found");

  $cfg['soa']['serial'] = next_serial($cfg['soa']['serial']);
  save_config($domain, $cfg);
  write_zone_file($domain, $cfg);
  printf("Removed %d record(s) from '%s'.\n", $removed, $domain);
  run_syncnamed();
}

// ---------- server commands ----------

function cmd_server_list() {
  $d = @opendir(server_dir());
  if (!$d) { echo "No servers configured.\n"; return; }
  $servers = [];
  while (($f = readdir($d)) !== false) {
    if (substr($f, -5) !== '.json') continue;
    $servers[] = substr($f, 0, -5);
  }
  closedir($d);
  sort($servers);
  if (empty($servers)) { echo "No servers configured.\n"; return; }
  printf("%-25s %-20s %s\n", 'Name', 'IP', 'TSIG Key');
  printf("%s\n", str_repeat('-', 60));
  foreach ($servers as $name) {
    $s = load_server_safe($name);
    printf("%-25s %-20s %s\n", $name, $s['ip'] ?? '', $s['tsig'] ?? '');
  }
}

function cmd_server_add($name, $opts) {
  if (!server_name_valid($name)) die_err("invalid server name '$name'");
  if (empty($opts['ip'])) die_err("--ip is required");
  $data = ['ip' => $opts['ip']];
  if (!empty($opts['tsig'])) $data['tsig'] = $opts['tsig'];
  if (!is_dir(server_dir())) mkdir(server_dir(), 0755, true);
  file_put_contents(server_file($name), json_encode($data, JSON_PRETTY_PRINT) . "\n");
  printf("Server '%s' saved: ip=%s%s\n", $name, $data['ip'], isset($data['tsig']) ? " tsig={$data['tsig']}" : '');
}

function cmd_server_delete($name) {
  if (!server_name_valid($name)) die_err("invalid server name '$name'");
  if (!file_exists(server_file($name))) die_err("server '$name' not found");
  // Warn if any zone references this server
  unlink(server_file($name));
  printf("Server '%s' deleted.\n", $name);
}

function cmd_server_show($name) {
  $s = load_server($name); // dies if not found
  printf("Name : %s\n", $name);
  printf("IP   : %s\n", $s['ip']);
  printf("TSIG : %s\n", $s['tsig'] ?? '(none)');
}

// ---------- zone secondary/primary commands ----------

function cmd_zone_secondary_add($domain, $serverName) {
  if (!server_name_valid($serverName)) die_err("invalid server name");
  load_server($serverName); // validate it exists
  $cfg = load_config($domain);
  $secondaries = $cfg['transfer']['secondaries'] ?? [];
  if (in_array($serverName, $secondaries)) die_err("server '$serverName' is already a secondary for '$domain'");
  $secondaries[] = $serverName;
  $cfg['transfer']['secondaries'] = $secondaries;
  save_config($domain, $cfg);
  write_named_conf_fragment($domain, $cfg);
  printf("Added '%s' as secondary for zone '%s'.\n", $serverName, $domain);
  run_syncnamed();
}

function cmd_zone_secondary_remove($domain, $serverName) {
  $cfg = load_config($domain);
  $secondaries = $cfg['transfer']['secondaries'] ?? [];
  if (!in_array($serverName, $secondaries)) die_err("server '$serverName' is not a secondary for '$domain'");
  $cfg['transfer']['secondaries'] = array_values(array_filter($secondaries, fn($s) => $s !== $serverName));
  save_config($domain, $cfg);
  write_named_conf_fragment($domain, $cfg);
  printf("Removed '%s' as secondary for zone '%s'.\n", $serverName, $domain);
  run_syncnamed();
}

function cmd_zone_secondary_show($domain) {
  $cfg = load_config($domain);
  $secondaries = $cfg['transfer']['secondaries'] ?? [];
  if (empty($secondaries)) { printf("Zone '%s' has no secondaries configured.\n", $domain); return; }
  printf("Secondaries for zone '%s':\n", $domain);
  foreach ($secondaries as $name) {
    $s = load_server_safe($name);
    if ($s) printf("  %-25s ip=%-20s tsig=%s\n", $name, $s['ip'], $s['tsig'] ?? '(none)');
    else    printf("  %-25s (server record missing)\n", $name);
  }
}

function cmd_zone_primary_set($domain, $serverName) {
  if (!server_name_valid($serverName)) die_err("invalid server name");
  load_server($serverName); // validate it exists
  $cfg = load_config($domain);
  $cfg['transfer']['primary'] = $serverName;
  save_config($domain, $cfg);
  write_named_conf_fragment($domain, $cfg);
  $s = load_server($serverName);
  printf("Zone '%s' set to secondary; primary is '%s' (%s).\n", $domain, $serverName, $s['ip']);
  run_syncnamed();
}

function cmd_zone_primary_clear($domain) {
  $cfg = load_config($domain);
  if (!isset($cfg['transfer']['primary'])) die_err("zone '$domain' does not have a primary set");
  unset($cfg['transfer']['primary']);
  save_config($domain, $cfg);
  write_named_conf_fragment($domain, $cfg);
  printf("Zone '%s' reverted to primary type.\n", $domain);
  run_syncnamed();
}

function cmd_zone_primary_show($domain) {
  $cfg = load_config($domain);
  $primarySrv  = $cfg['transfer']['primary']     ?? null;
  $secondaries = $cfg['transfer']['secondaries'] ?? [];
  if ($primarySrv) {
    $s = load_server_safe($primarySrv);
    printf("Zone '%s' type: secondary\n", $domain);
    printf("  Primary: %-25s ip=%s%s\n", $primarySrv, $s['ip'] ?? '?', !empty($s['tsig']) ? " tsig={$s['tsig']}" : '');
  } else {
    printf("Zone '%s' type: primary\n", $domain);
    if (!empty($secondaries)) {
      printf("  Secondaries:\n");
      foreach ($secondaries as $name) {
        $s = load_server_safe($name);
        printf("    %-25s ip=%s%s\n", $name, $s['ip'] ?? '?', !empty($s['tsig']) ? " tsig={$s['tsig']}" : '');
      }
    } else {
      printf("  No secondaries configured.\n");
    }
  }
}

// ---------- acl commands ----------

function acl_dir() {
  global $NAMED_ROOT;
  return "$NAMED_ROOT/etc/acl";
}

function acl_file($name) {
  return acl_dir() . "/$name.conf";
}

function acl_name_valid($name) {
  return preg_match('/^[a-zA-Z0-9._-]+$/', $name);
}

function load_acl_entries($name) {
  $f = acl_file($name);
  if (!file_exists($f)) die_err("ACL '$name' not found");
  $content = file_get_contents($f);
  $entries = [];
  // Parse entries between the braces
  if (preg_match('/acl\s+"[^"]+"\s*\{([^}]*)\}/s', $content, $m)) {
    foreach (preg_split('/[;\n]+/', $m[1]) as $part) {
      $part = trim($part);
      if ($part !== '') $entries[] = $part;
    }
  }
  return $entries;
}

function write_acl_file($name, $entries) {
  if (!is_dir(acl_dir())) mkdir(acl_dir(), 0755, true);
  $content = "acl \"$name\" {\n";
  foreach ($entries as $e) {
    $content .= "    $e;\n";
  }
  $content .= "};\n";
  file_put_contents(acl_file($name), $content);
}

function cmd_acl_list() {
  $d = @opendir(acl_dir());
  if (!$d) { echo "No ACLs configured.\n"; return; }
  $acls = [];
  while (($f = readdir($d)) !== false) {
    if (substr($f, -5) !== '.conf') continue;
    $acls[] = substr($f, 0, -5);
  }
  closedir($d);
  sort($acls);
  if (empty($acls)) { echo "No ACLs configured.\n"; return; }
  printf("%-25s %s\n", 'Name', 'Entries');
  printf("%s\n", str_repeat('-', 60));
  foreach ($acls as $name) {
    $entries = load_acl_entries($name);
    printf("%-25s %s\n", $name, implode(', ', $entries));
  }
}

function cmd_acl_add($name, $addrs) {
  if (!acl_name_valid($name)) die_err("invalid ACL name '$name'");
  if (empty($addrs)) die_err("at least one address required");
  $entries = file_exists(acl_file($name)) ? load_acl_entries($name) : [];
  foreach ($addrs as $addr) {
    if (!in_array($addr, $entries)) $entries[] = $addr;
  }
  write_acl_file($name, $entries);
  printf("ACL '%s' updated: %s\n", $name, implode(', ', $entries));
  run_syncnamed();
}

function cmd_acl_remove($name, $addr) {
  if (!acl_name_valid($name)) die_err("invalid ACL name '$name'");
  $entries = load_acl_entries($name);
  $before  = count($entries);
  $entries = array_values(array_filter($entries, fn($e) => $e !== $addr));
  if (count($entries) === $before) die_err("address '$addr' not found in ACL '$name'");
  write_acl_file($name, $entries);
  printf("Removed '%s' from ACL '%s'.\n", $addr, $name);
  run_syncnamed();
}

function cmd_acl_delete($name) {
  if (!acl_name_valid($name)) die_err("invalid ACL name '$name'");
  if (!file_exists(acl_file($name))) die_err("ACL '$name' not found");
  unlink(acl_file($name));
  printf("ACL '%s' deleted.\n", $name);
  run_syncnamed();
}

function cmd_acl_show($name) {
  if (!acl_name_valid($name)) die_err("invalid ACL name '$name'");
  if (!file_exists(acl_file($name))) die_err("ACL '$name' not found");
  echo file_get_contents(acl_file($name));
}

// ---------- zone allow commands ----------

function cmd_zone_allow_show($domain) {
  $cfg   = load_config($domain);
  $allow = $cfg['allow'] ?? [];
  printf("Zone: %s\n", $domain);
  printf("  allow-transfer : %s\n", implode(', ', $allow['transfer'] ?? ['none']));
  printf("  allow-query    : %s\n", implode(', ', $allow['query']    ?? ['any']));
  printf("  allow-update   : %s\n", implode(', ', $allow['update']   ?? ['none']));
}

function cmd_zone_allow_set($domain, $directive, $entries) {
  $valid = ['transfer', 'query', 'update'];
  if (!in_array($directive, $valid)) die_err("directive must be one of: " . implode(', ', $valid));
  if (empty($entries)) die_err("at least one entry required (use 'none' to restrict all)");

  $cfg = load_config($domain);
  if (!isset($cfg['allow'])) $cfg['allow'] = [];
  $cfg['allow'][$directive] = $entries;

  save_config($domain, $cfg);
  write_named_conf_fragment($domain, $cfg);
  printf("Zone '%s' %s: %s\n", $domain, "allow-$directive", implode(', ', $entries));
  run_syncnamed();
}

// ---------- tsig commands ----------

function tsig_dir() {
  global $NAMED_ROOT;
  return "$NAMED_ROOT/etc/tsig";
}

function tsig_file($name) {
  return tsig_dir() . "/$name.key";
}

function tsig_name_valid($name) {
  return preg_match('/^[a-zA-Z0-9._-]+$/', $name);
}

function cmd_tsig_list() {
  $dir = @opendir(tsig_dir());
  if (!$dir) { echo "No TSIG keys configured.\n"; return; }
  $keys = [];
  while (($f = readdir($dir)) !== false) {
    if (substr($f, -4) !== '.key') continue;
    $keys[] = substr($f, 0, -4);
  }
  closedir($dir);
  sort($keys);
  if (empty($keys)) { echo "No TSIG keys configured.\n"; return; }
  printf("%-30s %s\n", 'Name', 'Algorithm');
  printf("%s\n", str_repeat('-', 50));
  foreach ($keys as $name) {
    $content = file_get_contents(tsig_file($name));
    $algo = 'unknown';
    if (preg_match('/algorithm\s+([^;]+);/', $content, $m)) $algo = trim($m[1]);
    printf("%-30s %s\n", $name, $algo);
  }
}

function cmd_tsig_add($name, $opts) {
  if (!tsig_name_valid($name)) die_err("invalid TSIG key name '$name' (use letters, digits, dots, hyphens, underscores)");
  $keyFile = tsig_file($name);
  if (file_exists($keyFile)) die_err("TSIG key '$name' already exists");

  $algo = $opts['algo'] ?? 'hmac-sha256';
  $validAlgos = ['hmac-md5', 'hmac-sha1', 'hmac-sha224', 'hmac-sha256', 'hmac-sha384', 'hmac-sha512'];
  if (!in_array(strtolower($algo), $validAlgos)) die_err("unsupported algorithm '$algo'");

  if (isset($opts['key'])) {
    $secret = $opts['key'];
    if (base64_decode($secret, true) === false) die_err("--key value is not valid base64");
  } else {
    // Generate key using tsig-keygen if available, otherwise openssl
    $tsigKeygen = '/usr/daemon/named/sbin/tsig-keygen';
    if (file_exists($tsigKeygen)) {
      $output = [];
      exec(escapeshellarg($tsigKeygen) . ' -a ' . escapeshellarg($algo) . ' ' . escapeshellarg($name) . ' 2>&1', $output, $ret);
      if ($ret !== 0) die_err("tsig-keygen failed: " . implode(' ', $output));
      $block = implode("\n", $output) . "\n";
      // Extract the secret from tsig-keygen output for display
      preg_match('/secret\s+"([^"]+)"/', $block, $m);
      $secret = $m[1] ?? '(see key file)';
      if (!is_dir(tsig_dir())) mkdir(tsig_dir(), 0750, true);
      file_put_contents($keyFile, $block);
      chmod($keyFile, 0640);
      printf("TSIG key '%s' created (algo: %s)\n", $name, $algo);
      printf("Secret: %s\n", $secret);
      run_syncnamed();
      return;
    }
    // Fallback: generate with openssl
    $bits = str_contains($algo, '512') ? 64 : (str_contains($algo, '384') ? 48 : 32);
    $secret = base64_encode(random_bytes($bits));
  }

  $block = "key \"$name\" {\n    algorithm $algo;\n    secret \"$secret\";\n};\n";
  if (!is_dir(tsig_dir())) mkdir(tsig_dir(), 0750, true);
  file_put_contents($keyFile, $block);
  chmod($keyFile, 0640);
  printf("TSIG key '%s' created (algo: %s)\n", $name, $algo);
  printf("Secret: %s\n", $secret);
  run_syncnamed();
}

function cmd_tsig_show($name) {
  if (!tsig_name_valid($name)) die_err("invalid TSIG key name '$name'");
  $keyFile = tsig_file($name);
  if (!file_exists($keyFile)) die_err("TSIG key '$name' not found");
  echo file_get_contents($keyFile);
}

function cmd_tsig_delete($name) {
  if (!tsig_name_valid($name)) die_err("invalid TSIG key name '$name'");
  $keyFile = tsig_file($name);
  if (!file_exists($keyFile)) die_err("TSIG key '$name' not found");
  unlink($keyFile);
  printf("TSIG key '%s' deleted.\n", $name);
  run_syncnamed();
}

// ---------- argument parsing ----------

$argv_orig = $argv;
array_shift($argv); // remove script name

if (count($argv) < 2) usage();

$cmd1 = array_shift($argv);
$cmd2 = array_shift($argv);

// Parse --key value pairs from remaining args
function parse_opts(&$argv) {
  $opts     = [];
  $positional = [];
  while (count($argv) > 0) {
    $a = array_shift($argv);
    if (strncmp($a, '--', 2) === 0) {
      $key = substr($a, 2);
      $opts[$key] = array_shift($argv);
    } else {
      $positional[] = $a;
    }
  }
  return [$positional, $opts];
}

switch ($cmd1) {
  case 'zone':
    switch ($cmd2) {
      case 'list':
        cmd_zone_list();
        break;
      case 'add':
        if (count($argv) < 1) usage();
        $domain = array_shift($argv);
        [, $opts] = parse_opts($argv);
        cmd_zone_add($domain, $opts);
        break;
      case 'delete':
        if (count($argv) < 1) usage();
        cmd_zone_delete(array_shift($argv));
        break;
      case 'allow':
        if (count($argv) < 2) usage();
        $domain    = array_shift($argv);
        $directive = array_shift($argv);
        if ($directive === 'show') {
          cmd_zone_allow_show($domain);
        } else {
          if (count($argv) < 1) usage();
          cmd_zone_allow_set($domain, $directive, $argv);
        }
        break;
      case 'secondary':
        if (count($argv) < 2) usage();
        $sub    = array_shift($argv);
        $domain = array_shift($argv);
        switch ($sub) {
          case 'add':
            if (count($argv) < 1) usage();
            cmd_zone_secondary_add($domain, array_shift($argv));
            break;
          case 'remove':
            if (count($argv) < 1) usage();
            cmd_zone_secondary_remove($domain, array_shift($argv));
            break;
          case 'show':
            cmd_zone_secondary_show($domain);
            break;
          default: usage();
        }
        break;
      case 'primary':
        if (count($argv) < 2) usage();
        $sub    = array_shift($argv);
        $domain = array_shift($argv);
        switch ($sub) {
          case 'set':
            if (count($argv) < 1) usage();
            cmd_zone_primary_set($domain, array_shift($argv));
            break;
          case 'clear':
            cmd_zone_primary_clear($domain);
            break;
          case 'show':
            cmd_zone_primary_show($domain);
            break;
          default: usage();
        }
        break;
      default:
        usage();
    }
    break;

  case 'record':
    switch ($cmd2) {
      case 'list':
        if (count($argv) < 1) usage();
        cmd_record_list(array_shift($argv));
        break;
      case 'add':
        if (count($argv) < 4) usage();
        $domain = array_shift($argv);
        $type   = array_shift($argv);
        $name   = array_shift($argv);
        $value  = array_shift($argv);
        [, $opts] = parse_opts($argv);
        cmd_record_add($domain, $type, $name, $value, $opts);
        break;
      case 'delete':
        if (count($argv) < 3) usage();
        $domain = array_shift($argv);
        $type   = array_shift($argv);
        $name   = array_shift($argv);
        $value  = count($argv) > 0 ? array_shift($argv) : null;
        cmd_record_delete($domain, $type, $name, $value);
        break;
      default:
        usage();
    }
    break;

  case 'server':
    switch ($cmd2) {
      case 'list':
        cmd_server_list();
        break;
      case 'add':
        if (count($argv) < 1) usage();
        $name = array_shift($argv);
        [, $opts] = parse_opts($argv);
        cmd_server_add($name, $opts);
        break;
      case 'delete':
        if (count($argv) < 1) usage();
        cmd_server_delete(array_shift($argv));
        break;
      case 'show':
        if (count($argv) < 1) usage();
        cmd_server_show(array_shift($argv));
        break;
      default:
        usage();
    }
    break;

  case 'acl':
    switch ($cmd2) {
      case 'list':
        cmd_acl_list();
        break;
      case 'add':
        if (count($argv) < 2) usage();
        $name = array_shift($argv);
        cmd_acl_add($name, $argv);
        break;
      case 'remove':
        if (count($argv) < 2) usage();
        $name = array_shift($argv);
        cmd_acl_remove($name, array_shift($argv));
        break;
      case 'delete':
        if (count($argv) < 1) usage();
        cmd_acl_delete(array_shift($argv));
        break;
      case 'show':
        if (count($argv) < 1) usage();
        cmd_acl_show(array_shift($argv));
        break;
      default:
        usage();
    }
    break;

  case 'tsig':
    switch ($cmd2) {
      case 'list':
        cmd_tsig_list();
        break;
      case 'add':
        if (count($argv) < 1) usage();
        $name = array_shift($argv);
        [, $opts] = parse_opts($argv);
        cmd_tsig_add($name, $opts);
        break;
      case 'show':
        if (count($argv) < 1) usage();
        cmd_tsig_show(array_shift($argv));
        break;
      case 'delete':
        if (count($argv) < 1) usage();
        cmd_tsig_delete(array_shift($argv));
        break;
      default:
        usage();
    }
    break;

  case 'sync':
    run_syncnamed();
    break;

  default:
    usage();
}
?>
