# -*- coding: utf-8 -*-

from flask import Flask, request, redirect, send_from_directory, make_response
from werkzeug.utils import secure_filename
import os
import time
import sys
import re
import shutil
import py_compile
import threading
import html as html_lib
import glob
import zipfile
import subprocess
import ipaddress

app = Flask(__name__)

ADMIN_PANEL_VERSION = "TripServer Admin v13.0 locked verified + self-test center + smart media optimizer + accurate Japan image usage"
ADMIN_STARTED_AT = time.strftime("%Y-%m-%d %H:%M:%S")

BASE_DIR = r"C:\TripServer"
ADMIN_HOST = "0.0.0.0"
ADMIN_PORT = 8000

FOLDERS = {
    "JAPAN": os.path.join(BASE_DIR, "JAPAN"),
    "USA": os.path.join(BASE_DIR, "USA"),
    "Moneyapp": os.path.join(BASE_DIR, "Moneyapp"),
    "MoneyHome": os.path.join(BASE_DIR, "MoneyHome"),
    "TripForm": os.path.join(BASE_DIR, "TripForm"),
}

APP_FOLDERS = {"JAPAN", "USA", "Moneyapp", "MoneyHome", "TripForm"}

JAPAN_PRESENTATION_FILES = {
    "flight_intro": "japan-flight-intro.html",
    "japan_experience": "japan-experience.html",
}

USA_PRESENTATION_FILES = {
    "usa_presentation": "usa2027-presentation.html",
    "usa_presentation_tv": "usa2027-presentation-tv.html",
}

USA_EXTRA_HTML_FILES = {
    "usa_trip_tv": "usa2027-tv.html",
}

USA_MUSIC_FILES = {
    "usa_music": os.path.join("media", "usa-presentation", "music", "usa-roadtrip-theme.mp3"),
    "usa_music_part2": os.path.join("media", "usa-presentation", "music", "usa-part2-theme.mp3"),
}

USA_IMAGE_KEYS = ({"intro", "budget", "roadtrip-map", "summary", "extra-big-sur", "extra-diner", "extra-death-valley", "extra-mono-lake"} | {f"day-{i:02d}" for i in range(1, 37)})
USA_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}

JAPAN_MUSIC_TARGETS = {
    # The Japan experience presentation contains several fallback paths. Keep all aliases synced.
    "japan_experience_music": [
        os.path.join("media", "japan-experience", "music", "japan-theme.mp3"),
        os.path.join("media", "music", "japan-theme.mp3"),
        "japan-theme.mp3",
    ],
    "japan_flight_music": [
        os.path.join("media", "japan-flight", "music", "flight-intro.mp3"),
    ],
}

AUDIO_UPLOAD_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma", ".webm", ".mp4"}
JAPAN_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
JAPAN_IMAGES_ZIP_MAX_BYTES = 130 * 1024 * 1024
JAPAN_IMAGES_ZIP_MAX_FILES = 220
JAPAN_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES = 220 * 1024 * 1024
JAPAN_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES = 18 * 1024 * 1024
TRIP_PACKAGE_ZIP_MAX_BYTES = 180 * 1024 * 1024
TRIP_PACKAGE_ZIP_MAX_FILES = 320
TRIP_PACKAGE_ZIP_MAX_UNCOMPRESSED_BYTES = 260 * 1024 * 1024
TRIP_PACKAGE_ZIP_MAX_SINGLE_FILE_BYTES = 35 * 1024 * 1024
TRIP_PACKAGE_EXTENSIONS = {".html", ".htm", ".jpg", ".jpeg", ".png", ".webp", ".mp3", ".css", ".js", ".json", ".txt", ".svg"}


ADMIN_SERVER_PATH = os.path.abspath(__file__)
ADMIN_BACKUP_DIR = os.path.join(BASE_DIR, "backups", "admin_server")
ADMIN_UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "tmp", "admin_server_uploads")
ADMIN_MAX_UPLOAD_BYTES = 2 * 1024 * 1024
USA_IMAGES_ZIP_MAX_BYTES = 80 * 1024 * 1024
USA_IMAGES_ZIP_MAX_FILES = 160
USA_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES = 140 * 1024 * 1024
USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES = 12 * 1024 * 1024
ADMIN_RESTART_CMD_PATH = os.path.join(BASE_DIR, "restart_admin_server.cmd")
ADMIN_RESTART_LOG_PATH = os.path.join(BASE_DIR, "admin_restart.log")
ADMIN_RESTART_HELPER_PATH = os.path.join(BASE_DIR, "restart_admin_server_helper.py")
UPLOAD_BACKUP_DIR = os.path.join(BASE_DIR, "backups", "uploaded_files")


# Smart media optimizer settings.
# Images uploaded to fixed slide slots are always stored as real .jpg files.
# Audio uploaded to fixed music slots is always stored as .mp3 when FFmpeg is available.
IMAGE_OPTIMIZER_MAX_LONG_EDGE = int(os.environ.get("TRIPSERVER_IMAGE_MAX_LONG_EDGE", "1920"))
IMAGE_OPTIMIZER_TARGET_BYTES = int(os.environ.get("TRIPSERVER_IMAGE_TARGET_BYTES", str(1100 * 1024)))
IMAGE_OPTIMIZER_QUALITIES = tuple(int(q) for q in os.environ.get("TRIPSERVER_IMAGE_QUALITIES", "88,86,84,82,80,78").split(",") if q.strip())
AUDIO_UPLOAD_MAX_BYTES = int(os.environ.get("TRIPSERVER_AUDIO_MAX_BYTES", str(80 * 1024 * 1024)))
AUDIO_MP3_BITRATE = os.environ.get("TRIPSERVER_AUDIO_MP3_BITRATE", "192k")
AUDIO_MP3_SAMPLE_RATE = os.environ.get("TRIPSERVER_AUDIO_MP3_SAMPLE_RATE", "44100")
MEDIA_AUDIO_ACCEPT = ".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"
MEDIA_IMAGE_ACCEPT = ".jpg,.jpeg,.png,.webp,.bmp,.tif,.tiff,image/*"

# Power actions are intentionally guarded: POST only, LAN/local client only, typed Hebrew confirmation, and cancel support.
POWER_ACTIONS_ENABLED = os.environ.get("TRIPSERVER_ENABLE_POWER_ACTIONS", "1").strip().lower() not in {"0", "false", "no", "off"}
POWER_MIN_DELAY_SECONDS = 30
POWER_MAX_DELAY_SECONDS = 3600
POWER_DEFAULT_DELAY_SECONDS = 60
POWER_CONFIRM_TEXT = {
    "shutdown": "כיבוי",
    "reboot": "הפעלה מחדש",
}
POWER_LABELS = {
    "shutdown": "כיבוי מחשב",
    "reboot": "ריסטארט למחשב",
}



def safe_zip_relpath(raw_name):
    raw = (raw_name or "").replace("\\", "/").strip("/")
    if not raw or raw.startswith("__MACOSX/"):
        return None
    parts = [p for p in raw.split("/") if p and p not in (".", "..")]
    if not parts or len(parts) != len(raw.split("/")):
        return None
    return "/".join(parts)


def file_ext(filename):
    return os.path.splitext(filename or "")[1].lower()


def is_likely_jpeg_file(path):
    try:
        with open(path, "rb") as fh:
            return fh.read(3) == b"\xff\xd8\xff"
    except Exception:
        return False


def is_likely_mp3_file(path):
    """Fast MP3 sniffing: ID3 tag or MPEG audio frame sync near the beginning."""
    try:
        with open(path, "rb") as fh:
            data = fh.read(4096)
        if data.startswith(b"ID3"):
            return True
        for idx in range(0, max(0, len(data) - 1)):
            if data[idx] == 0xFF and (data[idx + 1] & 0xE0) == 0xE0:
                return True
        return False
    except Exception:
        return False


def find_ffmpeg_executable():
    candidates = [
        os.environ.get("FFMPEG_PATH"),
        "ffmpeg",
        r"C:\\ffmpeg\\bin\\ffmpeg.exe",
        r"C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe",
        r"C:\\Program Files (x86)\\ffmpeg\\bin\\ffmpeg.exe",
    ]
    for candidate in candidates:
        if not candidate:
            continue
        found = shutil.which(candidate) if os.path.basename(candidate) == candidate else (candidate if os.path.exists(candidate) else None)
        if found:
            return found
    return None


def media_engine_status():
    status = {"pillow": False, "ffmpeg": bool(find_ffmpeg_executable()), "ffmpeg_path": find_ffmpeg_executable() or ""}
    try:
        import PIL  # noqa: F401
        status["pillow"] = True
    except Exception:
        status["pillow"] = False
    return status


def format_bytes(num):
    try:
        num = int(num)
    except Exception:
        return str(num)
    units = ["B", "KB", "MB", "GB"]
    value = float(num)
    for unit in units:
        if value < 1024 or unit == units[-1]:
            return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
        value /= 1024


def convert_or_copy_image_to_jpg(source_path, target_path, original_name=""):
    """Create a real optimized JPG file; never writes a fake .jpg.

    Smart behavior:
    - Uses Pillow when installed.
    - Applies EXIF orientation.
    - Converts alpha/transparency to a white background.
    - Downscales only when the long edge is larger than IMAGE_OPTIMIZER_MAX_LONG_EDGE.
    - Uses progressive/optimized JPEG and a quality loop to reduce weight without aggressive damage.
    - If Pillow is missing, accepts only true JPEG bytes and copies them safely.
    """
    ensure_parent_dir(target_path)
    original_size = os.path.getsize(source_path) if os.path.exists(source_path) else 0
    try:
        from PIL import Image, ImageOps
        with Image.open(source_path) as img:
            img.verify()
        with Image.open(source_path) as img:
            img = ImageOps.exif_transpose(img)
            original_dimensions = tuple(img.size)
            if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
                rgba = img.convert("RGBA")
                background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
                background.alpha_composite(rgba)
                img = background.convert("RGB")
            elif img.mode != "RGB":
                img = img.convert("RGB")

            resized = False
            max_edge = max(img.size) if img.size else 0
            if IMAGE_OPTIMIZER_MAX_LONG_EDGE and max_edge > IMAGE_OPTIMIZER_MAX_LONG_EDGE:
                scale = IMAGE_OPTIMIZER_MAX_LONG_EDGE / float(max_edge)
                new_size = (max(1, int(round(img.size[0] * scale))), max(1, int(round(img.size[1] * scale))))
                img = img.resize(new_size, Image.Resampling.LANCZOS)
                resized = True

            tmp_candidates = []
            qualities = IMAGE_OPTIMIZER_QUALITIES or (88, 84, 80)
            for quality in qualities:
                tmp_candidate = f"{target_path}.q{quality}.tmp"
                img.save(tmp_candidate, "JPEG", quality=int(quality), optimize=True, progressive=True)
                tmp_candidates.append((quality, tmp_candidate, os.path.getsize(tmp_candidate)))
                if os.path.getsize(tmp_candidate) <= IMAGE_OPTIMIZER_TARGET_BYTES:
                    break

            # Keep the highest-quality candidate that reaches target size; otherwise keep the smallest candidate.
            under_target = [item for item in tmp_candidates if item[2] <= IMAGE_OPTIMIZER_TARGET_BYTES]
            chosen = under_target[0] if under_target else min(tmp_candidates, key=lambda item: item[2])
            for quality, path, _size in tmp_candidates:
                if path != chosen[1]:
                    try:
                        os.remove(path)
                    except Exception:
                        pass
            os.replace(chosen[1], target_path)
            final_size = os.path.getsize(target_path)
            return {
                "mode": "optimized_jpg",
                "quality": chosen[0],
                "original_size": original_size,
                "final_size": final_size,
                "original_dimensions": original_dimensions,
                "final_dimensions": tuple(img.size),
                "resized": resized,
                "saved_bytes": max(0, original_size - final_size),
            }
    except ImportError:
        if is_likely_jpeg_file(source_path):
            tmp_target = target_path + ".tmp"
            shutil.copy2(source_path, tmp_target)
            os.replace(tmp_target, target_path)
            final_size = os.path.getsize(target_path)
            return {
                "mode": "copied_real_jpeg_without_pillow",
                "quality": "source",
                "original_size": original_size,
                "final_size": final_size,
                "original_dimensions": "unknown",
                "final_dimensions": "unknown",
                "resized": False,
                "saved_bytes": max(0, original_size - final_size),
            }
        raise ValueError("Pillow is not installed, so non-JPG images cannot be converted. Install Pillow with: py -m pip install pillow")
    except ValueError:
        raise
    except Exception as exc:
        raise ValueError("Image conversion/compression failed: " + repr(exc))


def convert_or_copy_audio_to_mp3(source_path, target_path, original_name=""):
    """Store uploaded audio as a real .mp3 file.

    If FFmpeg exists, transcode supported audio/video containers to MP3.
    If FFmpeg is missing, accept only files that are already real MP3 bytes, even if their extension is wrong.
    """
    ensure_parent_dir(target_path)
    original_size = os.path.getsize(source_path) if os.path.exists(source_path) else 0
    if original_size <= 0:
        raise ValueError("קובץ האודיו ריק.")
    if original_size > AUDIO_UPLOAD_MAX_BYTES:
        raise ValueError(f"קובץ האודיו גדול מדי: {format_bytes(original_size)}. מגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}")

    source_is_mp3 = is_likely_mp3_file(source_path)
    ffmpeg = find_ffmpeg_executable()
    if ffmpeg:
        tmp_mp3 = target_path + ".ffmpeg.tmp.mp3"
        cmd = [
            ffmpeg,
            "-y",
            "-hide_banner",
            "-loglevel", "error",
            "-i", source_path,
            "-vn",
            "-codec:a", "libmp3lame",
            "-b:a", AUDIO_MP3_BITRATE,
            "-ar", AUDIO_MP3_SAMPLE_RATE,
            tmp_mp3,
        ]
        run = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
        if run.returncode != 0 or not os.path.exists(tmp_mp3) or os.path.getsize(tmp_mp3) <= 0:
            try:
                if os.path.exists(tmp_mp3):
                    os.remove(tmp_mp3)
            except Exception:
                pass
            raise ValueError("FFmpeg failed to convert audio to MP3: " + ((run.stderr or run.stdout or "unknown error")[-1200:]))

        converted_size = os.path.getsize(tmp_mp3)
        if source_is_mp3 and converted_size > int(original_size * 1.05):
            # Avoid bloating an already efficient MP3. Still normalize the target filename to .mp3.
            try:
                os.remove(tmp_mp3)
            except Exception:
                pass
            tmp_target = target_path + ".copy.tmp"
            shutil.copy2(source_path, tmp_target)
            os.replace(tmp_target, target_path)
            final_size = os.path.getsize(target_path)
            return {"mode": "copied_existing_mp3_smaller_than_reencode", "original_size": original_size, "final_size": final_size, "saved_bytes": max(0, original_size - final_size), "ffmpeg": ffmpeg}

        os.replace(tmp_mp3, target_path)
        final_size = os.path.getsize(target_path)
        return {"mode": "converted_to_mp3_ffmpeg", "original_size": original_size, "final_size": final_size, "saved_bytes": max(0, original_size - final_size), "ffmpeg": ffmpeg, "bitrate": AUDIO_MP3_BITRATE}

    if source_is_mp3:
        tmp_target = target_path + ".copy.tmp"
        shutil.copy2(source_path, tmp_target)
        os.replace(tmp_target, target_path)
        final_size = os.path.getsize(target_path)
        return {"mode": "copied_real_mp3_without_ffmpeg", "original_size": original_size, "final_size": final_size, "saved_bytes": max(0, original_size - final_size), "ffmpeg": "not_found"}

    raise ValueError("FFmpeg is not installed, so this audio file cannot be converted to MP3. Install FFmpeg or upload a real MP3 file.")


def save_audio_upload_to_target(uploaded_file, target_path, backup_group="audio"):
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    safe_name = secure_filename(uploaded_file.filename or "audio_upload") or "audio_upload"
    tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"audio_{stamp}_{os.getpid()}_{safe_name}")
    uploaded_file.save(tmp_path)
    try:
        backup_path = backup_existing_file(target_path, backup_group)
        media = convert_or_copy_audio_to_mp3(tmp_path, target_path, uploaded_file.filename or "")
        return {"path": target_path, "size": os.path.getsize(target_path), "sha256": file_sha256(target_path), "backup": backup_path, "media": media}
    finally:
        try:
            if os.path.exists(tmp_path):
                os.remove(tmp_path)
        except Exception:
            pass


def save_audio_upload_to_multiple_targets(uploaded_file, base_folder, rel_paths, backup_group):
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    safe_name = secure_filename(uploaded_file.filename or "audio_upload") or "audio_upload"
    source_tmp = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"audio_multi_{stamp}_{os.getpid()}_{safe_name}")
    converted_tmp = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"audio_multi_{stamp}_{os.getpid()}_converted.mp3")
    uploaded_file.save(source_tmp)
    try:
        media = convert_or_copy_audio_to_mp3(source_tmp, converted_tmp, uploaded_file.filename or "")
        sha = file_sha256(converted_tmp)
        results = []
        for rel_path in rel_paths:
            target_path = os.path.join(base_folder, rel_path)
            ensure_parent_dir(target_path)
            backup_path = backup_existing_file(target_path, backup_group)
            tmp_target = target_path + ".tmp"
            shutil.copy2(converted_tmp, tmp_target)
            os.replace(tmp_target, target_path)
            results.append({"rel": rel_path, "path": target_path, "size": os.path.getsize(target_path), "sha256": file_sha256(target_path), "backup": backup_path})
        return {"size": os.path.getsize(converted_tmp), "sha256": sha, "targets": results, "media": media}
    finally:
        for p in (source_tmp, converted_tmp):
            try:
                if os.path.exists(p):
                    os.remove(p)
            except Exception:
                pass


def media_details_text(media):
    if not media:
        return ""
    lines = []
    if media.get("mode"):
        lines.append(f"מצב עיבוד: {media.get('mode')}")
    if media.get("original_size") is not None and media.get("final_size") is not None:
        lines.append(f"גודל לפני: {format_bytes(media.get('original_size'))}")
        lines.append(f"גודל אחרי: {format_bytes(media.get('final_size'))}")
        saved = int(media.get("saved_bytes") or 0)
        if saved > 0:
            lines.append(f"חיסכון: {format_bytes(saved)}")
    if media.get("original_dimensions"):
        lines.append(f"מידות מקור: {media.get('original_dimensions')}")
    if media.get("final_dimensions"):
        lines.append(f"מידות סופיות: {media.get('final_dimensions')}")
    if media.get("quality"):
        lines.append(f"איכות JPG: {media.get('quality')}")
    if media.get("bitrate"):
        lines.append(f"ביטרייט MP3: {media.get('bitrate')}")
    if media.get("ffmpeg"):
        lines.append(f"FFmpeg: {media.get('ffmpeg')}")
    return "\n".join(lines)


def save_usa_presentation_image(image_key, uploaded_file):
    if image_key not in USA_IMAGE_KEYS:
        return None

    original_name = uploaded_file.filename or ""
    if file_ext(original_name) not in USA_IMAGE_EXTENSIONS:
        raise ValueError("סיומת תמונה לא נתמכת. העלה JPG/JPEG/PNG/WebP/BMP/TIFF.")

    image_dir = os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images")
    os.makedirs(image_dir, exist_ok=True)
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)

    stamp = time.strftime("%Y%m%d_%H%M%S")
    tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"usa_image_{stamp}_{os.getpid()}_{secure_filename(image_key)}")
    uploaded_file.save(tmp_path)
    try:
        if os.path.getsize(tmp_path) <= 0:
            raise ValueError("קובץ התמונה ריק.")
        if os.path.getsize(tmp_path) > USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES:
            raise ValueError("קובץ התמונה גדול מדי.")
        target_path = os.path.join(image_dir, image_key + ".jpg")
        backup_path = backup_existing_file(target_path, "USA_images_single")
        media = convert_or_copy_image_to_jpg(tmp_path, target_path, original_name)
        for old_ext in USA_IMAGE_EXTENSIONS - {".jpg"}:
            old_path = os.path.join(image_dir, image_key + old_ext)
            if os.path.exists(old_path):
                try:
                    backup_existing_file(old_path, "USA_images_single_old_ext")
                    os.remove(old_path)
                except Exception:
                    pass
        return {"rel": os.path.join("media", "usa-presentation", "images", image_key + ".jpg"), "path": target_path, "mode": media.get("mode", "optimized"), "media": media, "backup": backup_path, "sha256": file_sha256(target_path), "size": os.path.getsize(target_path)}
    finally:
        try:
            if os.path.exists(tmp_path):
                os.remove(tmp_path)
        except Exception:
            pass


