package Gniza4cpWHM::UI;
# Shared UI helpers: navigation, flash messages, CSRF, HTML escaping,
# account list, version detection, mode detection, schedule discovery.

use strict;
use warnings;
use Fcntl qw(:flock O_WRONLY O_CREAT O_EXCL);
use IPC::Open3;
use Symbol 'gensym';

my $CSRF_DIR          = '/var/cpanel/.gniza4cp-whm-csrf';
my $FLASH_DIR         = '/var/cpanel/.gniza4cp-whm-flash';
my $CONSTANTS_FILE    = '/usr/local/gniza4cp/lib/constants.sh';
my $MAIN_CONFIG       = '/etc/gniza4cp/gniza4cp.conf';
my $REMOTES_DIR       = '/etc/gniza4cp/remotes.d';
my $SCHEDULES_DIR     = '/etc/gniza4cp/schedules.d';
my $TRUEUSERDOMAINS   = '/etc/trueuserdomains';
my $REMOTE_EXAMPLE    = '/usr/local/gniza4cp/etc/remote.conf.example';
my $SCHEDULE_EXAMPLE  = '/usr/local/gniza4cp/etc/schedule.conf.example';
my $SSH_DIR           = '/root/.ssh';
my $CSS_FILE          = '/usr/local/cpanel/whostmgr/docroot/cgi/gniza4cp-whm/assets/gniza4cp-whm.css';
my $LOGO_FILE         = '/usr/local/cpanel/whostmgr/docroot/cgi/gniza4cp-whm/assets/gniza4cp-logo.svg';
my $_logo_data_uri    = '';   # populated by page_header(), used by render_nav()

# ── HTML Escaping ─────────────────────────────────────────────

sub esc {
    my ($str) = @_;
    $str //= '';
    $str =~ s/&/&amp;/g;
    $str =~ s/</&lt;/g;
    $str =~ s/>/&gt;/g;
    $str =~ s/"/&quot;/g;
    $str =~ s/'/&#39;/g;
    return $str;
}

# ── Navigation ────────────────────────────────────────────────

my @NAV_ITEMS = (
    { url => 'index.cgi',     label => 'Dashboard' },
    { url => 'remotes.cgi',   label => 'Remotes' },
    { url => 'schedules.cgi', label => 'Schedules' },
    { url => 'restore.cgi',   label => 'Restore' },
    { url => 'logs.cgi',      label => 'Logs' },
    { url => 'settings.cgi',  label => 'Settings' },
);

sub render_nav {
    my ($current_page) = @_;
    my $logo = '';
    if ($_logo_data_uri) {
        $logo = qq{<div class="flex items-center gap-2">}
              . qq{<img src="$_logo_data_uri" alt="" class="h-12 w-auto">}
              . qq{<span class="text-3xl font-bold leading-none">GNIZA4CP <span class="text-secondary">Backup</span></span>}
              . qq{</div>};
    }
    # Build menu items for reuse in both mobile dropdown and desktop menu
    my $menu_items = '';
    for my $item (@NAV_ITEMS) {
        my $active = ($item->{url} eq $current_page) ? ' active' : '';
        my $label = esc($item->{label});
        $menu_items .= qq{<li><a class="no-underline$active" href="$item->{url}">$label</a></li>\n};
    }

    my $html = qq{<div class="navbar bg-base-200 rounded-box mb-5">\n};
    $html .= qq{  <div class="navbar-start">\n};
    $html .= qq{    $logo\n} if $logo;
    $html .= qq{  </div>\n};
    $html .= qq{  <div class="navbar-end hidden lg:flex">\n};
    $html .= qq{    <ul class="menu menu-horizontal gap-1">\n};
    $html .= qq{      $menu_items};
    $html .= qq{    </ul>\n};
    $html .= qq{  </div>\n};
    # Mobile dropdown (visible on small screens, right-aligned)
    $html .= qq{  <div class="navbar-end lg:hidden">\n};
    $html .= qq{    <details class="dropdown dropdown-end">\n};
    $html .= qq{      <summary class="btn btn-ghost btn-sm"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg></summary>\n};
    $html .= qq{      <ul class="dropdown-content menu bg-base-200 rounded-box shadow-sm mt-2 w-52 p-2">\n};
    $html .= qq{        $menu_items};
    $html .= qq{      </ul>\n};
    $html .= qq{    </details>\n};
    $html .= qq{  </div>\n};
    $html .= qq{</div>\n};
    return $html;
}

