#!/usr/bin/env python3
import hashlib
import html
import json
import os
import re
import shutil
import socket
import subprocess
import tempfile
import zipfile
from email.parser import BytesParser
from email.policy import default
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import quote, unquote, urlparse
import xml.etree.ElementTree as ET


ROOT = Path(__file__).resolve().parent
OUTPUT_ROOT = ROOT / "generated"
ENV_PATH = ROOT / ".env"
DEFAULT_NGINX_SITES_DIR = "/var/www/ngx-config/sites"
DEFAULT_NGINX_WEB_ROOT = "/var/www"
DEFAULT_PUBLIC_REPO_PREFIX = "repo"
PROCESS_EXTENSIONS = {
    ".xml",
    ".json",
    ".txt",
    ".md",
    ".py",
    ".po",
    ".cfg",
    ".ini",
}
SKIP_DIRS = {"generated", "__pycache__", ".git", ".codex", ".agents"}
SKIP_FILES = {Path(__file__).name, "repo_brand_generator.html", ".env", ".env.example"}
SKIP_ARCHIVE_SUFFIXES = (".tar", ".tar.gz", ".tgz", ".rar", ".7z")
PROTECTED_TEXT = ("Apollo2000",)
DEFAULT_REPLACEMENTS = {
    "domains": [
        "premiumclub.me",
        "premiumclub.me",
        "premiumclub.me",
        "premiumclub.me",
    ],
    "base_names": [
        "premiumclub",
        "premiumclub",
        "premiumclub",
    ],
    "brand_names": [
        "premiumclub.me",
        "premiumclub.me",
        "premiumclub.me",
        "premiumclub.me",
        "premiumclub.me",
        "premiumclub.me",
    ],
    "custom": {
        "repo.premiumclub.me": "repo.{domain}",
        "premiumclub.me Repo": "{brand} Repo",
        "PREMIUMCLUB": "{brand_upper}",
        "premiumclubme": "{brand_compact}",
    },
}


def parse_env_value(value):
    value = value.strip()
    if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
        return value[1:-1]
    return value


def load_dotenv(path=ENV_PATH):
    if not path.exists():
        return
    for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("export "):
            line = line.removeprefix("export ").strip()
        if "=" not in line:
            print(f"Ignoring invalid .env line {line_number}: missing '='")
            continue
        key, value = line.split("=", 1)
        key = key.strip()
        if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
            print(f"Ignoring invalid .env line {line_number}: invalid key")
            continue
        os.environ.setdefault(key, parse_env_value(value))


load_dotenv()