def save_usa_presentation_images_zip(uploaded_file):
    image_dir = os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images")
    os.makedirs(image_dir, exist_ok=True)

    stamp = time.strftime("%Y%m%d_%H%M%S")
    tmp_zip = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"usa_images_{stamp}.zip")
    tmp_extract = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"usa_images_extract_{stamp}")
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    os.makedirs(tmp_extract, exist_ok=True)
    uploaded_file.save(tmp_zip)

    zip_size = os.path.getsize(tmp_zip)
    if zip_size > USA_IMAGES_ZIP_MAX_BYTES:
        try:
            os.remove(tmp_zip)
        except Exception:
            pass
        raise ValueError(f"zip_size_exceeded:{zip_size}")

    imported = []
    skipped = []
    seen = set()
    try:
        with zipfile.ZipFile(tmp_zip, "r") as zf:
            infos = [info for info in zf.infolist() if not info.is_dir()]
            if len(infos) > USA_IMAGES_ZIP_MAX_FILES:
                raise ValueError(f"too_many_files:{len(infos)}")
            total_uncompressed = sum(max(0, info.file_size) for info in infos)
            if total_uncompressed > USA_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES:
                raise ValueError(f"zip_uncompressed_size_exceeded:{total_uncompressed}")

            for info in infos:
                raw_name = (info.filename or "").replace("\\", "/")
                base = os.path.basename(raw_name)
                if not base or base.startswith(".") or raw_name.startswith("__MACOSX/"):
                    continue
                if info.file_size > USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES:
                    skipped.append(base + " — גדול מדי")
                    continue

                stem, ext = os.path.splitext(base)
                stem = stem.strip()
                ext = ext.lower()
                if ext not in USA_IMAGE_EXTENSIONS:
                    skipped.append(base + " — סיומת לא נתמכת")
                    continue
                if stem not in USA_IMAGE_KEYS:
                    skipped.append(base + " — שם לא שייך למצגת")
                    continue
                if stem in seen:
                    skipped.append(base + " — כפילות ב־ZIP")
                    continue
                seen.add(stem)

                source_tmp = os.path.join(tmp_extract, stem + ext)
                with zf.open(info, "r") as source, open(source_tmp, "wb") as dest:
                    shutil.copyfileobj(source, dest)

                target = os.path.join(image_dir, stem + ".jpg")
                try:
                    backup_existing_file(target, "USA_images_zip")
                    media = convert_or_copy_image_to_jpg(source_tmp, target, base)
                    for old_ext in USA_IMAGE_EXTENSIONS - {".jpg"}:
                        old_path = os.path.join(image_dir, stem + old_ext)
                        if os.path.exists(old_path):
                            try:
                                backup_existing_file(old_path, "USA_images_zip_old_ext")
                                os.remove(old_path)
                            except Exception:
                                pass
                    imported.append(stem + ".jpg" + " — " + format_bytes(media.get("original_size", 0)) + " → " + format_bytes(media.get("final_size", 0)))
                except Exception as exc:
                    skipped.append(base + " — לא הומר/נדחס ל־JPG: " + str(exc))
    finally:
        try:
            os.remove(tmp_zip)
        except Exception:
            pass
        try:
            shutil.rmtree(tmp_extract, ignore_errors=True)
        except Exception:
            pass

    imported.sort()
    skipped.sort()
    # Missing means absent on disk after the import, not merely absent from this specific ZIP.
    missing = sorted(key + ".jpg" for key in USA_IMAGE_KEYS if not os.path.exists(os.path.join(image_dir, key + ".jpg")))
    return imported, skipped, missing

for folder_path in FOLDERS.values():
    os.makedirs(folder_path, exist_ok=True)

os.makedirs(os.path.join(FOLDERS["USA"], "media", "usa-presentation", "music"), exist_ok=True)
os.makedirs(os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images"), exist_ok=True)
os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "images"), exist_ok=True)
os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "japan-experience", "music"), exist_ok=True)
os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "japan-flight", "music"), exist_ok=True)
os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "music"), exist_ok=True)
os.makedirs(ADMIN_BACKUP_DIR, exist_ok=True)
os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)


def no_cache_response(response):
    response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0, private"
    response.headers["Pragma"] = "no-cache"
    response.headers["Expires"] = "0"
    # Prevent old HTML/images from being reused after upload when the browser sends If-None-Match/If-Modified-Since.
    response.headers.pop("ETag", None)
    response.headers.pop("Last-Modified", None)
    return response


@app.after_request
def add_global_no_cache_headers(response):
    return no_cache_response(response)


def serve_file_no_cache(directory, file_name):
    try:
        response = make_response(send_from_directory(directory, file_name, conditional=False, max_age=0))
    except TypeError:
        # Compatibility fallback for older Flask/Werkzeug versions.
        response = make_response(send_from_directory(directory, file_name))
    return no_cache_response(response)


def file_sha256(path):
    import hashlib
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def ensure_parent_dir(path):
    parent = os.path.dirname(path)
    if parent:
        os.makedirs(parent, exist_ok=True)


def backup_existing_file(target_path, backup_group="uploads"):
    if not os.path.exists(target_path) or not os.path.isfile(target_path):
        return None
    stamp = time.strftime("%Y%m%d_%H%M%S")
    safe_group = secure_filename(str(backup_group)) or "uploads"
    safe_name = secure_filename(os.path.basename(target_path)) or "file"
    backup_dir = os.path.join(UPLOAD_BACKUP_DIR, safe_group)
    os.makedirs(backup_dir, exist_ok=True)
    backup_path = os.path.join(backup_dir, f"{stamp}_{safe_name}")
    shutil.copy2(target_path, backup_path)
    return backup_path


def atomic_save_upload(uploaded_file, target_path, backup_group="uploads"):
    """Save uploaded file atomically: tmp -> validate size -> backup old -> os.replace."""
    ensure_parent_dir(target_path)
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    safe_target = secure_filename(os.path.basename(target_path)) or "upload.bin"
    tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"tmp_{stamp}_{os.getpid()}_{safe_target}")
    try:
        uploaded_file.save(tmp_path)
        size = os.path.getsize(tmp_path)
        if size <= 0:
            raise ValueError("uploaded file is empty")
        backup_path = backup_existing_file(target_path, backup_group)
        os.replace(tmp_path, target_path)
        final_size = os.path.getsize(target_path)
        if final_size != size:
            raise IOError(f"size mismatch after save: tmp={size}, final={final_size}")
        return {"path": target_path, "size": final_size, "backup": backup_path, "sha256": file_sha256(target_path)}
    finally:
        try:
            if os.path.exists(tmp_path):
                os.remove(tmp_path)
        except Exception:
            pass


def find_html_file(folder_path):
    index_path = os.path.join(folder_path, "index.html")
    if os.path.exists(index_path):
        return "index.html"

    html_files = [f for f in os.listdir(folder_path) if f.lower().endswith((".html", ".htm"))]
    if html_files:
        return html_files[0]
    return None


def clean_wrong_main_files(folder_path):
    wrong_names = {"html", "htm", "index", "index.htm", "download", "unknown"}
    for name in os.listdir(folder_path):
        full_path = os.path.join(folder_path, name)
        if not os.path.isfile(full_path):
            continue
        if name.lower().strip() in wrong_names:
            try:
                os.remove(full_path)
            except Exception:
                pass


def save_uploaded_file(folder, uploaded_file):
    folder_path = FOLDERS[folder]
    os.makedirs(folder_path, exist_ok=True)

    original_filename = uploaded_file.filename or ""
    safe_name = secure_filename(original_filename)

    if folder in APP_FOLDERS:
        clean_wrong_main_files(folder_path)
        filename = "index.html"
    else:
        filename = safe_name or f"upload_{int(time.time())}"

    target_path = os.path.join(folder_path, filename)
    result = atomic_save_upload(uploaded_file, target_path, backup_group=f"{folder}_main")
    return filename, result


def save_japan_presentation(presentation_key, uploaded_file):
    if presentation_key not in JAPAN_PRESENTATION_FILES:
        return None
    japan_folder = FOLDERS["JAPAN"]
    os.makedirs(japan_folder, exist_ok=True)
    filename = JAPAN_PRESENTATION_FILES[presentation_key]
    target_path = os.path.join(japan_folder, filename)
    result = atomic_save_upload(uploaded_file, target_path, backup_group="JAPAN_presentations")
    return filename, result


def save_usa_presentation(presentation_key, uploaded_file):
    if presentation_key not in USA_PRESENTATION_FILES:
        return None
    usa_folder = FOLDERS["USA"]
    os.makedirs(usa_folder, exist_ok=True)
    filename = USA_PRESENTATION_FILES[presentation_key]
    target_path = os.path.join(usa_folder, filename)
    result = atomic_save_upload(uploaded_file, target_path, backup_group="USA_presentations")
    return filename, result


def save_usa_music(music_key, uploaded_file):
    if music_key not in USA_MUSIC_FILES:
        return None
    usa_folder = FOLDERS["USA"]
    rel_path = USA_MUSIC_FILES[music_key]
    full_path = os.path.join(usa_folder, rel_path)
    result = save_audio_upload_to_target(uploaded_file, full_path, backup_group="USA_music")
    return rel_path, result



def save_usa_extra_html(extra_key, uploaded_file):
    if extra_key not in USA_EXTRA_HTML_FILES:
        return None
    filename = USA_EXTRA_HTML_FILES[extra_key]
    target_path = os.path.join(FOLDERS["USA"], filename)
    result = atomic_save_upload(uploaded_file, target_path, backup_group="USA_extra_html")
    return filename, result


def save_file_to_multiple_targets(uploaded_file, base_folder, rel_paths, backup_group):
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"multi_upload_{stamp}_{os.getpid()}")
    uploaded_file.save(tmp_path)
    try:
        size = os.path.getsize(tmp_path)
        if size <= 0:
            raise ValueError("uploaded file is empty")
        sha = file_sha256(tmp_path)
        results = []
        for rel_path in rel_paths:
            target_path = os.path.join(base_folder, rel_path)
            ensure_parent_dir(target_path)
            backup_path = backup_existing_file(target_path, backup_group)
            tmp_target = target_path + ".tmp"
            shutil.copy2(tmp_path, tmp_target)
            os.replace(tmp_target, target_path)
            results.append({"rel": rel_path, "path": target_path, "size": os.path.getsize(target_path), "sha256": file_sha256(target_path), "backup": backup_path})
        return {"size": size, "sha256": sha, "targets": results}
    finally:
        try:
            if os.path.exists(tmp_path):
                os.remove(tmp_path)
        except Exception:
            pass


def save_japan_music(music_key, uploaded_file):
    if music_key not in JAPAN_MUSIC_TARGETS:
        return None
    return save_audio_upload_to_multiple_targets(uploaded_file, FOLDERS["JAPAN"], JAPAN_MUSIC_TARGETS[music_key], "JAPAN_music")


def normalize_japan_image_rel(raw_name):
    safe = safe_zip_relpath(raw_name)
    if not safe:
        return None
    lower = safe.lower()
    marker = "/media/images/"
    if marker in lower:
        idx = lower.index(marker) + len(marker)
        return safe[idx:]
    if lower.startswith("media/images/"):
        return safe[len("media/images/"):]
    if lower.startswith("images/"):
        return safe[len("images/"):]
    marker2 = "/images/"
    if marker2 in lower:
        idx = lower.index(marker2) + len(marker2)
        return safe[idx:]
    return None


def save_japan_images_zip(uploaded_file):
    image_root = os.path.join(FOLDERS["JAPAN"], "media", "images")
    os.makedirs(image_root, exist_ok=True)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    tmp_zip = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"japan_images_{stamp}.zip")
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    uploaded_file.save(tmp_zip)
    imported, skipped, overwritten = [], [], []
    try:
        if os.path.getsize(tmp_zip) > JAPAN_IMAGES_ZIP_MAX_BYTES:
            raise ValueError(f"zip_size_exceeded:{os.path.getsize(tmp_zip)}")
        with zipfile.ZipFile(tmp_zip, "r") as zf:
            infos = [info for info in zf.infolist() if not info.is_dir()]
            if len(infos) > JAPAN_IMAGES_ZIP_MAX_FILES:
                raise ValueError(f"too_many_files:{len(infos)}")
            total_uncompressed = sum(max(0, info.file_size) for info in infos)
            if total_uncompressed > JAPAN_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES:
                raise ValueError(f"zip_uncompressed_size_exceeded:{total_uncompressed}")
            for info in infos:
                rel = normalize_japan_image_rel(info.filename)
                base = os.path.basename((info.filename or "").replace("\\", "/"))
                if not rel:
                    skipped.append(base + " — לא בתיקיית images/media/images")
                    continue
                if info.file_size > JAPAN_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES:
                    skipped.append(rel + " — גדול מדי")
                    continue
                if file_ext(rel) not in JAPAN_IMAGE_EXTENSIONS:
                    skipped.append(rel + " — סיומת לא נתמכת")
                    continue
                # Prevent traversal after normalization.
                if not safe_zip_relpath(rel):
                    skipped.append(base + " — נתיב לא בטוח")
                    continue
                target = os.path.join(image_root, *rel.split("/"))
                ensure_parent_dir(target)
                if os.path.exists(target):
                    overwritten.append(rel)
                    backup_existing_file(target, "JAPAN_images_zip")
                tmp_target = target + ".tmp"
                with zf.open(info, "r") as source, open(tmp_target, "wb") as dest:
                    shutil.copyfileobj(source, dest)
                os.replace(tmp_target, target)
                imported.append(rel)
    finally:
        try:
            os.remove(tmp_zip)
        except Exception:
            pass
    return sorted(imported), sorted(skipped), sorted(overwritten)


def normalize_trip_package_rel(trip, raw_name):
    safe = safe_zip_relpath(raw_name)
    if not safe:
        return None
    lower = safe.lower()
    if trip == "USA":
        if lower.startswith("usa/"):
            return safe[4:]
        # Also allow a package that contains USA files at root.
        # Do not accept root index.html from Netlify ZIPs, because it is usually only a root redirect/landing file and would overwrite USA/index.html.
        if lower in {"usa2027-presentation.html", "usa2027-presentation-tv.html", "usa2027-tv.html"} or lower.startswith("media/usa-presentation/"):
            return safe
        return None
    if trip == "JAPAN":
        if lower.startswith("japan/"):
            return safe[6:]
        if lower in {"index.html", "japan-experience.html", "japan-flight-intro.html", "japan-theme.mp3"} or lower.startswith("media/"):
            return safe
        return None
    return None


def save_trip_package_zip(trip, uploaded_file):
    if trip not in {"USA", "JAPAN"}:
        return None
    base_folder = FOLDERS[trip]
    os.makedirs(base_folder, exist_ok=True)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    tmp_zip = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"{trip.lower()}_package_{stamp}.zip")
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    uploaded_file.save(tmp_zip)
    imported, skipped, overwritten = [], [], []
    try:
        if os.path.getsize(tmp_zip) > TRIP_PACKAGE_ZIP_MAX_BYTES:
            raise ValueError(f"zip_size_exceeded:{os.path.getsize(tmp_zip)}")
        with zipfile.ZipFile(tmp_zip, "r") as zf:
            infos = [info for info in zf.infolist() if not info.is_dir()]
            if len(infos) > TRIP_PACKAGE_ZIP_MAX_FILES:
                raise ValueError(f"too_many_files:{len(infos)}")
            total_uncompressed = sum(max(0, info.file_size) for info in infos)
            if total_uncompressed > TRIP_PACKAGE_ZIP_MAX_UNCOMPRESSED_BYTES:
                raise ValueError(f"zip_uncompressed_size_exceeded:{total_uncompressed}")
            for info in infos:
                rel = normalize_trip_package_rel(trip, info.filename)
                base = os.path.basename((info.filename or "").replace("\\", "/"))
                if not rel:
                    skipped.append(base + " — לא שייך לחבילת " + trip)
                    continue
                if info.file_size > TRIP_PACKAGE_ZIP_MAX_SINGLE_FILE_BYTES:
                    skipped.append(rel + " — גדול מדי")
                    continue
                ext = file_ext(rel)
                if ext not in TRIP_PACKAGE_EXTENSIONS:
                    skipped.append(rel + " — סיומת לא נתמכת")
                    continue
                if not safe_zip_relpath(rel):
                    skipped.append(base + " — נתיב לא בטוח")
                    continue
                target = os.path.join(base_folder, *rel.split("/"))
                ensure_parent_dir(target)
                if os.path.exists(target):
                    overwritten.append(rel)
                    backup_existing_file(target, f"{trip}_package_zip")
                tmp_target = target + ".tmp"
                with zf.open(info, "r") as source, open(tmp_target, "wb") as dest:
                    shutil.copyfileobj(source, dest)
                os.replace(tmp_target, target)
                imported.append(rel)
    finally:
        try:
            os.remove(tmp_zip)
        except Exception:
            pass
    return sorted(imported), sorted(skipped), sorted(overwritten)


def describe_path(path):
    exists = os.path.exists(path)
    item = {"path": path, "exists": exists}
    if exists and os.path.isfile(path):
        try:
            item["size"] = os.path.getsize(path)
            item["mtime"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(path)))
            item["sha256"] = file_sha256(path)[:16]
        except Exception as exc:
            item["error"] = repr(exc)
    return item


def count_files(root, extensions=None):
    total = 0
    if not os.path.isdir(root):
        return 0
    for dirpath, _, filenames in os.walk(root):
        for name in filenames:
            if extensions is None or file_ext(name) in extensions:
                total += 1
    return total

def list_relative_files(root, extensions=None):
    files = []
    if not os.path.isdir(root):
        return files
    for dirpath, _, filenames in os.walk(root):
        for name in filenames:
            if extensions is None or file_ext(name) in extensions:
                full = os.path.join(dirpath, name)
                rel = os.path.relpath(full, root).replace("\\", "/")
                files.append(rel)
    return sorted(files, key=lambda x: x.lower())


def normalize_web_path(path):
    value = str(path or "").strip().replace("\\", "/")
    value = value.split("?", 1)[0].split("#", 1)[0]
    while value.startswith("./"):
        value = value[2:]
    return value