# ── Safe file I/O (symlink-attack resistant) ─────────────────

sub _safe_write {
    my ($file, $content) = @_;
    # Remove existing file/symlink before writing (prevents symlink attacks)
    unlink $file if -e $file || -l $file;
    # Create new file with O_CREAT|O_EXCL (fails if file already exists/race)
    if (sysopen my $fh, $file, O_WRONLY | O_CREAT | O_EXCL, 0600) {
        print $fh $content;
        close $fh;
        return 1;
    }
    return 0;
}

sub _safe_read {
    my ($file) = @_;
    # Refuse to read symlinks
    return if -l $file;
    return unless -f $file;
    if (open my $fh, '<', $file) {
        local $/;
        my $content = <$fh>;
        close $fh;
        return $content;
    }
    return;
}

# ── Flash Messages ────────────────────────────────────────────

sub _flash_file {
    return "$FLASH_DIR/gniza4cp-whm-flash";
}

sub set_flash {
    my ($type, $text) = @_;
    _ensure_dir($FLASH_DIR);
    _safe_write(_flash_file(), "$type\n$text\n");
    return;
}

sub get_flash {
    my $file = _flash_file();
    my $content = _safe_read($file);
    return unless defined $content;
    unlink $file;

    my ($type, $text) = split /\n/, $content, 2;
    return unless defined $type && defined $text;
    chomp $type;
    chomp $text;
    return ($type, $text);
}

sub render_flash {
    my @flash = get_flash();
    return '' unless defined $flash[0];
    my ($type, $text) = @flash;
    # Validate flash type against allowlist
    my %valid_types = map { $_ => 1 } qw(success error info warning);
    $type = 'info' unless $valid_types{$type // ''};
    my $escaped = esc($text);
    return qq{<div class="alert alert-$type mb-4">$escaped</div>\n};
}

sub _ensure_dir {
    my ($dir) = @_;
    # Remove stale plain file left by older versions (upgrade path)
    unlink $dir if -e $dir && ! -d $dir;
    unless (-d $dir) {
        mkdir $dir, 0700;
    }
    return;
}

# ── CSRF Protection ──────────────────────────────────────────

sub _csrf_file {
    return $CSRF_DIR;
}

my $_current_csrf_token;

sub generate_csrf_token {
    # Reuse the same token within a single request so multiple forms
    # on one page all share the same valid token.
    return $_current_csrf_token if defined $_current_csrf_token;

    my $token = '';
    if (open my $ufh, '<:raw', '/dev/urandom') {
        my $bytes;
        read $ufh, $bytes, 32;
        close $ufh;
        $token = unpack('H*', $bytes);
    } else {
        # Fallback (should not happen on Linux)
        for (1..32) {
            $token .= sprintf('%02x', int(rand(256)));
        }
    }

    _ensure_dir($CSRF_DIR);
    unless (_safe_write("$CSRF_DIR/token", time() . "\n" . $token . "\n")) {
        # O_EXCL can fail if unlink didn't fully remove the file;
        # fall back to plain overwrite so the token is always persisted.
        if (open my $fh, '>', "$CSRF_DIR/token") {
            print $fh time() . "\n" . $token . "\n";
            close $fh;
        }
    }

    $_current_csrf_token = $token;
    return $token;
}

sub verify_csrf_token {
    my ($submitted) = @_;
    return 0 unless defined $submitted && $submitted ne '';

    my $file = "$CSRF_DIR/token";
    my $content = _safe_read($file);
    return 0 unless defined $content;

    my ($stored_time, $stored_token) = split /\n/, $content, 2;
    return 0 unless defined $stored_time && defined $stored_token;

    chomp $stored_time;
    chomp $stored_token;

    # Delete after use (single-use)
    unlink $file;

    # Check expiry (1 hour)
    return 0 if (time() - $stored_time) > 3600;

    # Constant-time comparison
    return 0 if length($submitted) != length($stored_token);
    my $result = 0;
    for my $i (0 .. length($submitted) - 1) {
        $result |= ord(substr($submitted, $i, 1)) ^ ord(substr($stored_token, $i, 1));
    }
    return $result == 0;
}

sub csrf_hidden_field {
    my $token = generate_csrf_token();
    return qq{<input type="hidden" name="gniza4cp_csrf" value="} . esc($token) . qq{">};
}

# ── Account List ──────────────────────────────────────────────

sub get_cpanel_accounts {
    my @accounts;
    if (open my $fh, '<', $TRUEUSERDOMAINS) {
        while (my $line = <$fh>) {
            chomp $line;
            if ($line =~ /:\s*(\S+)/) {
                push @accounts, $1;
            }
        }
        close $fh;
    }
    my %seen;
    @accounts = sort grep { !$seen{$_}++ } @accounts;
    return @accounts;
}

# ── gniza4cp Version ────────────────────────────────────────────

sub get_gniza4cp_version {
    if (open my $fh, '<', $CONSTANTS_FILE) {
        while (my $line = <$fh>) {
            if ($line =~ /GNIZA4CP_VERSION="([^"]+)"/) {
                close $fh;
                return $1;
            }
        }
        close $fh;
    }
    return 'unknown';
}