INDEX_HTML = """<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Kodi Repo Brand Generator</title>
  <style>
    :root { color-scheme: light; --ink:#17202a; --muted:#667085; --line:#d0d5dd; --soft:#f2f4f7; --accent:#0f766e; --warn:#b45309; --bad:#b42318; }
    * { box-sizing: border-box; }
    body { margin:0; font:14px/1.45 system-ui,-apple-system,Segoe UI,sans-serif; color:var(--ink); background:#f7f8fa; }
    header { background:#ffffff; border-bottom:1px solid var(--line); padding:18px 24px; }
    h1 { margin:0; font-size:22px; letter-spacing:0; }
    main { max-width:1180px; margin:0 auto; padding:24px; display:grid; grid-template-columns:minmax(320px, 440px) 1fr; gap:24px; }
    section { background:#fff; border:1px solid var(--line); border-radius:8px; padding:18px; }
    h2 { margin:0 0 14px; font-size:16px; }
    label { display:block; font-weight:650; margin:14px 0 6px; }
    input, textarea, button { font:inherit; }
    input[type="url"], input[type="text"], textarea { width:100%; border:1px solid var(--line); border-radius:6px; padding:10px 11px; background:#fff; }
    textarea { min-height:170px; resize:vertical; font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:12px; }
    input[type="file"] { width:100%; border:1px dashed var(--line); border-radius:6px; padding:10px; background:var(--soft); }
    .check { display:flex; align-items:flex-start; gap:9px; margin-top:16px; font-weight:650; }
    .check input { margin-top:3px; }
    .check span { display:block; }
    button { margin-top:18px; border:0; border-radius:6px; padding:11px 14px; background:var(--accent); color:#fff; font-weight:700; cursor:pointer; }
    button:disabled { opacity:.6; cursor:wait; }
    .hint { color:var(--muted); font-size:12px; margin-top:5px; }
    .status { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin-bottom:16px; }
    .pill { border:1px solid var(--line); border-radius:8px; padding:10px; background:#fff; }
    .pill span { display:block; color:var(--muted); font-size:12px; }
    .download { display:none; margin:0 0 14px; color:var(--accent); font-weight:700; text-decoration:none; }
    .download:hover { text-decoration:underline; }
    .ok { color:var(--accent); font-weight:700; }
    .warn { color:var(--warn); font-weight:700; }
    .bad { color:var(--bad); font-weight:700; }
    pre { min-height:360px; margin:0; overflow:auto; white-space:pre-wrap; word-break:break-word; background:#101828; color:#f2f4f7; border-radius:8px; padding:14px; font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace; }
    @media (max-width: 850px) { main { grid-template-columns:1fr; padding:16px; } header { padding:16px; } }
  </style>
</head>
<body>
  <header><h1>Kodi Repo Brand Generator</h1></header>
  <main>
    <section>
      <h2>Build Input</h2>
      <form id="form">
        <label for="domain">New HTTPS base domain</label>
        <input id="domain" name="domain" type="url" required placeholder="https://domain.xyz">
        <div class="hint">Use the base domain. Nginx publishing serves it from repo.domain.xyz; generation preserves template subdomains.</div>

        <label for="brand">New brand name</label>
        <input id="brand" name="brand" type="text" required placeholder="NewBrand">

        <label for="icon">icon.png upload optional</label>
        <input id="icon" name="icon" type="file" accept="image/png">
        <div class="hint">Leave empty to copy icon images from the template repo.</div>

        <label for="fanart">fanart.jpg/png upload optional</label>
        <input id="fanart" name="fanart" type="file" accept="image/jpeg,image/png">
        <div class="hint">Leave empty to copy fanart images from the template repo.</div>

        <label for="map">Replacement map</label>
        <textarea id="map" name="map">REPLACEMENTS</textarea>
        <div class="hint">Supports domains, base_names, brand_names, and custom maps. Use {brand}, {brand_compact}, {domain}, and {domain_base} placeholders.</div>

        <label class="check" for="publish_nginx">
          <input id="publish_nginx" name="publish_nginx" type="checkbox" value="1">
          <span>Publish to nginx</span>
        </label>
        <div class="hint">When checked, writes files and nginx config using the .env deployment settings, then tests and reloads nginx.</div>
        <button id="submit" type="submit">Generate Repo</button>
      </form>
    </section>
    <section>
      <h2>Result</h2>
      <div class="status">
        <div class="pill"><span>Domain valid</span><strong id="valid">not checked</strong></div>
        <div class="pill"><span>Domain reachable</span><strong id="reachable">not checked</strong></div>
      </div>
      <a id="download" class="download" href="#">Download generated ZIP</a>
      <pre id="log">Waiting for input.</pre>
    </section>
  </main>
  <script>
    const form = document.getElementById('form');
    const log = document.getElementById('log');
    const valid = document.getElementById('valid');
    const reachable = document.getElementById('reachable');
    const download = document.getElementById('download');
    const submit = document.getElementById('submit');
    function mark(el, value) {
      el.className = value === true ? 'ok' : value === false ? 'bad' : 'warn';
      el.textContent = value === true ? 'yes' : value === false ? 'no' : value;
    }
    form.addEventListener('submit', async (event) => {
      event.preventDefault();
      submit.disabled = true;
      log.textContent = 'Building...';
      download.style.display = 'none';
      download.removeAttribute('href');
      mark(valid, 'checking');
      mark(reachable, 'checking');
      try {
        const response = await fetch('/generate', { method: 'POST', body: new FormData(form) });
        const data = await response.json();
        mark(valid, data.validation.valid);
        mark(reachable, data.validation.reachable);
        log.textContent = data.log.join('\\n');
        if (response.ok && data.download_url) {
          download.href = data.download_url;
          download.style.display = 'inline-block';
        }
        if (!response.ok) log.textContent = 'Error: ' + data.error + '\\n\\n' + log.textContent;
      } catch (error) {
        log.textContent = 'Request failed: ' + error.message;
        mark(valid, 'unknown');
        mark(reachable, 'unknown');
      } finally {
        submit.disabled = false;
      }
    });
  </script>
</body>
</html>
""".replace("REPLACEMENTS", html.escape(json.dumps(DEFAULT_REPLACEMENTS, indent=2)))