def analyze_japan_presentation_images(presentation_path=None):
    """Count images that Japan experience actually uses, not just files in media/images."""
    presentation_path = presentation_path or os.path.join(FOLDERS["JAPAN"], "japan-experience.html")
    image_root = os.path.join(FOLDERS["JAPAN"], "media", "images")
    folder_files = list_relative_files(image_root, JAPAN_IMAGE_EXTENSIONS)
    result = {
        "presentation_path": presentation_path,
        "presentation_exists": os.path.isfile(presentation_path),
        "folder_images": len(folder_files),
        "image_slides": 0,
        "external_image_uses": 0,
        "embedded_image_uses": 0,
        "remote_image_uses": 0,
        "other_src_uses": 0,
        "unique_external_images": 0,
        "missing_external_images": 0,
        "unused_folder_images": 0,
        "chapter_slides": 0,
        "notes": [],
        "missing_external_list": [],
        "unused_folder_list": [],
    }
    if not result["presentation_exists"]:
        result["notes"].append("קובץ המצגת japan-experience.html לא נמצא, לכן נספרו רק קבצים בתיקיית התמונות.")
        return result

    try:
        with open(presentation_path, "r", encoding="utf-8", errors="ignore") as fh:
            html_text = fh.read()
    except Exception as exc:
        result["notes"].append("לא ניתן לקרוא את קובץ מצגת יפן: " + repr(exc))
        return result

    refs = [m.group(2).strip() for m in re.finditer(r'["\']src["\']\s*:\s*(["\'])(.*?)\1', html_text, re.S) if m.group(2).strip()]
    if not refs:
        refs = [m.group(2).strip() for m in re.finditer(r'\bsrc\s*:\s*(["\'])(.*?)\1', html_text, re.S) if m.group(2).strip()]

    external = []
    embedded = []
    remote = []
    other = []
    for ref in refs:
        clean = normalize_web_path(ref)
        if clean.startswith("media/images/"):
            external.append(clean[len("media/images/"):])
        elif clean.startswith("data:image"):
            embedded.append(ref)
        elif clean.startswith("http://") or clean.startswith("https://"):
            remote.append(ref)
        else:
            other.append(ref)

    unique_external = sorted(set(external), key=lambda x: x.lower())
    folder_lower = {f.lower(): f for f in folder_files}
    used_lower = {f.lower() for f in unique_external}
    missing = [rel for rel in unique_external if rel.lower() not in folder_lower]
    unused = [rel for rel in folder_files if rel.lower() not in used_lower]

    result.update({
        "image_slides": len(refs),
        "external_image_uses": len(external),
        "embedded_image_uses": len(embedded),
        "remote_image_uses": len(remote),
        "other_src_uses": len(other),
        "unique_external_images": len(unique_external),
        "missing_external_images": len(missing),
        "unused_folder_images": len(unused),
        "chapter_slides": len(re.findall(r'["\']chapter["\']\s*:\s*true', html_text)),
        "missing_external_list": missing[:40],
        "unused_folder_list": unused[:40],
    })
    if result["image_slides"] == 0:
        result["notes"].append("לא נמצאו src בשקפי מצגת יפן. ייתכן שמבנה המצגת השתנה.")
    if result["missing_external_images"]:
        result["notes"].append("יש תמונות שהמצגת מבקשת אבל אינן קיימות בתיקיית media/images.")
    if result["unused_folder_images"]:
        result["notes"].append("יש תמונות בתיקייה שאינן בשימוש במצגת הפעילה.")
    return result


def compact_japan_usage_for_health():
    usage = analyze_japan_presentation_images()
    return {
        "image_slides_total": usage.get("image_slides", 0),
        "external_files_used": usage.get("external_image_uses", 0),
        "embedded_images": usage.get("embedded_image_uses", 0),
        "folder_images": usage.get("folder_images", 0),
        "missing_external_images": usage.get("missing_external_images", 0),
        "unused_folder_images": usage.get("unused_folder_images", 0),
        "chapter_slides": usage.get("chapter_slides", 0),
        "presentation_exists": usage.get("presentation_exists", False),
    }


def render_admin_status():
    rows = []
    def add(label, path):
        info = describe_path(path)
        status = "✅ קיים" if info.get("exists") else "❌ חסר"
        size = f"{info.get('size', 0):,}" if info.get("exists") and "size" in info else "—"
        mtime = info.get("mtime", "—")
        sha = info.get("sha256", "—")
        rows.append(f"<tr><td>{html_lib.escape(label)}</td><td>{status}</td><td>{size}</td><td>{html_lib.escape(mtime)}</td><td>{html_lib.escape(sha)}</td><td><code>{html_lib.escape(path)}</code></td></tr>")

    add("פאנל פעיל", ADMIN_SERVER_PATH)
    add("טיול יפן ראשי", os.path.join(FOLDERS["JAPAN"], "index.html"))
    add("פתיח טיסה יפן", os.path.join(FOLDERS["JAPAN"], "japan-flight-intro.html"))
    add("מצגת יפן", os.path.join(FOLDERS["JAPAN"], "japan-experience.html"))
    add("מוזיקת מצגת יפן", os.path.join(FOLDERS["JAPAN"], "media", "japan-experience", "music", "japan-theme.mp3"))
    add("מוזיקת פתיח יפן", os.path.join(FOLDERS["JAPAN"], "media", "japan-flight", "music", "flight-intro.mp3"))
    add("טיול ארה״ב ראשי", os.path.join(FOLDERS["USA"], "index.html"))
    add("טיול ארה״ב TV", os.path.join(FOLDERS["USA"], "usa2027-tv.html"))
    add("מצגת ארה״ב", os.path.join(FOLDERS["USA"], "usa2027-presentation.html"))
    add("מצגת ארה״ב TV", os.path.join(FOLDERS["USA"], "usa2027-presentation-tv.html"))
    add("שיר ארה״ב חלק 1", os.path.join(FOLDERS["USA"], "media", "usa-presentation", "music", "usa-roadtrip-theme.mp3"))
    add("שיר ארה״ב חלק 2", os.path.join(FOLDERS["USA"], "media", "usa-presentation", "music", "usa-part2-theme.mp3"))
    add("תקציב טיולים", os.path.join(FOLDERS["Moneyapp"], "index.html"))
    add("תקציב משפחתי", os.path.join(FOLDERS["MoneyHome"], "index.html"))
    add("טופס פתיחת טיול", os.path.join(FOLDERS["TripForm"], "index.html"))

    usa_img_count = count_files(os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images"), USA_IMAGE_EXTENSIONS)
    japan_usage = analyze_japan_presentation_images()
    japan_img_count = japan_usage["folder_images"]
    japan_usage_ok = japan_usage["presentation_exists"] and japan_usage["missing_external_images"] == 0
    japan_usage_badge = "✅ תקין" if japan_usage_ok else "⚠️ לבדיקה"
    japan_usage_details = (
        f"{japan_usage['external_image_uses']} קבצים חיצוניים + "
        f"{japan_usage['embedded_image_uses']} מוטמעות · "
        f"בתיקייה: {japan_img_count} · "
        f"לא בשימוש: {japan_usage['unused_folder_images']} · "
        f"חסרות: {japan_usage['missing_external_images']}"
    )
    missing_html = ""
    if japan_usage["missing_external_images"]:
        missing_items = "".join(f"<li><code>{html_lib.escape(x)}</code></li>" for x in japan_usage["missing_external_list"][:20])
        missing_html = f"<div class='warn'><b>תמונות חסרות למצגת יפן</b><ul>{missing_items}</ul></div>"
    unused_html = ""
    if japan_usage["unused_folder_images"]:
        unused_items = "".join(f"<li><code>{html_lib.escape(x)}</code></li>" for x in japan_usage["unused_folder_list"][:20])
        more = ""
        if japan_usage["unused_folder_images"] > 20:
            more = f"<p>ועוד {japan_usage['unused_folder_images'] - 20} קבצים שלא מוצגים כאן.</p>"
        unused_html = f"<details class='details'><summary>תמונות בתיקיית יפן שלא בשימוש במצגת ({japan_usage['unused_folder_images']})</summary><ul>{unused_items}</ul>{more}</details>"
    return f"""
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>סטטוס TripServer</title>
<style>
body{{margin:0;background:#0f172a;color:white;font-family:Arial;padding:24px}}.wrap{{max-width:1180px;margin:auto}}a{{color:#93c5fd}}table{{width:100%;border-collapse:collapse;background:#1e293b;border-radius:18px;overflow:hidden}}th,td{{border-bottom:1px solid #334155;padding:10px;text-align:right;vertical-align:top}}th{{background:#111827;color:#bfdbfe}}code{{direction:ltr;display:block;white-space:normal;color:#fde68a}}.cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:18px 0}}.card{{background:#1e293b;border:1px solid #334155;border-radius:18px;padding:16px}}.num{{font-size:30px;font-weight:900;color:#fde68a}}
.small{{color:#cbd5e1;font-size:13px;line-height:1.5;margin-top:6px}}.warn{{background:rgba(127,29,29,.38);border:1px solid rgba(248,113,113,.35);color:#fecaca;border-radius:16px;padding:14px;margin:14px 0}}.details{{background:#111827;border:1px solid #334155;border-radius:16px;padding:14px;margin:14px 0;color:#cbd5e1}}summary{{cursor:pointer;font-weight:900;color:#fde68a}}ul{{margin:10px 0 0}}
</style></head><body><div class="wrap"><a href="/admin">⬅ חזרה לפאנל</a><h1>סטטוס קבצים פעילים</h1><p>כאן רואים בפועל מה מותקן כרגע ב־C:\\TripServer, כולל גודל, תאריך שינוי ו־SHA קצר. זה נועד למנוע מצב של “העליתי אבל אני רואה גרסה ישנה”.</p><div class="cards"><div class="card"><div>תמונות מצגת ארה״ב</div><div class="num">{usa_img_count}</div><div class="small">כרטיסיות העלאה מסונכרנות לשקפי ארה״ב.</div></div><div class="card"><div>מצגת יפן — תמונות בשימוש בפועל</div><div class="num">{japan_usage['image_slides']}</div><div class="small">{japan_usage_badge} · {japan_usage_details}</div></div><div class="card"><div>קבצים בתיקיית תמונות יפן</div><div class="num">{japan_img_count}</div><div class="small">זה מספר קבצים בתיקייה, לא מספר התמונות שהמצגת משתמשת בהן.</div></div><div class="card"><div>גרסת פאנל</div><div>{html_lib.escape(ADMIN_PANEL_VERSION)}</div></div></div>{missing_html}{unused_html}<table><thead><tr><th>רכיב</th><th>מצב</th><th>גודל bytes</th><th>עודכן</th><th>SHA16</th><th>נתיב</th></tr></thead><tbody>{''.join(rows)}</tbody></table></div></body></html>
"""

def validate_admin_server_upload(tmp_path):
    size = os.path.getsize(tmp_path)
    if size <= 0:
        return False, "הקובץ ריק."
    if size > ADMIN_MAX_UPLOAD_BYTES:
        return False, f"הקובץ גדול מדי: {size:,} bytes. המגבלה היא {ADMIN_MAX_UPLOAD_BYTES:,} bytes."

    try:
        py_compile.compile(tmp_path, doraise=True)
    except py_compile.PyCompileError as exc:
        return False, "בדיקת קומפילציה נכשלה:\n" + str(exc)
    except Exception as exc:
        return False, "שגיאה בבדיקת הקובץ:\n" + repr(exc)

    try:
        with open(tmp_path, "r", encoding="utf-8", errors="ignore") as fh:
            content = fh.read(400000)
    except Exception as exc:
        return False, "לא ניתן לקרוא את הקובץ:\n" + repr(exc)

    required_tokens = ["Flask", "@app.route", "app.run", "BASE_DIR", "ADMIN_PORT", "restart_process_later", "upload_admin_server", "run_self_test_suite"]
    missing = [token for token in required_tokens if token not in content]
    if missing:
        return False, "הקובץ עבר קומפילציה, אבל לא נראה כמו admin_server.py של TripServer. חסרים סימנים: " + ", ".join(missing)

    return True, "OK"


def admin_status_page(title, message, ok=True, details=""):
    color = "#16a34a" if ok else "#dc2626"
    bg = "#052e1b" if ok else "#3f1d1d"
    safe_title = html_lib.escape(title)
    safe_message = html_lib.escape(message).replace("\n", "<br>")
    safe_details = html_lib.escape(details).replace("\n", "<br>")
    details_html = f"<pre>{safe_details}</pre>" if details else ""
    return f"""
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{safe_title}</title>
<style>
body{{margin:0;font-family:Arial;background:#0f172a;color:white;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}}
.card{{width:min(780px,94vw);background:#1e293b;border-radius:24px;padding:26px;box-shadow:0 18px 70px rgba(0,0,0,.35)}}
.badge{{display:inline-block;background:{bg};border:1px solid {color};color:white;border-radius:999px;padding:8px 14px;font-weight:bold;margin-bottom:12px}}
h1{{margin:0 0 12px;font-size:30px}}
p{{line-height:1.7;color:#e2e8f0}}
pre{{white-space:pre-wrap;direction:ltr;text-align:left;background:#020617;color:#fde68a;border:1px solid #334155;border-radius:14px;padding:14px;overflow:auto;max-height:360px}}
a,button{{display:inline-block;margin:8px 0 0 8px;background:#2563eb;color:white;border:0;border-radius:12px;padding:12px 16px;text-decoration:none;font-weight:bold;cursor:pointer}}
button.restart{{background:#ea580c}}
form{{display:inline}}
</style>
</head>
<body>
<div class="card">
<div class="badge">TripServer Admin</div>
<h1>{safe_title}</h1>
<p>{safe_message}</p>
{details_html}
<a href="/admin">חזרה לפאנל</a>
<form action="/restart_admin_server" method="post" onsubmit="return confirm('לאתחל את הפאנל עכשיו?')"><button class="restart">אתחל פאנל עכשיו</button></form>
</div>
</body>
</html>
"""


def backup_current_admin_server(prefix="admin_server_backup"):
    os.makedirs(ADMIN_BACKUP_DIR, exist_ok=True)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    backup_path = os.path.join(ADMIN_BACKUP_DIR, f"{prefix}_{stamp}.py")
    shutil.copy2(ADMIN_SERVER_PATH, backup_path)
    return backup_path


def latest_admin_backup():
    os.makedirs(ADMIN_BACKUP_DIR, exist_ok=True)
    backups = glob.glob(os.path.join(ADMIN_BACKUP_DIR, "admin_server_backup_*.py"))
    if not backups:
        return None
    return max(backups, key=os.path.getmtime)


def log_admin_restart(message):
    try:
        os.makedirs(BASE_DIR, exist_ok=True)
        with open(ADMIN_RESTART_LOG_PATH, "a", encoding="utf-8") as fh:
            fh.write(time.strftime("%Y-%m-%d %H:%M:%S") + " | " + str(message) + "\n")
    except Exception:
        pass


def build_restart_helper_code(script_path, python_exe, work_dir):
    """Build the external restart helper source code.

    Important: this function returns valid Python source. It is intentionally built
    with a line list rather than a nested triple-quoted string, so newline escaping
    inside the generated helper cannot silently break restart after an admin upload.
    """
    return "\n".join([
        "# -*- coding: utf-8 -*-",
        "import os",
        "import subprocess",
        "import time",
        "import socket",
        "",
        f"script_path = {script_path!r}",
        f"python_exe = {python_exe!r}",
        f"work_dir = {work_dir!r}",
        f"log_path = {ADMIN_RESTART_LOG_PATH!r}",
        f"admin_port = {ADMIN_PORT!r}",
        "",
        "def log(message):",
        "    try:",
        "        os.makedirs(os.path.dirname(log_path), exist_ok=True)",
        "        with open(log_path, 'a', encoding='utf-8') as fh:",
        "            fh.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | helper | ' + str(message) + '\\n')",
        "    except Exception:",
        "        pass",
        "",
        "def wait_for_port(port, timeout=18):",
        "    deadline = time.time() + timeout",
        "    last_error = None",
        "    while time.time() < deadline:",
        "        try:",
        "            with socket.create_connection(('127.0.0.1', int(port)), timeout=1.0):",
        "                return True, 'OK'",
        "        except Exception as exc:",
        "            last_error = repr(exc)",
        "            time.sleep(0.7)",
        "    return False, last_error or 'timeout'",
        "",
        "time.sleep(2.5)",
        "try:",
        "    flags = 0",
        "    if os.name == 'nt':",
        "        flags = getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0) | getattr(subprocess, 'DETACHED_PROCESS', 0)",
        "    os.makedirs(os.path.dirname(log_path), exist_ok=True)",
        "    with open(log_path, 'a', encoding='utf-8') as log_file:",
        "        log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | helper | starting new server: ' + script_path + '\\n')",
        "        log_file.flush()",
        "        proc = subprocess.Popen(",
        "            [python_exe, script_path],",
        "            cwd=work_dir,",
        "            stdin=subprocess.DEVNULL,",
        "            stdout=log_file,",
        "            stderr=log_file,",
        "            close_fds=(False if os.name == 'nt' else True),",
        "            creationflags=flags,",
        "            start_new_session=(os.name != 'nt'),",
        "        )",
        "        log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | helper | new server pid=' + str(proc.pid) + '\\n')",
        "        log_file.flush()",
        "    ok, info = wait_for_port(admin_port)",
        "    log('health port check ' + ('OK' if ok else 'FAILED') + ': ' + str(info))",
        "except Exception as exc:",
        "    log('failed to start new server: ' + repr(exc))",
        "",
    ]) + "\n"


def validate_restart_helper_source(helper_code):
    try:
        compile(helper_code, "<tripserver_restart_helper>", "exec")
        return True, "OK"
    except Exception as exc:
        return False, repr(exc)


def write_restart_helper(script_path, python_exe, work_dir):
    helper_code = build_restart_helper_code(script_path, python_exe, work_dir)
    ok, info = validate_restart_helper_source(helper_code)
    if not ok:
        raise RuntimeError("Generated restart helper is invalid: " + info)
    ensure_parent_dir(ADMIN_RESTART_HELPER_PATH)
    with open(ADMIN_RESTART_HELPER_PATH, "w", encoding="utf-8") as fh:
        fh.write(helper_code)
    return ADMIN_RESTART_HELPER_PATH

def restart_process_later():
    """Reliable self-restart for Windows/iPhone admin workflow."""
    time.sleep(1.2)
    script_path = os.path.abspath(ADMIN_SERVER_PATH)
    python_exe = sys.executable or "python"
    work_dir = os.path.dirname(script_path) or BASE_DIR
    try:
        log_admin_restart(f"restart requested | version={ADMIN_PANEL_VERSION} | script={script_path} | python={python_exe} | argv={sys.argv}")
        helper_path = write_restart_helper(script_path, python_exe, work_dir)
        flags = 0
        if os.name == "nt":
            flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0)
        subprocess.Popen(
            [python_exe, helper_path],
            cwd=work_dir,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            close_fds=(False if os.name == "nt" else True),
            creationflags=flags,
            start_new_session=(os.name != "nt"),
        )
        log_admin_restart("restart helper launched: " + helper_path)
        os._exit(0)
    except Exception as exc:
        log_admin_restart("restart helper failed: " + repr(exc))
        try:
            subprocess.Popen([python_exe, script_path], cwd=work_dir, close_fds=True, start_new_session=(os.name != "nt"))
            os._exit(0)
        except Exception as exc2:
            log_admin_restart("direct restart fallback failed: " + repr(exc2))
            try:
                os.execv(python_exe, [python_exe, script_path])
            except Exception as exc3:
                log_admin_restart("execv fallback failed: " + repr(exc3))
                os._exit(1)



def get_client_ip():
    # Use the direct remote address rather than trusting arbitrary X-Forwarded-For headers.
    return request.remote_addr or ""


def is_private_or_loopback_ip(ip_text):
    try:
        ip = ipaddress.ip_address((ip_text or "").split("%", 1)[0])
        tailscale_ipv4 = ip.version == 4 and ip in ipaddress.ip_network("100.64.0.0/10")
        tailscale_ipv6 = ip.version == 6 and ip in ipaddress.ip_network("fd7a:115c:a1e0::/48")
        return ip.is_loopback or ip.is_private or ip.is_link_local or tailscale_ipv4 or tailscale_ipv6
    except Exception:
        return False


def power_action_allowed():
    if not POWER_ACTIONS_ENABLED:
        return False, "פעולות כיבוי/ריסטארט מחשב כבויות בהגדרת TRIPSERVER_ENABLE_POWER_ACTIONS."
    ip = get_client_ip()
    if not is_private_or_loopback_ip(ip):
        return False, f"פעולת מחשב חסומה מכתובת שאינה מקומית/רשת ביתית: {ip}"
    if os.name != "nt":
        return False, "פעולות מחשב נתמכות כאן רק על Windows."
    return True, "OK"


def normalize_power_delay(raw_value):
    try:
        delay = int(raw_value)
    except Exception:
        delay = POWER_DEFAULT_DELAY_SECONDS
    return max(POWER_MIN_DELAY_SECONDS, min(POWER_MAX_DELAY_SECONDS, delay))


def run_power_command(action, delay_seconds):
    if action == "shutdown":
        mode = "/s"
        label = "כיבוי מחשב"
    elif action == "reboot":
        mode = "/r"
        label = "ריסטארט למחשב"
    else:
        raise ValueError("unknown power action")
    delay = normalize_power_delay(delay_seconds)
    comment = f"TripServer Admin scheduled {label}"
    cmd = ["shutdown", mode, "/t", str(delay), "/c", comment]
    completed = subprocess.run(cmd, capture_output=True, text=True, shell=False)
    if completed.returncode != 0:
        raise RuntimeError((completed.stderr or completed.stdout or "shutdown command failed").strip())
    log_admin_restart(f"POWER {action} scheduled in {delay}s by {get_client_ip()}")
    return label, delay, completed.stdout.strip() or completed.stderr.strip()


