<?php

declare(strict_types=1);

const MAX_UPLOAD_BYTES = 4 * 1024 * 1024;
const MAX_ARCHIVE_FILES = 32;

function h(?string $value): string
{
    return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function renderPage(?string $error = null): void
{
    ?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HCT to GTK3 Theme Converter</title>
<style>
:root {
    color-scheme: light dark;
    --fg: #e5e7eb;
    --muted: #94a3b8;
    --card: #111827;
    --accent: #60a5fa;
    --border: #334155;
}
body {
    font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    margin: 0;
    background: linear-gradient(180deg, #020617, #111827);
    color: var(--fg);
}
.wrap { max-width: 900px; margin: 48px auto; padding: 0 20px; }
.card {
    background: rgba(17,24,39,.94);
    border: 1px solid var(--border);
    border-radius: 16px;
    padding: 24px;
    box-shadow: 0 10px 30px rgba(0,0,0,.35);
}
h1 { margin-top: 0; font-size: 1.85rem; }
p, li { line-height: 1.55; }
.small { color: var(--muted); font-size: .95rem; }
.error {
    background: rgba(127, 29, 29, .35);
    border: 1px solid rgba(248, 113, 113, .4);
    color: #fecaca;
    border-radius: 10px;
    padding: 12px 14px;
    margin-bottom: 18px;
}
input[type=file] {
    width: 100%;
    margin: 12px 0 18px;
    padding: 16px;
    border-radius: 12px;
    border: 1px dashed var(--border);
    background: #0b1220;
    color: var(--fg);
}
button {
    appearance: none;
    border: 0;
    border-radius: 12px;
    background: var(--accent);
    color: #08111f;
    font-weight: 700;
    padding: 12px 18px;
    cursor: pointer;
}
code { background: rgba(2,6,23,.6); border-radius: 8px; padding: 1px 6px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
</style>
</head>
<body>
<div class="wrap">
  <div class="card">
    <h1>HexChat <code>.hct</code> → GTK3 <code>.tar.xz</code></h1>
    <p class="small">Upload a HexChat / ZoiteChat theme archive. This page extracts <code>colors.conf</code>, maps the palette into GTK3 CSS roles, builds a GTK theme directory, and returns a ready-to-download <code>tar.xz</code> archive.</p>
    <?php if ($error !== null): ?>
      <div class="error"><?= h($error) ?></div>
    <?php endif; ?>

    <form method="post" enctype="multipart/form-data">
      <label for="hct"><strong>Theme file</strong></label>
      <input id="hct" type="file" name="hct" accept=".hct,.zip,application/zip" required>
      <button type="submit">Convert theme</button>
    </form>

    <div class="grid">
      <div>
        <h3>Converted</h3>
        <ul>
          <li><code>colors.conf</code> palette values</li>
          <li>Window / view / selection / tooltip colors</li>
          <li>Button, entry, headerbar, menu, and tab styling</li>
          <li><code>index.theme</code>, <code>gtk.css</code>, <code>gtk-dark.css</code></li>
        </ul>
      </div>
      <div>
        <h3>Not directly convertible</h3>
        <ul>
          <li><code>pevents.conf</code> message-format templates</li>
          <li>IRC inline color codes inside event strings</li>
          <li>HexChat-specific message rendering behavior</li>
        </ul>
      </div>
    </div>

    <p class="small">Server requirements: PHP plus the system tools <code>unzip</code>, <code>tar</code>, and <code>xz</code>. Generated files are streamed to the browser and removed from temp storage after download.</p>
  </div>
</div>
</body>
</html>
<?php
}

function fail(string $message, int $status = 400): never
{
    http_response_code($status);
    renderPage($message);
    exit;
}

function ensureDependencies(): void
{
    foreach (['unzip', 'tar', 'xz'] as $bin) {
        $path = trim((string)shell_exec('command -v ' . escapeshellarg($bin) . ' 2>/dev/null'));
        if ($path === '') {
            fail("Required system binary '{$bin}' was not found on the server PATH.", 500);
        }
    }
}

function tempPath(string $prefix): string
{
    return sys_get_temp_dir() . DIRECTORY_SEPARATOR . $prefix . '_' . bin2hex(random_bytes(8));
}

function rrmdir(string $dir): void
{
    if (!is_dir($dir)) {
        return;
    }
    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ($it as $item) {
        if ($item->isDir()) {
            @rmdir($item->getPathname());
        } else {
            @unlink($item->getPathname());
        }
    }
    @rmdir($dir);
}

function normalizeThemeName(string $name): string
{
    $name = preg_replace('/\.[^.]+$/', '', $name) ?? $name;
    $name = preg_replace('/[^A-Za-z0-9 _.-]+/', '', $name) ?? 'Converted Theme';
    $name = trim($name);
    return $name !== '' ? $name : 'Converted Theme';
}

function safeExtractZip(string $zipPath, string $outDir): array
{
    if (!mkdir($outDir, 0700, true) && !is_dir($outDir)) {
        fail('Failed to create temporary extraction directory.', 500);
    }

    $listCmd = sprintf('unzip -Z1 %s 2>&1', escapeshellarg($zipPath));
    exec($listCmd, $entries, $status);
    if ($status !== 0 || $entries === []) {
        rrmdir($outDir);
        fail('Unable to read uploaded archive as ZIP.');
    }
    if (count($entries) > MAX_ARCHIVE_FILES) {
        rrmdir($outDir);
        fail('Archive file count is out of allowed range.');
    }

    foreach ($entries as $entry) {
        $entry = str_replace('\\', '/', trim((string)$entry));
        if ($entry === '' || str_ends_with($entry, '/')) {
            continue;
        }
        if (str_contains($entry, '../') || str_starts_with($entry, '/') || preg_match('/^[A-Za-z]:\//', $entry)) {
            rrmdir($outDir);
            fail('Archive contains an unsafe file path.');
        }
    }

    $extractCmd = sprintf('unzip -qq -j %s -d %s 2>&1', escapeshellarg($zipPath), escapeshellarg($outDir));
    exec($extractCmd, $extractOut, $extractStatus);
    if ($extractStatus !== 0) {
        rrmdir($outDir);
        $msg = trim(implode("\n", $extractOut));
        fail('Failed extracting uploaded archive.' . ($msg !== '' ? ' ' . $msg : ''));
    }

    $files = glob($outDir . DIRECTORY_SEPARATOR . '*') ?: [];
    $extracted = [];
    foreach ($files as $path) {
        if (!is_file($path)) {
            continue;
        }
        if (filesize($path) > MAX_UPLOAD_BYTES) {
            rrmdir($outDir);
            fail('A file inside the archive exceeds the size limit.');
        }
        $extracted[] = basename($path);
    }
    if ($extracted === []) {
        rrmdir($outDir);
        fail('No usable files were extracted from the archive.');
    }
    sort($extracted);
    return $extracted;
}

function rgb16ToHex(string $r, string $g, string $b): string
{
    $r8 = intdiv(hexdec($r), 257);
    $g8 = intdiv(hexdec($g), 257);
    $b8 = intdiv(hexdec($b), 257);
    return sprintf('#%02x%02x%02x', $r8, $g8, $b8);
}

function parseColorsConf(string $file): array
{
    if (!is_file($file)) {
        fail('colors.conf was not found inside the uploaded .hct archive.');
    }
    $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    if ($lines === false) {
        fail('Unable to read colors.conf.');
    }
    $colors = [];
    foreach ($lines as $line) {
        $line = trim($line);
        if ($line === '' || str_starts_with($line, '#')) {
            continue;
        }
        if (!preg_match('/^color_(\d+)\s*=\s*([0-9A-Fa-f]{4})\s+([0-9A-Fa-f]{4})\s+([0-9A-Fa-f]{4})$/', $line, $m)) {
            continue;
        }
        $colors[(int)$m[1]] = rgb16ToHex($m[2], $m[3], $m[4]);
    }
    if ($colors === []) {
        fail('No usable colors were found in colors.conf.');
    }
    return $colors;
}

function fallback(array $colors, int $preferred, int $secondary, string $default): string
{
    return $colors[$preferred] ?? $colors[$secondary] ?? $default;
}

function relativeLuminance(string $hex): float
{
    $hex = ltrim($hex, '#');
    $parts = [substr($hex, 0, 2), substr($hex, 2, 2), substr($hex, 4, 2)];
    $rgb = array_map(static function (string $x): float {
        $v = hexdec($x) / 255;
        return $v <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4;
    }, $parts);
    return 0.2126 * $rgb[0] + 0.7152 * $rgb[1] + 0.0722 * $rgb[2];
}

function contrastText(string $hex): string
{
    return relativeLuminance($hex) > 0.35 ? '#111111' : '#f4f4f5';
}

function buildGtkPalette(array $colors): array
{
    /*
     * Real HexChat slot meanings:
     * 256 selected foreground, 257 selected background,
     * 258 default foreground, 259 default background,
     * 260 marker line, 261 new data, 262 highlight,
     * 263 new message, 264 away user.
     *
     * So the GTK window/view base must come from 259/258, not from the
     * event-status colors. The earlier version guessed here and could shove
     * alert-ish colors into core backgrounds.
     */
    $windowBg = fallback($colors, 259, 1, '#1f2937');
    $windowFg = fallback($colors, 258, 0, '#f8fafc');
    $viewBg = fallback($colors, 259, 1, '#111827');
    $viewFg = fallback($colors, 258, 15, '#f8fafc');

    $selectionBg = fallback($colors, 257, 12, '#2563eb');
    $selectionFg = fallback($colors, 256, 0, contrastText($selectionBg));

    $headerBg = fallback($colors, 263, 12, $selectionBg);
    $headerFg = contrastText($headerBg);
    $accent = fallback($colors, 263, 12, '#60a5fa');
    $accentFg = contrastText($accent);

    $success = fallback($colors, 3, 17, '#22c55e');
    $warning = fallback($colors, 7, 8, '#f59e0b');
    $danger = fallback($colors, 262, 4, '#ef4444');
    $link = fallback($colors, 12, 2, '#60a5fa');
    $mutedFg = fallback($colors, 264, 14, '#9ca3af');
    $border = fallback($colors, 264, 16, '#475569');

    $tooltipBg = fallback($colors, 1, 260, '#111827');
    $tooltipFg = contrastText($tooltipBg);
    $entryBg = $viewBg;
    $entryFg = $viewFg;
    $sidebarBg = $windowBg;
    $sidebarFg = $windowFg;
    $tabBackdrop = fallback($colors, 264, 16, '#334155');

    return compact(
        'windowBg', 'windowFg', 'viewBg', 'viewFg', 'headerBg', 'headerFg',
        'accent', 'accentFg', 'success', 'warning', 'danger', 'link',
        'selectionBg', 'selectionFg', 'mutedFg', 'border', 'tooltipBg',
        'tooltipFg', 'entryBg', 'entryFg', 'sidebarBg', 'sidebarFg', 'tabBackdrop'
    );
}

function generateGtkCss(string $themeName, array $p): string
{
    return <<<CSS
/* Auto-generated from HexChat/ZoiteChat .hct */
@define-color theme_bg_color {$p['windowBg']};
@define-color theme_fg_color {$p['windowFg']};
@define-color theme_base_color {$p['viewBg']};
@define-color theme_text_color {$p['viewFg']};
@define-color accent_bg_color {$p['accent']};
@define-color accent_fg_color {$p['accentFg']};
@define-color destructive_color {$p['danger']};
@define-color success_color {$p['success']};
@define-color warning_color {$p['warning']};
@define-color borders {$p['border']};
@define-color headerbar_bg_color {$p['headerBg']};
@define-color headerbar_fg_color {$p['headerFg']};
@define-color selection_bg {$p['selectionBg']};
@define-color selection_fg {$p['selectionFg']};
@define-color tooltip_bg {$p['tooltipBg']};
@define-color tooltip_fg {$p['tooltipFg']};
@define-color link_color {$p['link']};
@define-color muted_fg {$p['mutedFg']};
@define-color tab_backdrop {$p['tabBackdrop']};

* { outline-color: alpha(@accent_bg_color, 0.65); caret-color: @theme_text_color; }
window, dialog, .background { background-color: @theme_bg_color; color: @theme_fg_color; }
headerbar, .titlebar {
  background-image: none; background-color: @headerbar_bg_color; color: @headerbar_fg_color;
  border-bottom: 1px solid alpha(@borders, 0.75); box-shadow: none;
}
button {
  background-image: none; background-color: mix(@theme_bg_color, @accent_bg_color, 0.18);
  color: @theme_fg_color; border: 1px solid alpha(@borders, 0.9); box-shadow: none; text-shadow: none;
}
button:hover { background-color: mix(@theme_bg_color, @accent_bg_color, 0.28); }
button:active, button:checked, button.suggested-action {
  background-color: @accent_bg_color; color: @accent_fg_color; border-color: shade(@accent_bg_color, 0.85);
}
button.destructive-action { background-color: @destructive_color; color: #ffffff; }
entry, textview, treeview.view, .view, list, list row {
  background-color: @theme_base_color; color: @theme_text_color; border-color: alpha(@borders, 0.85);
}
entry { border: 1px solid alpha(@borders, 0.95); border-radius: 4px; }
entry selection, textview text selection, treeview.view:selected, .view:selected, flowboxchild:selected, iconview:selected, row:selected {
  background-color: @selection_bg; color: @selection_fg;
}
notebook > header { background-color: shade(@theme_bg_color, 0.95); border-color: @borders; }
notebook > header > tabs > tab {
  background-image: none; background-color: alpha(@tab_backdrop, 0.75); color: @muted_fg;
  border: 1px solid alpha(@borders, 0.65);
}
notebook > header > tabs > tab:checked { background-color: @theme_bg_color; color: @theme_fg_color; }
menu, popover, tooltip {
  background-color: @theme_bg_color; color: @theme_fg_color; border: 1px solid alpha(@borders, 0.9);
}
menubar, .menubar {
  background-image: none;
  background-color: shade(@theme_bg_color, 0.97);
  color: @theme_fg_color;
  border-bottom: 1px solid alpha(@borders, 0.85);
  padding: 0 6px;
}

menubar:backdrop, .menubar:backdrop {
  background-image: none;
  background-color: shade(@theme_bg_color, 0.95);
  color: alpha(@theme_fg_color, 0.92);
}

menubar > menuitem, .menubar > menuitem {
  background-image: none;
  background-color: transparent;
  color: @theme_fg_color;
  padding: 4px 10px;
  margin: 0 2px;
  border-radius: 4px;
}

menubar > menuitem:hover,
menubar > menuitem:active,
menubar > menuitem:checked,
.menubar > menuitem:hover,
.menubar > menuitem:active,
.menubar > menuitem:checked {
  background-image: none;
  background-color: alpha(@selection_bg, 0.72);
  color: @selection_fg;
}

menubar > menuitem:backdrop, .menubar > menuitem:backdrop {
  color: alpha(@theme_fg_color, 0.92);
}
menuitem, modelbutton { color: @theme_fg_color; }
menuitem:hover, modelbutton:hover { background-color: alpha(@selection_bg, 0.85); color: @selection_fg; }
a, link, label.link { color: @link_color; }
progressbar progress, scale highlight { background-color: @accent_bg_color; }
.gtk-theme-name-{$themeName} {}
CSS;
}

function buildThemeTree(string $baseDir, string $themeName, array $palette, array $sourceFiles): string
{
    $safeDir = preg_replace('/[^A-Za-z0-9_. -]+/', '', $themeName) ?: 'Converted Theme';
    $themeDir = $baseDir . DIRECTORY_SEPARATOR . $safeDir;
    $gtkDir = $themeDir . DIRECTORY_SEPARATOR . 'gtk-3.0';
    if (!mkdir($gtkDir, 0700, true) && !is_dir($gtkDir)) {
        fail('Failed to create theme output directory.', 500);
    }

    $css = generateGtkCss($safeDir, $palette);
    file_put_contents($gtkDir . DIRECTORY_SEPARATOR . 'gtk.css', $css);
    file_put_contents($gtkDir . DIRECTORY_SEPARATOR . 'gtk-dark.css', $css);

    $indexTheme = <<<INI
[Desktop Entry]
Type=X-GNOME-Metatheme
Name={$safeDir}
Comment=Converted from HexChat/ZoiteChat .hct by web converter

[X-GNOME-Metatheme]
GtkTheme={$safeDir}
MetacityTheme=Default
IconTheme=Adwaita
CursorTheme=Adwaita
ButtonLayout=menu:minimize,maximize,close
INI;
    file_put_contents($themeDir . DIRECTORY_SEPARATOR . 'index.theme', $indexTheme);

    $readme = "{$safeDir}\n" . str_repeat('=', strlen($safeDir)) . "\n\n"
        . "This GTK3 theme was generated from a HexChat / ZoiteChat .hct archive.\n\n"
        . "Source files found in archive:\n- " . implode("\n- ", $sourceFiles) . "\n\n"
        . "Conversion notes:\n"
        . "- colors.conf was converted into GTK CSS color roles.\n"
        . "- pevents.conf was not converted because IRC event templates do not map directly to GTK widget styling.\n"
        . "- You may still want to tune gtk.css manually for perfect app-specific visuals.\n\n"
        . "Palette used:\n";
    foreach ($palette as $k => $v) {
        $readme .= "- {$k}: {$v}\n";
    }
    file_put_contents($themeDir . DIRECTORY_SEPARATOR . 'README.txt', $readme);

    return $themeDir;
}

function createTarXzFromThemeDir(string $themeDir, string $archiveBase): string
{
    $parent = dirname($themeDir);
    $themeFolder = basename($themeDir);
    $output = $parent . DIRECTORY_SEPARATOR . $archiveBase . '.tar.xz';
    $cmd = sprintf('tar -C %s -cJf %s %s 2>&1', escapeshellarg($parent), escapeshellarg($output), escapeshellarg($themeFolder));
    exec($cmd, $lines, $status);
    if ($status !== 0 || !is_file($output)) {
        $msg = trim(implode("\n", $lines));
        fail('Failed to create tar.xz archive.' . ($msg !== '' ? ' ' . $msg : ''), 500);
    }
    return $output;
}

function streamFileAndDelete(string $file, string $downloadName, array $cleanupDirs = []): never
{
    if (!is_file($file)) {
        foreach ($cleanupDirs as $dir) {
            rrmdir($dir);
        }
        fail('Generated archive file is missing.', 500);
    }

    header('Content-Description: File Transfer');
    header('Content-Type: application/x-xz');
    header('Content-Disposition: attachment; filename="' . rawurlencode($downloadName) . '"; filename*=UTF-8\'\'' . rawurlencode($downloadName));
    header('Content-Length: ' . (string)filesize($file));
    header('Cache-Control: no-store, no-cache, must-revalidate');
    header('Pragma: no-cache');

    while (ob_get_level() > 0) {
        ob_end_clean();
    }

    $fp = fopen($file, 'rb');
    if ($fp === false) {
        foreach ($cleanupDirs as $dir) {
            rrmdir($dir);
        }
        fail('Unable to read generated archive for download.', 500);
    }
    fpassthru($fp);
    fclose($fp);
    @unlink($file);
    foreach ($cleanupDirs as $dir) {
        rrmdir($dir);
    }
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    renderPage();
    exit;
}

ensureDependencies();

if (!isset($_FILES['hct']) || !is_array($_FILES['hct'])) {
    fail('No upload was received.');
}
$file = $_FILES['hct'];
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
    fail('Upload failed with PHP error code ' . (string)($file['error'] ?? 'unknown') . '.');
}
$tmpUpload = (string)($file['tmp_name'] ?? '');
$origName = (string)($file['name'] ?? 'theme.hct');
$size = (int)($file['size'] ?? 0);

if (!is_uploaded_file($tmpUpload)) {
    fail('The uploaded file was not received as a valid HTTP upload.');
}
if ($size < 1 || $size > MAX_UPLOAD_BYTES) {
    fail('Upload size is outside the allowed range.');
}

$workRoot = tempPath('hctgtk');
$extractDir = $workRoot . DIRECTORY_SEPARATOR . 'extract';
$outRoot = $workRoot . DIRECTORY_SEPARATOR . 'output';
register_shutdown_function(static function () use ($workRoot): void { rrmdir($workRoot); });

$sourceFiles = safeExtractZip($tmpUpload, $extractDir);
$colors = parseColorsConf($extractDir . DIRECTORY_SEPARATOR . 'colors.conf');
$palette = buildGtkPalette($colors);
$themeName = normalizeThemeName($origName);
$themeDir = buildThemeTree($outRoot, $themeName, $palette, $sourceFiles);
$archiveBase = preg_replace('/[^A-Za-z0-9_.-]+/', '-', $themeName) ?: 'converted-theme';
$archive = createTarXzFromThemeDir($themeDir, $archiveBase);
streamFileAndDelete($archive, basename($archive), [$workRoot]);