def normalize_domain(value):
    parsed = urlparse(value.strip())
    if parsed.scheme != "https" or not parsed.netloc:
        return None, None
    host = parsed.hostname.lower() if parsed.hostname else ""
    if not re.fullmatch(r"[a-z0-9.-]+\.[a-z]{2,}", host):
        return None, None
    return host, f"https://{host}"


def validate_domain(value):
    host, domain_url = normalize_domain(value)
    result = {"valid": bool(host), "reachable": False}
    if host:
        try:
            socket.getaddrinfo(host, 443)
            result["reachable"] = True
        except OSError:
            result["reachable"] = False
    return result, host, domain_url


def domain_basename(domain):
    labels = domain.split(".")
    if labels[0] == "www" and len(labels) > 2:
        return labels[1]
    return labels[0]


def render_template(value, brand, domain, domain_url):
    compact = re.sub(r"[^A-Za-z0-9]+", "", brand)
    base = domain_basename(domain)
    return (
        value.replace("{brand}", brand)
        .replace("{brand_lower}", brand.lower())
        .replace("{brand_upper}", brand.upper())
        .replace("{brand_compact}", compact)
        .replace("{domain}", domain)
        .replace("{domain_url}", domain_url)
        .replace("{domain_base}", base)
        .replace("{domain_base_upper}", base.upper())
        .replace("{domain_base_title}", base.title())
    )


def as_list(value):
    if value is None:
        return []
    if isinstance(value, str):
        return [value]
    if isinstance(value, list):
        return [str(item) for item in value]
    return [str(value)]


def unique(values):
    seen = set()
    result = []
    for value in values:
        if value and value not in seen:
            seen.add(value)
            result.append(value)
    return result


def domain_variants(value):
    value = value.strip()
    if not value:
        return []
    parsed = urlparse(value if "://" in value else f"https://{value}")
    host = parsed.hostname or value
    variants = [value, host, host.lower(), host.upper()]
    return unique(variants)


def parse_replacement_map(raw_map):
    try:
        mapping = json.loads(raw_map or "{}")
    except json.JSONDecodeError as exc:
        raise ValueError(f"Replacement map is not valid JSON: {exc}") from exc
    if not isinstance(mapping, dict):
        raise ValueError("Replacement map must be a JSON object.")

    structured_keys = {"domains", "base_names", "brand_names", "custom", "protected_text"}
    if structured_keys.intersection(mapping):
        return {
            "domains": as_list(mapping.get("domains")),
            "base_names": as_list(mapping.get("base_names")),
            "brand_names": as_list(mapping.get("brand_names")),
            "custom": mapping.get("custom") or {},
            "protected_text": as_list(mapping.get("protected_text")),
        }

    return {
        "domains": [],
        "base_names": [],
        "brand_names": [],
        "custom": mapping,
        "protected_text": [],
    }


def add_pair(pairs, old, new):
    if old and new and old != new:
        pairs.append((str(old), str(new)))


def build_replacements(raw_map, brand, domain, domain_url):
    config = parse_replacement_map(raw_map)
    base = domain_basename(domain)
    text_pairs = []
    path_pairs = []
    old_domains = []

    for old_domain in config["domains"]:
        for variant in domain_variants(old_domain):
            add_pair(text_pairs, variant, domain)
            add_pair(path_pairs, variant, base)
        parsed = urlparse(old_domain if "://" in old_domain else f"https://{old_domain}")
        if parsed.hostname:
            old_domains.append(parsed.hostname.lower())

    for old_base in config["base_names"]:
        add_pair(text_pairs, old_base, base)
        add_pair(text_pairs, old_base.upper(), base.upper())
        add_pair(path_pairs, old_base, base)
        add_pair(path_pairs, old_base.upper(), base.upper())
        if old_base[:1].isupper():
            add_pair(text_pairs, old_base, brand)
        else:
            add_pair(text_pairs, old_base.title(), brand)
            add_pair(path_pairs, old_base.title(), base)

    for old_brand in config["brand_names"]:
        add_pair(text_pairs, old_brand, brand)
        if old_brand.isupper():
            add_pair(text_pairs, old_brand, brand.upper())

    custom = config["custom"]
    if not isinstance(custom, dict):
        raise ValueError("Replacement map custom value must be a JSON object.")
    for old, new in custom.items():
        add_pair(text_pairs, old, render_template(str(new), brand, domain, domain_url))

    text_pairs = sorted(unique(text_pairs), key=lambda item: len(item[0]), reverse=True)
    path_pairs = sorted(unique(path_pairs), key=lambda item: len(item[0]), reverse=True)
    return {
        "text": text_pairs,
        "path": path_pairs,
        "old_domains": unique(old_domains),
        "protected_text": tuple(PROTECTED_TEXT) + tuple(config["protected_text"]),
    }