def cancel_power_command():
    completed = subprocess.run(["shutdown", "/a"], capture_output=True, text=True, shell=False)
    # Windows returns non-zero when there is no pending shutdown. Treat it as useful feedback, not a server crash.
    log_admin_restart(f"POWER cancel requested by {get_client_ip()} | rc={completed.returncode} | out={completed.stdout.strip()} | err={completed.stderr.strip()}")
    return completed.returncode, (completed.stdout or completed.stderr or "").strip()

def serve_app(folder):
    file_name = find_html_file(FOLDERS[folder])
    if not file_name:
        return f"No HTML file found in {folder} folder", 404
    return serve_file_no_cache(FOLDERS[folder], file_name)


@app.route("/")
def home():
    return """
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>מרכז ניהול</title>
<style>
body{margin:0;font-family:Arial;background:linear-gradient(135deg,#081229,#102447);color:white;min-height:100vh}
.hero{text-align:center;padding:50px 20px 20px}
.hero h1{font-size:42px;margin:0}
.hero p{color:#d8e6ff;font-size:18px}
.grid{width:min(1100px,92%);margin:40px auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:22px}
.card{text-decoration:none;color:white;border-radius:28px;padding:28px;min-height:180px;box-shadow:0 10px 30px rgba(0,0,0,.25);transition:.2s transform,.2s box-shadow}
.card:hover{transform:translateY(-4px);box-shadow:0 16px 40px rgba(0,0,0,.35)}
.card h2{font-size:30px;margin:0 0 12px}
.card p{line-height:1.6;margin:0}
.icon{font-size:52px;margin-bottom:15px}
.admin{background:linear-gradient(135deg,#0f9b0f,#38ef7d)}
.money{background:linear-gradient(135deg,#5f2c82,#49a09d)}
.homebudget{background:linear-gradient(135deg,#11998e,#38ef7d)}
.japan{background:linear-gradient(135deg,#ff416c,#ff4b2b)}
.usa{background:linear-gradient(135deg,#0052d4,#4364f7,#6fb1fc)}
.footer{text-align:center;color:#b8c8e6;padding:25px}

.image-guide{margin:12px 0 16px;background:#0f172a;border:1px solid #334155;border-radius:14px;padding:12px;color:#e2e8f0}
.image-guide summary{cursor:pointer;font-weight:bold;color:#bfdbfe;margin-bottom:10px}
.image-guide-table{width:100%;border-collapse:collapse;margin-top:10px;font-size:14px}
.image-guide-table th,.image-guide-table td{border-bottom:1px solid #334155;padding:9px;text-align:right;vertical-align:top}
.image-guide-table th{color:#93c5fd;background:#111827}
.image-guide-table td:first-child{white-space:nowrap;color:#fde68a}


.image-upload-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px;margin-top:16px}
.image-upload-card{margin:0}
.image-file-input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
.image-upload-label{display:block;height:100%;cursor:pointer;background:#0f172a;border:1px solid #334155;border-radius:18px;padding:15px;transition:.18s transform,.18s border-color,.18s background;box-shadow:0 8px 22px rgba(0,0,0,.14)}
.image-upload-label:hover{transform:translateY(-2px);border-color:#60a5fa;background:#13213a}
.image-key{display:inline-flex;background:#1d4ed8;color:#dbeafe;border-radius:999px;padding:4px 9px;font-size:12px;font-weight:900;margin-bottom:8px}
.image-title{display:block;color:#fff;font-size:17px;font-weight:950;margin-bottom:7px;line-height:1.25}
.image-desc{display:block;color:#cbd5e1;font-size:14px;line-height:1.45;min-height:42px}
.upload-status{display:block;margin-top:12px;color:#fde68a;font-size:13px;font-weight:900;border-top:1px solid #334155;padding-top:10px}
@media(max-width:640px){.image-upload-grid{grid-template-columns:1fr}.image-title{font-size:16px}.image-desc{min-height:auto}}

.app-hero{background:radial-gradient(circle at top right,rgba(59,130,246,.35),transparent 35%),linear-gradient(135deg,#172554,#0f172a);border:1px solid #334155;border-radius:28px;padding:26px;margin-bottom:18px;box-shadow:0 20px 60px rgba(0,0,0,.25)}
.app-hero h1{text-align:right;margin:0 0 8px;font-size:36px;letter-spacing:-.8px}.app-hero p{margin:0;color:#cbd5e1;line-height:1.65}.top-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}.pill-link{display:inline-flex;align-items:center;gap:8px;background:#1d4ed8;color:white!important;text-decoration:none;border-radius:999px;padding:10px 14px;font-weight:900;border:1px solid #60a5fa}.pill-link.secondary{background:#334155;border-color:#475569}.dashboard-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;margin:18px 0}.dash-card{background:linear-gradient(180deg,#1e293b,#111827);border:1px solid #334155;border-radius:22px;padding:16px;box-shadow:0 10px 32px rgba(0,0,0,.18)}.dash-card b{display:block;font-size:18px;margin-bottom:6px}.dash-card span{color:#cbd5e1;font-size:14px;line-height:1.5}.box{box-shadow:0 16px 44px rgba(0,0,0,.20);border:1px solid #334155}.box h2{font-size:25px;letter-spacing:-.4px}.box h3{color:#bfdbfe;margin-top:20px}.split-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}.mini-panel{background:#0f172a;border:1px solid #334155;border-radius:16px;padding:14px}.mini-panel h3{margin-top:0}.danger-zone{background:linear-gradient(135deg,#451a1a,#1e293b)!important;border-color:#7f1d1d!important}.safe-zone{background:linear-gradient(135deg,#132f25,#1e293b)!important;border-color:#166534!important}.field-label{display:block;color:#cbd5e1;font-size:13px;font-weight:900;margin:12px 0 6px}select,input[type=text]{width:100%;box-sizing:border-box;padding:13px;background:#0f172a;color:white;border-radius:12px;border:1px solid #334155}label.check{display:flex;align-items:center;gap:8px;margin-top:12px;color:#e2e8f0;font-weight:bold}.small-danger{background:#991b1b!important}.cancel{background:#475569!important}.muted{color:#94a3b8;font-size:13px;line-height:1.6}.success-dot{display:inline-block;width:10px;height:10px;background:#22c55e;border-radius:50%;box-shadow:0 0 14px #22c55e;margin-left:8px}.quick-nav{box-shadow:0 12px 32px rgba(0,0,0,.22)}@media(max-width:720px){.wrap{width:min(100%,94%);padding:18px 0}.app-hero{padding:20px}.app-hero h1{font-size:28px}.quick-nav{overflow:auto;flex-wrap:nowrap}.quick-nav a{white-space:nowrap}.dashboard-grid{grid-template-columns:1fr}}


</style>
</head>
<body>
<div class="hero">
<h1>מרכז ניהול</h1>
<p>מערכת מרכזית לניהול קבצים, תקציבים ולוחות זמנים</p>
</div>
<div class="grid">
<a class="card admin" href="/admin"><div class="icon">📁</div><h2>ניהול קבצים</h2><p>העלאת קבצים ישירות למחשב.</p></a>
<a class="card money" href="/Moneyapp/"><div class="icon">💰</div><h2>תקציב טיולים</h2><p>ניהול תקציב חופשות וסנכרון נתונים.</p></a>
<a class="card homebudget" href="/MoneyHome/"><div class="icon">🏠</div><h2>תקציב משפחתי</h2><p>ניהול תקציב חודשי, Limits, הוצאות בפועל וארכיון חודשים.</p></a>
<a class="card admin" href="/TripForm/"><div class="icon">🧾</div><h2>טופס פתיחת טיול</h2><p>טופס קליטת נתונים לטיול חדש.</p></a>
<a class="card japan" href="/JAPAN/"><div class="icon">🇯🇵</div><h2>טיול יפן</h2><p>לוז, ניווטים ומסמכי הטיול.</p></a>
<a class="card usa" href="/USA/"><div class="icon">🇺🇸</div><h2>טיול ארה״ב</h2><p>טיול בר מצווה 2027.</p></a>
</div>
<div class="footer">TripServer</div>
</body>
</html>
"""




USA_IMAGE_UI_ITEMS = [('intro', '🇺🇸 ארה״ב 2027 — אמריקה על פול ווליום', 'Road Trip • Orlando • Miami • Cruise / 36 ימים של נופים, פארקים, קניות, קרוז ורגעים שגורמים להגיד: “רגע… לשם אנחנו באמת הולכים?” / מערב ארה״ב: סן פרנסיסקו , כביש 1, LA , קניונים, וגאס ויוסמיטי'), ('roadtrip-map', '🗺️ מסלול הרוד טריפ — הסיפור הגדול, מלמעלה', 'סן פרנסיסקו • כביש 1 • LA • וגאס • יוסמיטי / מפה אחת שמסבירה את כל הרעיון: מתחילים באוקיינוס, מטפסים לקניונים, נוגעים במדבר וחוזרים לטבע. / המסלול נותן את כל המנה: חופים, כבישים יפים, פארקים לאומיים, וגאס ויוסמיטי — בלי לנסות לדחוס כאן את כל הלו״ז.'), ('day-01', 'יום 1 — ✈️ נחיתה ישירה ב סן פרנסיסקו', 'סן פרנסיסקו / נוחתים בעיר שיודעת לפתוח טיול עם סטייל. / נחיתה, התארגנות וטעימה ראשונה מהעיר'), ('day-02', 'יום 2 — 🌉 סן פרנסיסקו + Alcatraz', 'סן פרנסיסקו / Alcatraz / יום של אייקונים: מפרץ, גשרים, אלקטרז ואווירה של סרט. / אלקטרז, תצפיות והעיר הכי פוטוגנית בצפון קליפורניה'), ('day-03', 'יום 3 — 🌊 סן פרנסיסקו → מונטריי', 'מונטריי / 17-Mile Drive / עוברים למוד רוד־טריפ אמיתי: ים, כביש ונופים שמתחילים לעבוד עלינו חזק. / נסיעה דרומה, מונטריי וקטע חוף מהמם'), ('day-04', 'יום 4 — 🐋 Monterey Bay Whale Watch + Big Sur', 'מונטריי / Big Sur / היום שבו האוקיינוס נותן הופעה, ואם לוויתן קופץ — זה כבר בונוס קולנועי. / שייט לווייתנים ואז נופי Big Sur וכביש 1'), ('extra-big-sur', 'Road Trip Bonus — 🌉 Big Sur – Bixby Bridge + McWay Falls', 'כביש 1 / קליפורניה / כאן קליפורניה כבר לא רק יפה — היא מתחילה ממש להשוויץ. / גשר Bixby והחופים הדרמטיים של Big Sur הם מהשיאים המצולמים של כביש 1'), ('day-05', 'יום 5 — 🎡 סנטה מוניקה → לוס אנג׳לס', 'Santa Monica / Los Angeles / נכנסים ל־ LA דרך סנטה מוניקה : חוף, טיילת, גלגל ענק ואווירת קליפורניה קלאסית. / Santa Monica Pier, חוף, טיילת וכניסה לאווירת לוס אנג׳לס'), ('day-06', 'יום 6 — 🎥 Warner Bros + Beverly Hills + Griffith', 'לוס אנג׳לס / LA במצב קלאסי: אולפנים, בוורלי הילס ותצפית של סוף סרט. / Warner Bros , Beverly Hills ו־ Griffith'), ('day-07', 'יום 7 — 🏜️ לוס אנג׳לס → Zion', 'מעבר למדבר / Utah / עוזבים עיר ונכנסים למרחבים. זה יום מעבר, אבל עם סוף ששווה את זה. / נסיעה ארוכה אל אזור Zion'), ('extra-diner', 'Road Trip Bonus — 🍔 Peggy Sue’s 50’s Diner', 'עצירת דרך אמריקאית / מדבר / באמצע כל הנופים הגדולים, מגיע רגע קטן ומושלם של אמריקה קלאסית: ניאון, מילקשייק ואווירת רוד־טריפ אמיתית. / עצירת דיינר שנותנת לטיול את הטעם של אמריקה הישנה והכיפית של הכביש'), ('day-08', 'יום 8 — 🏞️ Zion → Bryce → Page', 'Zion / Bryce / Page / יום טבע חזק במיוחד: צוקים, צבעים ומעברים שנראים לא אמיתיים. / Zion , Bryce והגעה ל־ Page'), ('day-09', 'יום 9 — 🧡 Antelope Canyon + Horseshoe Bend', 'Page Arizona / זה היום של “וואו, לאן אתם הולכים?” — בדיוק זה. / Antelope Canyon ו־ Horseshoe Bend'), ('day-10', 'יום 10 — 🏜️ Page → Grand Canyon South Rim', 'Grand Canyon / היום שבו המילה “גדול” מקבלת קנה מידה חדש. / נסיעה אל South Rim ומפגש עם הגרנד קניון'), ('day-11', 'יום 11 — 🎰 Grand Canyon → Las Vegas', 'לאס וגאס / מהטבע הכי גדול — לעיר הכי מוגזמת. מעבר חד, אבל כיפי. / נסיעה לווגאס וכניסה לאורות של הסטריפ'), ('day-12', 'יום 12 — 🛍️ Vegas Outlets + Sphere', 'לאס וגאס / וגאס יודעת לשלב שופינג, שואו ואורות ברמה לא סבירה. / אאוטלטים, Sphere והסטריפ'), ('day-13', 'יום 13 — ✨ Vegas Extra Day + Strip', 'לאס וגאס / עוד יום בווגאס, כי כנראה יום אחד לא מספיק כדי להגזים כמו שצריך. / קניות, Strip , מלונות ותצוגות ערב'), ('day-14', 'יום 14 — 🏔️ Las Vegas → Mammoth Lakes', 'Mammoth Lakes / נפרדים מהניאון וחוזרים לאוויר הרים כמו אנשים אחראיים. בערך. / נסיעה צפונה אל Mammoth Lakes'), ('extra-death-valley', 'Road Trip Bonus — 🔥 Death Valley', 'בין וגאס ל־Mammoth / זה המעבר הכי חד במסלול: מווגאס המנצנצת אל מדבר עצום, שקט ובלתי נתפס. / עמק המוות מוסיף למסלול מרחבים אינסופיים, תצפיות דרמטיות ותחושת קצה של המערב'), ('extra-mono-lake', 'Road Trip Bonus — 🏜️ Mono Lake / Eastern Sierra', 'בדרך אל יוסמיטי / פה הרוד־טריפ משנה טון שוב: מדבר, הרים, אגם דרמטי ושמיים ענקיים — הכל בפריים אחד. / אזור Eastern Sierra מוסיף תחושת מרחב אדירה לפני הכניסה ליוסמיטי'), ('day-15', 'יום 15 — 🚗 Mammoth → Yosemite Valley', 'יוסמיטי / אחד מימי ה־וואו של הטיול. יוסמיטי נכנסת לפריים. / דרך נופית אל Yosemite Valley'), ('day-16', 'יום 16 — 🌲 Yosemite Highlights + Mariposa Grove', 'יוסמיטי / Mariposa / יום של מפלים, גרניט ועצי סקויה שגורמים לך להרגיש בגודל של סיכה. / יוסמיטי קלאסי + Mariposa Grove'), ('day-17', 'יום 17 — ✈️ Yosemite → SFO → Orlando', 'מעבר לאורלנדו / סוגרים פרק מערב ופותחים פרק פארקים. המזוודות עוד לא יודעות מה מחכה להן. / נסיעה לשדה וטיסה לאורלנדו'), ('day-18', 'יום 18 — 🛍️ התאוששות + Disney Springs', 'אורלנדו / יום רך לפני שמתחילים לצרוח בפארקים. / התאוששות, Disney Springs ואווירה קלילה'), ('day-19', 'יום 19 — 🎢 Islands of Adventure', 'Universal Orlando / כאן מתחיל פרק האדרנלין הרשמי. אקספרס ביד, חיוך בפנים. / צ׳ק־אין Premier ו־ Islands of Adventure'), ('day-20', 'יום 20 — 🎬 Universal Studios Florida + CityWalk', 'Universal Studios / יום Universal קלאסי: מתקנים, אווירה ו־ CityWalk לסיום. / Universal Studios Florida וערב CityWalk'), ('day-21', 'יום 21 — ⛳ מנוחה + Hollywood Drive-In Mini Golf', 'אורלנדו / יום רגוע יותר, כי גם למשפחה עם אנרגיה צריך טעינה. / מנוחה, בריכה/קניות קלות ומיני גולף'), ('day-22', 'יום 22 — 🌌 Epic Universe + Express', 'Epic Universe / אחד הימים הכי מסקרנים בטיול — פארק חדש, גדול ומלא הייפ. / Epic Universe עם Express'), ('day-23', 'יום 23 — 🧗 פארק חבלים + בריכה / I‑Drive', 'אורלנדו / קצת אקשן, קצת בריכה, קצת אורלנדו בלי לחץ. / פארק חבלים, בריכה או International Drive'), ('day-24', 'יום 24 — 🌊 Volcano Bay + Express', 'Volcano Bay / מים, מגלשות ויום רענון שנראה כמו פרסומת לחופשה. / Volcano Bay עם Express'), ('day-25', 'יום 25 — 🎃 רגוע + Halloween Horror Nights', 'Universal HHN / היום מתחיל רגוע ונגמר בפנים לבנות. / מנוחה ביום ו־HHN בערב'), ('day-26', 'יום 26 — 🚗 Orlando → Sawgrass Mills → Miami', 'Sawgrass / Miami / מחליפים פארקים בשופינג, ואז ממשיכים ל מיאמי . / Sawgrass Mills והמשך ל מיאמי'), ('day-27', 'יום 27 — 🌴 Miami Chill Before Cruise', 'מיאמי / מיאמי מורידה הילוך בסטייל לפני הקרוז. / חוף, אווירה, התארגנות ונשימה עמוקה'), ('day-28', 'יום 28 — 🚢 החזרת רכב ועלייה לקרוז', 'נמל מיאמי / הרכב חוזר, האונייה מתחילה. החופשה עוברת למצב צף. / עלייה לקרוז ותחילת פרק הים'), ('day-29', 'יום 29 — 🌊 קרוז – יום ים 1', 'בלב הים / יום ים ראשון: בריכה, אוכל, סיפון ונוף בלי סוף. / יום חופשי על האונייה'), ('day-30', 'יום 30 — 🌊 קרוז – יום ים 2', 'בלב הים / עוד יום ים, כי למה לעצור משהו שעובד? / מתקני האונייה, אוכל ומנוחה'), ('day-31', 'יום 31 — 🏝️ St. Maarten', 'St. Maarten / הקריביים נכנסים לתמונה בצבעים שלא נראים חוקיים. / יום עגינה ב־ St. Maarten'), ('day-32', 'יום 32 — 🐬 St. Thomas + Dolphin Splash', 'St. Thomas / יום שיכול להפוך לזיכרון משפחתי חזק במיוחד. / St. Thomas ואטרקציית דולפינים'), ('day-33', 'יום 33 — 🌅 קרוז – יום ים 3', 'בלב הים / עוד קצת ים כדי לא להיגמל מהר מדי. / יום ים, סיפון, אוכל ונוף'), ('day-34', 'יום 34 — 🏖️ Perfect Day at CocoCay', 'CocoCay / שם מחייב — ובמקרה הזה הוא לגמרי מתכוון לזה. / CocoCay , חוף, מים טורקיז ופארק מים'), ('day-35', 'יום 35 — 🏨 ירידה מהקרוז + לילה ליד השדה', 'מיאמי / החופשה מתחילה להתקפל, אבל לא שוברים בבת אחת. / ירידה מהקרוז, התארגנות ולילה ליד השדה'), ('day-36', 'יום 36 — 🛫 טיסה חזרה לישראל', 'הביתה / היום הפחות אהוב, אבל עם אלבום מטורף בזיכרון. / עלייה לטיסה חזרה לישראל'), ('budget', '💰 תקציב — המספרים, בלי דרמה', 'תמונת מצב פיננסית / זה לא טיול קטן. זה פרויקט משפחתי עם נוף, פארקים, ים, שופינג וקרוז. / עלות כללית מוערכת: $81,297 ≈ ₪243,890'), ('summary', '🥂 סיום — אז לאן אתם באמת הולכים?', 'טיול חלום משפחתי / אם מי שרואה את זה אומר “וואו” — הוא פשוט צודק. / מערב ארה״ב, אורלנדו, מיאמי וקרוז — בטיול אחד')]