# ── Remote Discovery ─────────────────────────────────────────

sub has_remotes {
    return 0 unless -d $REMOTES_DIR;
    if (opendir my $dh, $REMOTES_DIR) {
        while (my $entry = readdir $dh) {
            if ($entry =~ /\.conf$/) {
                closedir $dh;
                return 1;
            }
        }
        closedir $dh;
    }
    return 0;
}

sub list_remotes {
    my @remotes;
    if (-d $REMOTES_DIR && opendir my $dh, $REMOTES_DIR) {
        while (my $entry = readdir $dh) {
            if ($entry =~ /^(.+)\.conf$/) {
                push @remotes, $1;
            }
        }
        closedir $dh;
    }
    return (sort @remotes);
}

sub remote_conf_path {
    my ($name) = @_;
    return "$REMOTES_DIR/$name.conf";
}

sub remote_example_path {
    return $REMOTE_EXAMPLE;
}

# ── Schedule Discovery ───────────────────────────────────────

sub has_schedules {
    return 0 unless -d $SCHEDULES_DIR;
    if (opendir my $dh, $SCHEDULES_DIR) {
        while (my $entry = readdir $dh) {
            if ($entry =~ /\.conf$/) {
                closedir $dh;
                return 1;
            }
        }
        closedir $dh;
    }
    return 0;
}

sub list_schedules {
    my @schedules;
    if (-d $SCHEDULES_DIR && opendir my $dh, $SCHEDULES_DIR) {
        while (my $entry = readdir $dh) {
            if ($entry =~ /^(.+)\.conf$/) {
                push @schedules, $1;
            }
        }
        closedir $dh;
    }
    return (sort @schedules);
}

sub schedule_conf_path {
    my ($name) = @_;
    return "$SCHEDULES_DIR/$name.conf";
}

sub schedule_example_path {
    return $SCHEDULE_EXAMPLE;
}

# ── Configuration Detection ──────────────────────────────────

sub is_configured {
    return has_remotes();
}

# ── SSH Key Detection ────────────────────────────────────────

