#!/usr/bin/perl

use strict;
use warnings;

use File::Temp qw/tempfile/;
use Getopt::Long qw/GetOptions/;
use POSIX qw/strftime/;
use Martnet::DDNS;

# Hidden-master unified include generator.
#
# - Generates /etc/bind/unified.zones.9
# - Creates missing zonefiles for any emitted zones
#   - _vhosts: full vhost template using config defaults (and optional per-zone override)
#   - other: minimal valid skeleton
# - Emits DDNS policy for ALL zones:
#     update-policy grants master-ddns-tsig zonesub ANY
#     plus additional ddns_keys[] from per-zone JSON payload
# - Optionally reconciles _vhost A records via DDNS (--reconcile-all)
#
# Control plane (private.invalid) remains static and is NOT generated here.

my $out           = '/etc/bind/unified.zones.9';
my $self_master   = undef;   # if unset, uses $ddns->default_master() (from _config)
my $force_zf      = 0;       # overwrite existing zonefiles too
my $force_out     = 0;       # rewrite unified include even if identical
my $reload_cmd    = '/usr/sbin/rndc';
my $reconfig      = 0;       # default to reload (safer; reconfig requires includes to be perfect)
my $reconcile_all = 0;
my $verbose       = 0;

GetOptions(
    'out=s'             => \$out,
    'self=s'            => \$self_master,
    'force-zonefiles!'  => \$force_zf,
    'force!'            => \$force_out,
    'reload-cmd=s'      => \$reload_cmd,
    'reconfig!'         => \$reconfig,
    'reconcile-all!'    => \$reconcile_all,
    'verbose|v!'        => \$verbose,
) or die "Usage: $0 [--out PATH] [--self IP] [--force-zonefiles] [--force] [--reconcile-all] [-v|--verbose] [--reload-cmd /path/to/rndc] [--reconfig]\n";

my $ddns = Martnet::DDNS->new();
my $cfg  = $ddns->get_config();

$self_master = $ddns->default_master() unless defined $self_master;

my $default_ddns_keyname = $cfg->{master_ddns_keyname} // 'master-ddns-tsig';
my $ddns_keyfile         = $cfg->{master_ddns_keyfile} // '/etc/bind/master-ddns-tsig.key';

# Pull all control-plane records and select the zones this hidden master should serve as MASTER.
my @all = $ddns->get();

my @zones;
for my $r (@all) {
    next unless defined $r->{zone} && $r->{zone} ne '';

    # Skip the control plane itself
    next if $r->{zone} eq 'private.invalid';

    # Skip explicitly foreign records
    next if defined($r->{type}) && $r->{type} eq '_foreign';

    # Skip config records (not BIND zones)
    next if defined($r->{type}) && $r->{type} eq '_config';

    next unless defined $r->{master} && $r->{master} ne '';

    my %masters = map { $_ => 1 } grep { $_ ne '' } split(/;/, $r->{master});
    next unless $masters{$self_master};

    push @zones, $r;
}