def render_usa_image_upload_cards():
    cards = []
    for image_key, title, desc in USA_IMAGE_UI_ITEMS:
        safe_key = html_lib.escape(image_key, quote=True)
        safe_title = html_lib.escape(title, quote=True)
        safe_desc = html_lib.escape(desc, quote=True)
        search_text = html_lib.escape((image_key + " " + title + " " + desc).lower(), quote=True)
        cards.append("""
<form class="image-upload-card" data-search="%s" action="/upload_usa_image" method="post" enctype="multipart/form-data">
<input type="hidden" name="image_key" value="%s">
<input class="image-file-input" id="image-file-%s" type="file" name="file" accept=".jpg,.jpeg,.png,.webp,.bmp,.tif,.tiff,image/*" onchange="if(this.files && this.files.length){this.closest('form').querySelector('.upload-status').textContent='מעלה תמונה...';this.closest('form').submit();}">
<label for="image-file-%s" class="image-upload-label">
<span class="image-key">%s</span>
<span class="image-title">%s</span>
<span class="image-desc">%s</span>
<span class="upload-status">לחץ כאן ובחר תמונה מהטלפון</span>
</label>
</form>
""" % (search_text, safe_key, safe_key, safe_key, safe_key, safe_title, safe_desc))
    return "\n".join(cards)

@app.route("/admin")
@app.route("/admin/")
def admin():
    return """
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TripServer Admin</title>
<style>
:root{--bg:#07111f;--panel:#101c31;--panel2:#14233c;--line:#253650;--text:#f8fafc;--muted:#a8b3c7;--brand:#3b82f6;--brand2:#60a5fa;--good:#16a34a;--warn:#f59e0b;--bad:#dc2626;--glass:rgba(16,28,49,.74)}
*{box-sizing:border-box}body{margin:0;font-family:Arial,'Segoe UI',sans-serif;background:radial-gradient(circle at 85% -10%,rgba(59,130,246,.35),transparent 28%),radial-gradient(circle at 10% 10%,rgba(20,184,166,.18),transparent 24%),linear-gradient(180deg,#07111f,#0f172a 52%,#07111f);color:var(--text);min-height:100vh}a{color:inherit}.wrap{width:min(1180px,94%);margin:auto;padding:22px 0 48px}.app-shell{min-height:calc(100vh - 44px)}
.hero{display:grid;grid-template-columns:1fr auto;gap:16px;align-items:center;background:linear-gradient(135deg,rgba(30,64,175,.92),rgba(15,23,42,.88));border:1px solid rgba(147,197,253,.25);border-radius:30px;padding:22px;box-shadow:0 25px 70px rgba(0,0,0,.30);position:relative;overflow:hidden}.hero:after{content:"";position:absolute;inset:auto -60px -90px auto;width:260px;height:260px;border-radius:50%;background:rgba(96,165,250,.16)}.hero h1{margin:0 0 8px;font-size:clamp(28px,5vw,44px);letter-spacing:-1px}.hero p{margin:0;color:#dbeafe;line-height:1.65;max-width:760px}.hero-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.btn-link,.back-home{display:inline-flex;align-items:center;justify-content:center;gap:8px;text-decoration:none;border:1px solid rgba(148,163,184,.35);background:rgba(15,23,42,.65);border-radius:999px;padding:11px 15px;font-weight:900;white-space:nowrap}.btn-link:hover,.back-home:hover{border-color:var(--brand2);background:rgba(59,130,246,.22)}
.status-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:16px 0}.status-card{background:var(--glass);border:1px solid var(--line);border-radius:20px;padding:14px;box-shadow:0 16px 40px rgba(0,0,0,.18)}.status-card b{display:block;font-size:16px;margin-bottom:5px}.status-card span{color:var(--muted);font-size:13px;line-height:1.45}.dot{display:inline-block;width:10px;height:10px;background:#22c55e;border-radius:50%;box-shadow:0 0 16px #22c55e;margin-left:7px}.main-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:18px}.main-card{margin-top:0;border:1px solid var(--line);background:linear-gradient(180deg,rgba(30,41,59,.94),rgba(15,23,42,.98));border-radius:26px;padding:20px;text-align:right;cursor:pointer;color:white;box-shadow:0 20px 55px rgba(0,0,0,.24);min-height:160px;transition:.18s transform,.18s border-color,.18s background;position:relative;overflow:hidden}.main-card:after{content:"›";position:absolute;left:18px;bottom:10px;font-size:42px;color:rgba(191,219,254,.35);transform:rotate(180deg)}.main-card:hover{transform:translateY(-4px);border-color:var(--brand2);background:linear-gradient(180deg,rgba(37,99,235,.28),rgba(15,23,42,.98))}.main-card .icon{font-size:34px;margin-bottom:10px}.main-card h2{margin:0 0 8px;font-size:24px}.main-card p{margin:0;color:var(--muted);line-height:1.55}.main-card.danger-card{border-color:rgba(248,113,113,.45);background:linear-gradient(180deg,rgba(127,29,29,.65),rgba(15,23,42,.98))}.main-card.admin-card{border-color:rgba(251,191,36,.45)}
.screen{display:none;margin-top:18px}.screen.active{display:block}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:20px 0 14px}.section-title h2{margin:0 0 6px;font-size:32px;letter-spacing:-.5px}.section-title p{margin:0;color:var(--muted);line-height:1.55}.section-actions{display:flex;gap:10px;flex-wrap:wrap}.back-btn{border:1px solid var(--line);background:#1e293b;color:white;border-radius:999px;padding:10px 14px;cursor:pointer;font-weight:900}.back-btn:hover{background:#334155}.sub-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.sub-card{background:linear-gradient(180deg,var(--panel),#0f172a);border:1px solid var(--line);border-radius:22px;padding:18px;cursor:pointer;min-height:126px;transition:.18s transform,.18s border-color}.sub-card:hover{transform:translateY(-3px);border-color:var(--brand2)}.sub-card h3{margin:0 0 8px;font-size:22px}.sub-card p{margin:0;color:var(--muted);line-height:1.55}.tool-panel{display:none;background:rgba(16,28,49,.92);border:1px solid var(--line);border-radius:26px;padding:20px;box-shadow:0 20px 60px rgba(0,0,0,.22);margin-top:16px}.tool-panel.active{display:block}.tool-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}.tool-head h3{font-size:26px;margin:0 0 6px}.tool-head p{margin:0;color:var(--muted);line-height:1.55}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.upload-box{background:#0b1220;border:1px solid #24344d;border-radius:18px;padding:16px}.upload-box h4{margin:0 0 8px;font-size:18px}.upload-box p{color:var(--muted);line-height:1.55;margin:8px 0 0;font-size:14px}.note{color:var(--muted);font-size:14px;line-height:1.65}.warning{background:rgba(127,29,29,.38);border:1px solid rgba(248,113,113,.35);color:#fecaca;border-radius:16px;padding:13px;line-height:1.6;margin:12px 0}.good{background:rgba(22,101,52,.30);border:1px solid rgba(74,222,128,.35);color:#bbf7d0;border-radius:16px;padding:13px;line-height:1.6;margin:12px 0}.mini-note{background:#0f172a;border:1px solid var(--line);border-radius:14px;padding:12px;color:#cbd5e1;line-height:1.6;font-size:14px}input[type=file],select,input[type=text]{width:100%;padding:14px;background:#07111f;color:white;border-radius:14px;border:1px solid #334155;font-size:15px}input[type=file]::file-selector-button{border:0;border-radius:10px;background:#1d4ed8;color:white;padding:10px 12px;margin-left:10px;font-weight:900}button{border:0;background:#2563eb;color:white;padding:13px 17px;border-radius:14px;font-size:15px;font-weight:950;cursor:pointer;margin-top:12px;box-shadow:0 12px 30px rgba(37,99,235,.20)}button:hover{filter:brightness(1.08)}button.orange{background:#ea580c}button.danger{background:#dc2626}button.cancel{background:#475569}.preview-links{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}.preview-links a{display:inline-flex;text-decoration:none;background:#172033;border:1px solid #334155;border-radius:12px;padding:10px 12px;font-weight:900;color:#dbeafe}.preview-links a:hover{border-color:var(--brand2)}.field-label{display:block;color:#cbd5e1;font-size:13px;font-weight:900;margin:12px 0 6px}label.check{display:flex;align-items:center;gap:8px;margin-top:12px;color:#e2e8f0;font-weight:bold}.danger-zone{background:linear-gradient(135deg,rgba(69,26,26,.72),rgba(16,28,49,.94))!important;border-color:rgba(248,113,113,.35)!important}.split-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.filter-bar{position:sticky;top:0;z-index:8;background:rgba(7,17,31,.92);backdrop-filter:blur(12px);border:1px solid var(--line);border-radius:18px;padding:12px;margin:10px 0 14px;display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center}.jump-days{display:flex;gap:6px;flex-wrap:wrap;max-width:500px;justify-content:flex-end}.jump-days button{margin:0;padding:7px 9px;border-radius:10px;background:#1e293b;box-shadow:none;font-size:12px}.image-upload-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}.image-upload-card{margin:0}.image-file-input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}.image-upload-label{display:block;height:100%;cursor:pointer;background:#0b1220;border:1px solid #283950;border-radius:18px;padding:15px;transition:.18s transform,.18s border-color,.18s background;box-shadow:0 8px 22px rgba(0,0,0,.14)}.image-upload-label:hover{transform:translateY(-2px);border-color:#60a5fa;background:#13213a}.image-key{display:inline-flex;background:#1d4ed8;color:#dbeafe;border-radius:999px;padding:4px 9px;font-size:12px;font-weight:900;margin-bottom:8px}.image-title{display:block;color:#fff;font-size:17px;font-weight:950;margin-bottom:7px;line-height:1.25}.image-desc{display:block;color:#cbd5e1;font-size:14px;line-height:1.45;min-height:42px}.upload-status{display:block;margin-top:12px;color:#fde68a;font-size:13px;font-weight:900;border-top:1px solid #334155;padding-top:10px}.breadcrumb{display:flex;gap:8px;align-items:center;color:#bfdbfe;font-size:14px;margin:16px 0 0}.breadcrumb button{margin:0;background:transparent;border:1px solid #334155;box-shadow:none;padding:7px 10px}.mobile-footer{display:none;position:fixed;bottom:12px;right:12px;left:12px;z-index:20}.mobile-footer button{width:100%;box-shadow:0 12px 40px rgba(0,0,0,.35)}
@media(max-width:860px){.hero{grid-template-columns:1fr;padding:18px}.hero h1{font-size:30px}.hero p{font-size:14px;line-height:1.55}.hero-actions{justify-content:flex-start}.status-strip{display:none}.main-grid{grid-template-columns:repeat(2,1fr);gap:10px}.main-card{min-height:128px;padding:14px;border-radius:22px}.main-card .icon{font-size:27px;margin-bottom:7px}.main-card h2{font-size:20px;margin-bottom:6px}.main-card p{font-size:12.5px;line-height:1.38}.sub-grid,.form-grid,.split-grid{grid-template-columns:1fr}.section-head,.tool-head{display:block}.section-actions{margin-top:12px}.filter-bar{grid-template-columns:1fr}.jump-days{justify-content:flex-start;max-width:none}.wrap{width:min(100%,94%);padding:14px 0 72px}.mobile-footer{display:none}.image-upload-grid{grid-template-columns:1fr}.image-desc{min-height:auto}}@media(max-width:340px){.main-grid{grid-template-columns:1fr}.main-card{min-height:120px}}
</style>
</head>
<body>
<div class="wrap app-shell">
<div class="hero">
<div>
<h1><span class="dot"></span>TripServer Admin</h1>
<p>מסך ניהול לפי היגיון עבודה: בוחרים תחום, נכנסים לתת־קטגוריה, ואז מעלים רק את מה שצריך. בלי גלילה אינסופית ובלי לחפש ידיים ורגליים.</p>
</div>
<div class="hero-actions">
<a class="btn-link" href="/admin/status">📊 סטטוס קבצים</a>
<a class="btn-link" href="/admin/health">🩺 בדיקת מערכת</a>
<a class="btn-link" href="/admin/self-test">🧪 בדיקה עצמית מלאה</a>
<a class="btn-link" href="/">🏠 ראשי</a>
</div>
</div>

<div id="screen-home" class="screen active">
<div class="status-strip">
<div class="status-card"><b>📁 העלאה בטוחה</b><span>שמירה זמנית, גיבוי, החלפה אטומית, גודל ו־SHA אחרי שמירה.</span></div>
<div class="status-card"><b>🔁 Restart לאדמין</b><span>עדכון admin מפעיל helper חיצוני, לוג ובדיקת פורט.</span></div>
<div class="status-card"><b>🧭 מבנה היררכי</b><span>מסך ראשי → תחום → תת־תחום → פעולה.</span></div>
<div class="status-card"><b>🖥️ מחשב</b><span>כיבוי/ריסטארט עם טיימר, אישור כתוב וביטול.</span></div>
</div>
<div class="main-grid">
<button class="main-card" onclick="showScreen('japan')"><div class="icon">🇯🇵</div><h2>יפן</h2><p>קובץ טיול, מצגות, תמונות ומוזיקה של יפן.</p></button>
<button class="main-card" onclick="showScreen('usa')"><div class="icon">🇺🇸</div><h2>ארצות הברית</h2><p>קובץ טיול, מצגת, TV, תמונות שקפים ומוזיקה.</p></button>
<button class="main-card" onclick="showScreen('budgets')"><div class="icon">💰</div><h2>תקציבים</h2><p>אפליקציית תקציב חופשה ואפליקציית תקציב חודשי/משפחתי.</p></button>
<button class="main-card" onclick="showScreen('tripform')"><div class="icon">🧾</div><h2>טופס פתיחת טיול</h2><p>טופס קליטת טיול חדש, שמירה כ־TripForm/index.html.</p></button>
<button class="main-card danger-card" onclick="showScreen('power')"><div class="icon">🖥️</div><h2>פעולות מחשב</h2><p>כיבוי, ריסטארט, טיימר וביטול פעולה מתוזמנת.</p></button>
<button class="main-card admin-card" onclick="showScreen('admin-tools')"><div class="icon">⚙️</div><h2>ניהול האדמין</h2><p>העלאת admin.py/admin_server.py, restart ושחזור גיבוי.</p></button>
</div>
</div>

<div id="screen-japan" class="screen">
<div class="section-head"><div class="section-title"><h2>🇯🇵 יפן</h2><p>בחר קודם מה אתה מעדכן: קובץ הטיול, המצגות או המדיה.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/JAPAN/" target="_blank">פתח יפן</a></div></div>
<div class="sub-grid">
<div class="sub-card" onclick="showTool('japan','japan-trip')"><h3>📄 קובץ טיול יפן</h3><p>העלאת index.html או חבילת ZIP מלאה.</p></div>
<div class="sub-card" onclick="showTool('japan','japan-presentations')"><h3>🎬 מצגות יפן</h3><p>פתיח טיסה ומצגת יפן.</p></div>
<div class="sub-card" onclick="showTool('japan','japan-media')"><h3>🖼️🎵 מדיה יפן</h3><p>ZIP תמונות ושירי MP3.</p></div>
<div class="sub-card" onclick="window.open('/JAPAN/japan-experience.html','_blank')"><h3>🔎 בדיקה מהירה</h3><p>פתיחת מצגת יפן בחלון חדש.</p></div>
</div>
<div id="tool-japan-trip" class="tool-panel">
<div class="tool-head"><div><h3>📄 קובץ טיול יפן</h3><p>קובץ יחיד נשמר כ־JAPAN/index.html. ZIP מלא מעדכן גם מצגות ומדיה אם הן קיימות בחבילה.</p></div><button class="back-btn" onclick="hideTools('japan')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>קובץ HTML ראשי</h4><form action="/upload/JAPAN" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה קובץ טיול יפן</button></form><p>יישמר כ־C:\\TripServer\\JAPAN\\index.html</p></div><div class="upload-box"><h4>ZIP מלא יפן</h4><form action="/upload_trip_package/JAPAN" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP מלא של יפן</button></form><p>מתאים לחבילה עם index.html, japan-experience.html, japan-flight-intro.html ותיקיית media.</p></div></div>
</div>
<div id="tool-japan-presentations" class="tool-panel">
<div class="tool-head"><div><h3>🎬 מצגות יפן</h3><p>שמות היעד קבועים כדי שהקישורים מתוך קובץ הטיול לא יישברו.</p></div><button class="back-btn" onclick="hideTools('japan')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>✈️ פתיח טיסה</h4><form action="/upload_presentation/flight_intro" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה פתיח טיסה</button></form><p>יישמר כ־japan-flight-intro.html</p></div><div class="upload-box"><h4>🎌 מצגת יפן</h4><form action="/upload_presentation/japan_experience" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה מצגת יפן</button></form><p>יישמר כ־japan-experience.html</p></div></div>
<div class="preview-links"><a href="/JAPAN/japan-flight-intro.html" target="_blank">בדיקת פתיח</a><a href="/JAPAN/japan-experience.html" target="_blank">בדיקת מצגת</a></div>
</div>
<div id="tool-japan-media" class="tool-panel">
<div class="tool-head"><div><h3>🖼️🎵 מדיה יפן</h3><p>תמונות ושירים נשמרים לנתיבים שהמצגת מחפשת בפועל. ספירה מדויקת של תמונות בשימוש נמצאת במסך סטטוס קבצים.</p></div><button class="back-btn" onclick="hideTools('japan')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>ZIP תמונות מצגת יפן</h4><form action="/upload_japan_images_zip" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP תמונות יפן</button></form><p>הפאנל מסנן קובצי תמונה נתמכים. הספירה החשובה היא לא כמה קבצים יש בתיקייה, אלא כמה src מופיעים בפועל בתוך japan-experience.html. בחבילות/ZIP מלאים הוא לא משנה שמות קבצים כדי לא לשבור קישורים פנימיים במצגת.</p></div><div class="upload-box"><h4>🎵 שיר מצגת יפן</h4><form action="/upload_japan_music/japan_experience_music" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה japan-theme.mp3</button></form><p>כל קובץ אודיו נתמך יישמר כ־MP3 אמיתי בכל נתיבי ה־fallback של המצגת. המרה מלאה דורשת FFmpeg.</p></div><div class="upload-box"><h4>✈️ מוזיקת פתיח טיסה</h4><form action="/upload_japan_music/japan_flight_music" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה flight-intro.mp3</button></form><p>כל קובץ אודיו נתמך יישמר כ־MP3 אמיתי לנתיב מוזיקת פתיח הטיסה. המרה מלאה דורשת FFmpeg.</p></div></div>
</div>
</div>

<div id="screen-usa" class="screen">
<div class="section-head"><div class="section-title"><h2>🇺🇸 ארצות הברית</h2><p>אזור ארה״ב מחולק לקובץ טיול, מצגות, תמונות שקפים ומוזיקה/TV.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/USA/" target="_blank">פתח ארה״ב</a></div></div>
<div class="sub-grid">
<div class="sub-card" onclick="showTool('usa','usa-trip')"><h3>📄 קובץ טיול</h3><p>HTML ראשי, ZIP מלא וגרסת טלוויזיה.</p></div>
<div class="sub-card" onclick="showTool('usa','usa-presentation')"><h3>🎬 מצגת</h3><p>מצגת עם סכומים וגרסת TV.</p></div>
<div class="sub-card" onclick="showTool('usa','usa-images')"><h3>🖼️ תמונות שקפים</h3><p>ZIP מלא או העלאה לפי שקף/יום.</p></div>
<div class="sub-card" onclick="showTool('usa','usa-music')"><h3>🎵 מוזיקה</h3><p>שיר חלק 1 ושיר חלק 2 של המצגת.</p></div>
</div>
<div id="tool-usa-trip" class="tool-panel">
<div class="tool-head"><div><h3>📄 קובץ טיול ארה״ב</h3><p>קובץ הטיול הראשי נשמר כ־USA/index.html. גרסת TV נשמרת בנפרד.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>HTML ראשי</h4><form action="/upload/USA" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה קובץ טיול ארה״ב</button></form><p>יישמר כ־C:\\TripServer\\USA\\index.html</p></div><div class="upload-box"><h4>ZIP מלא ארה״ב</h4><form action="/upload_trip_package/USA" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP מלא של ארה״ב</button></form><p>מתאים לחבילת Netlify/שרת עם index.html, מצגת, media ותמונות.</p></div><div class="upload-box"><h4>גרסת טלוויזיה</h4><form action="/upload_usa_extra_html/usa_trip_tv" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה קובץ ארה״ב TV</button></form><p>יישמר כ־usa2027-tv.html.</p></div></div>
</div>
<div id="tool-usa-presentation" class="tool-panel">
<div class="tool-head"><div><h3>🎬 מצגת ארה״ב</h3><p>המצגת הרגילה והמצגת המותאמת למסך טלוויזיה נשמרות בשמות קבועים.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>מצגת ארה״ב עם סכומים</h4><form action="/upload_usa_presentation/usa_presentation" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה מצגת ארה״ב</button></form><p>יישמר כ־usa2027-presentation.html</p></div><div class="upload-box"><h4>מצגת ארה״ב TV</h4><form action="/upload_usa_presentation/usa_presentation_tv" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה מצגת TV</button></form><p>יישמר כ־usa2027-presentation-tv.html</p></div></div>
<div class="preview-links"><a href="/USA/usa2027-presentation.html" target="_blank">בדיקת מצגת</a><a href="/USA/usa2027-presentation-tv.html" target="_blank">בדיקת מצגת TV</a></div>
</div>
<div id="tool-usa-images" class="tool-panel">
<div class="tool-head"><div><h3>🖼️ תמונות מצגת ארה״ב</h3><p>התיאורים כאן מסונכרנים לסדר השקפים בפועל: Disney Springs, ימי ים בקרוז, St. Maarten, St. Thomas ו־CocoCay.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div>
<div class="good">העלאה בודדת ו־ZIP שומרים תמיד JPG אמיתי בשם השקף, עם דחיסה חכמה והקטנה עדינה עד 1920px בצלע הארוכה. העלאת ZIP מקבלת שמות כמו day-01.jpg עד day-36.jpg וגם intro/budget/summary/extra.</div>
<div class="form-grid"><div class="upload-box"><h4>ZIP תמונות מלא</h4><form action="/upload_usa_images_zip" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP תמונות ארה״ב</button></form><p>שמות נתמכים: intro, roadtrip-map, day-01 עד day-36, budget, summary ותמונות bonus.</p></div><div class="upload-box"><h4>בדיקת מצגת</h4><p>אחרי העלאת תמונות פתח את המצגת כדי לוודא שאין cache ישן.</p><div class="preview-links"><a href="/USA/usa2027-presentation.html?from=admin" target="_blank">פתח מצגת</a><a href="/admin/status" target="_blank">סטטוס תמונות</a></div></div></div>
<div class="filter-bar"><input id="imageSearch" type="text" placeholder="חיפוש שקף: יום 18, Disney Springs, קרוז, CocoCay..." oninput="filterImages()"><div class="jump-days"><button onclick="jumpImage('intro')">פתיחה</button><button onclick="jumpImage('day-01')">יום 1</button><button onclick="jumpImage('day-18')">יום 18</button><button onclick="jumpImage('day-28')">קרוז</button><button onclick="jumpImage('budget')">תקציב</button><button onclick="jumpImage('summary')">סיום</button></div></div>
<div class="image-upload-grid" id="usaImageGrid">
""" + render_usa_image_upload_cards() + """
</div>
</div>
<div id="tool-usa-music" class="tool-panel">
<div class="tool-head"><div><h3>🎵 מוזיקת מצגת ארה״ב</h3><p>שני קבצי MP3 אמיתיים בלבד, כדי שהדפדפן ינגן בלי קובץ מזויף.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div>
<div class="form-grid"><div class="upload-box"><h4>שיר חלק 1 — Road Trip</h4><form action="/upload_usa_music/usa_music" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה שיר חלק 1</button></form><p>usa-roadtrip-theme.mp3 — ממיר ל־MP3 אם FFmpeg מותקן.</p></div><div class="upload-box"><h4>שיר חלק 2 — Orlando / Miami / Cruise</h4><form action="/upload_usa_music/usa_music_part2" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה שיר חלק 2</button></form><p>usa-part2-theme.mp3 — ממיר ל־MP3 אם FFmpeg מותקן.</p></div></div>
</div>
</div>

<div id="screen-budgets" class="screen">
<div class="section-head"><div class="section-title"><h2>💰 תקציבים</h2><p>שתי אפליקציות נפרדות: תקציב חופשה ותקציב חודשי/משפחתי.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button></div></div>
<div class="form-grid"><div class="upload-box"><h4>💼 אפליקציית ניהול תקציב חופשה</h4><form action="/upload/Moneyapp" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה אפליקציית תקציב חופשה</button></form><p>יישמר כ־Moneyapp/index.html.</p><div class="preview-links"><a href="/Moneyapp/" target="_blank">פתח תקציב חופשה</a></div></div><div class="upload-box"><h4>🏠 אפליקציית תקציב חודשי / משפחתי</h4><form action="/upload/MoneyHome" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה תקציב חודשי</button></form><p>יישמר כ־MoneyHome/index.html.</p><div class="preview-links"><a href="/MoneyHome/" target="_blank">פתח תקציב חודשי</a></div></div></div>
</div>

<div id="screen-tripform" class="screen">
<div class="section-head"><div class="section-title"><h2>🧾 טופס פתיחת טיול</h2><p>טופס הקליטה ליצירת טיול חדש.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/TripForm/" target="_blank">פתח טופס</a></div></div>
<div class="upload-box"><h4>העלאת טופס HTML</h4><form action="/upload/TripForm" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה טופס פתיחת טיול</button></form><p>יישמר כ־TripForm/index.html.</p></div>
</div>

<div id="screen-power" class="screen">
<div class="section-head"><div class="section-title"><h2>🖥️ פעולות מחשב</h2><p>פעולות Windows אמיתיות. הן מוגנות באישור כתוב, טיימר וחסימת גישה מחוץ לרשת מקומית.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button></div></div>
<div class="tool-panel active danger-zone"><div class="warning"><strong>זהירות:</strong> כיבוי או ריסטארט מנתקים את השרת עד שהמחשב עולה מחדש. השתמש בטיימר ולא ב־0 שניות.</div><div class="split-grid"><div class="upload-box"><h4>⏻ כיבוי מחשב</h4><form action="/power/shutdown" method="post" onsubmit="return confirm('לקבוע כיבוי למחשב? אפשר לבטל עם כפתור ביטול לפני שהטיימר נגמר.')"><label class="field-label">טיימר לפני כיבוי</label><select name="delay"><option value="60">עוד דקה</option><option value="300">עוד 5 דקות</option><option value="900">עוד 15 דקות</option><option value="1800">עוד 30 דקות</option></select><label class="field-label">הקלד בדיוק: כיבוי</label><input type="text" name="confirm_text" autocomplete="off" placeholder="כיבוי"><label class="check"><input type="checkbox" name="ack" value="1"> אני מבין שזה יכבה את המחשב</label><button class="danger">קבע כיבוי מחשב</button></form></div><div class="upload-box"><h4>🔄 ריסטארט למחשב</h4><form action="/power/reboot" method="post" onsubmit="return confirm('לקבוע ריסטארט למחשב? אפשר לבטל עם כפתור ביטול לפני שהטיימר נגמר.')"><label class="field-label">טיימר לפני ריסטארט</label><select name="delay"><option value="60">עוד דקה</option><option value="300">עוד 5 דקות</option><option value="900">עוד 15 דקות</option><option value="1800">עוד 30 דקות</option></select><label class="field-label">הקלד בדיוק: הפעלה מחדש</label><input type="text" name="confirm_text" autocomplete="off" placeholder="הפעלה מחדש"><label class="check"><input type="checkbox" name="ack" value="1"> אני מבין שזה יפעיל את המחשב מחדש</label><button class="orange">קבע ריסטארט למחשב</button></form></div></div><form action="/power/cancel" method="post" onsubmit="return confirm('לבטל כיבוי/ריסטארט מתוזמן של Windows?')"><button class="cancel">בטל כיבוי/ריסטארט מתוזמן</button></form><p class="note">פעולות נרשמות ללוג C:\\TripServer\\admin_restart.log.</p></div>
</div>

<div id="screen-admin-tools" class="screen">
<div class="section-head"><div class="section-title"><h2>⚙️ ניהול אפליקציית האדמין</h2><p>כאן מעדכנים את הקובץ שמפעיל את הפאנל עצמו.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/admin/status">סטטוס</a><a class="btn-link" href="/admin/health">בדיקת מערכת</a><a class="btn-link" href="/admin/self-test">בדיקה עצמית מלאה</a></div></div>
<div class="tool-panel active"><div class="warning"><strong>פעולה רגישה:</strong> עדכון admin_server.py מחליף את המנוע שמריץ את הפאנל. לכן יש בדיקת קומפילציה, גיבוי, החלפה אטומית ו־restart אוטומטי.</div><div class="good">הזרימה: העלאה → בדיקת Python → בדיקת סימני TripServer → גיבוי → החלפה → helper חיצוני → restart → לוג.</div><div class="form-grid"><div class="upload-box"><h4>העלאת admin חדש</h4><form action="/upload_admin_server" method="post" enctype="multipart/form-data" onsubmit="return confirm('להעלות admin_server.py חדש ולהחליף את קובץ הפאנל הפעיל אחרי בדיקה וגיבוי?')"><input type="file" name="file" accept=".py,text/x-python,text/plain"><button class="orange">העלה admin_server.py חדש</button></form><p>אם אצלך הקובץ נקרא admin.py, שמור את הקובץ החדש בשם הזה בהחלפה הידנית הראשונה.</p></div><div class="upload-box"><h4>פעולות תחזוקה</h4><form action="/restart_admin_server" method="post" onsubmit="return confirm('לאתחל את הפאנל עכשיו?')"><button class="orange">אתחל פאנל</button></form><form action="/restore_admin_server_backup" method="post" onsubmit="return confirm('לשחזר את הגיבוי האחרון של admin_server.py?')"><button class="danger">שחזר גיבוי אחרון</button></form><div class="preview-links"><a href="/admin/self-test">🧪 הרץ בדיקה עצמית מלאה</a></div><p>גיבויים: C:\\TripServer\\backups\\admin_server</p></div></div></div>
</div>

<div class="mobile-footer"><button onclick="showScreen('home')">🏠 חזרה למסך הראשי</button></div>
</div>
<script>
function showScreen(name){
  document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active'));
  const el=document.getElementById('screen-'+name);
  if(el){el.classList.add('active'); location.hash=name==='home'?'':name; window.scrollTo({top:0,behavior:'smooth'});}
}
function hideTools(group){
  document.querySelectorAll('#screen-'+group+' .tool-panel').forEach(t=>t.classList.remove('active'));
  window.scrollTo({top:document.getElementById('screen-'+group).offsetTop-10,behavior:'smooth'});
}
function showTool(group, tool){
  hideTools(group);
  const el=document.getElementById('tool-'+tool);
  if(el){el.classList.add('active'); setTimeout(()=>el.scrollIntoView({behavior:'smooth',block:'start'}),70); location.hash=tool;}
}
function filterImages(){
  const q=(document.getElementById('imageSearch')?.value||'').toLowerCase().trim();
  document.querySelectorAll('#usaImageGrid .image-upload-card').forEach(card=>{card.style.display=!q||card.dataset.search.includes(q)?'block':'none';});
}
function jumpImage(key){
  const card=document.querySelector('#usaImageGrid input[value="'+key+'"]').closest('.image-upload-card');
  if(card){card.scrollIntoView({behavior:'smooth',block:'center'}); card.querySelector('.image-upload-label').style.borderColor='#fbbf24'; setTimeout(()=>card.querySelector('.image-upload-label').style.borderColor='',1600);}
}
window.addEventListener('load',()=>{const h=(location.hash||'').replace('#',''); if(['japan','usa','budgets','tripform','power','admin-tools'].includes(h)) showScreen(h); if(h.startsWith('usa-')){showScreen('usa'); setTimeout(()=>showTool('usa',h),80);} if(h.startsWith('japan-')){showScreen('japan'); setTimeout(()=>showTool('japan',h),80);}});
</script>
</body>
</html>
"""