def apply_path_replacements(name, replacements):
    new_name = name
    for old, new in replacements:
        new_name = new_name.replace(old, new)
    return new_name


def root_folder_renames(replacements):
    renames = {}
    for path in ROOT.iterdir():
        if not path.is_dir() or path.name in SKIP_DIRS:
            continue
        new_name = apply_path_replacements(path.name, replacements)
        if new_name != path.name:
            renames[path.name] = new_name
    return renames


def rename_root_folders(target, renames):
    count = 0
    for old_name, new_name in renames.items():
        old_path = target / old_name
        new_path = target / new_name
        if old_path.exists() and old_path != new_path:
            if new_path.exists():
                shutil.rmtree(new_path)
            old_path.rename(new_path)
            count += 1
    return count


def rename_generated_paths(target, plan):
    count = 0
    paths = sorted(
        (path for path in target.rglob("*") if path.name),
        key=lambda path: len(path.parts),
        reverse=True,
    )
    for path in paths:
        new_name = apply_path_replacements(path.name, plan["path"])
        if new_name == path.name:
            continue
        new_path = path.with_name(new_name)
        if new_path.exists():
            if new_path.is_dir():
                shutil.rmtree(new_path)
            else:
                new_path.unlink()
        path.rename(new_path)
        count += 1
    return count


def protected_addon_names():
    return []


def archive_artifact_names():
    names = set()
    for zip_path in ROOT.rglob("*.zip"):
        if any(part in SKIP_DIRS for part in zip_path.relative_to(ROOT).parts):
            continue
        names.add(zip_path.stem)
        try:
            with zipfile.ZipFile(zip_path, "r") as archive:
                for name in archive.namelist():
                    first = name.split("/", 1)[0]
                    if first:
                        names.add(first)
                        break
        except zipfile.BadZipFile:
            continue
    return names


def is_extracted_archive_dir(path, artifact_names):
    if not path.is_dir() or path.name not in artifact_names:
        return False
    if path.parent == ROOT and not re.search(r"-\d+(?:\.\d+)*$", path.name):
        return False
    return True


def should_skip_archive_file(path):
    name = path.name.lower()
    return any(name.endswith(suffix) for suffix in SKIP_ARCHIVE_SUFFIXES)


def should_copy(path, artifact_names):
    rel = path.relative_to(ROOT)
    if any(part in SKIP_DIRS for part in rel.parts):
        return False
    if rel.name in SKIP_FILES:
        return False
    if path.is_dir() and is_extracted_archive_dir(path, artifact_names):
        return False
    if path.is_file() and should_skip_archive_file(path):
        return False
    return True


def copy_template(target):
    if target.exists():
        shutil.rmtree(target)
    target.mkdir(parents=True)
    artifact_names = archive_artifact_names()

    def ignore_extracted_dirs(directory, names):
        ignored = set()
        base = Path(directory)
        for name in names:
            path = base / name
            if name in SKIP_DIRS:
                ignored.add(name)
            elif path.is_dir() and is_extracted_archive_dir(path, artifact_names):
                ignored.add(name)
            elif path.is_file() and should_skip_archive_file(path):
                ignored.add(name)
        return ignored

    for item in ROOT.iterdir():
        if not should_copy(item, artifact_names):
            continue
        dest = target / item.name
        if item.is_dir():
            shutil.copytree(item, dest, ignore=ignore_extracted_dirs)
        else:
            shutil.copy2(item, dest)


def url_host_matches(host, old_domains):
    host = (host or "").lower()
    return any(host == domain or host.endswith(f".{domain}") for domain in old_domains)


def mask_external_urls(text, old_domains):
    masks = {}

    def replace(match):
        url = match.group(0)
        host = urlparse(url).hostname
        if url_host_matches(host, old_domains):
            return url
        token = f"__URL_MASK_{len(masks)}__"
        masks[token] = url
        return token

    return re.sub(r"https?://[^\s\"'<>)]+", replace, text), masks