@zones = sort {
    ($a->{zone} cmp $b->{zone})
        ||
    (($a->{type} // '') cmp ($b->{type} // ''))
} @zones;

sub zonefile_for {
    my ($zone, $type) = @_;
    my $dir = '/var/lib/bind/custom';  # default

    if (defined $type) {
        if ($type eq '_vhosts') {
            $dir = '/var/lib/bind/vhost';
        } elsif ($type eq '_custom') {
            $dir = '/var/lib/bind/custom';
        } else {
            $dir = '/var/lib/bind/custom';
        }
    }

    return "$dir/db.$zone";
}

sub ensure_dir {
    my ($file) = @_;
    if ($file =~ m{^(.+)/[^/]+$}) {
        my $dir = $1;
        if (!-d $dir) {
            mkdir $dir or die "Cannot mkdir $dir: $!\n";
        }
    }
}

sub write_minimal_zonefile {
    my ($fh, $zone, $cfg) = @_;

    my $fqdn   = $zone . '.';
    my $serial = strftime('%Y%m%d01', localtime());

    my $soa_mname = $cfg->{soa_mname} // 'mister-dns.martnet.com.';
    my $soa_rname = $cfg->{soa_rname} // 'root.martnet.com.';

    my @ns = ('ns1.martnet.com.', 'ns2.martnet.com.');
    if (ref($cfg->{ns}) eq 'ARRAY' && @{$cfg->{ns}}) {
        @ns = @{$cfg->{ns}};
    }

    print $fh ";\n";
    print $fh "; Autogenerated minimal zone file. Do not edit by hand.\n";
    print $fh ";\n";
    print $fh "\$TTL 43200\n";
    print $fh "$fqdn\tIN\tSOA\t$soa_mname $soa_rname (\n";
    print $fh "\t\t$serial\t; Serial\n";
    print $fh "\t\t43200\t; Refresh 12h\n";
    print $fh "\t\t3600\t; Retry 1h\n";
    print $fh "\t\t604800\t; Expire 7d\n";
    print $fh "\t\t43200 )\t; Negative cache TTL 12h\n\n";

    print $fh "; authoritative name servers\n";
    for my $n (@ns) {
        print $fh "$fqdn\tIN\tNS\t$n\n";
    }
    print $fh "\n";
}

sub write_vhost_zonefile {
    my ($fh, $zone, $webip, $cfg) = @_;

    my $fqdn   = $zone . '.';
    my $serial = strftime('%Y%m%d01', localtime());

    my $soa_mname = $cfg->{soa_mname} // 'mister-dns.martnet.com.';
    my $soa_rname = $cfg->{soa_rname} // 'root.martnet.com.';

    my @ns = ('ns1.martnet.com.', 'ns2.martnet.com.');
    if (ref($cfg->{ns}) eq 'ARRAY' && @{$cfg->{ns}}) {
        @ns = @{$cfg->{ns}};
    }

    my @mx = (
        { priority => 5,  host => '@' },
        { priority => 10, host => 'mx2.martnet.com.' },
        { priority => 15, host => 'mx3.martnet.com.' },
    );
    if (ref($cfg->{vhost_mx}) eq 'ARRAY' && @{$cfg->{vhost_mx}}) {
        @mx = @{$cfg->{vhost_mx}};
    }

    print $fh ";\n";
    print $fh "; This is an automatically-generated file. Do not edit by hand; it will be overwritten.\n";
    print $fh ";\n";
    print $fh "\$TTL\t43200\n";
    print $fh "$fqdn\tIN\tSOA\t$soa_mname $soa_rname (\n";
    print $fh "        $serial  ; Serial\n";
    print $fh "\t43200       ; Refresh every 12 hours\n";
    print $fh "\t3600        ; Retry every hour\n";
    print $fh "\t604800      ; Expire after a week\n";
    print $fh "\t43200 )     ; Negative Cache TTL 12 hours\n\n";

    print $fh "; define name servers\n";
    for my $n (@ns) {
        print $fh "$fqdn\tIN\tNS\t$n\n";
    }
    print $fh "\n";

    print $fh "; define localhost\n";
    print $fh "localhost\tIN\tA\t127.0.0.1\n\n";

    print $fh "; define machine names\n";
    print $fh "$fqdn\tIN\tA\t$webip\n";

    for my $mx (@mx) {
        my $prio = $mx->{priority};
        my $host = $mx->{host};
        $host = $fqdn if ($host eq '@');
        print $fh "$fqdn\tIN\tMX\t$prio\t$host\n";
    }
    print $fh "\n";

    print $fh "*.$fqdn\tIN\tA\t$webip\n";
    for my $mx (@mx) {
        my $prio = $mx->{priority};
        my $host = $mx->{host};
        $host = $fqdn if ($host eq '@');
        print $fh "*.$fqdn\tIN\tMX\t$prio\t$host\n";
    }
    print $fh "\n";
}

sub uniq_keys {
    my (@k) = @_;
    my %seen;
    return grep { defined($_) && $_ ne '' && !$seen{$_}++ } @k;
}

sub zone_extra_ddns_keys {
    my ($r) = @_;
    my $p = $r->{payload};
    return () unless (defined $p && ref($p) eq 'HASH');
    return () unless (defined $p->{ddns_keys});
    if (ref($p->{ddns_keys}) eq 'ARRAY') {
        return @{$p->{ddns_keys}};
    }
    # allow string fallback
    return ($p->{ddns_keys});
}

# Ensure zonefiles exist; create minimal or vhost templates as appropriate.
my $zf_changes = 0;
for my $r (@zones) {
    my $zone = $r->{zone};
    my $type = $r->{type} // '';
    my $zf   = zonefile_for($zone, $type);

    if ($force_zf || !-f $zf) {
        print "Creating zone file for $zone at $zf\n" if $verbose;

        ensure_dir($zf);
        open(my $fh, '>', $zf) or die "Can't create zone file $zf: $!\n";

        if ($type eq '_vhosts') {
            my $webip = $cfg->{vhost_default_web_ip} // '190.92.153.173';
            my $p = $r->{payload};
            if (defined $p && ref($p) eq 'HASH' && defined $p->{webserver_ip} && $p->{webserver_ip} ne '') {
                $webip = $p->{webserver_ip};
            }
            write_vhost_zonefile($fh, $zone, $webip, $cfg);
        } else {
            write_minimal_zonefile($fh, $zone, $cfg);
        }

        close $fh;
        system('/usr/bin/chown', 'bind:bind', $zf);
        chmod 0644, $zf;

        $zf_changes++;
    }
}

# Optional reconcile for vhost A records via DDNS
sub dig_a {
    my ($name) = @_;
    my $ans = '';
    my $cmd = "dig +short -t A \@127.0.0.1 $name";
    open(my $fh, "$cmd |") or die "Can't run dig: $!";
    while (<$fh>) {
        chomp;
        next unless $_;
        $ans = $_;
        last;
    }
    close $fh;
    return $ans;
}

sub nsupdate_vhost_ip {
    my ($zone, $ip, $keyfile) = @_;

    my ($tmpfh, $tmpname) = tempfile('nsupdate.XXXXXX', DIR => '/tmp', UNLINK => 0);
    print $tmpfh "server 127.0.0.1\n";
    print $tmpfh "zone $zone.\n";
    # Replace apex A
    print $tmpfh "update delete $zone. A\n";
    print $tmpfh "update add $zone. 60 A $ip\n";
    # Replace wildcard A
    print $tmpfh "update delete *.$zone. A\n";
    print $tmpfh "update add *.$zone. 60 A $ip\n";
    print $tmpfh "send\n";
    close $tmpfh;

    system("nsupdate -k $keyfile $tmpname") == 0
        or die "nsupdate failed for $zone: $?\n";

    unlink $tmpname;
}

my $reconcile_changes = 0;
if ($reconcile_all) {
    my $total = scalar(@zones);
    my $idx = 0;

    for my $r (@zones) {
        $idx++;
        next unless (($r->{type} // '') eq '_vhosts');

        my $zone = $r->{zone};
        my $p = $r->{payload};
        my $desired = $cfg->{vhost_default_web_ip} // '190.92.153.173';
        if (defined $p && ref($p) eq 'HASH' && defined $p->{webserver_ip} && $p->{webserver_ip} ne '') {
            $desired = $p->{webserver_ip};
        }

        print "Reconcile [$idx/$total] $zone -> $desired\n" if $verbose;

        my $apex = dig_a("$zone.");
        my $wild = dig_a("*.$zone.");

        # If either is missing or wrong, reconcile both.
        if (($apex // '') ne $desired || ($wild // '') ne $desired) {
            print "  Updating A records (apex='$apex' wildcard='$wild')\n" if $verbose;
            nsupdate_vhost_ip($zone, $desired, $ddns_keyfile);
            $reconcile_changes++;
        } else {
            print "  OK\n" if $verbose;
        }
    }
}

# Render unified include content (with DDNS policy)
my $content = "";
$content .= "#\n";
$content .= "# Autogenerated. DO NOT EDIT.\n";
$content .= "# Generated by sync-unified-master from control-plane data.\n";
$content .= "#\n\n";

for my $r (@zones) {
    my $zone = $r->{zone};
    my $type = $r->{type} // '';
    my $zf   = zonefile_for($zone, $type);

    my @keys = uniq_keys($default_ddns_keyname, zone_extra_ddns_keys($r));

    my $allow_transfer = 'allow-transfer { trusted;';
    for my $k (@keys) {
        $allow_transfer .= " key \"$k\";";
    }
    $allow_transfer .= ' };';

    my $update_policy = 'update-policy {';
    for my $k (@keys) {
        $update_policy .= " grant $k zonesub ANY;";
    }
    $update_policy .= ' };';

    my $journal = "journal \"$zf.jnl\";";

    $content .= "zone \"$zone\" { type master; file \"$zf\"; $allow_transfer $update_policy $journal };\n";
}

# Decide whether unified include changed
my $out_changed = 1;
if (!$force_out && -e $out) {
    open my $fh, '<', $out or die "Cannot read $out: $!\n";
    local $/;
    my $existing = <$fh>;
    close $fh;

    if (defined $existing && $existing eq $content) {
        $out_changed = 0;
    }
}

if ($out_changed) {
    my ($tmpfh, $tmpname) = tempfile('unified.zones.9.XXXXXX', DIR => '/tmp', UNLINK => 0);
    print $tmpfh $content;
    close $tmpfh;

    my $mode = 0644;
    if (-e $out) {
        my @st = stat($out);
        $mode = $st[2] & 07777 if @st;
    }
    chmod $mode, $tmpname;

    system('/usr/bin/chown', 'bind:bind', $tmpname);
    rename($tmpname, $out) or die "Failed to install $out: $!\n";
    print "Wrote $out (" . scalar(@zones) . " zones).\n" if $verbose;
} else {
    print "No changes to $out.\n" if $verbose;
}

# Reload only if something changed
if ($zf_changes || $out_changed || $reconcile_changes) {
    if ($reconfig) {
        system($reload_cmd, 'reconfig') == 0
            or die "rndc reconfig failed: $?\n";
    } else {
        system($reload_cmd, 'reload') == 0
            or die "rndc reload failed: $?\n";
    }
    print "Reloaded named.\n" if $verbose;
}

exit 0;