def add_self_test_result(results, category, name, ok, details="", severity="critical"):
    if ok:
        status = "pass"
    elif severity == "warning":
        status = "warn"
    else:
        status = "fail"
    results.append({
        "category": category,
        "name": name,
        "status": status,
        "severity": severity,
        "details": str(details or ""),
    })


def route_rule_matches_action(rule, action):
    # Support both exact rules and Flask dynamic rules such as /upload/<folder>.
    if rule == action:
        return True
    rule_parts = [part for part in (rule or "").strip("/").split("/") if part]
    action_parts = [part for part in (action or "").strip("/").split("/") if part]
    if len(rule_parts) != len(action_parts):
        return False
    for rule_part, action_part in zip(rule_parts, action_parts):
        if rule_part.startswith("<") and rule_part.endswith(">"):
            if not action_part:
                return False
            continue
        if rule_part != action_part:
            return False
    return True


def collect_admin_form_actions(html_text):
    return sorted(set(re.findall(r"<form[^>]+action=[\"\']([^\"\']+)[\"\']", html_text or "")))


def collect_duplicate_ids(html_text):
    ids = re.findall(r"\bid=[\"\']([^\"\']+)[\"\']", html_text or "")
    seen, duplicates = set(), []
    for item in ids:
        if item in seen and item not in duplicates:
            duplicates.append(item)
        seen.add(item)
    return duplicates


def check_temp_write_permission():
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    test_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"self_test_{os.getpid()}_{int(time.time())}.tmp")
    try:
        with open(test_path, "w", encoding="utf-8") as fh:
            fh.write("TripServer self test")
        with open(test_path, "r", encoding="utf-8") as fh:
            data = fh.read()
        return data == "TripServer self test", test_path
    finally:
        try:
            if os.path.exists(test_path):
                os.remove(test_path)
        except Exception:
            pass