my @SSH_KEY_TYPES = (
    { file => 'id_ed25519', type => 'ed25519' },
    { file => 'id_rsa',     type => 'rsa'     },
    { file => 'id_ecdsa',   type => 'ecdsa'   },
    { file => 'id_dsa',     type => 'dsa'     },
);

sub detect_ssh_keys {
    my @keys;
    for my $kt (@SSH_KEY_TYPES) {
        my $path = "$SSH_DIR/$kt->{file}";
        next unless -f $path;
        push @keys, {
            path    => $path,
            type    => $kt->{type},
            has_pub => (-f "$path.pub") ? 1 : 0,
        };
    }
    return \@keys;
}

sub render_ssh_guidance {
    my $keys = detect_ssh_keys();

    my $html = qq{<div class="collapse collapse-arrow bg-base-200 shadow-sm border border-base-300 mb-6">\n};
    $html .= qq{<input type="checkbox">\n};
    $html .= qq{<div class="collapse-title font-medium text-sm">SSH Key Setup</div>\n};
    $html .= qq{<div class="collapse-content">\n};

    if (@$keys) {
        $html .= qq{<p>Existing SSH keys detected on this server:</p>\n};
        $html .= qq{<div class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100"><table class="table">\n};
        $html .= qq{<tr><th>Type</th><th>Path</th><th>Public Key</th></tr>\n};
        for my $k (@$keys) {
            my $pub = $k->{has_pub} ? 'Available' : 'Missing';
            $html .= qq{<tr><td>} . esc($k->{type}) . qq{</td>};
            $html .= qq{<td><code>} . esc($k->{path}) . qq{</code></td>};
            $html .= qq{<td>$pub</td></tr>\n};
        }
        $html .= qq{</table></div>\n};
    } else {
        $html .= qq{<p>No SSH keys found in <code>/root/.ssh/</code>.</p>\n};
    }

    $html .= qq{<p class="mt-3"><strong>Generate a new key</strong> (if needed):</p>\n};
    $html .= qq{<pre class="bg-neutral text-neutral-content p-3 rounded-lg text-sm font-mono overflow-x-auto my-2">ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""</pre>\n};
    $html .= qq{<p><strong>Copy the public key</strong> to the remote server:</p>\n};
    $html .= qq{<pre class="bg-neutral text-neutral-content p-3 rounded-lg text-sm font-mono overflow-x-auto my-2">ssh-copy-id -i /root/.ssh/id_ed25519.pub user\@host</pre>\n};
    $html .= qq{<p class="text-xs text-base-content/60 mt-2">Run these commands in WHM &rarr; Server Configuration &rarr; Terminal, or via SSH.</p>\n};
    $html .= qq{</div>\n</div>\n};

    return $html;
}

# ── SSH Connection Test ──────────────────────────────────────

sub test_ssh_connection {
    my @params = @_;

    # Support legacy positional args: ($host, $port, $user, $key)
    my %args;
    if (@params == 4 && !ref $params[0]) {
        %args = (host => $params[0], port => $params[1], user => $params[2], key => $params[3]);
    } else {
        %args = @params;
    }

    my $host        = $args{host};
    my $port        = $args{port} || '22';
    my $user        = $args{user} || 'root';
    my $auth_method = $args{auth_method} || 'key';
    my $key         = $args{key} // '';
    my $password    = $args{password} // '';

    my @ssh_args = (
        'ssh', '-n',
        '-p', $port,
        '-o', 'StrictHostKeyChecking=accept-new',
        '-o', 'ConnectTimeout=10',
        '-o', 'ServerAliveInterval=60',
        '-o', 'ServerAliveCountMax=3',
    );

    my @cmd;
    if ($auth_method eq 'password') {
        push @ssh_args, "$user\@$host", 'echo ok';
        @cmd = ('sshpass', '-p', $password, @ssh_args);
    } else {
        push @ssh_args, '-i', $key, '-o', 'BatchMode=yes';
        push @ssh_args, "$user\@$host", 'echo ok';
        @cmd = @ssh_args;
    }

    my ($in, $out, $err_fh) = (undef, undef, gensym);
    my $pid = eval { open3($in, $out, $err_fh, @cmd) };
    unless ($pid) {
        return (0, "Failed to run ssh: $@");
    }
    close $in if $in;

    my $stdout = do { local $/; <$out> } // '';
    my $stderr = do { local $/; <$err_fh> } // '';
    close $out;
    close $err_fh;

    waitpid($pid, 0);
    my $exit_code = $? >> 8;

    chomp $stdout;
    chomp $stderr;

    if ($exit_code == 0 && $stdout eq 'ok') {
        return (1, undef);
    }

    my $msg = $stderr || "SSH exited with code $exit_code";
    return (0, $msg);
}

# ── Rclone Connection Test ────────────────────────────────────

sub test_rclone_connection {
    my (%args) = @_;
    my $type = $args{type} // 's3';

    # Build temp rclone config
    my $tmpfile = "/tmp/gniza4cp-rclone-test-$$.conf";
    my $conf_content = '';
    my $test_path = '';

    if ($type eq 's3') {
        my $key_id   = $args{s3_access_key_id} // '';
        my $secret   = $args{s3_secret_access_key} // '';
        my $region   = $args{s3_region} || 'us-east-1';
        my $endpoint = $args{s3_endpoint} // '';
        my $bucket   = $args{s3_bucket} // '';

        $conf_content = "[remote]\ntype = s3\nprovider = AWS\naccess_key_id = $key_id\nsecret_access_key = $secret\nregion = $region\n";
        $conf_content .= "endpoint = $endpoint\n" if $endpoint ne '';
        $test_path = "remote:$bucket";
    }
    elsif ($type eq 'gdrive') {
        my $sa_file   = $args{gdrive_service_account_file} // '';
        my $folder_id = $args{gdrive_root_folder_id} // '';

        $conf_content = "[remote]\ntype = drive\nscope = drive\nservice_account_file = $sa_file\n";
        $conf_content .= "root_folder_id = $folder_id\n" if $folder_id ne '';
        $test_path = "remote:";
    }
    else {
        return (0, "Unknown remote type: $type");
    }

    # Write config
    if (open my $fh, '>', $tmpfile) {
        print $fh $conf_content;
        close $fh;
        chmod 0600, $tmpfile;
    } else {
        return (0, "Failed to write temp rclone config: $!");
    }

    # Run rclone lsd
    my @cmd = ('rclone', '--config', $tmpfile, 'lsd', $test_path);
    my ($in, $out, $err_fh) = (undef, undef, gensym);
    my $pid = eval { open3($in, $out, $err_fh, @cmd) };
    unless ($pid) {
        unlink $tmpfile;
        return (0, "Failed to run rclone: $@");
    }
    close $in if $in;

    my $stdout = do { local $/; <$out> } // '';
    my $stderr = do { local $/; <$err_fh> } // '';
    close $out;
    close $err_fh;

    waitpid($pid, 0);
    my $exit_code = $? >> 8;
    unlink $tmpfile;

    chomp $stderr;

    if ($exit_code == 0) {
        return (1, undef);
    }

    my $msg = $stderr || "rclone exited with code $exit_code";
    return (0, $msg);
}

# ── Remote Init (create base directory) ──────────────────────

sub init_remote_dir {
    my (%args) = @_;
    my $type = $args{type} // 'ssh';
    my $base = $args{remote_base} // '/backups';

    chomp(my $hostname = `hostname -f 2>/dev/null` || `hostname`);
    my $remote_path = "$base/$hostname/accounts";

    if ($type eq 'ssh') {
        my @ssh_args = (
            'ssh', '-n',
            '-p', ($args{port} || '22'),
            '-o', 'StrictHostKeyChecking=accept-new',
            '-o', 'ConnectTimeout=10',
            '-o', 'BatchMode=yes',
        );

        if (($args{auth_method} // 'key') eq 'password') {
            push @ssh_args, "$args{user}\@$args{host}", "mkdir -p '$remote_path'";
            my @cmd = ('sshpass', '-p', $args{password}, @ssh_args);
            system(@cmd);
        } else {
            push @ssh_args, '-i', $args{key};
            push @ssh_args, "$args{user}\@$args{host}", "mkdir -p '$remote_path'";
            system(@ssh_args);
        }
        return ($? == 0, $? == 0 ? undef : "Failed to create remote directory");
    }
    elsif ($type eq 's3' || $type eq 'gdrive') {
        my $tmpfile = "/tmp/gniza4cp-rclone-init-$$.conf";
        my $conf_content = '';
        my $rclone_path = '';

        if ($type eq 's3') {
            $conf_content = "[remote]\ntype = s3\nprovider = AWS\naccess_key_id = $args{s3_access_key_id}\nsecret_access_key = $args{s3_secret_access_key}\nregion = " . ($args{s3_region} || 'us-east-1') . "\n";
            $conf_content .= "endpoint = $args{s3_endpoint}\n" if ($args{s3_endpoint} // '') ne '';
            $rclone_path = "remote:$args{s3_bucket}$remote_path";
        } else {
            $conf_content = "[remote]\ntype = drive\nscope = drive\nservice_account_file = $args{gdrive_service_account_file}\n";
            $conf_content .= "root_folder_id = $args{gdrive_root_folder_id}\n" if ($args{gdrive_root_folder_id} // '') ne '';
            $rclone_path = "remote:$remote_path";
        }

        if (open my $fh, '>', $tmpfile) {
            print $fh $conf_content;
            close $fh;
            chmod 0600, $tmpfile;
        } else {
            return (0, "Failed to write temp rclone config: $!");
        }

        system('rclone', '--config', $tmpfile, 'mkdir', $rclone_path);
        my $ok = ($? == 0);
        unlink $tmpfile;
        return ($ok, $ok ? undef : "Failed to create remote directory");
    }

    return (0, "Unknown remote type: $type");
}

# ── SMTP Connection Test ──────────────────────────────────────

sub test_smtp_connection {
    my (%args) = @_;

    my $host     = $args{host} // '';
    my $port     = $args{port} || '587';
    my $user     = $args{user} // '';
    my $password = $args{password} // '';
    my $from     = $args{from} // $user;
    my $security = $args{security} || 'tls';
    my $to       = $args{to} // '';

    return (0, 'SMTP host is required') if $host eq '';
    return (0, 'Recipient email is required') if $to eq '';
    return (0, 'From address or SMTP user is required') if $from eq '';

    # Build the test email
    chomp(my $hostname = `hostname -f 2>/dev/null` || `hostname`);
    my $date = `date -R`; chomp $date;
    my $message = "From: $from\r\nTo: $to\r\nSubject: [gniza4cp] SMTP Test from $hostname\r\n"
                . "Content-Type: text/plain; charset=UTF-8\r\nDate: $date\r\n\r\n"
                . "This is a test email from gniza4cp on $hostname.\r\n"
                . "If you received this, SMTP is configured correctly.\r\n";

    # Write message to temp file
    my $tmpfile = "/tmp/gniza4cp-smtp-test-$$.eml";
    if (open my $fh, '>', $tmpfile) {
        print $fh $message;
        close $fh;
    } else {
        return (0, "Failed to write temp file: $!");
    }

    # Build curl command
    my @cmd = ('curl', '--silent', '--show-error', '--connect-timeout', '30', '--max-time', '60');

    if ($security eq 'ssl') {
        push @cmd, '--url', "smtps://$host:$port";
    } elsif ($security eq 'tls') {
        push @cmd, '--url', "smtp://$host:$port", '--ssl-reqd';
    } else {
        push @cmd, '--url', "smtp://$host:$port";
    }

    if ($user ne '') {
        push @cmd, '--user', "$user:$password";
    }

    push @cmd, '--mail-from', $from;

    # Support multiple recipients
    my @recipients = split /\s*,\s*/, $to;
    for my $rcpt (@recipients) {
        next if $rcpt eq '';
        push @cmd, '--mail-rcpt', $rcpt;
    }

    push @cmd, '-T', $tmpfile;

    my ($in, $out, $err_fh) = (undef, undef, gensym);
    my $pid = eval { open3($in, $out, $err_fh, @cmd) };
    unless ($pid) {
        unlink $tmpfile;
        return (0, "Failed to run curl: $@");
    }
    close $in if $in;

    my $stdout = do { local $/; <$out> } // '';
    my $stderr = do { local $/; <$err_fh> } // '';
    close $out;
    close $err_fh;

    waitpid($pid, 0);
    my $exit_code = $? >> 8;
    unlink $tmpfile;

    chomp $stderr;

    if ($exit_code == 0) {
        return (1, undef);
    }

    my $msg = $stderr || "curl exited with code $exit_code";
    return (0, $msg);
}

# ── Page Wrappers ────────────────────────────────────────────

sub page_header {
    my ($title) = @_;
    $title = esc($title // 'GNIZA4CP Backup Manager');
    my $css = '';
    if (open my $fh, '<', $CSS_FILE) {
        local $/;
        $css = <$fh>;
        close $fh;
    }
    # Strip @layer wrappers so our styles are un-layered and compete
    # with WHM's CSS on normal specificity instead of losing to it.
    $css = _unwrap_layers($css);
    # Scope :root/:host to our container so DaisyUI base styles
    # (background, color, overflow, scrollbar) don't leak into WHM.
    $css = _scope_to_container($css);
    # Pre-compute logo data URI for render_nav()
    if (!$_logo_data_uri && open my $lfh, '<', $LOGO_FILE) {
        local $/;
        my $svg_data = <$lfh>;
        close $lfh;
        require MIME::Base64;
        $_logo_data_uri = 'data:image/svg+xml;base64,' . MIME::Base64::encode_base64($svg_data, '');
    }
    return qq{<style>$css</style>\n}
         . qq{<div data-theme="gniza4cp" class="font-sans text-[1.6rem]" style="padding:30px 10px 10px 10px">\n};
}

sub _unwrap_layers {
    my ($css) = @_;
    # Loop until all @layer wrappers are removed (handles nesting)
    while ($css =~ /\@layer\s/) {
        # Remove @layer order declarations: @layer components; @layer theme, base;
        $css =~ s/\@layer\s+[\w.,\s]+\s*;//g;
        # Unwrap @layer name { ... } blocks, keeping inner contents
        my $out = '';
        my $i = 0;
        my $len = length($css);
        while ($i < $len) {
            if (substr($css, $i, 6) eq '@layer') {
                my $brace = index($css, '{', $i);
                if ($brace == -1) { $out .= substr($css, $i); last; }
                my $semi = index($css, ';', $i);
                if ($semi != -1 && $semi < $brace) {
                    $i = $semi + 1;
                    next;
                }
                my $depth = 1;
                my $j = $brace + 1;
                while ($j < $len && $depth > 0) {
                    my $c = substr($css, $j, 1);
                    $depth++ if $c eq '{';
                    $depth-- if $c eq '}';
                    $j++;
                }
                $out .= substr($css, $brace + 1, $j - $brace - 2);
                $i = $j;
            } else {
                $out .= substr($css, $i, 1);
                $i++;
            }
        }
        $css = $out;
    }
    return $css;
}

sub _scope_to_container {
    my ($css) = @_;
    # Step 1: Replace :root/:host with & so CSS variables and base styles
    # attach to our container element (not lost as descendant selectors).
    $css =~ s/:root,\s*:host/\&/g;
    $css =~ s/:where\(:root,\s*\[data-theme[^\]]*\]\)/\&/g;
    $css =~ s/:where\(:root\)/\&/g;
    $css =~ s/:root,\s*\[data-theme[^\]]*\]/\&/g;
    $css =~ s/\[data-theme=light\]/\&/g;
    $css =~ s/\[data-theme=gniza4cp\]/\&/g;
    $css =~ s/:root:not\(span\)/\&/g;
    $css =~ s/:root:has\(/\&:has(/g;
    $css =~ s/:root\b/\&/g;

    # Step 2: Extract @keyframes and @property to keep at top level
    my @top_level;
    my $scoped = '';
    my $i = 0;
    my $len = length($css);
    while ($i < $len) {
        if (substr($css, $i, 1) eq '@') {
            if (substr($css, $i, 11) eq '@keyframes '
             || substr($css, $i, 10) eq '@property ') {
                my $brace = index($css, '{', $i);
                if ($brace == -1) { $scoped .= substr($css, $i); last; }
                my $depth = 1;
                my $j = $brace + 1;
                while ($j < $len && $depth > 0) {
                    my $c = substr($css, $j, 1);
                    $depth++ if $c eq '{';
                    $depth-- if $c eq '}';
                    $j++;
                }
                push @top_level, substr($css, $i, $j - $i);
                $i = $j;
                next;
            }
        }
        $scoped .= substr($css, $i, 1);
        $i++;
    }

    # Step 3: Wrap in container scope — & references resolve to this selector
    return join('', @top_level) . '[data-theme="gniza4cp"]{' . $scoped . '}';
}

sub page_footer {
    return qq{<script>
(function(){
  document.querySelectorAll('table.table').forEach(function(table){
    var headers = table.querySelectorAll('thead th');
    headers.forEach(function(th, colIdx){
      if(!th.textContent.trim()) return;
      th.style.cursor='pointer';
      th.style.userSelect='none';
      th.dataset.sortDir='';
      th.addEventListener('click',function(){
        var tbody=table.querySelector('tbody');
        if(!tbody) return;
        var rows=Array.from(tbody.querySelectorAll('tr'));
        headers.forEach(function(h){if(h!==th){h.dataset.sortDir='';h.textContent=h.textContent.replace(/ [\\u25B2\\u25BC]\$/,'')}});
        var dir=th.dataset.sortDir==='asc'?'desc':'asc';
        th.dataset.sortDir=dir;
        th.textContent=th.textContent.replace(/ [\\u25B2\\u25BC]\$/,'')+(dir==='asc'?' \\u25B2':' \\u25BC');
        rows.sort(function(a,b){
          var ca=a.cells[colIdx],cb=b.cells[colIdx];
          if(!ca||!cb) return 0;
          var va=ca.textContent.trim(),vb=cb.textContent.trim();
          var na=parseFloat(va.replace(/[^0-9.\\-]/g,'')),nb=parseFloat(vb.replace(/[^0-9.\\-]/g,''));
          if(!isNaN(na)&&!isNaN(nb)&&va.match(/^[0-9.,\\-\\s]+[A-Za-z\\s]*\$/)){
            return dir==='asc'?na-nb:nb-na;
          }
          return dir==='asc'?va.localeCompare(vb):vb.localeCompare(va);
        });
        rows.forEach(function(r){tbody.appendChild(r)});
      });
    });
  });
})();
</script>\n</div>\n};
}

sub render_errors {
    my ($errors) = @_;
    return '' unless $errors && @$errors;
    my $html = qq{<div class="alert alert-error mb-4">\n<ul class="list-disc pl-5">\n};
    for my $err (@$errors) {
        $html .= '  <li>' . esc($err) . "</li>\n";
    }
    $html .= "</ul>\n</div>\n";
    return $html;
}

1;