def unmask_external_urls(text, masks):
    for token, url in masks.items():
        text = text.replace(token, url)
    return text


def normalize_xml_bytes(data):
    try:
        text = data.decode("utf-8")
        encoding = "utf-8"
    except UnicodeDecodeError:
        try:
            text = data.decode("latin-1")
            encoding = "latin-1"
        except UnicodeDecodeError:
            return data, False
    stripped = text.lstrip()
    if stripped.startswith("<?xml") and stripped != text:
        return stripped.encode(encoding), True
    return data, False


def replace_bytes(data, plan, protected_names):
    try:
        text = data.decode("utf-8")
        encoding = "utf-8"
    except UnicodeDecodeError:
        try:
            text = data.decode("latin-1")
            encoding = "latin-1"
        except UnicodeDecodeError:
            return data, False
    original = text
    text, masks = mask_external_urls(text, plan["old_domains"])
    for old, new in plan["text"]:
        text = text.replace(old, new)
    for name in protected_names:
        changed_name = name
        for old, new in plan["text"]:
            changed_name = changed_name.replace(old, new)
        if changed_name != name:
            text = text.replace(changed_name, name)
    for value in plan["protected_text"]:
        changed_value = value
        for old, new in plan["text"]:
            changed_value = changed_value.replace(old, new)
        if changed_value != value:
            text = text.replace(changed_value, value)
    text = unmask_external_urls(text, masks)
    if text == original:
        return data, False
    return text.encode(encoding), True


def process_text_file(path, plan, protected_names):
    new_data, changed = replace_bytes(path.read_bytes(), plan, protected_names)
    if path.suffix.lower() == ".xml":
        new_data, normalized = normalize_xml_bytes(new_data)
        changed = changed or normalized
    if changed:
        path.write_bytes(new_data)
    return changed


def replace_images(target, icon_data, fanart_name, fanart_data):
    changed = 0
    for path in target.rglob("*"):
        if not path.is_file():
            continue
        lower = path.name.lower()
        if icon_data and lower == "icon.png":
            path.write_bytes(icon_data)
            changed += 1
        elif fanart_data and lower in {"fanart.jpg", "fanart.jpeg", "fanart.png"}:
            suffix = Path(fanart_name).suffix.lower() or path.suffix
            if suffix != path.suffix.lower():
                path.unlink()
                path = path.with_suffix(suffix)
            path.write_bytes(fanart_data)
            changed += 1
    return changed


def replace_path_name(name, plan, protected_names):
    trailing_slash = name.endswith("/")
    parts = name.rstrip("/").split("/")
    changed = False
    new_parts = []
    for part in parts:
        new_part = apply_path_replacements(part, plan["path"])
        for protected in protected_names:
            changed_protected = protected
            for old, new in plan["path"]:
                changed_protected = changed_protected.replace(old, new)
            if changed_protected != protected:
                new_part = new_part.replace(changed_protected, protected)
        if new_part != part:
            changed = True
        new_parts.append(new_part)
    new_name = "/".join(new_parts)
    if trailing_slash:
        new_name += "/"
    return new_name, changed


def process_zip(zip_path, plan, protected_names, icon_data, fanart_name, fanart_data):
    changed = False
    with tempfile.TemporaryDirectory() as td:
        temp_zip = Path(td) / zip_path.name
        with zipfile.ZipFile(zip_path, "r") as zin, zipfile.ZipFile(temp_zip, "w", zipfile.ZIP_DEFLATED) as zout:
            names = zin.namelist()
            skip_names = set()
            if fanart_data:
                for name in names:
                    if Path(name).name.lower() in {"fanart.jpg", "fanart.jpeg", "fanart.png"}:
                        skip_names.add(name)
            for info in zin.infolist():
                original_filename = info.filename
                new_filename, renamed = replace_path_name(info.filename, plan, protected_names)
                out_info = info
                if renamed:
                    changed = True
                    out_info = zipfile.ZipInfo(new_filename, info.date_time)
                    out_info.compress_type = info.compress_type
                    out_info.external_attr = info.external_attr
                    out_info.comment = info.comment
                if original_filename.endswith("/"):
                    zout.writestr(out_info, b"")
                    continue
                if original_filename in skip_names:
                    continue
                data = zin.read(original_filename)
                name = Path(out_info.filename).name.lower()
                if icon_data and name == "icon.png":
                    data = icon_data
                    changed = True
                elif Path(out_info.filename).suffix.lower() in PROCESS_EXTENSIONS:
                    data, did_change = replace_bytes(data, plan, protected_names)
                    changed = changed or did_change
                    if Path(out_info.filename).suffix.lower() == ".xml":
                        data, normalized = normalize_xml_bytes(data)
                        changed = changed or normalized
                zout.writestr(out_info, data)
            if fanart_data:
                for old_name in sorted(skip_names):
                    renamed_old_name, _ = replace_path_name(old_name, plan, protected_names)
                    new_name = str(Path(renamed_old_name).with_suffix(Path(fanart_name).suffix.lower() or Path(renamed_old_name).suffix))
                    zout.writestr(new_name, fanart_data)
                    changed = True
        if changed:
            shutil.move(str(temp_zip), zip_path)
    return changed