def run_self_test_suite(include_media_conversion=False):
    """Non-destructive self-test for the admin panel.

    It checks code validity, routes/forms, folders, media engine availability,
    presentation image mappings, power guards and restart-helper generation.
    It intentionally does not upload real files, restart the server, shut down Windows,
    or modify active trip files.
    """
    started = time.time()
    results = []

    # 1) Python/code validity.
    try:
        py_compile.compile(ADMIN_SERVER_PATH, doraise=True)
        add_self_test_result(results, "קוד", "קומפילציית Python לקובץ האדמין הפעיל", True, ADMIN_SERVER_PATH)
    except Exception as exc:
        add_self_test_result(results, "קוד", "קומפילציית Python לקובץ האדמין הפעיל", False, repr(exc))

    try:
        proc = subprocess.run([sys.executable, "-Werror", "-m", "py_compile", ADMIN_SERVER_PATH], capture_output=True, text=True, timeout=40)
        add_self_test_result(results, "קוד", "קומפילציה עם Werror", proc.returncode == 0, (proc.stderr or proc.stdout or "OK")[-1200:])
    except Exception as exc:
        add_self_test_result(results, "קוד", "קומפילציה עם Werror", False, repr(exc), severity="warning")

    try:
        valid, info = validate_admin_server_upload(ADMIN_SERVER_PATH)
        add_self_test_result(results, "קוד", "בדיקת קובץ אדמין מול מנגנון upload_admin_server", bool(valid), info)
    except Exception as exc:
        add_self_test_result(results, "קוד", "בדיקת קובץ אדמין מול מנגנון upload_admin_server", False, repr(exc))

    try:
        helper_source = build_restart_helper_code(os.path.abspath(ADMIN_SERVER_PATH), sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR)
        ok, info = validate_restart_helper_source(helper_source)
        add_self_test_result(results, "Restart", "קוד helper חיצוני ל־restart עובר קומפילציה", bool(ok), info)
    except Exception as exc:
        add_self_test_result(results, "Restart", "קוד helper חיצוני ל־restart עובר קומפילציה", False, repr(exc))

    # 2) Paths and write access.
    for key, folder_path in sorted(FOLDERS.items()):
        add_self_test_result(results, "נתיבים", f"תיקיית {key} קיימת", os.path.isdir(folder_path), folder_path)

    try:
        ok, path = check_temp_write_permission()
        add_self_test_result(results, "נתיבים", "כתיבה/מחיקה בתיקיית tmp של האדמין", ok, path)
    except Exception as exc:
        add_self_test_result(results, "נתיבים", "כתיבה/מחיקה בתיקיית tmp של האדמין", False, repr(exc))

    for label, path in [
        ("תיקיית גיבויי אדמין", ADMIN_BACKUP_DIR),
        ("תיקיית גיבויי העלאות", UPLOAD_BACKUP_DIR),
    ]:
        try:
            os.makedirs(path, exist_ok=True)
            add_self_test_result(results, "נתיבים", label, os.path.isdir(path), path)
        except Exception as exc:
            add_self_test_result(results, "נתיבים", label, False, repr(exc))

    # 3) Flask routes and HTML forms.
    try:
        rules = sorted(str(rule.rule) for rule in app.url_map.iter_rules())
        expected_routes = [
            "/", "/admin", "/admin/status", "/admin/health", "/admin/self-test", "/admin/self-test.json",
            "/health", "/health.json", "/upload_admin_server", "/restart_admin_server", "/restore_admin_server_backup",
            "/upload/<folder>", "/upload_presentation/<presentation_key>", "/upload_usa_presentation/<presentation_key>",
            "/upload_usa_image", "/upload_usa_images_zip", "/upload_usa_extra_html/<extra_key>",
            "/upload_japan_music/<music_key>", "/upload_japan_images_zip", "/upload_trip_package/<trip>",
            "/upload_usa_music/<music_key>", "/power/<action>", "/power/cancel",
        ]
        missing_routes = [route for route in expected_routes if route not in rules]
        add_self_test_result(results, "ממשק", "כל routes המרכזיים קיימים", not missing_routes, ", ".join(missing_routes) if missing_routes else f"{len(rules)} routes")
    except Exception as exc:
        rules = []
        add_self_test_result(results, "ממשק", "קריאת routes מהאפליקציה", False, repr(exc))

    try:
        admin_html = admin()
        actions = collect_admin_form_actions(admin_html)
        missing_actions = []
        for action in actions:
            if not any(route_rule_matches_action(rule, action) for rule in rules):
                missing_actions.append(action)
        add_self_test_result(results, "ממשק", "כל טפסי הפאנל מחוברים ל־route קיים", not missing_actions, ", ".join(missing_actions) if missing_actions else f"{len(actions)} טפסים")

        dup_ids = collect_duplicate_ids(admin_html)
        add_self_test_result(results, "ממשק", "אין IDs כפולים במסך האדמין", not dup_ids, ", ".join(dup_ids) if dup_ids else "OK")

        add_self_test_result(results, "ממשק", "כפתור בדיקה עצמית קיים בפאנל", "/admin/self-test" in admin_html, "נבדק href=/admin/self-test")
    except Exception as exc:
        add_self_test_result(results, "ממשק", "ניתוח HTML של הפאנל", False, repr(exc))

    try:
        status_html = render_admin_status()
        add_self_test_result(results, "ממשק", "מסך סטטוס נבנה ללא שגיאה", "סטטוס קבצים פעילים" in status_html, "OK")
    except Exception as exc:
        add_self_test_result(results, "ממשק", "מסך סטטוס נבנה ללא שגיאה", False, repr(exc))

    try:
        health_html = render_admin_health()
        add_self_test_result(results, "ממשק", "מסך Health נבנה ללא שגיאה", "בדיקת מערכת" in health_html, "OK")
    except Exception as exc:
        add_self_test_result(results, "ממשק", "מסך Health נבנה ללא שגיאה", False, repr(exc))

    # 4) Media engine.
    try:
        media_status = media_engine_status()
        add_self_test_result(results, "מדיה", "Pillow זמין להמרת/דחיסת תמונות", bool(media_status.get("pillow")), "py -m pip install --upgrade Pillow" if not media_status.get("pillow") else "OK")
        add_self_test_result(results, "מדיה", "FFmpeg זמין להמרת אודיו ל־MP3", bool(media_status.get("ffmpeg")), media_status.get("ffmpeg_path") or r"שים FFmpeg ב־C:\ffmpeg\bin\ffmpeg.exe")
    except Exception as exc:
        add_self_test_result(results, "מדיה", "בדיקת מנוע מדיה", False, repr(exc))

    # Optional conversion mini-test: only in temp folder, never touches active media.
    if include_media_conversion:
        try:
            from PIL import Image
            source = os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.png")
            target = os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.jpg")
            Image.new("RGBA", (1600, 900), (30, 80, 150, 255)).save(source, "PNG")
            media = convert_or_copy_image_to_jpg(source, target, "self_test.png")
            ok = os.path.exists(target) and is_likely_jpeg_file(target)
            add_self_test_result(results, "מדיה", "סימולציית PNG → JPG בתיקיית tmp", ok, media_details_text(media))
        except Exception as exc:
            add_self_test_result(results, "מדיה", "סימולציית PNG → JPG בתיקיית tmp", False, repr(exc), severity="warning")
        finally:
            for p in (os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.png"), os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.jpg")):
                try:
                    if os.path.exists(p):
                        os.remove(p)
                except Exception:
                    pass

    # 5) Presentation-specific checks.
    try:
        usa_count = len(USA_IMAGE_UI_ITEMS)
        usa_keys = {item[0] for item in USA_IMAGE_UI_ITEMS}
        add_self_test_result(results, "ארה״ב", "44 כרטיסיות תמונות ארה״ב קיימות", usa_count == 44, str(usa_count))
        add_self_test_result(results, "ארה״ב", "כרטיסיות ארה״ב תואמות למפת שמות התמונות", usa_keys == set(USA_IMAGE_KEYS), f"ui={len(usa_keys)} keys={len(USA_IMAGE_KEYS)}")
        ui_blob = "\n".join(" ".join(item) for item in USA_IMAGE_UI_ITEMS)
        forbidden = [word for word in ["Nassau", "נסאו", "פארק דיסני"] if word in ui_blob]
        add_self_test_result(results, "ארה״ב", "אין תיאורים שגויים ידועים בתמונות ארה״ב", not forbidden, ", ".join(forbidden) if forbidden else "OK")
        required = ["Disney Springs", "קרוז – יום ים 1", "קרוז – יום ים 2", "קרוז – יום ים 3", "St. Maarten", "St. Thomas", "CocoCay"]
        missing_words = [word for word in required if word not in ui_blob]
        add_self_test_result(results, "ארה״ב", "תיאורי קרוז/אורלנדו מסונכרנים", not missing_words, ", ".join(missing_words) if missing_words else "OK")
    except Exception as exc:
        add_self_test_result(results, "ארה״ב", "בדיקת כרטיסיות ארה״ב", False, repr(exc))

    try:
        japan_usage = analyze_japan_presentation_images()
        if not japan_usage.get("presentation_exists"):
            add_self_test_result(results, "יפן", "מצגת יפן קיימת לניתוח", False, japan_usage.get("presentation_path", ""), severity="warning")
        else:
            add_self_test_result(results, "יפן", "מצגת יפן קיימת לניתוח", True, japan_usage.get("presentation_path", ""))
            add_self_test_result(results, "יפן", "מצגת יפן משתמשת בתמונות בפועל", japan_usage.get("image_slides", 0) > 0, f"{japan_usage.get('image_slides', 0)} תמונות")
            add_self_test_result(results, "יפן", "אין תמונות חיצוניות חסרות למצגת יפן", japan_usage.get("missing_external_images", 0) == 0, f"חסרות: {japan_usage.get('missing_external_images', 0)}")
            add_self_test_result(results, "יפן", "ספירת תמונות יפן מוצגת כספירת שימוש ולא כספירת תיקייה", "תמונות בשימוש בפועל" in render_admin_status(), "OK")
    except Exception as exc:
        add_self_test_result(results, "יפן", "ניתוח מצגת יפן", False, repr(exc))

    # 6) Upload target mapping and safety guards.
    try:
        main_targets = {key: os.path.join(path, "index.html") for key, path in FOLDERS.items()}
        bad_targets = [f"{key}:{target}" for key, target in main_targets.items() if not target.lower().endswith("index.html")]
        add_self_test_result(results, "העלאות", "קבצים ראשיים נשמרים תמיד כ־index.html", not bad_targets, ", ".join(bad_targets) if bad_targets else "OK")
    except Exception as exc:
        add_self_test_result(results, "העלאות", "מפת יעדי העלאה ראשיים", False, repr(exc))

    try:
        safe_examples = ["index.html", "media/images/a.jpg", "USA/index.html"]
        unsafe_examples = ["../x.html", "a/../../x.html", "__MACOSX/a.jpg"]
        ok_safe = all(safe_zip_relpath(x) is not None for x in safe_examples)
        ok_unsafe = all(safe_zip_relpath(x) is None for x in unsafe_examples)
        add_self_test_result(results, "העלאות", "הגנת ZIP Slip / נתיבים מסוכנים", ok_safe and ok_unsafe, "OK" if ok_safe and ok_unsafe else f"safe={ok_safe} unsafe={ok_unsafe}")
    except Exception as exc:
        add_self_test_result(results, "העלאות", "הגנת ZIP Slip / נתיבים מסוכנים", False, repr(exc))

    try:
        add_self_test_result(results, "אבטחה", "זיהוי IP מקומי/בית/Tailscale", is_private_or_loopback_ip("127.0.0.1") and is_private_or_loopback_ip("192.168.1.10") and is_private_or_loopback_ip("100.64.1.2") and is_private_or_loopback_ip("fd7a:115c:a1e0::1"), "OK")
        add_self_test_result(results, "אבטחה", "כתובת ציבורית נחסמת לפעולות מחשב", not is_private_or_loopback_ip("8.8.8.8"), "OK")
        add_self_test_result(results, "אבטחה", "טווח טיימר כיבוי/ריסטארט מוגבל", normalize_power_delay("0") == POWER_MIN_DELAY_SECONDS and normalize_power_delay("999999") == POWER_MAX_DELAY_SECONDS, f"{POWER_MIN_DELAY_SECONDS}-{POWER_MAX_DELAY_SECONDS} שניות")
    except Exception as exc:
        add_self_test_result(results, "אבטחה", "בדיקות אבטחת פעולות מחשב", False, repr(exc))

    elapsed = round(time.time() - started, 3)
    passed = sum(1 for item in results if item["status"] == "pass")
    warned = sum(1 for item in results if item["status"] == "warn")
    failed = sum(1 for item in results if item["status"] == "fail")
    return {
        "ok": failed == 0,
        "passed": passed,
        "warnings": warned,
        "failed": failed,
        "total": len(results),
        "elapsed_seconds": elapsed,
        "version": ADMIN_PANEL_VERSION,
        "started_at": ADMIN_STARTED_AT,
        "checked_at": time.strftime("%Y-%m-%d %H:%M:%S"),
        "results": results,
    }


def render_admin_self_test():
    include_conversion = (request.args.get("conversion") == "1")
    payload = run_self_test_suite(include_media_conversion=include_conversion)
    badge_class = "ok" if payload["ok"] else "bad"
    badge_text = "הבדיקה עברה" if payload["ok"] else "נמצאו כשלים"
    grouped = {}
    for item in payload["results"]:
        grouped.setdefault(item["category"], []).append(item)

    def row_html(item):
        status = item.get("status")
        if status == "pass":
            mark = "✅"
            cls = "pass"
            label = "תקין"
        elif status == "warn":
            mark = "⚠️"
            cls = "warn"
            label = "אזהרה"
        else:
            mark = "❌"
            cls = "fail"
            label = "כשל"
        details = html_lib.escape(item.get("details") or "").replace("\n", "<br>")
        return f"<tr class='{cls}'><td>{mark} {label}</td><td>{html_lib.escape(item.get('name',''))}</td><td>{details}</td></tr>"

    groups_html = []
    for category, items in grouped.items():
        rows = "".join(row_html(item) for item in items)
        groups_html.append(f"<section class='group'><h2>{html_lib.escape(category)}</h2><table><thead><tr><th>מצב</th><th>בדיקה</th><th>פירוט</th></tr></thead><tbody>{rows}</tbody></table></section>")

    return f"""
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>בדיקה עצמית TripServer</title>
<style>
body{{margin:0;background:#0f172a;color:white;font-family:Arial;padding:22px}}.wrap{{max-width:1180px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#1d4ed8,#0f172a 62%,#111827);border:1px solid #334155;border-radius:26px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.25)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.65}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin-top:8px}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.bad{{background:#7f1d1d;color:#fecaca;border:1px solid #ef4444}}.summary{{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:14px 0}}.card{{background:#1e293b;border:1px solid #334155;border-radius:18px;padding:14px}}.num{{font-size:30px;font-weight:950;color:#fde68a}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 0}}.actions a{{display:inline-flex;background:#1e293b;border:1px solid #475569;border-radius:999px;padding:10px 13px;font-weight:900}}.group{{background:#111827;border:1px solid #334155;border-radius:22px;padding:16px;margin:14px 0}}.group h2{{margin:0 0 12px;color:#dbeafe}}table{{width:100%;border-collapse:collapse;overflow:hidden;border-radius:14px}}th,td{{padding:10px;border-bottom:1px solid #334155;text-align:right;vertical-align:top}}th{{background:#0b1220;color:#bfdbfe}}td:last-child{{direction:ltr;text-align:left;color:#cbd5e1;word-break:break-word}}tr.pass td:first-child{{color:#bbf7d0;font-weight:900}}tr.warn td:first-child{{color:#fde68a;font-weight:900}}tr.fail td:first-child{{color:#fecaca;font-weight:900}}.note{{background:#0b1220;border:1px solid #334155;border-radius:16px;padding:13px;color:#cbd5e1;line-height:1.65}}@media(max-width:700px){{body{{padding:14px}}h1{{font-size:28px}}table,thead,tbody,tr,th,td{{display:block}}thead{{display:none}}tr{{border:1px solid #334155;border-radius:14px;margin:10px 0;padding:8px}}td{{border:0;padding:7px}}td:last-child{{text-align:right;direction:rtl}}}}
</style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>🧪 בדיקה עצמית מלאה</h1><p>בדיקה לא־הרסנית של קוד, routes, טפסים, נתיבים, מדיה, מצגות, אבטחה ו־restart helper. היא לא מבצעת העלאות אמיתיות, לא מאתחלת את השרת ולא מכבה את המחשב.</p><span class="badge {badge_class}">{badge_text}</span><div class="actions"><a href="/admin/self-test">הרץ שוב</a><a href="/admin/self-test?conversion=1">בדיקה כולל סימולציית המרת תמונה</a><a href="/admin/self-test.json" target="_blank">JSON טכני</a><a href="/admin/status">סטטוס קבצים</a><a href="/admin/health">Health</a></div></div><div class="summary"><div class="card"><div>סה״כ בדיקות</div><div class="num">{payload['total']}</div></div><div class="card"><div>עברו</div><div class="num">{payload['passed']}</div></div><div class="card"><div>אזהרות</div><div class="num">{payload['warnings']}</div></div><div class="card"><div>כשלים</div><div class="num">{payload['failed']}</div></div><div class="card"><div>משך</div><div class="num">{payload['elapsed_seconds']}s</div></div></div><div class="note">גרסה: {html_lib.escape(payload['version'])}<br>נבדק: {html_lib.escape(payload['checked_at'])}</div>{''.join(groups_html)}</div></body></html>
"""

def health_payload():
    helper_ok, helper_info = validate_restart_helper_source(build_restart_helper_code(ADMIN_SERVER_PATH, sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR))
    return {
        "ok": True,
        "service": "TripServer Admin",
        "version": ADMIN_PANEL_VERSION,
        "started_at": ADMIN_STARTED_AT,
        "script": ADMIN_SERVER_PATH,
        "host": ADMIN_HOST,
        "port": ADMIN_PORT,
        "usa_image_keys": len(USA_IMAGE_KEYS),
        "japan_presentation_images": compact_japan_usage_for_health(),
        "power_actions_enabled": POWER_ACTIONS_ENABLED,
        "restart_helper_ok": helper_ok,
        "restart_helper_status": helper_info,
        "media_engine": media_engine_status(),
    }


def render_admin_health():
    payload = health_payload()
    media = payload.get("media_engine", {})
    pillow_ok = bool(media.get("pillow"))
    ffmpeg_ok = bool(media.get("ffmpeg"))
    ffmpeg_path = media.get("ffmpeg_path") or "לא נמצא"
    power_ok = bool(payload.get("power_actions_enabled"))
    japan_usage = payload.get("japan_presentation_images", {})

    def badge(ok, text_ok="תקין", text_bad="חסר"):
        cls = "ok" if ok else "bad"
        txt = text_ok if ok else text_bad
        return f'<span class="badge {cls}">{txt}</span>'

    json_text = html_lib.escape(str(payload)).replace(", '", ",\n '").replace("{", "{\n ").replace("}", "\n}")
    return f"""
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>בדיקת מערכת TripServer</title>
<style>
:root{{--bg:#07111f;--panel:#101c31;--line:#253650;--muted:#a8b3c7;--blue:#60a5fa;--green:#22c55e;--red:#ef4444;--yellow:#fbbf24}}
*{{box-sizing:border-box}}body{{margin:0;font-family:Arial,'Segoe UI',sans-serif;background:radial-gradient(circle at 80% -10%,rgba(59,130,246,.35),transparent 28%),linear-gradient(180deg,#07111f,#0f172a);color:white;min-height:100vh;padding:20px}}
.wrap{{width:min(980px,94vw);margin:auto}}.hero{{background:linear-gradient(135deg,rgba(30,64,175,.9),rgba(15,23,42,.92));border:1px solid rgba(147,197,253,.25);border-radius:28px;padding:22px;box-shadow:0 25px 70px rgba(0,0,0,.32)}}
h1{{margin:0 0 8px;font-size:clamp(28px,5vw,42px)}}p{{color:#dbeafe;line-height:1.65;margin:8px 0}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}}a{{display:inline-flex;align-items:center;justify-content:center;gap:8px;text-decoration:none;border:1px solid rgba(148,163,184,.35);background:rgba(15,23,42,.65);border-radius:999px;padding:11px 15px;font-weight:900;color:white}}
a:hover{{border-color:var(--blue);background:rgba(59,130,246,.22)}}.grid{{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin:18px 0}}.card{{background:rgba(16,28,49,.9);border:1px solid var(--line);border-radius:22px;padding:18px;box-shadow:0 16px 45px rgba(0,0,0,.22)}}.card h2{{margin:0 0 10px;font-size:22px}}.line{{display:flex;justify-content:space-between;gap:12px;border-top:1px solid #263852;padding-top:10px;margin-top:10px;color:#cbd5e1;direction:rtl}}.ltr{{direction:ltr;text-align:left;overflow-wrap:anywhere;color:#fde68a}}.badge{{display:inline-flex;border-radius:999px;padding:6px 10px;font-weight:900;font-size:13px}}.badge.ok{{background:rgba(22,101,52,.45);border:1px solid rgba(74,222,128,.45);color:#bbf7d0}}.badge.bad{{background:rgba(127,29,29,.45);border:1px solid rgba(248,113,113,.45);color:#fecaca}}.note{{background:rgba(15,23,42,.75);border:1px solid #334155;border-radius:18px;padding:14px;color:#cbd5e1;line-height:1.6}}pre{{white-space:pre-wrap;direction:ltr;text-align:left;background:#020617;color:#fde68a;border:1px solid #334155;border-radius:18px;padding:16px;overflow:auto;max-height:360px}}
@media(max-width:760px){{body{{padding:14px}}.grid{{grid-template-columns:1fr}}.hero{{padding:18px;border-radius:24px}}.card{{padding:15px}}.line{{display:block}}.ltr{{margin-top:5px}}}}
</style>
</head>
<body><div class="wrap">
<div class="hero">
<h1>🩺 בדיקת מערכת</h1>
<p>זה מסך קריא לבני אדם. נקודת הקצה הטכנית עדיין קיימת ב־<span class="ltr">/health?json=1</span> למקרה שצריך בדיקה אוטומטית.</p>
<div class="actions"><a href="/admin">⬅ חזרה לפאנל</a><a href="/admin/status">📊 סטטוס קבצים</a><a href="/health?json=1" target="_blank">JSON טכני</a><a href="/admin/self-test">🧪 בדיקה עצמית מלאה</a></div>
</div>
<div class="grid">
<div class="card"><h2>מצב שרת</h2><div>{badge(True)}</div><div class="line"><b>גרסה</b><span>{html_lib.escape(ADMIN_PANEL_VERSION)}</span></div><div class="line"><b>עלה בתאריך</b><span>{html_lib.escape(ADMIN_STARTED_AT)}</span></div><div class="line"><b>פורט</b><span>{ADMIN_PORT}</span></div><div class="line"><b>Host</b><span>{html_lib.escape(ADMIN_HOST)}</span></div><div class="line"><b>Restart helper</b><span>{badge(payload.get("restart_helper_ok", False), "תקין", "תקול")}</span></div></div>
<div class="card"><h2>מנוע מדיה</h2><div>{badge(pillow_ok, "Pillow תקין", "Pillow חסר")} {badge(ffmpeg_ok, "FFmpeg תקין", "FFmpeg חסר")}</div><div class="line"><b>תמונות</b><span>המרה ודחיסה ל־JPG</span></div><div class="line"><b>אודיו</b><span>המרה ל־MP3</span></div><div class="line"><b>FFmpeg</b><span class="ltr">{html_lib.escape(ffmpeg_path)}</span></div></div>
<div class="card"><h2>פעולות מחשב</h2><div>{badge(power_ok, "פעיל", "כבוי")}</div><div class="line"><b>כיבוי / ריסטארט</b><span>{"מופעל בפאנל" if power_ok else "מנוטרל בהגדרות"}</span></div><div class="line"><b>הגנות</b><span>POST, רשת מקומית, אישור כתוב, טיימר וביטול</span></div></div>
<div class="card"><h2>כיסוי מצגת ארה״ב</h2><div>{badge(len(USA_IMAGE_KEYS) == 44, "44 שקפים", str(len(USA_IMAGE_KEYS)) + " שקפים")}</div><div class="line"><b>תמונות שקפים</b><span>{len(USA_IMAGE_KEYS)}</span></div><div class="line"><b>העלאה</b><span>JPG/PNG/WebP וכו׳ → JPG דחוס</span></div></div>
<div class="card"><h2>כיסוי מצגת יפן</h2><div>{badge(japan_usage.get('presentation_exists') and japan_usage.get('missing_external_images', 0) == 0, "תקין", "לבדיקה")}</div><div class="line"><b>תמונות בשימוש בפועל</b><span>{japan_usage.get('image_slides_total', 0)}</span></div><div class="line"><b>קבצים חיצוניים</b><span>{japan_usage.get('external_files_used', 0)}</span></div><div class="line"><b>תמונות מוטמעות</b><span>{japan_usage.get('embedded_images', 0)}</span></div><div class="line"><b>קבצים בתיקייה</b><span>{japan_usage.get('folder_images', 0)}</span></div><div class="line"><b>לא בשימוש / חסרות</b><span>{japan_usage.get('unused_folder_images', 0)} / {japan_usage.get('missing_external_images', 0)}</span></div></div>
</div>
<div class="note"><b>מה קרה קודם?</b><br>לחצן Health פתח את נקודת הקצה הטכנית והציג JSON שחור. זה לא כשל בשרת — להפך, לפי הנתונים אצלך FFmpeg ו־Pillow זמינים — אבל זה לא מתאים ככפתור במסך ראשי. בגרסה הזו הכפתור מוביל למסך בדיקה קריא, וה־JSON נשאר רק לבדיקה טכנית.</div>
<h2>מידע טכני מלא</h2><pre>{json_text}</pre>
</div></body></html>
"""



@app.route("/admin/self-test")
def admin_self_test():
    return render_admin_self_test()


@app.route("/admin/self-test.json")
def admin_self_test_json():
    include_conversion = (request.args.get("conversion") == "1")
    return run_self_test_suite(include_media_conversion=include_conversion)

@app.route("/health")
def health():
    accept = request.headers.get("Accept", "")
    if request.args.get("json") != "1" and "text/html" in accept:
        return redirect("/admin/health")
    return health_payload()


@app.route("/health.json")
def health_json():
    return health_payload()


@app.route("/admin/health")
def admin_health():
    return render_admin_health()


@app.route("/admin/status")
def admin_status():
    return render_admin_status()


@app.route("/upload_admin_server", methods=["POST"])
def upload_admin_server():
    file = request.files.get("file")
    if not file or file.filename == "":
        return admin_status_page("לא נבחר קובץ", "בחר קובץ admin_server.py להעלאה.", ok=False), 400

    original_name = file.filename or ""
    if not original_name.lower().endswith(".py"):
        return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ Python עם סיומת .py", ok=False), 400

    if request.content_length and request.content_length > ADMIN_MAX_UPLOAD_BYTES + 250000:
        return admin_status_page("קובץ גדול מדי", f"הקובץ גדול מהמותר. המגבלה היא בערך {ADMIN_MAX_UPLOAD_BYTES:,} bytes.", ok=False), 413

    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"admin_server_upload_{stamp}.py")

    try:
        file.save(tmp_path)
        valid, details = validate_admin_server_upload(tmp_path)
        if not valid:
            try:
                os.remove(tmp_path)
            except Exception:
                pass
            return admin_status_page("העלאת admin_server.py נדחתה", "הקובץ הקיים לא הוחלף. נמצאה בעיה בקובץ שהעלית.", ok=False, details=details), 400

        backup_path = backup_current_admin_server("admin_server_backup")
        os.replace(tmp_path, ADMIN_SERVER_PATH)
        new_sha = file_sha256(ADMIN_SERVER_PATH)
        message = (
            "הקובץ החדש עבר בדיקת קומפילציה והחליף את admin_server.py הפעיל.\n"
            "נוצר גיבוי לקובץ הקודם.\n\n"
            "מנגנון אתחול אוטומטי הופעל עכשיו. רענן את הפאנל בעוד כמה שניות."
        )
        details = f"קובץ פעיל:\n{ADMIN_SERVER_PATH}\n\nSHA256 חדש:\n{new_sha}\n\nגיבוי:\n{backup_path}\n\nלוג אתחול:\n{ADMIN_RESTART_LOG_PATH}"
        threading.Thread(target=restart_process_later, daemon=True).start()
        return admin_status_page("admin_server.py עודכן והאתחול הופעל", message, ok=True, details=details)
    except Exception as exc:
        try:
            if os.path.exists(tmp_path):
                os.remove(tmp_path)
        except Exception:
            pass
        return admin_status_page("שגיאה בעדכון admin_server.py", "הקובץ הקיים לא הוחלף.", ok=False, details=repr(exc)), 500


@app.route("/restart_admin_server", methods=["POST"])
def restart_admin_server():
    threading.Thread(target=restart_process_later, daemon=True).start()
    return admin_status_page(
        "הפאנל מופעל מחדש",
        "האיפוס התחיל דרך מנגנון Restart חיצוני. המתן 5–10 שניות ואז רענן את הפאנל. אם לא עולה, בדוק את C:\\TripServer\\admin_restart.log.",
        ok=True,
    )


@app.route("/restore_admin_server_backup", methods=["POST"])
def restore_admin_server_backup():
    backup_path = latest_admin_backup()
    if not backup_path:
        return admin_status_page("אין גיבוי לשחזור", "לא נמצא קובץ גיבוי בתיקיית הגיבויים.", ok=False), 404

    try:
        valid, details = validate_admin_server_upload(backup_path)
        if not valid:
            return admin_status_page("הגיבוי האחרון לא תקין", "הגיבוי לא שוחזר כי הוא לא עבר בדיקת תקינות.", ok=False, details=details), 400
        safety_backup = backup_current_admin_server("admin_server_before_restore")
        shutil.copy2(backup_path, ADMIN_SERVER_PATH)
        msg = "הגיבוי האחרון שוחזר אל admin_server.py. לחץ אתחל פאנל כדי להפעיל אותו."
        det = f"שוחזר מתוך:\n{backup_path}\n\nגיבוי בטיחות לגרסה שהייתה פעילה לפני השחזור:\n{safety_backup}"
        return admin_status_page("שחזור גיבוי הסתיים", msg, ok=True, details=det)
    except Exception as exc:
        return admin_status_page("שגיאה בשחזור גיבוי", "השחזור לא הושלם.", ok=False, details=repr(exc)), 500


@app.route("/upload/<folder>", methods=["POST"])
def upload(folder):
    if folder not in FOLDERS:
        return "Folder not found", 404
    file = request.files.get("file")
    if not file or file.filename == "":
        return "No file selected", 400
    if folder in APP_FOLDERS and file_ext(file.filename) not in {".html", ".htm"}:
        return admin_status_page("קובץ לא תקין", "לאזור הזה ניתן להעלות רק קובץ HTML.", ok=False), 400
    try:
        filename, result = save_uploaded_file(folder, file)
        details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}"
        if result.get("backup"):
            details += f"\n\nגיבוי קודם:\n{result['backup']}"
        return admin_status_page("העלאה הושלמה", f"הקובץ נשמר בהצלחה כ־{filename}. אם אתה רואה גרסה ישנה, פתח מחדש עם רענון מלא.", ok=True, details=details)
    except Exception as exc:
        return admin_status_page("שגיאה בהעלאה", "הקובץ לא נשמר. הקובץ הקודם נשאר במקומו אם היה קיים.", ok=False, details=repr(exc)), 500


@app.route("/upload_presentation/<presentation_key>", methods=["POST"])
def upload_presentation(presentation_key):
    file = request.files.get("file")
    if not file or file.filename == "":
        return "No file selected", 400
    if file_ext(file.filename) not in {".html", ".htm"}:
        return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ HTML של מצגת יפן.", ok=False), 400
    saved = save_japan_presentation(presentation_key, file)
    if not saved:
        return "Presentation type not found", 404
    filename, result = saved
    details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}"
    if result.get("backup"):
        details += f"\n\nגיבוי קודם:\n{result['backup']}"
    return admin_status_page("מצגת יפן הועלתה", f"הקובץ נשמר בשם הקבוע: {filename}", ok=True, details=details)


@app.route("/upload_usa_presentation/<presentation_key>", methods=["POST"])
def upload_usa_presentation(presentation_key):
    file = request.files.get("file")
    if not file or file.filename == "":
        return "No file selected", 400
    if file_ext(file.filename) not in {".html", ".htm"}:
        return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ HTML של מצגת ארה״ב.", ok=False), 400
    saved = save_usa_presentation(presentation_key, file)
    if not saved:
        return "USA presentation type not found", 404
    filename, result = saved
    details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}"
    if result.get("backup"):
        details += f"\n\nגיבוי קודם:\n{result['backup']}"
    return admin_status_page("מצגת ארה״ב הועלתה", f"הקובץ נשמר בשם הקבוע: {filename}", ok=True, details=details)


@app.route("/upload_usa_image", methods=["POST"])
def upload_usa_image():
    image_key = request.form.get("image_key", "").strip()
    file = request.files.get("file")
    if not file or file.filename == "":
        return "No file selected", 400
    try:
        result = save_usa_presentation_image(image_key, file)
        if not result:
            return "USA image key not found", 404
        details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}\n\nמצב שמירה:\n{result['mode']}"
        if result.get("backup"):
            details += f"\n\nגיבוי קודם:\n{result['backup']}"
        details += "\n\n" + media_details_text(result.get("media"))
        return admin_status_page("תמונת מצגת ארה״ב הועלתה", f"התמונה נשמרה בשם הקבוע: {result['rel']}", ok=True, details=details)
    except Exception as exc:
        return admin_status_page("שגיאה בהעלאת תמונה", "התמונה לא נשמרה. להמרה ודחיסה של PNG/WebP/BMP/TIFF צריך Pillow בשרת. JPG אמיתי יתקבל גם בלי Pillow.", ok=False, details=repr(exc)), 400


@app.route("/upload_usa_images_zip", methods=["POST"])
def upload_usa_images_zip():
    file = request.files.get("file")
    if not file or file.filename == "":
        return "No file selected", 400
    original_name = file.filename or ""
    if not original_name.lower().endswith(".zip"):
        return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ ZIP של תמונות מצגת.", ok=False), 400
    if request.content_length and request.content_length > USA_IMAGES_ZIP_MAX_BYTES + 1024 * 1024:
        return admin_status_page("קובץ גדול מדי", f"קובץ התמונות גדול מהמגבלה: {USA_IMAGES_ZIP_MAX_BYTES:,} bytes.", ok=False), 413
    try:
        imported, skipped, missing = save_usa_presentation_images_zip(file)
        details = "נשמרו בתיקיית התמונות:\n" + ("\n".join(imported) if imported else "לא נשמרו תמונות.")
        if skipped:
            details += "\n\nדולגו:\n" + "\n".join(skipped[:120])
        if missing:
            details += "\n\nלא נמצאו ב־ZIP, ולכן לא הוחלפו:\n" + "\n".join(missing[:120])
        return admin_status_page(
            "ZIP תמונות ארה״ב נטען",
            f"נשמרו {len(imported)} תמונות למצגת. קבצים קיימים באותו שם הוחלפו.",
            ok=True,
            details=details,
        )
    except zipfile.BadZipFile:
        return admin_status_page("ZIP לא תקין", "הקובץ שהועלה אינו ZIP תקין או שלא ניתן לפתוח אותו.", ok=False), 400
    except ValueError as exc:
        return admin_status_page("ZIP לא תקין", "הקובץ לא עומד במגבלות העלאה של הפאנל.", ok=False, details=str(exc)), 400
    except Exception as exc:
        return admin_status_page("שגיאה בהעלאת ZIP", "לא ניתן היה לעבד את קובץ התמונות.", ok=False, details=repr(exc)), 500


@app.route("/upload_usa_extra_html/<extra_key>", methods=["POST"])
def upload_usa_extra_html(extra_key):
    file = request.files.get("file")
    if not file or file.filename == "":
        return "No file selected", 400
    if file_ext(file.filename) not in {".html", ".htm"}:
        return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ HTML.", ok=False), 400
    saved = save_usa_extra_html(extra_key, file)
    if not saved:
        return "USA extra HTML type not found", 404
    filename, result = saved
    details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}"
    if result.get("backup"):
        details += f"\n\nגיבוי קודם:\n{result['backup']}"
    return admin_status_page("קובץ ארה״ב נוסף הועלה", f"הקובץ נשמר בשם הקבוע: {filename}", ok=True, details=details)


@app.route("/upload_japan_music/<music_key>", methods=["POST"])
def upload_japan_music(music_key):
    file = request.files.get("file")
    if not file or file.filename == "":
        return "No file selected", 400
    if request.content_length and request.content_length > AUDIO_UPLOAD_MAX_BYTES + 1024 * 1024:
        return admin_status_page("קובץ מוזיקה גדול מדי", f"קובץ האודיו גדול מהמגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}.", ok=False), 413
    result = save_japan_music(music_key, file)
    if not result:
        return "Japan music type not found", 404
    details = "נשמר אל:\n" + "\n".join([item["path"] for item in result["targets"]]) + f"\n\nSHA256 MP3 סופי:\n{result['sha256']}"
    details += "\n\n" + media_details_text(result.get("media"))
    return admin_status_page("מוזיקת יפן הועלתה", "הקובץ נשמר בכל הנתיבים שהמצגת מחפשת כ־MP3 אמיתי.", ok=True, details=details)


@app.route("/upload_japan_images_zip", methods=["POST"])
def upload_japan_images_zip():
    file = request.files.get("file")
    if not file or file.filename == "":
        return "No file selected", 400
    if not (file.filename or "").lower().endswith(".zip"):
        return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק ZIP תמונות יפן.", ok=False), 400
    if request.content_length and request.content_length > JAPAN_IMAGES_ZIP_MAX_BYTES + 1024 * 1024:
        return admin_status_page("קובץ גדול מדי", f"קובץ התמונות גדול מהמגבלה: {JAPAN_IMAGES_ZIP_MAX_BYTES:,} bytes.", ok=False), 413
    try:
        imported, skipped, overwritten = save_japan_images_zip(file)
        details = "יובאו:\n" + ("\n".join(imported[:180]) if imported else "לא יובאו תמונות.")
        if overwritten:
            details += "\n\nהוחלפו קבצים קיימים:\n" + "\n".join(overwritten[:120])
        if skipped:
            details += "\n\nדולגו:\n" + "\n".join(skipped[:120])
        return admin_status_page("ZIP תמונות יפן נטען", f"נשמרו {len(imported)} תמונות תחת JAPAN/media/images.", ok=True, details=details)
    except zipfile.BadZipFile:
        return admin_status_page("ZIP לא תקין", "הקובץ שהועלה אינו ZIP תקין או שלא ניתן לפתוח אותו.", ok=False), 400
    except Exception as exc:
        return admin_status_page("שגיאה בהעלאת ZIP יפן", "לא ניתן היה לעבד את קובץ התמונות.", ok=False, details=repr(exc)), 500


@app.route("/upload_trip_package/<trip>", methods=["POST"])
def upload_trip_package(trip):
    trip = (trip or "").upper()
    file = request.files.get("file")
    if trip not in {"USA", "JAPAN"}:
        return "Trip package type not found", 404
    if not file or file.filename == "":
        return "No file selected", 400
    if not (file.filename or "").lower().endswith(".zip"):
        return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק ZIP מלא של הטיול.", ok=False), 400
    if request.content_length and request.content_length > TRIP_PACKAGE_ZIP_MAX_BYTES + 1024 * 1024:
        return admin_status_page("קובץ גדול מדי", f"ה־ZIP גדול מהמגבלה: {TRIP_PACKAGE_ZIP_MAX_BYTES:,} bytes.", ok=False), 413
    try:
        imported, skipped, overwritten = save_trip_package_zip(trip, file)
        details = "יובאו:\n" + ("\n".join(imported[:200]) if imported else "לא יובאו קבצים.")
        if overwritten:
            details += "\n\nהוחלפו קבצים קיימים:\n" + "\n".join(overwritten[:140])
        if skipped:
            details += "\n\nדולגו:\n" + "\n".join(skipped[:140])
        return admin_status_page(f"ZIP מלא של {trip} נטען", f"נשמרו {len(imported)} קבצים בתיקיית {trip}.", ok=True, details=details)
    except zipfile.BadZipFile:
        return admin_status_page("ZIP לא תקין", "הקובץ שהועלה אינו ZIP תקין או שלא ניתן לפתוח אותו.", ok=False), 400
    except Exception as exc:
        return admin_status_page("שגיאה בהעלאת ZIP מלא", "לא ניתן היה לעבד את חבילת הטיול.", ok=False, details=repr(exc)), 500


@app.route("/upload_usa_music/<music_key>", methods=["POST"])
def upload_usa_music(music_key):
    file = request.files.get("file")
    if not file or file.filename == "":
        return "No file selected", 400
    if request.content_length and request.content_length > AUDIO_UPLOAD_MAX_BYTES + 1024 * 1024:
        return admin_status_page("קובץ מוזיקה גדול מדי", f"קובץ האודיו גדול מהמגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}.", ok=False), 413
    saved = save_usa_music(music_key, file)
    if not saved:
        return "USA music type not found", 404
    filename, result = saved
    details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}"
    if result.get("backup"):
        details += f"\n\nגיבוי קודם:\n{result['backup']}"
    details += "\n\n" + media_details_text(result.get("media"))
    return admin_status_page("מוזיקת ארה״ב הועלתה", f"הקובץ נשמר בנתיב הקבוע: {filename}", ok=True, details=details)



@app.route("/power/<action>", methods=["POST"])
def power_action(action):
    action = (action or "").strip().lower()
    if action not in POWER_CONFIRM_TEXT:
        return admin_status_page("פעולת מחשב לא מוכרת", "הפעולה המבוקשת אינה קיימת.", ok=False), 404

    allowed, reason = power_action_allowed()
    if not allowed:
        return admin_status_page("פעולת מחשב נחסמה", reason, ok=False), 403

    if request.form.get("ack") != "1":
        return admin_status_page("חסר אישור פעולה", "כדי למנוע לחיצה בטעות, צריך לסמן שאתה מבין את המשמעות של הפעולה.", ok=False), 400

    expected = POWER_CONFIRM_TEXT[action]
    confirm_text = (request.form.get("confirm_text") or "").strip()
    if confirm_text != expected:
        return admin_status_page("מילת האישור לא נכונה", f"כדי לבצע את הפעולה צריך להקליד בדיוק: {expected}", ok=False), 400

    delay = normalize_power_delay(request.form.get("delay"))
    try:
        label, final_delay, output = run_power_command(action, delay)
        details = f"פעולה:\n{label}\n\nתבוצע בעוד:\n{final_delay} שניות\n\nכתובת מבקשת:\n{get_client_ip()}\n\nפלט Windows:\n{output or 'OK'}"
        return admin_status_page("פעולת מחשב נקבעה", f"{label} נקבע בהצלחה. אפשר לבטל לפני סוף הטיימר דרך כפתור הביטול בפאנל.", ok=True, details=details)
    except Exception as exc:
        return admin_status_page("שגיאה בהפעלת פקודת מחשב", "Windows לא קיבל את פקודת הכיבוי/ריסטארט.", ok=False, details=repr(exc)), 500


@app.route("/power/cancel", methods=["POST"])
def power_cancel():
    allowed, reason = power_action_allowed()
    if not allowed:
        return admin_status_page("ביטול פעולת מחשב נחסם", reason, ok=False), 403
    rc, output = cancel_power_command()
    ok = (rc == 0)
    title = "פעולת מחשב מתוזמנת בוטלה" if ok else "לא נמצאה פעולה מתוזמנת לביטול"
    msg = "אם היה כיבוי/ריסטארט בהמתנה — הוא בוטל." if ok else "Windows החזיר שאין כרגע כיבוי/ריסטארט בהמתנה, או שלא ניתן לבטל."
    return admin_status_page(title, msg, ok=ok, details=output or f"return code: {rc}")

@app.route("/JAPAN")
@app.route("/JAPAN/")
def japan():
    return serve_app("JAPAN")


@app.route("/USA")
@app.route("/USA/")
def usa():
    return serve_app("USA")


@app.route("/Moneyapp")
@app.route("/Moneyapp/")
def moneyapp():
    return serve_app("Moneyapp")


@app.route("/MoneyHome")
@app.route("/MoneyHome/")
def moneyhome():
    return serve_app("MoneyHome")


@app.route("/JAPAN/<path:path>")
def japan_static(path):
    return serve_file_no_cache(FOLDERS["JAPAN"], path)


@app.route("/USA/<path:path>")
def usa_static(path):
    return serve_file_no_cache(FOLDERS["USA"], path)


@app.route("/Moneyapp/<path:path>")
def money_static(path):
    return serve_file_no_cache(FOLDERS["Moneyapp"], path)


@app.route("/TripForm")
@app.route("/TripForm/")
def tripform():
    return serve_app("TripForm")


@app.route("/MoneyHome/<path:path>")
def moneyhome_static(path):
    return serve_file_no_cache(FOLDERS["MoneyHome"], path)


@app.route("/TripForm/<path:path>")
def tripform_static(path):
    return serve_file_no_cache(FOLDERS["TripForm"], path)


if __name__ == "__main__":
    app.run(host=ADMIN_HOST, port=ADMIN_PORT, threaded=True, use_reloader=False)