def md5_file(path):
    return hashlib.md5(path.read_bytes()).hexdigest()


def regenerate_md5s(target):
    count = 0
    for zip_path in target.rglob("*.zip"):
        md5_path = zip_path.with_name(zip_path.name + ".md5")
        md5_path.write_text(md5_file(zip_path), encoding="utf-8")
        count += 1
    addons = target / "addons.xml"
    if addons.exists():
        (target / "addons.xml.md5").write_text(md5_file(addons), encoding="utf-8")
        count += 1
    return count


def create_download_zip(target):
    archive_path = target.with_suffix(".zip")
    if archive_path.exists():
        archive_path.unlink()
    with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
        for path in sorted(target.rglob("*")):
            if path.is_file():
                archive.write(path, path.relative_to(target.parent))
    return archive_path


def public_repo_host(domain):
    prefix = os.environ.get("PUBLIC_REPO_PREFIX", DEFAULT_PUBLIC_REPO_PREFIX).strip(".")
    if not prefix:
        return domain
    return f"{prefix}.{domain}"


def nginx_repo_root(domain):
    return Path(os.environ.get("NGINX_WEB_ROOT", DEFAULT_NGINX_WEB_ROOT)) / domain / "repo"


def nginx_config_path(domain):
    return Path(os.environ.get("NGINX_SITES_DIR", DEFAULT_NGINX_SITES_DIR)) / f"{domain}.conf"


def render_nginx_config(domain):
    host = public_repo_host(domain)
    root = nginx_repo_root(domain)
    return f"""server {{
    listen 80;
    server_name {host};

    location / {{
        autoindex on;
        root {root};
        index index.html index.htm;
    }}

    error_page 500 502 503 504 /50x.html;
    location = /50x.html {{
        root {root};
    }}
}}
"""


def validate_generated_repo(target):
    errors = []
    addons_path = target / "addons.xml"
    expected_addons = {}
    expected_zip_paths = {}
    if not addons_path.exists():
        errors.append("addons.xml is missing")
    else:
        try:
            addons = ET.parse(addons_path).getroot()
            for addon in addons.findall("addon"):
                addon_id = addon.attrib.get("id")
                version = addon.attrib.get("version")
                if not addon_id or not version:
                    errors.append("addon entry missing id or version")
                    continue
                expected_addons[addon_id] = version
                folder = target / addon_id
                if not folder.is_dir():
                    errors.append(f"missing addon folder: {addon_id}")
                    continue
                zip_path = folder / f"{addon_id}-{version}.zip"
                expected_zip_paths[zip_path] = (addon_id, version)
                if not zip_path.exists():
                    errors.append(f"missing addon zip: {zip_path.relative_to(target)}")
        except Exception as exc:
            errors.append(f"addons.xml parse failed: {exc}")

    for md5_path in sorted(target.rglob("*.md5")):
        target_file = Path(str(md5_path)[:-4])
        if not target_file.exists():
            errors.append(f"md5 target missing: {md5_path.relative_to(target)}")
            continue
        actual = md5_file(target_file)
        expected = md5_path.read_text(encoding="utf-8").strip()
        if actual != expected:
            errors.append(f"md5 mismatch: {md5_path.relative_to(target)}")

    for zip_path in sorted(target.rglob("*.zip")):
        try:
            with zipfile.ZipFile(zip_path) as archive:
                bad_member = archive.testzip()
                if bad_member:
                    errors.append(f"bad zip member {bad_member} in {zip_path.relative_to(target)}")
                roots = {name.split("/", 1)[0] for name in archive.namelist() if name and not name.endswith("/")}
                if not roots:
                    errors.append(f"zip has no root folder: {zip_path.relative_to(target)}")
                addon_entries = []
                for name in archive.namelist():
                    if Path(name).name == "addon.xml":
                        addon = ET.fromstring(archive.read(name))
                        addon_entries.append((name, addon.attrib.get("id"), addon.attrib.get("version")))
                if not addon_entries:
                    errors.append(f"zip missing internal addon.xml: {zip_path.relative_to(target)}")
                expected = expected_zip_paths.get(zip_path)
                if expected and expected not in {(addon_id, version) for _, addon_id, version in addon_entries}:
                    errors.append(
                        f"zip addon.xml mismatch in {zip_path.relative_to(target)}: expected {expected[0]} {expected[1]}"
                    )
                for name, addon_id, version in addon_entries:
                    if addon_id in expected_addons and version != expected_addons[addon_id]:
                        errors.append(
                            f"zip addon.xml version mismatch in {zip_path.relative_to(target)}:{name}"
                        )
        except Exception as exc:
            errors.append(f"zip validation failed {zip_path.relative_to(target)}: {exc}")

    return errors


def atomic_write_text(path, text):
    path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
        handle.write(text)
        temp_name = handle.name
    os.replace(temp_name, path)


def atomic_copytree(source, destination):
    destination.parent.mkdir(parents=True, exist_ok=True)
    temp_destination = destination.parent / f".{destination.name}.tmp-{os.getpid()}"
    backup_destination = destination.parent / f".{destination.name}.bak-{os.getpid()}"
    if temp_destination.exists():
        shutil.rmtree(temp_destination)
    if backup_destination.exists():
        shutil.rmtree(backup_destination)
    shutil.copytree(source, temp_destination)
    try:
        if destination.exists():
            destination.rename(backup_destination)
        temp_destination.rename(destination)
    except Exception:
        if temp_destination.exists():
            shutil.rmtree(temp_destination)
        if backup_destination.exists() and not destination.exists():
            backup_destination.rename(destination)
        raise
    if backup_destination.exists():
        shutil.rmtree(backup_destination)


def run_command(args):
    completed = subprocess.run(args, text=True, capture_output=True, check=False)
    output = "\n".join(part for part in (completed.stdout.strip(), completed.stderr.strip()) if part)
    return completed.returncode, output


def publish_generated_repo(target, domain):
    log = []
    errors = validate_generated_repo(target)
    if errors:
        log.append("Publish skipped: generated repo validation failed.")
        log.extend(f"Validation error: {error}" for error in errors)
        return log

    repo_root = nginx_repo_root(domain)
    config_path = nginx_config_path(domain)
    config_text = render_nginx_config(domain)
    log.append(f"Public repo URL: https://{public_repo_host(domain)}")
    log.append(f"Publishing files to: {repo_root}")
    atomic_copytree(target, repo_root)
    log.append(f"Writing nginx config: {config_path}")
    atomic_write_text(config_path, config_text)

    container = os.environ.get("NGINX_CONTAINER", "").strip()
    if not container:
        log.append("NGINX_CONTAINER is not set; skipped nginx -t and reload.")
        return log

    test_code, test_output = run_command(["docker", "exec", container, "nginx", "-t"])
    log.append(f"nginx -t exit code: {test_code}")
    if test_output:
        log.append(test_output)
    if test_code != 0:
        log.append("Nginx reload skipped because config test failed.")
        return log

    reload_code, reload_output = run_command(["docker", "exec", container, "nginx", "-s", "reload"])
    log.append(f"nginx reload exit code: {reload_code}")
    if reload_output:
        log.append(reload_output)
    return log


def generate(fields, files):
    domain_value = fields.get("domain", "")
    brand = fields.get("brand", "").strip()
    validation, domain, domain_url = validate_domain(domain_value)
    if not brand:
        raise ValueError("Brand name is required.")
    if not validation["valid"]:
        raise ValueError("New domain must be a valid HTTPS URL, for example https://newdomain.com.")
    plan = build_replacements(fields.get("map", ""), brand, domain, domain_url)
    renames = root_folder_renames(plan["path"])
    protected_names = protected_addon_names()
    target = OUTPUT_ROOT / domain
    copy_template(target)
    icon_data = files.get("icon", (None, None))[1]
    fanart_name, fanart_data = files.get("fanart", (None, None))
    log = [f"Output folder: {target}", f"Domain valid: {'yes' if validation['valid'] else 'no'}", f"Domain reachable: {'yes' if validation['reachable'] else 'no'}"]
    if icon_data:
        log.append("Icon source: uploaded icon.png")
    else:
        log.append("Icon source: copied from template")
    if fanart_data:
        log.append(f"Fanart source: uploaded {fanart_name}")
    else:
        log.append("Fanart source: copied from template")
    renamed_folders = rename_root_folders(target, renames)
    log.append(f"Root folders renamed: {renamed_folders}")
    renamed_paths = rename_generated_paths(target, plan)
    log.append(f"Generated paths renamed: {renamed_paths}")
    text_changes = 0
    for path in target.rglob("*"):
        if path.is_file() and path.suffix.lower() in PROCESS_EXTENSIONS:
            text_changes += int(process_text_file(path, plan, protected_names))
    log.append(f"Text files updated: {text_changes}")
    image_changes = replace_images(target, icon_data, fanart_name, fanart_data)
    log.append(f"Image files replaced: {image_changes}")
    zip_changes = 0
    for zip_path in sorted(target.rglob("*.zip")):
        zip_changes += int(process_zip(zip_path, plan, protected_names, icon_data, fanart_name, fanart_data))
    log.append(f"ZIPs rebuilt: {zip_changes}")
    md5_count = regenerate_md5s(target)
    log.append(f"MD5 files regenerated: {md5_count}")
    archive_path = create_download_zip(target)
    log.append(f"Download ZIP: {archive_path}")
    publish_requested = fields.get("publish_nginx", "").strip().lower() in {"1", "true", "yes", "on"}
    if publish_requested:
        try:
            log.extend(publish_generated_repo(target, domain))
        except Exception as exc:
            log.append(f"Publish failed: {exc}")
    else:
        log.append("Nginx publish not requested.")
    log.append("Done.")
    return {"validation": validation, "log": log, "download_url": f"/download/{quote(archive_path.name)}"}


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path in {"/", "/index.html"}:
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(INDEX_HTML.encode("utf-8"))
            return
        if self.path.startswith("/download/"):
            name = unquote(self.path.removeprefix("/download/"))
            if "/" in name or "\\" in name or not name.endswith(".zip"):
                self.send_error(404)
                return
            archive_path = OUTPUT_ROOT / name
            if not archive_path.is_file():
                self.send_error(404)
                return
            body = archive_path.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "application/zip")
            self.send_header("Content-Disposition", f'attachment; filename="{name}"')
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        self.send_error(404)

    def do_POST(self):
        if self.path != "/generate":
            self.send_error(404)
            return
        content_type = self.headers.get("content-type", "")
        if not content_type.startswith("multipart/form-data"):
            self.send_error(400, "multipart/form-data required")
            return
        length = int(self.headers.get("content-length", "0"))
        body = self.rfile.read(length)
        message = BytesParser(policy=default).parsebytes(
            b"Content-Type: " + content_type.encode("utf-8") + b"\r\n"
            b"MIME-Version: 1.0\r\n\r\n" + body
        )
        fields = {}
        files = {}
        for part in message.iter_parts():
            name = part.get_param("name", header="content-disposition")
            filename = part.get_filename()
            payload = part.get_payload(decode=True) or b""
            if not name:
                continue
            if filename:
                if payload:
                    files[name] = (filename, payload)
            else:
                fields[name] = payload.decode(part.get_content_charset() or "utf-8")
        try:
            payload = generate(fields, files)
            status = 200
        except Exception as exc:
            payload = {"error": str(exc), "validation": {"valid": False, "reachable": False}, "log": []}
            status = 400
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        print("%s - %s" % (self.address_string(), fmt % args))


def main():
    host = os.environ.get("HOST", "127.0.0.1")
    port = int(os.environ.get("PORT", "8765"))
    server = ThreadingHTTPServer((host, port), Handler)
    print(f"Kodi Repo Brand Generator running at http://{host}:{port}")
    server.serve_forever()


if __name__ == "__main__":
    main()
