⬅ חזרה לתיקייה · הורד קובץ · ערוך

📄 admin_server_backup_20260702_211043.py

C:\TripServer\backups\admin_server\admin_server_backup_20260702_211043.py

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

from flask import Flask, request, redirect, send_from_directory, make_response, 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 v31.0 stable self-exit restart"
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")

# External watchdog. This is intentionally separate from the Flask process: if the admin
# server is down, Windows Task Scheduler can still run this script every minute and bring
# the admin back without walking to the physical computer.
WATCHDOG_TASK_NAME = os.environ.get("TRIPSERVER_WATCHDOG_TASK", "TripServerAdminWatchdog")
WATCHDOG_SCRIPT_PATH = os.path.join(BASE_DIR, "tripserver_admin_watchdog.py")
WATCHDOG_LOG_PATH = os.path.join(BASE_DIR, "tripserver_watchdog.log")
WATCHDOG_STATE_PATH = os.path.join(BASE_DIR, "tripserver_watchdog_state.txt")
WATCHDOG_INTERVAL_MINUTES = int(os.environ.get("TRIPSERVER_WATCHDOG_INTERVAL_MINUTES", "5"))
WATCHDOG_MIN_RESTART_INTERVAL_SECONDS = int(os.environ.get("TRIPSERVER_WATCHDOG_RESTART_COOLDOWN", "90"))

# v27: self-update/restart is launched through Windows Task Scheduler, not as a
# direct child of the running Flask process. This prevents the old admin process
# from killing the helper that is supposed to bring the new admin back online.
RESTART_TASK_NAME = os.environ.get("TRIPSERVER_RESTART_TASK", "TripServerAdminRestartOnce")



def get_pythonw_executable(preferred=None):
    """Return a no-console Python executable for Windows scheduled tasks when available.

    Running the watchdog every few minutes with python.exe can flash a black console
    window and steal focus. pythonw.exe runs the same script without a console.
    On non-Windows or if pythonw.exe is not available, fall back to the normal
    interpreter.
    """
    exe = preferred or sys.executable or "python"
    if os.name != "nt":
        return exe
    try:
        base = os.path.basename(exe).lower()
        folder = os.path.dirname(exe)
        if base == "pythonw.exe" and os.path.exists(exe):
            return exe
        if folder:
            candidate = os.path.join(folder, "pythonw.exe")
            if os.path.exists(candidate):
                return candidate
        # Last-resort common launcher name. Do not validate here because it may be in PATH.
        return "pythonw.exe"
    except Exception:
        return exe

# Admin log viewer. Only these fixed paths are exposed in the UI; user-supplied paths are never accepted.
LOG_FILE_REGISTRY = {
    "restart": {"label": "לוג אתחול פאנל", "path": ADMIN_RESTART_LOG_PATH, "note": "Restart helper, העלאת admin חדש, שחזור גיבוי ופעולות מחשב."},
    "watchdog": {"label": "לוג Watchdog", "path": WATCHDOG_LOG_PATH, "note": "בדיקות Health, הפעלה אוטומטית של הפאנל, cooldown ושגיאות Task Scheduler."},
}
LOG_TAIL_DEFAULT_LINES = 120
LOG_TAIL_MAX_LINES = 500


# 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": "ריסטארט למחשב",
}

# Webcam / room camera control. The camera is never opened automatically on page load.
# It starts only when the user presses the camera button, and it is guarded to LAN/Tailscale/private IPs.
CAMERA_ENABLED = os.environ.get("TRIPSERVER_ENABLE_WEBCAM", "1").strip().lower() not in {"0", "false", "no", "off"}
CAMERA_INDEX = int(os.environ.get("TRIPSERVER_WEBCAM_INDEX", "0"))
CAMERA_WIDTH = int(os.environ.get("TRIPSERVER_WEBCAM_WIDTH", "1280"))
CAMERA_HEIGHT = int(os.environ.get("TRIPSERVER_WEBCAM_HEIGHT", "720"))
CAMERA_FPS = max(1, min(20, int(os.environ.get("TRIPSERVER_WEBCAM_FPS", "8"))))
CAMERA_JPEG_QUALITY = max(45, min(92, int(os.environ.get("TRIPSERVER_WEBCAM_JPEG_QUALITY", "78"))))
CAMERA_MAX_STREAM_SECONDS = int(os.environ.get("TRIPSERVER_WEBCAM_MAX_STREAM_SECONDS", "1800"))
CAMERA_FRAME_INTERVAL_SECONDS = 1.0 / float(CAMERA_FPS)

# Optional audio + recording layer. Audio and recording are manual-only and guarded by the same
# LAN/Tailscale/private-IP checks as the live camera. FFmpeg is required for audio and MP4 recording.
CAMERA_AUDIO_ENABLED = os.environ.get("TRIPSERVER_ENABLE_CAMERA_AUDIO", "1").strip().lower() not in {"0", "false", "no", "off"}
CAMERA_RECORDING_ENABLED = os.environ.get("TRIPSERVER_ENABLE_CAMERA_RECORDING", "1").strip().lower() not in {"0", "false", "no", "off"}
CAMERA_VIDEO_DEVICE = os.environ.get("TRIPSERVER_FFMPEG_VIDEO_DEVICE", "").strip()
CAMERA_AUDIO_DEVICE = os.environ.get("TRIPSERVER_FFMPEG_AUDIO_DEVICE", "").strip()
CAMERA_RECORDINGS_DIR = os.environ.get("TRIPSERVER_CAMERA_RECORDINGS_DIR", os.path.join(BASE_DIR, "camera_recordings"))
CAMERA_RECORD_FPS = max(1, min(30, int(os.environ.get("TRIPSERVER_CAMERA_RECORD_FPS", "15"))))
CAMERA_RECORD_MAX_SECONDS = max(30, min(8 * 3600, int(os.environ.get("TRIPSERVER_CAMERA_RECORD_MAX_SECONDS", "3600"))))
CAMERA_AUDIO_BITRATE = os.environ.get("TRIPSERVER_CAMERA_AUDIO_BITRATE", "128k")

# Camera component installer. First-run auto installer: if OpenCV is missing, the admin
# starts a background pip installation automatically when the Flask process starts.
# It uses the same Python executable that runs this admin server and writes a log.
# The camera itself still never turns on automatically; only the dependency installer runs.
CAMERA_SETUP_LOG_PATH = os.path.join(BASE_DIR, "camera_setup.log")
CAMERA_AUDIO_LOG_PATH = os.path.join(BASE_DIR, "camera_audio_stream.log")
CAMERA_HLS_DIR = os.path.join(BASE_DIR, "camera_live_hls")
CAMERA_HLS_LOG_PATH = os.path.join(BASE_DIR, "camera_hls_stream.log")
CAMERA_SETUP_CONFIRM_TEXT = "התקן"
CAMERA_OPENCV_PACKAGE = os.environ.get("TRIPSERVER_OPENCV_PACKAGE", "opencv-python").strip() or "opencv-python"
CAMERA_OPENCV_INSTALL_TIMEOUT_SECONDS = int(os.environ.get("TRIPSERVER_OPENCV_INSTALL_TIMEOUT_SECONDS", "900"))
CAMERA_AUTO_INSTALL_OPENCV = os.environ.get("TRIPSERVER_CAMERA_AUTO_INSTALL_OPENCV", "1").strip().lower() not in {"0", "false", "no", "off"}
CAMERA_AUTO_INSTALL_DELAY_SECONDS = int(os.environ.get("TRIPSERVER_CAMERA_AUTO_INSTALL_DELAY_SECONDS", "4"))
CAMERA_SETUP_MARKER_PATH = os.path.join(BASE_DIR, "camera_opencv_installed.ok")
CAMERA_SETUP_STATE = {"running": False, "started_at": "", "finished_at": "", "ok": False, "last_message": "", "mode": "idle", "auto_requested": False}
CAMERA_SETUP_LOCK = threading.RLock()
LOG_FILE_REGISTRY["camera_setup"] = {"label": "לוג התקנת רכיבי מצלמה", "path": CAMERA_SETUP_LOG_PATH, "note": "התקנת OpenCV דרך pip ובדיקת רכיבי מצלמה."}
LOG_FILE_REGISTRY["camera_audio"] = {"label": "לוג אודיו מצלמה", "path": CAMERA_AUDIO_LOG_PATH, "note": "FFmpeg DirectShow, בחירת מיקרופון וניסיונות הפעלת אודיו."}
LOG_FILE_REGISTRY["camera_hls"] = {"label": "לוג שידור מצלמה מאוחד HLS", "path": CAMERA_HLS_LOG_PATH, "note": "FFmpeg DirectShow וידאו+אודיו בתהליך אחד, כדי למנוע נעילת התקני USB."}


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']}"
    )
    watchdog_info = watchdog_status(include_log=False)
    watchdog_label = "ON" if watchdog_info.get("task_installed") else "OFF"
    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 class="card"><div>Watchdog</div><div class="num">{watchdog_label}</div><div class="small">משימה מתוזמנת חיצונית לשמירת הפאנל באוויר.</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", "build_watchdog_code", "watchdog_status"]
    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}}
.advanced-note{{margin-top:10px;color:#cbd5e1;font-size:13px;line-height:1.55}}
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 class="advanced-note">לא חובה אחרי התקנת Watchdog. מיועד לבדיקה יזומה או אחרי עדכון/שחזור קובץ אדמין.</div>
</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, current_pid=None, backup_path=None, expected_sha=None, reason=""):
    """Build a simple and conservative restart helper.

    v29 change: stop doing rollback/health gating inside the helper. The logs from
    the real Windows machine showed the new Flask server starts, but the helper's
    own health request sometimes times out and then incorrectly rolls back or
    leaves the port in a bad state. Since uploaded admin files already pass
    compile + TripServer validation before replacement, the safest behavior is:
    wait, release port 8000, start admin_server.py, write logs, exit. Watchdog and
    the browser then verify availability through /ping.json.
    """
    return "\n".join([
        "# -*- coding: utf-8 -*-",
        "import os",
        "import sys",
        "import subprocess",
        "import time",
        "import socket",
        "import hashlib",
        "",
        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}",
        f"current_pid = {int(current_pid or 0)!r}",
        f"backup_path = {backup_path!r}",
        f"expected_sha = {expected_sha!r}",
        f"reason = {reason!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') + ' | restart-v31 | ' + str(message) + '\\n')",
        "    except Exception:",
        "        pass",
        "",
        "def file_sha256(path):",
        "    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 port_open():",
        "    try:",
        "        with socket.create_connection(('127.0.0.1', int(admin_port)), timeout=0.7):",
        "            return True",
        "    except Exception:",
        "        return False",
        "",
        "def wait_port_closed(timeout=12):",
        "    deadline = time.time() + timeout",
        "    while time.time() < deadline:",
        "        if not port_open():",
        "            return True",
        "        time.sleep(0.4)",
        "    return not port_open()",
        "",
        "def pids_on_port():",
        "    pids = []",
        "    if os.name != 'nt':",
        "        return pids",
        "    try:",
        "        cp = subprocess.run(['netstat', '-ano', '-p', 'tcp'], capture_output=True, text=True, shell=False, timeout=10)",
        "        out = (cp.stdout or '') + '\\n' + (cp.stderr or '')",
        "        marker = ':' + str(admin_port)",
        "        for line in out.splitlines():",
        "            if marker not in line or 'listen' not in line.lower():",
        "                continue",
        "            parts = line.split()",
        "            if parts:",
        "                try:",
        "                    pid = int(parts[-1])",
        "                    if pid and pid not in pids:",
        "                        pids.append(pid)",
        "                except Exception:",
        "                    pass",
        "    except Exception as exc:",
        "        log('netstat failed: ' + repr(exc))",
        "    return pids",
        "",
        "def kill_pid(pid):",
        "    try:",
        "        pid = int(pid)",
        "    except Exception:",
        "        return False",
        "    if pid <= 0 or pid == os.getpid():",
        "        log('skip kill pid=' + str(pid) + ' self=' + str(os.getpid()))",
        "        return False",
        "    try:",
        "        if os.name == 'nt':",
        "            # No /T. Kill only the process that owns port 8000, never a tree.",
        "            cp = subprocess.run(['taskkill', '/PID', str(pid), '/F'], capture_output=True, text=True, shell=False, timeout=12)",
        "            log('taskkill pid=' + str(pid) + ' rc=' + str(cp.returncode) + ' out=' + ((cp.stdout or cp.stderr or '').strip()[:500]))",
        "            return cp.returncode == 0",
        "        os.kill(pid, 9)",
        "        return True",
        "    except Exception as exc:",
        "        log('kill failed pid=' + str(pid) + ' err=' + repr(exc))",
        "        return False",
        "",
        "def release_port():",
        "    # Give the old request time to finish and Flask to exit if it exits naturally.",
        "    if wait_port_closed(10):",
        "        log('port closed naturally')",
        "        return",
        "    pids = pids_on_port()",
        "    log('port still open; killing owners without rollback; current_pid=' + str(current_pid) + ' pids=' + str(pids))",
        "    for pid in pids:",
        "        kill_pid(pid)",
        "    wait_port_closed(10)",
        "    log('port open after release=' + str(port_open()) + ' pids=' + str(pids_on_port()))",
        "",
        "def start_server():",
        "    flags = 0",
        "    if os.name == 'nt':",
        "        flags = getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0) | getattr(subprocess, 'DETACHED_PROCESS', 0) | getattr(subprocess, 'CREATE_BREAKAWAY_FROM_JOB', 0)",
        "    env = os.environ.copy()",
        "    env.setdefault('PYTHONIOENCODING', 'utf-8')",
        "    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') + ' | restart-v31 | starting admin: ' + 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'), env=env)",
        "        log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | restart-v31 | started admin pid=' + str(proc.pid) + '\\n')",
        "        log_file.flush()",
        "    return proc.pid",
        "",
        "def main():",
        "    log('simple restart started reason=' + str(reason) + ' script=' + script_path + ' python=' + python_exe + ' expected_sha=' + str(expected_sha) + ' backup=' + str(backup_path))",
        "    time.sleep(5.0)",
        "    if expected_sha:",
        "        try:",
        "            actual = file_sha256(script_path)",
        "            log('active sha=' + actual + ' expected=' + str(expected_sha))",
        "        except Exception as exc:",
        "            log('sha check failed: ' + repr(exc))",
        "    release_port()",
        "    start_server()",
        "    # Do not call /health or /ping here and do not roll back. Previous versions",
        "    # produced false timeout failures even though Flask was already running.",
        "    log('simple restart helper finished; browser/watchdog will verify /ping.json')",
        "    return 0",
        "",
        "if __name__ == '__main__':",
        "    raise SystemExit(main())",
        "",
    ]) + "\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, backup_path=None, expected_sha=None, reason=""):
    helper_code = build_restart_helper_code(script_path, python_exe, work_dir, os.getpid(), backup_path, expected_sha, reason)
    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)
    py_compile.compile(ADMIN_RESTART_HELPER_PATH, doraise=True)
    return ADMIN_RESTART_HELPER_PATH

def restart_process_later(backup_path=None, expected_sha=None, reason="manual_restart"):
    """Hard self-restart for Windows/iPhone admin workflow.

    v23: the helper can force-release a stuck port safely and roll back to the previous admin
    backup if the newly uploaded admin does not pass /ping.json after boot.
    """
    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"hard restart requested | reason={reason} | version={ADMIN_PANEL_VERSION} | script={script_path} | python={python_exe} | pid={os.getpid()} | backup={backup_path} | expected_sha={expected_sha} | argv={sys.argv}")
        helper_path = write_restart_helper(script_path, python_exe, work_dir, backup_path=backup_path, expected_sha=expected_sha, reason=reason)
        flags = 0
        if os.name == "nt":
            flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 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("hard restart helper launched: " + helper_path)
        os._exit(0)
    except Exception as exc:
        log_admin_restart("hard 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 launch_restart_helper_via_scheduler(helper_path, python_exe, work_dir):
    """Launch restart helper as an independent Windows Scheduled Task.

    This is more reliable than subprocess.Popen from the Flask process because the
    helper is not a child of the admin process it is about to kill. On Windows,
    self-restart helpers launched as child processes can be terminated together
    with the old server or stuck in the same job/process group.
    """
    if os.name != "nt":
        raise RuntimeError("Task Scheduler restart is supported only on Windows")
    quiet_python = get_pythonw_executable(python_exe)
    task_command = f'"{quiet_python}" "{helper_path}"'
    # Create a normal on-demand task. The time is deliberately far in the future;
    # we immediately run it with /Run, and then overwrite it on the next update.
    create_cmd = [
        "schtasks", "/Create",
        "/TN", RESTART_TASK_NAME,
        "/TR", task_command,
        "/SC", "ONCE",
        "/ST", "23:59",
        "/F",
    ]
    cp_create = subprocess.run(create_cmd, capture_output=True, text=True, shell=False, timeout=20)
    create_out = (cp_create.stdout or cp_create.stderr or "").strip()
    if cp_create.returncode != 0:
        raise RuntimeError("Failed to create restart task: " + (create_out or str(cp_create.returncode)))
    cp_run = subprocess.run(["schtasks", "/Run", "/TN", RESTART_TASK_NAME], capture_output=True, text=True, shell=False, timeout=20)
    run_out = (cp_run.stdout or cp_run.stderr or "").strip()
    if cp_run.returncode != 0:
        raise RuntimeError("Failed to run restart task: " + (run_out or str(cp_run.returncode)))
    log_admin_restart("restart helper launched via Task Scheduler | task=" + RESTART_TASK_NAME + " | python=" + quiet_python + " | create=" + create_out + " | run=" + run_out)
    return {"task": RESTART_TASK_NAME, "python": quiet_python, "create": create_out, "run": run_out}


def schedule_admin_self_exit(delay_seconds=2.5, reason='self_update'):
    """Exit the current Flask process shortly after the HTTP response is sent.

    This restores the behavior that made the early admin versions reliable: the
    old server releases port 8000 by exiting itself. The external restart helper
    then starts the new admin after a short delay instead of having to fight a
    still-running process.
    """
    def _exit_later():
        try:
            log_admin_restart(f'self-exit timer fired | reason={reason} | pid={os.getpid()}')
        finally:
            os._exit(0)
    try:
        t = threading.Timer(float(delay_seconds), _exit_later)
        t.daemon = True
        t.start()
        log_admin_restart(f'self-exit scheduled in {delay_seconds}s | reason={reason} | pid={os.getpid()}')
    except Exception as exc:
        log_admin_restart('self-exit scheduling failed: ' + repr(exc))


def launch_safe_external_restart(backup_path=None, expected_sha=None, reason="safe_external_restart"):
    """Launch a detached restart helper through Task Scheduler.

    v27: this is the important architectural fix. The helper is no longer a
    direct child of the Flask/admin process. It is started by Windows Task
    Scheduler, then it can safely kill the old process on port 8000 and start the
    new admin without dying with its parent.
    """
    script_path = os.path.abspath(ADMIN_SERVER_PATH)
    python_exe = sys.executable or "python"
    work_dir = os.path.dirname(script_path) or BASE_DIR
    log_admin_restart(
        f"scheduler restart requested | reason={reason} | version={ADMIN_PANEL_VERSION} | script={script_path} | python={python_exe} | pid={os.getpid()} | backup={backup_path} | expected_sha={expected_sha}"
    )
    helper_path = write_restart_helper(script_path, python_exe, work_dir, backup_path=backup_path, expected_sha=expected_sha, reason=reason)
    try:
        task_info = launch_restart_helper_via_scheduler(helper_path, python_exe, work_dir)
        schedule_admin_self_exit(2.5, reason=reason)
        return helper_path + " | task=" + str(task_info.get("task")) + " | python=" + str(task_info.get("python")) + " | self_exit=scheduled"
    except Exception as exc:
        # Fallback only: use direct detached Popen if Task Scheduler is unavailable.
        # This preserves some functionality, but the scheduled task path is the
        # intended reliable path on the user's Windows server.
        log_admin_restart("Task Scheduler restart launch failed, falling back to detached Popen: " + repr(exc))
        flags = 0
        if os.name == "nt":
            flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0)
        quiet_python = get_pythonw_executable(python_exe)
        subprocess.Popen(
            [quiet_python, 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 through fallback detached Popen: " + helper_path)
        schedule_admin_self_exit(2.5, reason=reason)
        return helper_path + " | fallback=detached-popen | self_exit=scheduled"


def build_watchdog_code(script_path, python_exe, work_dir):
    """Build a one-shot watchdog script for Windows Task Scheduler.

    v28 change: if the port is open but /ping.json fails repeatedly, the watchdog
    treats it as a stuck admin process, kills the process that owns port 8000, and
    starts admin_server.py again. This is the case that forced a physical reboot.
    """
    return "\n".join([
        "# -*- coding: utf-8 -*-",
        "import os",
        "import sys",
        "import time",
        "import socket",
        "import subprocess",
        "import urllib.request",
        "import json",
        "",
        f"script_path = {script_path!r}",
        f"python_exe = {python_exe!r}",
        f"work_dir = {work_dir!r}",
        f"admin_port = {ADMIN_PORT!r}",
        f"health_url = {'http://127.0.0.1:%s/ping.json' % ADMIN_PORT!r}",
        f"log_path = {WATCHDOG_LOG_PATH!r}",
        f"state_path = {WATCHDOG_STATE_PATH!r}",
        f"cooldown_seconds = {WATCHDOG_MIN_RESTART_INTERVAL_SECONDS!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') + ' | watchdog-v28 | ' + str(message) + '\\n')",
        "    except Exception:",
        "        pass",
        "",
        "def load_state():",
        "    try:",
        "        raw = open(state_path, 'r', encoding='utf-8').read().strip()",
        "        if not raw:",
        "            return {}",
        "        try:",
        "            data = json.loads(raw)",
        "            return data if isinstance(data, dict) else {}",
        "        except Exception:",
        "            return {'last_start': float(raw)}",
        "    except Exception:",
        "        return {}",
        "",
        "def save_state(state):",
        "    try:",
        "        os.makedirs(os.path.dirname(state_path), exist_ok=True)",
        "        with open(state_path, 'w', encoding='utf-8') as fh:",
        "            json.dump(state, fh)",
        "    except Exception:",
        "        pass",
        "",
        "def port_open():",
        "    try:",
        "        with socket.create_connection(('127.0.0.1', int(admin_port)), timeout=2.0):",
        "            return True",
        "    except Exception:",
        "        return False",
        "",
        "def pids_on_port():",
        "    pids = []",
        "    if os.name != 'nt':",
        "        return pids",
        "    try:",
        "        cp = subprocess.run(['netstat', '-ano', '-p', 'tcp'], capture_output=True, text=True, shell=False, timeout=10)",
        "        out = (cp.stdout or '') + '\\n' + (cp.stderr or '')",
        "        marker = ':' + str(admin_port)",
        "        for line in out.splitlines():",
        "            if marker not in line or 'listen' not in line.lower():",
        "                continue",
        "            parts = line.split()",
        "            if parts:",
        "                try:",
        "                    pid = int(parts[-1])",
        "                    if pid and pid not in pids:",
        "                        pids.append(pid)",
        "                except Exception:",
        "                    pass",
        "    except Exception as exc:",
        "        log('netstat failed: ' + repr(exc))",
        "    return pids",
        "",
        "def kill_port_owner(pid):",
        "    # v23: avoid /T even in watchdog. If run-once was launched from the admin",
        "    # panel, /T could kill the watchdog child together with the stuck admin.",
        "    try:",
        "        pid = int(pid)",
        "    except Exception:",
        "        return False",
        "    if pid <= 0 or pid == os.getpid():",
        "        log('skip killing pid=' + str(pid) + ' self=' + str(os.getpid()))",
        "        return False",
        "    try:",
        "        if os.name == 'nt':",
        "            cp = subprocess.run(['taskkill', '/PID', str(pid), '/F'], capture_output=True, text=True, shell=False, timeout=12)",
        "            log('taskkill-port-owner pid=' + str(pid) + ' rc=' + str(cp.returncode) + ' out=' + ((cp.stdout or cp.stderr or '').strip()[:500]))",
        "            return cp.returncode == 0",
        "        os.kill(pid, 9)",
        "        return True",
        "    except Exception as exc:",
        "        log('kill port owner failed pid=' + str(pid) + ' err=' + repr(exc))",
        "        return False",
        "",
        "def wait_port_closed(timeout=12):",
        "    deadline = time.time() + timeout",
        "    while time.time() < deadline:",
        "        if not port_open():",
        "            return True",
        "        time.sleep(0.6)",
        "    return not port_open()",
        "",
        "def health_ok():",
        "    try:",
        "        req = urllib.request.Request(health_url, headers={'Accept': 'application/json'})",
        "        with urllib.request.urlopen(req, timeout=4.0) as resp:",
        "            raw = resp.read(8192).decode('utf-8', errors='replace')",
        "        data = json.loads(raw)",
        "        return bool(data.get('ok')), 'OK' if data.get('ok') else raw[:500]",
        "    except Exception as exc:",
        "        return False, repr(exc)",
        "",
        "def recently_started(state):",
        "    try:",
        "        last = float(state.get('last_start') or 0)",
        "        return (time.time() - last) < float(cooldown_seconds)",
        "    except Exception:",
        "        return False",
        "",
        "def mark_started(state):",
        "    state['last_start'] = time.time()",
        "    state['failure_count'] = 0",
        "    save_state(state)",
        "",
        "def script_compiles():",
        "    try:",
        "        import py_compile",
        "        py_compile.compile(script_path, doraise=True)",
        "        return True, 'OK'",
        "    except Exception as exc:",
        "        return False, repr(exc)",
        "",
        "def launch_admin(state):",
        "    flags = 0",
        "    if os.name == 'nt':",
        "        flags = getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0) | getattr(subprocess, 'DETACHED_PROCESS', 0)",
        "    env = os.environ.copy()",
        "    env.setdefault('PYTHONIOENCODING', 'utf-8')",
        "    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') + ' | watchdog-v28 | starting admin: ' + 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'),",
        "            env=env,",
        "        )",
        "        log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | watchdog-v28 | started pid=' + str(proc.pid) + '\\n')",
        "    mark_started(state)",
        "",
        "def main():",
        "    state = load_state()",
        "    ok, info = health_ok()",
        "    if ok:",
        "        if state.get('failure_count'):",
        "            state['failure_count'] = 0",
        "            save_state(state)",
        "        return 0",
        "    fail_count = int(state.get('failure_count') or 0) + 1",
        "    state['failure_count'] = fail_count",
        "    save_state(state)",
        "    is_port_open = port_open()",
        "    log('health failed count=' + str(fail_count) + ' port_open=' + str(is_port_open) + ' info=' + str(info))",
        "    if recently_started(state):",
        "        log('restart cooldown active; skip this minute')",
        "        return 3",
        "    if is_port_open:",
        "        pids = pids_on_port()",
        "        if fail_count < 2:",
        "            log('port is open but health failed; waiting for second failure before hard kill. pids=' + str(pids))",
        "            return 2",
        "        log('port open + repeated health failure; killing stuck pids=' + str(pids))",
        "        for pid in pids:",
        "            kill_port_owner(pid)",
        "        wait_port_closed(14)",
        "    if not os.path.exists(script_path):",
        "        log('admin script not found: ' + script_path)",
        "        return 4",
        "    ok_compile, compile_info = script_compiles()",
        "    if not ok_compile:",
        "        log('admin script does not compile; not starting. info=' + str(compile_info))",
        "        return 5",
        "    try:",
        "        launch_admin(state)",
        "        return 10",
        "    except Exception as exc:",
        "        log('failed to launch admin: ' + repr(exc))",
        "        return 6",
        "",
        "if __name__ == '__main__':",
        "    raise SystemExit(main())",
        "",
    ]) + "\n"

def validate_watchdog_source(watchdog_code):
    try:
        compile(watchdog_code, "<tripserver_watchdog>", "exec")
        return True, "OK"
    except Exception as exc:
        return False, repr(exc)


def write_watchdog_script():
    script_path = os.path.abspath(ADMIN_SERVER_PATH)
    python_exe = sys.executable or "python"
    work_dir = os.path.dirname(script_path) or BASE_DIR
    source = build_watchdog_code(script_path, python_exe, work_dir)
    ok, info = validate_watchdog_source(source)
    if not ok:
        raise RuntimeError("Generated watchdog script is invalid: " + info)
    ensure_parent_dir(WATCHDOG_SCRIPT_PATH)
    with open(WATCHDOG_SCRIPT_PATH, "w", encoding="utf-8") as fh:
        fh.write(source)
    py_compile.compile(WATCHDOG_SCRIPT_PATH, doraise=True)
    return WATCHDOG_SCRIPT_PATH


def read_log_tail(path, max_lines=18):
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as fh:
            lines = fh.readlines()
        return "".join(lines[-max_lines:]).strip()
    except Exception:
        return ""


def safe_int(value, default, min_value=None, max_value=None):
    try:
        parsed = int(value)
    except Exception:
        parsed = default
    if min_value is not None:
        parsed = max(min_value, parsed)
    if max_value is not None:
        parsed = min(max_value, parsed)
    return parsed


def log_file_summary(log_key, max_lines=LOG_TAIL_DEFAULT_LINES):
    meta = LOG_FILE_REGISTRY.get(log_key)
    if not meta:
        return None
    path = meta["path"]
    exists = os.path.exists(path)
    size = os.path.getsize(path) if exists else 0
    updated = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(path))) if exists else "לא קיים עדיין"
    tail = read_log_tail(path, max_lines=max_lines) if exists else "אין עדיין לוג. הוא יווצר אחרי פעולה ראשונה."
    return {
        "key": log_key,
        "label": meta["label"],
        "note": meta.get("note", ""),
        "path": path,
        "exists": exists,
        "size": size,
        "size_label": format_bytes(size),
        "updated": updated,
        "tail": tail,
    }


def build_logs_payload(max_lines=LOG_TAIL_DEFAULT_LINES):
    max_lines = safe_int(max_lines, LOG_TAIL_DEFAULT_LINES, 20, LOG_TAIL_MAX_LINES)
    items = []
    for key in LOG_FILE_REGISTRY:
        info = log_file_summary(key, max_lines=max_lines)
        if info:
            items.append(info)
    return {
        "ok": True,
        "version": ADMIN_PANEL_VERSION,
        "checked_at": time.strftime("%Y-%m-%d %H:%M:%S"),
        "lines": max_lines,
        "logs": items,
    }


def render_admin_logs():
    payload = build_logs_payload(request.args.get("lines", LOG_TAIL_DEFAULT_LINES))
    cards = []
    for info in payload["logs"]:
        badge_cls = "ok" if info["exists"] else "warn"
        badge_text = "קיים" if info["exists"] else "לא נוצר עדיין"
        tail = html_lib.escape(info["tail"] or "אין תוכן להצגה.").replace("\n", "<br>")
        cards.append(f'''
<div class="log-card">
  <div class="log-head">
    <div><h2>{html_lib.escape(info["label"])}</h2><p>{html_lib.escape(info["note"])}</p></div>
    <span class="badge {badge_cls}">{badge_text}</span>
  </div>
  <div class="meta"><b>נתיב</b><code>{html_lib.escape(info["path"])}</code></div>
  <div class="stats"><span>גודל: <b>{html_lib.escape(info["size_label"])}</b></span><span>עודכן: <b>{html_lib.escape(info["updated"])}</b></span></div>
  <pre>{tail}</pre>
</div>''')
    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:#07111f;color:white;font-family:Arial;padding:22px}}.wrap{{max-width:1120px;margin:auto}}a{{color:#dbeafe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#1e3a8a,#0f172a 62%,#111827);border:1px solid #1d4ed8;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.6}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}}.actions a{{background:#2563eb;color:white;border-radius:999px;padding:10px 14px;font-weight:900}}.actions a.secondary{{background:#1e293b;border:1px solid #334155}}.log-card{{background:#101c31;border:1px solid #253650;border-radius:24px;padding:18px;margin:14px 0;box-shadow:0 18px 55px rgba(0,0,0,.22)}}.log-head{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}}.log-head h2{{margin:0 0 6px;font-size:24px}}.badge{{white-space:nowrap;border-radius:999px;padding:7px 11px;font-weight:900}}.badge.ok{{background:#14532d;color:#bbf7d0;border:1px solid #22c55e}}.badge.warn{{background:#451a03;color:#fed7aa;border:1px solid #f97316}}.meta{{margin-top:10px;color:#cbd5e1}}code{{direction:ltr;display:block;background:#0b1220;border:1px solid #334155;border-radius:12px;padding:10px;color:#fde68a;margin-top:6px;white-space:normal}}.stats{{display:flex;gap:12px;flex-wrap:wrap;color:#cbd5e1;margin:12px 0}}.stats span{{background:#0b1220;border:1px solid #334155;border-radius:999px;padding:8px 10px}}pre{{direction:ltr;text-align:left;white-space:pre-wrap;background:#020617;color:#fde68a;border:1px solid #334155;border-radius:16px;padding:14px;overflow:auto;max-height:420px;font-size:13px;line-height:1.55}}@media(max-width:700px){{body{{padding:14px}}.hero{{padding:18px;border-radius:22px}}h1{{font-size:28px}}.log-head{{display:block}}}}
</style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>📜 מרכז לוגים</h1><p>צפייה נוחה בלוגי האדמין וה־Watchdog בלי לפתוח קבצים ידנית במחשב. המסך לקריאה בלבד ולא מוחק או משנה שום דבר.</p><div class="actions"><a href="/admin/logs">רענן</a><a class="secondary" href="/admin/logs?lines=250">הצג 250 שורות</a><a class="secondary" href="/admin/logs?lines=500">הצג 500 שורות</a><a class="secondary" href="/admin/logs.json" target="_blank">JSON טכני</a><a class="secondary" href="/admin/self-test">בדיקה עצמית</a></div></div>{''.join(cards)}</div></body></html>
'''


def query_watchdog_task():
    if os.name != "nt":
        return {"supported": False, "installed": False, "ok": False, "details": "Task Scheduler זמין רק על Windows."}
    try:
        completed = subprocess.run(["schtasks", "/Query", "/TN", WATCHDOG_TASK_NAME, "/FO", "LIST"], capture_output=True, text=True, shell=False, timeout=12)
        output = (completed.stdout or completed.stderr or "").strip()
        return {"supported": True, "installed": completed.returncode == 0, "ok": completed.returncode == 0, "details": output}
    except Exception as exc:
        return {"supported": True, "installed": False, "ok": False, "details": repr(exc)}


def watchdog_status(include_log=True, include_task=True):
    generated = build_watchdog_code(os.path.abspath(ADMIN_SERVER_PATH), sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR)
    source_ok, source_info = validate_watchdog_source(generated)
    file_exists = os.path.exists(WATCHDOG_SCRIPT_PATH)
    file_compiles = False
    file_info = "לא נכתב עדיין"
    if file_exists:
        try:
            py_compile.compile(WATCHDOG_SCRIPT_PATH, doraise=True)
            file_compiles = True
            file_info = "OK"
        except Exception as exc:
            file_info = repr(exc)
    task = query_watchdog_task() if include_task else {"supported": None, "installed": None, "ok": None, "details": "not queried"}
    return {
        "source_ok": source_ok,
        "source_status": source_info,
        "script_path": WATCHDOG_SCRIPT_PATH,
        "script_exists": file_exists,
        "script_compiles": file_compiles,
        "script_status": file_info,
        "task_name": WATCHDOG_TASK_NAME,
        "task_supported": task.get("supported"),
        "task_installed": task.get("installed"),
        "task_status": task.get("details", ""),
        "interval_minutes": WATCHDOG_INTERVAL_MINUTES,
        "cooldown_seconds": WATCHDOG_MIN_RESTART_INTERVAL_SECONDS,
        "quiet_python": get_pythonw_executable(sys.executable or "python"),
        "log_path": WATCHDOG_LOG_PATH,
        "log_tail": read_log_tail(WATCHDOG_LOG_PATH) if include_log else "",
    }


def install_watchdog_task():
    script_path = write_watchdog_script()
    if os.name != "nt":
        return False, "קובץ ה־Watchdog נכתב, אבל התקנת Task Scheduler נתמכת רק על Windows.", f"script={script_path}"
    # Use pythonw.exe for the scheduled watchdog so Windows will not flash a black
    # console window and steal keyboard focus every run.
    quiet_python = get_pythonw_executable(sys.executable or "python")
    task_command = f'"{quiet_python}" "{script_path}"'
    cmd = ["schtasks", "/Create", "/TN", WATCHDOG_TASK_NAME, "/TR", task_command, "/SC", "MINUTE", "/MO", str(max(1, WATCHDOG_INTERVAL_MINUTES)), "/F"]
    completed = subprocess.run(cmd, capture_output=True, text=True, shell=False, timeout=20)
    output = (completed.stdout or completed.stderr or "").strip()
    if completed.returncode != 0:
        return False, "קובץ ה־Watchdog נכתב, אבל התקנת המשימה נכשלה.", output or f"return code: {completed.returncode}"
    return True, f"Watchdog שקט הותקן כמשימה מתוזמנת שרצה כל {max(1, WATCHDOG_INTERVAL_MINUTES)} דקות עם pythonw.exe, בלי חלון שחור קופץ.", output


def uninstall_watchdog_task():
    if os.name != "nt":
        return False, "הסרת Task Scheduler נתמכת רק על Windows.", ""
    completed = subprocess.run(["schtasks", "/Delete", "/TN", WATCHDOG_TASK_NAME, "/F"], capture_output=True, text=True, shell=False, timeout=20)
    output = (completed.stdout or completed.stderr or "").strip()
    if completed.returncode != 0:
        return False, "לא הצלחתי להסיר את משימת ה־Watchdog, או שהיא לא קיימת.", output or f"return code: {completed.returncode}"
    return True, "משימת ה־Watchdog הוסרה.", output


def run_watchdog_once_now():
    script_path = write_watchdog_script()
    completed = subprocess.run([sys.executable or "python", script_path], capture_output=True, text=True, shell=False, timeout=25)
    output = (completed.stdout or completed.stderr or "").strip()
    ok = completed.returncode in {0, 2, 3, 10}
    return ok, f"Watchdog רץ פעם אחת והחזיר קוד {completed.returncode}.", output or read_log_tail(WATCHDOG_LOG_PATH)


def render_admin_watchdog():
    status = watchdog_status(include_log=True)
    installed = bool(status.get("task_installed"))
    script_ok = bool(status.get("script_exists")) and bool(status.get("script_compiles"))
    source_ok = bool(status.get("source_ok"))
    state_badge = "מותקן ופעיל" if installed else "לא מותקן עדיין"
    state_cls = "ok" if installed and source_ok else "warn"
    task_text = html_lib.escape(status.get("task_status") or "")
    log_tail = html_lib.escape(status.get("log_tail") or "אין עדיין לוג.").replace("\n", "<br>")
    script_label = "✅ קיים ותקין" if script_ok else "⚠️ עדיין לא נכתב/לא תקין"
    task_label = "✅ מותקנת" if installed 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>TripServer Watchdog</title>
<style>
body{{margin:0;background:#0f172a;color:white;font-family:Arial;padding:22px}}.wrap{{max-width:1050px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#14532d,#0f172a 60%,#111827);border:1px solid #166534;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.7}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin:8px 0}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.warn{{background:#713f12;color:#fde68a;border:1px solid #f59e0b}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}}.card{{background:#111827;border:1px solid #334155;border-radius:20px;padding:16px;margin-top:14px}}.card b{{display:block;font-size:18px;margin-bottom:8px}}code,pre{{direction:ltr;text-align:left;white-space:pre-wrap;word-break:break-word}}pre{{background:#020617;border:1px solid #334155;color:#fde68a;border-radius:14px;padding:13px;max-height:340px;overflow:auto}}button{{border:0;border-radius:14px;padding:13px 16px;font-weight:950;color:white;background:#2563eb;margin:8px 8px 0 0;cursor:pointer}}button.orange{{background:#ea580c}}button.danger{{background:#dc2626}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}}.actions a{{display:inline-flex;background:#172033;border:1px solid #334155;border-radius:999px;padding:10px 14px;font-weight:900}}.note{{background:#0b1220;border:1px solid #334155;border-radius:18px;padding:14px;margin:14px 0;color:#cbd5e1;line-height:1.7}}@media(max-width:700px){{body{{padding:14px}}h1{{font-size:28px}}}}
</style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>🛡️ Watchdog לשמירת פאנל האדמין</h1><p>שכבת ביטחון חיצונית: Windows מריץ סקריפט שקט כל כמה דקות. אם הפאנל לא עונה ב־localhost:{ADMIN_PORT}, הוא מפעיל מחדש את admin_server.py לבד.</p><span class="badge {state_cls}">{state_badge}</span><div class="actions"><a href="/admin/self-test">בדיקה עצמית</a><a href="/admin/health">Health</a><a href="/admin/status">סטטוס קבצים</a></div></div>
<div class="grid"><div class="card"><b>סקריפט Watchdog</b><p>{script_label}</p><code>{html_lib.escape(status.get('script_path',''))}</code></div><div class="card"><b>משימה מתוזמנת</b><p>{task_label}</p><p>שם: <code>{html_lib.escape(status.get('task_name',''))}</code></p><p>תדירות: כל {status.get('interval_minutes')} דקות</p></div><div class="card"><b>הרצה שקטה</b><p>מותקן דרך <code>pythonw.exe</code> כדי לא לפתוח חלון CMD שחור ולא לגנוב פוקוס בזמן הקלדה.</p><code>{html_lib.escape(status.get('quiet_python',''))}</code></div><div class="card"><b>Cooldown</b><p>{status.get('cooldown_seconds')} שניות בין ניסיונות הפעלה כדי למנוע לופ.</p></div></div>
<div class="note"><b>מה זה עושה בפועל?</b><br>ה־Watchdog לא מחליף קבצים, לא מכבה מחשב ולא עושה restart למחשב. הוא רק בודק אם פאנל האדמין חי, ואם לא — מפעיל את קובץ האדמין הפעיל מחדש. זה בדיוק הכיסוי למקרה שבו העלאת admin או restart נפלו ואתה לא רוצה ללכת פיזית למחשב.</div>
<div class="card"><b>פעולות</b><form action="/watchdog/install" method="post" onsubmit="return confirm('להתקין Watchdog מתוזמן שרץ שקט כל כמה דקות?')"><button>התקן / עדכן Watchdog</button></form><form action="/watchdog/run-once" method="post" onsubmit="return confirm('להריץ בדיקת Watchdog חד־פעמית עכשיו?')"><button class="orange">הרץ פעם אחת עכשיו</button></form><form action="/watchdog/uninstall" method="post" onsubmit="return confirm('להסיר את משימת ה־Watchdog?')"><button class="danger">הסר Watchdog</button></form></div>
<div class="card"><b>סטטוס Task Scheduler</b><pre>{task_text or 'אין מידע.'}</pre></div><div class="card"><b>לוג אחרון</b><pre>{log_tail}</pre></div></div></body></html>
"""



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 camera_access_allowed():
    if not CAMERA_ENABLED:
        return False, "מצלמת החדר כבויה בהגדרת TRIPSERVER_ENABLE_WEBCAM."
    ip = get_client_ip()
    if not is_private_or_loopback_ip(ip):
        return False, f"גישה למצלמה חסומה מכתובת שאינה מקומית/רשת ביתית/Tailscale: {ip}"
    return True, "OK"


def camera_engine_status():
    status = {"enabled": CAMERA_ENABLED, "opencv": False, "opencv_version": "", "camera_index": CAMERA_INDEX}
    try:
        import cv2  # noqa: F401
        status["opencv"] = True
        status["opencv_version"] = getattr(cv2, "__version__", "")
    except Exception as exc:
        status["opencv"] = False
        status["error"] = str(exc)
    return status


class WebcamManager:
    def __init__(self):
        self.lock = threading.RLock()
        self.cap = None
        self.opened_at = 0.0
        self.last_frame_at = 0.0
        self.frames = 0
        self.last_error = ""
        self.stop_requested = False

    def _import_cv2(self):
        import cv2
        return cv2

    def _open_locked(self):
        cv2 = self._import_cv2()
        if self.cap is not None and self.cap.isOpened():
            return cv2
        self.stop_requested = False
        backend = getattr(cv2, "CAP_DSHOW", 0) if os.name == "nt" else 0
        cap = cv2.VideoCapture(CAMERA_INDEX, backend)
        try:
            cap.set(getattr(cv2, "CAP_PROP_FRAME_WIDTH", 3), CAMERA_WIDTH)
            cap.set(getattr(cv2, "CAP_PROP_FRAME_HEIGHT", 4), CAMERA_HEIGHT)
            cap.set(getattr(cv2, "CAP_PROP_FPS", 5), CAMERA_FPS)
        except Exception:
            pass
        if not cap or not cap.isOpened():
            try:
                cap.release()
            except Exception:
                pass
            self.last_error = f"לא הצלחתי לפתוח מצלמה index={CAMERA_INDEX}. בדוק הרשאות Windows או מצלמה תפוסה באפליקציה אחרת."
            raise RuntimeError(self.last_error)
        self.cap = cap
        self.opened_at = time.time()
        self.frames = 0
        self.last_error = ""
        return cv2

    def read_jpeg(self):
        with self.lock:
            # v21 fix: stop_requested is only for the CURRENT stream. In older builds,
            # once /camera/stop or a browser disconnect set it to True, future starts
            # were blocked forever until the whole admin process rebooted. If the
            # capture was already released, allow a new stream to open cleanly.
            if self.cap is None or not getattr(self.cap, "isOpened", lambda: False)():
                self.stop_requested = False
            elif self.stop_requested:
                raise RuntimeError("המצלמה כובתה ידנית")
            cv2 = self._open_locked()
            ok, frame = self.cap.read()
            if not ok or frame is None:
                time.sleep(0.15)
                ok, frame = self.cap.read()
            if not ok or frame is None:
                self.last_error = "לא התקבלה תמונה מהמצלמה."
                raise RuntimeError(self.last_error)
            ok, encoded = cv2.imencode(".jpg", frame, [int(getattr(cv2, "IMWRITE_JPEG_QUALITY", 1)), CAMERA_JPEG_QUALITY])
            if not ok:
                self.last_error = "קידוד תמונת המצלמה ל־JPG נכשל."
                raise RuntimeError(self.last_error)
            self.frames += 1
            self.last_frame_at = time.time()
            return encoded.tobytes()

    def stop(self):
        with self.lock:
            self.stop_requested = True
            if self.cap is not None:
                try:
                    self.cap.release()
                except Exception:
                    pass
            self.cap = None
            self.opened_at = 0.0

    def prepare_new_stream(self):
        """Release stale webcam handles and allow the next stream to start.

        This is intentionally lighter than a full camera reset: it does not stop
        audio or recording. It is used before assigning a new MJPEG src in the UI.
        """
        with self.lock:
            if self.cap is not None:
                try:
                    self.cap.release()
                except Exception:
                    pass
            self.cap = None
            self.opened_at = 0.0
            self.stop_requested = False
            self.last_error = ""
            return True

    def force_reset(self):
        """Stop all current webcam capture state and clear sticky errors."""
        self.stop()
        with self.lock:
            self.stop_requested = False
            self.last_error = ""
            self.frames = 0
            self.last_frame_at = 0.0
        return True

    def status(self):
        with self.lock:
            active = bool(self.cap is not None and self.cap.isOpened())
            return {
                "enabled": CAMERA_ENABLED,
                "active": active,
                "camera_index": CAMERA_INDEX,
                "width": CAMERA_WIDTH,
                "height": CAMERA_HEIGHT,
                "fps": CAMERA_FPS,
                "jpeg_quality": CAMERA_JPEG_QUALITY,
                "opened_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.opened_at)) if self.opened_at else "",
                "last_frame_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.last_frame_at)) if self.last_frame_at else "",
                "frames": self.frames,
                "last_error": self.last_error,
            }


CAMERA_MANAGER = WebcamManager()


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


def ffmpeg_dshow_devices():
    """Return DirectShow audio/video devices available to FFmpeg on Windows.

    v22 fix: FFmpeg on Windows usually prints devices under section headers like
    "DirectShow audio devices" and then plain quoted names. Older builds only
    looked for a non-standard `"name" (audio)` suffix, so microphones could be
    missed even when they existed. This parser supports both formats and also
    stores Alternative names, which are often more reliable than friendly names.
    """
    devices = {"video": [], "audio": [], "video_alt": [], "audio_alt": [], "alternatives": {"video": {}, "audio": {}}, "error": "", "raw": ""}
    ffmpeg = find_ffmpeg_executable()
    if not ffmpeg:
        devices["error"] = "FFmpeg לא נמצא."
        return devices
    if os.name != "nt":
        devices["error"] = "זיהוי התקני מצלמה/מיקרופון דרך DirectShow נתמך כאן בעיקר ב־Windows."
        return devices
    try:
        completed = subprocess.run(
            [ffmpeg, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=12,
        )
        raw = (completed.stdout or "") + "\n" + (completed.stderr or "")
        devices["raw"] = raw[-9000:]
        section = None
        current_name = None
        current_kind = None

        def add_device(kind, name):
            if not kind or not name:
                return
            bucket = "video" if kind == "video" else "audio"
            if name not in devices[bucket]:
                devices[bucket].append(name)

        def add_alt(kind, name, alt):
            if not kind or not name or not alt:
                return
            alt_bucket = "video_alt" if kind == "video" else "audio_alt"
            devices["alternatives"].setdefault(kind, {})[name] = alt
            if alt not in devices[alt_bucket]:
                devices[alt_bucket].append(alt)

        for line in raw.splitlines():
            low = line.lower()
            if "directshow video devices" in low:
                section = "video"
                current_name = None
                current_kind = None
                continue
            if "directshow audio devices" in low:
                section = "audio"
                current_name = None
                current_kind = None
                continue

            alt_match = re.search(r'Alternative name\s+"([^"]+)"', line, flags=re.I)
            if alt_match and current_name and current_kind:
                add_alt(current_kind, current_name, alt_match.group(1))
                continue

            name_match = re.search(r'\]\s+"([^"]+)"', line)
            if name_match and section in {"video", "audio"} and "alternative name" not in low:
                name = name_match.group(1).strip()
                add_device(section, name)
                current_name = name
                current_kind = section
                continue

            for name, kind in re.findall(r'"([^"]+)"\s+\((video|audio)\)', line, flags=re.I):
                kind = "video" if kind.lower() == "video" else "audio"
                add_device(kind, name.strip())
                current_name = name.strip()
                current_kind = kind
    except Exception as exc:
        devices["error"] = str(exc)
    return devices


def _device_tokens(name):
    text = (name or "").lower()
    tokens = re.findall(r"[a-z0-9א-ת]+", text)
    stop = {"microphone", "mic", "audio", "device", "usb", "video", "camera", "webcam", "hd", "pro", "stream", "capture", "array", "מערך", "מיקרופון", "מצלמה"}
    return {t for t in tokens if len(t) >= 3 and t not in stop}


def preferred_audio_candidates(devices=None):
    devices = devices or ffmpeg_dshow_devices()
    if CAMERA_AUDIO_DEVICE:
        return [{"name": CAMERA_AUDIO_DEVICE, "source": "env", "score": 999, "alt": False}]

    video = CAMERA_VIDEO_DEVICE or (devices.get("video") or [""])[0]
    video_tokens = _device_tokens(video)
    rows = []
    alternatives = (devices.get("alternatives") or {}).get("audio", {})
    for i, name in enumerate(devices.get("audio") or []):
        tokens = _device_tokens(name)
        score = 100 - i
        if video_tokens and tokens:
            score += 20 * len(video_tokens & tokens)
        low = name.lower()
        if any(w in low for w in ["webcam", "camera", "logitech", "c920", "c922", "brio", "usb"]):
            score += 15
        rows.append({"name": name, "source": "friendly", "score": score, "alt": False})
        alt = alternatives.get(name)
        if alt:
            rows.append({"name": alt, "source": f"alternative for {name}", "score": score - 1, "alt": True})
    rows.sort(key=lambda r: r.get("score", 0), reverse=True)
    result = []
    seen = set()
    for row in rows:
        if row["name"] and row["name"] not in seen:
            result.append(row)
            seen.add(row["name"])
    return result


def preferred_video_candidates(devices=None):
    devices = devices or ffmpeg_dshow_devices()
    if CAMERA_VIDEO_DEVICE:
        return [{"name": CAMERA_VIDEO_DEVICE, "source": "env", "score": 999, "alt": False}]
    alternatives = (devices.get("alternatives") or {}).get("video", {})
    rows = []
    for i, name in enumerate(devices.get("video") or []):
        score = 100 - i
        rows.append({"name": name, "source": "friendly", "score": score, "alt": False})
        alt = alternatives.get(name)
        if alt:
            rows.append({"name": alt, "source": f"alternative for {name}", "score": score - 1, "alt": True})
    result = []
    seen = set()
    for row in rows:
        if row["name"] and row["name"] not in seen:
            result.append(row)
            seen.add(row["name"])
    return result


def selected_ffmpeg_devices():
    devices = ffmpeg_dshow_devices()
    video_candidates = preferred_video_candidates(devices)
    audio_candidates = preferred_audio_candidates(devices)
    devices["video_candidates"] = video_candidates
    devices["audio_candidates"] = audio_candidates
    video = video_candidates[0]["name"] if video_candidates else ""
    audio = audio_candidates[0]["name"] if audio_candidates else ""
    return video, audio, devices


def dshow_input_name(video_device="", audio_device=""):
    parts = []
    if video_device:
        parts.append("video=" + video_device)
    if audio_device:
        parts.append("audio=" + audio_device)
    return ":".join(parts)


def tail_text_file(path, max_chars=5000):
    try:
        if not path or not os.path.exists(path):
            return ""
        with open(path, "rb") as fh:
            fh.seek(0, os.SEEK_END)
            size = fh.tell()
            fh.seek(max(0, size - max_chars))
            data = fh.read().decode("utf-8", errors="replace")
        return data[-max_chars:]
    except Exception:
        return ""


class CameraAudioStreamManager:
    def __init__(self):
        self.lock = threading.RLock()
        self.proc = None
        self.started_at = 0.0
        self.device = ""
        self.last_error = ""
        self.log_path = CAMERA_AUDIO_LOG_PATH
        self.log_handle = None
        self.candidates_tried = []

    def _close_log_locked(self):
        try:
            if self.log_handle:
                self.log_handle.close()
        except Exception:
            pass
        self.log_handle = None

    def _append_audio_log_locked(self, message):
        try:
            os.makedirs(BASE_DIR, exist_ok=True)
            with open(CAMERA_AUDIO_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 _start_candidate_locked(self, ffmpeg, audio_name):
        os.makedirs(BASE_DIR, exist_ok=True)
        self._append_audio_log_locked("trying audio device: " + str(audio_name))
        logfh = open(CAMERA_AUDIO_LOG_PATH, "ab")
        header = ("\n" + "=" * 70 + "\n" + time.strftime("%Y-%m-%d %H:%M:%S") + " | ffmpeg audio start | device=" + str(audio_name) + "\n").encode("utf-8", errors="replace")
        try:
            logfh.write(header)
            logfh.flush()
        except Exception:
            pass
        cmd = [
            ffmpeg, "-hide_banner", "-nostdin", "-loglevel", "warning",
            "-f", "dshow", "-rtbufsize", "64M", "-i", "audio=" + audio_name,
            "-vn", "-ac", "1", "-ar", "44100", "-codec:a", "libmp3lame", "-b:a", "96k", "-f", "mp3", "pipe:1",
        ]
        creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0
        proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=logfh, creationflags=creationflags)
        time.sleep(0.75)
        if proc.poll() is not None:
            try:
                logfh.flush()
                logfh.close()
            except Exception:
                pass
            err = tail_text_file(CAMERA_AUDIO_LOG_PATH, 2500)
            self._append_audio_log_locked("device failed immediately: " + str(audio_name))
            self.candidates_tried.append({"device": audio_name, "ok": False})
            self.last_error = "FFmpeg לא הצליח לפתוח את המיקרופון: " + str(audio_name) + "\n" + err[-1200:]
            return None, None
        self.candidates_tried.append({"device": audio_name, "ok": True})
        return proc, logfh

    def start(self, restart=False):
        with self.lock:
            if restart:
                self.stop_locked()
            return self.start_locked()

    def start_locked(self):
        if self.proc is not None and self.proc.poll() is None:
            return self.proc
        if not CAMERA_AUDIO_ENABLED:
            raise RuntimeError("אודיו מצלמה כבוי בהגדרת TRIPSERVER_ENABLE_CAMERA_AUDIO.")
        ffmpeg = find_ffmpeg_executable()
        if not ffmpeg:
            raise RuntimeError("FFmpeg לא נמצא. נדרש FFmpeg כדי להזרים אודיו חי.")
        _, audio, devices = selected_ffmpeg_devices()
        candidates = preferred_audio_candidates(devices)
        self.candidates_tried = []
        if not candidates:
            raw_tail = (devices.get("raw") or "")[-2500:]
            self.last_error = "לא נמצא התקן מיקרופון ב־FFmpeg. בדוק הרשאות Windows או הגדר TRIPSERVER_FFMPEG_AUDIO_DEVICE.\n" + raw_tail
            raise RuntimeError(self.last_error)
        last_error = ""
        for row in candidates:
            name = row.get("name") or ""
            if not name:
                continue
            proc, logfh = self._start_candidate_locked(ffmpeg, name)
            if proc is not None:
                self.proc = proc
                self.log_handle = logfh
                self.started_at = time.time()
                self.device = name
                self.last_error = ""
                log_camera_event(f"audio stream started | device={name}")
                return self.proc
            last_error = self.last_error
        self.proc = None
        self._close_log_locked()
        if not last_error:
            last_error = "כל ניסיונות פתיחת האודיו נכשלו. בדוק לוג אודיו מצלמה."
        raise RuntimeError(last_error)

    def stream_chunks(self):
        try:
            with self.lock:
                proc = self.start_locked()
            while proc is not None and proc.poll() is None:
                chunk = proc.stdout.read(4096) if proc.stdout else b""
                if not chunk:
                    if proc.poll() is not None:
                        self.last_error = "שידור האודיו נסגר על ידי FFmpeg. " + tail_text_file(CAMERA_AUDIO_LOG_PATH, 1600)
                    break
                yield chunk
        except GeneratorExit:
            pass
        except Exception as exc:
            self.last_error = str(exc)
            self._append_audio_log_locked("stream exception: " + repr(exc))
            yield b""
        finally:
            self.stop()

    def stop_locked(self):
        proc = self.proc
        self.proc = None
        if proc is not None and proc.poll() is None:
            try:
                proc.terminate()
                proc.wait(timeout=3)
            except Exception:
                try:
                    proc.kill()
                except Exception:
                    pass
        self._close_log_locked()
        self.started_at = 0.0

    def stop(self):
        with self.lock:
            self.stop_locked()
            log_camera_event("audio stream stopped")

    def status(self):
        with self.lock:
            active = bool(self.proc is not None and self.proc.poll() is None)
            return {
                "audio_enabled": CAMERA_AUDIO_ENABLED,
                "active": active,
                "device": self.device,
                "started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.started_at)) if self.started_at else "",
                "last_error": self.last_error,
                "log_path": CAMERA_AUDIO_LOG_PATH,
                "candidates_tried": self.candidates_tried,
            }


class CameraRecordingManager:
    def __init__(self):
        self.lock = threading.RLock()
        self.proc = None
        self.started_at = 0.0
        self.file_path = ""
        self.log_path = ""
        self.last_error = ""
        self.with_audio = False
        self.video_device = ""
        self.audio_device = ""
        self.log_handle = None

    def _refresh_locked(self):
        if self.proc is not None and self.proc.poll() is not None:
            try:
                if self.log_handle:
                    self.log_handle.close()
            except Exception:
                pass
            self.log_handle = None
            self.proc = None
            self.started_at = 0.0

    def start(self, with_audio=True):
        with self.lock:
            self._refresh_locked()
            if self.proc is not None and self.proc.poll() is None:
                raise RuntimeError("יש כבר הקלטה פעילה. עצור אותה לפני התחלה חדשה.")
            if not CAMERA_RECORDING_ENABLED:
                raise RuntimeError("הקלטת מצלמה כבויה בהגדרת TRIPSERVER_ENABLE_CAMERA_RECORDING.")
            ffmpeg = find_ffmpeg_executable()
            if not ffmpeg:
                raise RuntimeError("FFmpeg לא נמצא. נדרש FFmpeg כדי להקליט וידאו/אודיו.")
            video, audio, devices = selected_ffmpeg_devices()
            if not video:
                raise RuntimeError("לא נמצא התקן מצלמה ב־FFmpeg. בדוק הרשאות Windows או הגדר TRIPSERVER_FFMPEG_VIDEO_DEVICE.")
            if with_audio and not audio:
                raise RuntimeError("ביקשת הקלטה עם אודיו, אבל לא נמצא התקן מיקרופון ב־FFmpeg.")
            os.makedirs(CAMERA_RECORDINGS_DIR, exist_ok=True)
            stamp = time.strftime("%Y%m%d_%H%M%S")
            target = os.path.join(CAMERA_RECORDINGS_DIR, f"room_camera_{stamp}.mp4")
            log_path = os.path.join(CAMERA_RECORDINGS_DIR, f"room_camera_{stamp}.ffmpeg.log")
            input_name = dshow_input_name(video, audio if with_audio else "")
            cmd = [
                ffmpeg, "-hide_banner", "-y", "-f", "dshow", "-rtbufsize", "512M",
                "-framerate", str(CAMERA_RECORD_FPS), "-video_size", f"{CAMERA_WIDTH}x{CAMERA_HEIGHT}",
                "-i", input_name, "-t", str(CAMERA_RECORD_MAX_SECONDS),
                "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
            ]
            if with_audio:
                cmd += ["-c:a", "aac", "-b:a", CAMERA_AUDIO_BITRATE]
            else:
                cmd += ["-an"]
            cmd += ["-movflags", "+faststart", target]
            logfh = open(log_path, "ab")
            creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0
            self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=logfh, creationflags=creationflags)
            self.log_handle = logfh
            self.started_at = time.time()
            self.file_path = target
            self.log_path = log_path
            self.last_error = ""
            self.with_audio = bool(with_audio)
            self.video_device = video
            self.audio_device = audio if with_audio else ""
            log_camera_event(f"recording started by {get_client_ip()} | file={target} | video={video} | audio={self.audio_device or 'no-audio'}")
            return target

    def stop(self):
        with self.lock:
            self._refresh_locked()
            proc = self.proc
            if proc is None:
                return self.file_path or ""
            try:
                if proc.stdin:
                    proc.stdin.write(b"q")
                    proc.stdin.flush()
            except Exception:
                try:
                    proc.terminate()
                except Exception:
                    pass
            try:
                proc.wait(timeout=10)
            except Exception:
                try:
                    proc.kill()
                except Exception:
                    pass
            try:
                if self.log_handle:
                    self.log_handle.close()
            except Exception:
                pass
            self.log_handle = None
            self.proc = None
            self.started_at = 0.0
            log_camera_event(f"recording stopped by {get_client_ip()} | file={self.file_path}")
            return self.file_path

    def status(self):
        with self.lock:
            self._refresh_locked()
            active = bool(self.proc is not None and self.proc.poll() is None)
            size = os.path.getsize(self.file_path) if self.file_path and os.path.exists(self.file_path) else 0
            return {
                "recording_enabled": CAMERA_RECORDING_ENABLED,
                "active": active,
                "file_path": self.file_path,
                "file_name": os.path.basename(self.file_path) if self.file_path else "",
                "file_size": size,
                "started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.started_at)) if self.started_at else "",
                "elapsed_seconds": int(time.time() - self.started_at) if active and self.started_at else 0,
                "with_audio": self.with_audio,
                "video_device": self.video_device,
                "audio_device": self.audio_device,
                "log_path": self.log_path,
                "last_error": self.last_error,
            }


class CameraHlsStreamManager:
    """Unified video+audio live stream using one FFmpeg DirectShow process.

    Some USB webcams expose camera and microphone as one physical device. Opening
    video through OpenCV and audio through a separate FFmpeg process can fail or
    lock the device. HLS captures `video=...:audio=...` in one process, closer to
    how Zoom/Teams uses the camera+mic together.
    """
    def __init__(self):
        self.lock = threading.RLock()
        self.proc = None
        self.started_at = 0.0
        self.video_device = ""
        self.audio_device = ""
        self.with_audio = True
        self.last_error = ""
        self.mode = "idle"

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

    def _clean_dir_locked(self):
        try:
            os.makedirs(CAMERA_HLS_DIR, exist_ok=True)
            for name in os.listdir(CAMERA_HLS_DIR):
                if name.lower().endswith((".m3u8", ".ts", ".tmp")):
                    try:
                        os.remove(os.path.join(CAMERA_HLS_DIR, name))
                    except Exception:
                        pass
        except Exception as exc:
            self._log("clean hls dir failed: " + repr(exc))

    def _hls_input_option_candidates(self):
        """Return DirectShow option fallbacks for webcams.

        Some USB webcams, including EMEET models, work in OpenCV but reject a
        forced FFmpeg DirectShow combination such as -video_size 1280x720
        together with -framerate 15. FFmpeg then fails with: Could not set video
        options. v25 tries progressively safer combinations and finally lets the
        driver auto-negotiate the video mode.
        """
        preferred_size = f"{CAMERA_WIDTH}x{CAMERA_HEIGHT}"
        common_sizes = []
        for item in (preferred_size, "1280x720", "640x480", "1920x1080"):
            if item not in common_sizes:
                common_sizes.append(item)
        fps_values = []
        for item in (CAMERA_RECORD_FPS, 30, 15, 10):
            try:
                item = int(item)
            except Exception:
                continue
            if item > 0 and item not in fps_values:
                fps_values.append(item)
        candidates = []
        # Try common explicit modes first, then fall back to auto negotiation.
        for fps in fps_values[:3]:
            candidates.append((f"size={preferred_size},fps={fps}", ["-video_size", preferred_size, "-framerate", str(fps)]))
        for size in common_sizes:
            candidates.append((f"size={size},auto-fps", ["-video_size", size]))
        for fps in fps_values:
            candidates.append((f"auto-size,fps={fps}", ["-framerate", str(fps)]))
        candidates.append(("driver-auto", []))
        seen = set()
        unique = []
        for label, opts in candidates:
            key = tuple(opts)
            if key not in seen:
                unique.append((label, opts))
                seen.add(key)
        return unique

    def _start_ffmpeg_locked(self, video_name, audio_name=""):
        ffmpeg = find_ffmpeg_executable()
        if not ffmpeg:
            raise RuntimeError("FFmpeg לא נמצא. נדרש FFmpeg לשידור וידאו+אודיו מאוחד.")
        self._clean_dir_locked()
        playlist = os.path.join(CAMERA_HLS_DIR, "stream.m3u8")
        segment_pattern = os.path.join(CAMERA_HLS_DIR, "seg_%05d.ts")
        input_name = dshow_input_name(video_name, audio_name)
        os.makedirs(os.path.dirname(CAMERA_HLS_LOG_PATH), exist_ok=True)
        last_err = ""
        for mode_label, input_options in self._hls_input_option_candidates():
            self._clean_dir_locked()
            cmd = [
                ffmpeg, "-hide_banner", "-y", "-nostdin", "-loglevel", "warning",
                "-f", "dshow", "-rtbufsize", "512M",
            ] + list(input_options) + [
                "-i", input_name,
                "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
                "-pix_fmt", "yuv420p", "-r", str(CAMERA_RECORD_FPS),
            ]
            if audio_name:
                cmd += ["-c:a", "aac", "-b:a", CAMERA_AUDIO_BITRATE, "-ar", "44100", "-ac", "1"]
            else:
                cmd += ["-an"]
            cmd += [
                "-f", "hls", "-hls_time", "1", "-hls_list_size", "5",
                "-hls_flags", "delete_segments+omit_endlist+program_date_time",
                "-hls_segment_filename", segment_pattern,
                playlist,
            ]
            logfh = open(CAMERA_HLS_LOG_PATH, "ab")
            try:
                header = ("\n" + "=" * 70 + "\n" + time.strftime("%Y-%m-%d %H:%M:%S") + " | unified HLS start | mode=" + mode_label + " | input=" + input_name + "\ncmd=" + " ".join(cmd) + "\n").encode("utf-8", errors="replace")
                logfh.write(header)
                logfh.flush()
            except Exception:
                pass
            flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0
            proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=logfh, creationflags=flags)
            deadline = time.time() + 8.0
            while time.time() < deadline:
                if proc.poll() is not None:
                    break
                if os.path.exists(playlist) and os.path.getsize(playlist) > 20:
                    self.proc = proc
                    self.started_at = time.time()
                    self.video_device = video_name
                    self.audio_device = audio_name
                    self.with_audio = bool(audio_name)
                    self.last_error = ""
                    self.mode = ("hls_av:" if audio_name else "hls_video_only:") + mode_label
                    self._log("HLS started ok | mode=" + mode_label + " | video=" + str(video_name) + " | audio=" + str(audio_name or "no-audio"))
                    return True
                time.sleep(0.35)
            try:
                if proc.poll() is None:
                    proc.terminate()
                    proc.wait(timeout=3)
            except Exception:
                try:
                    proc.kill()
                except Exception:
                    pass
            try:
                logfh.close()
            except Exception:
                pass
            last_err = tail_text_file(CAMERA_HLS_LOG_PATH, 4200)
            if "Could not set video options" in last_err or "Error opening input" in last_err:
                self._log("HLS candidate failed; trying next | mode=" + mode_label + " | input=" + input_name)
                continue
            # Keep trying all candidates even for other errors, because DirectShow is inconsistent.
            self._log("HLS candidate failed; trying next | mode=" + mode_label + " | input=" + input_name)
        self.last_error = "FFmpeg לא הצליח לפתוח שידור מאוחד אחרי כל מצבי DirectShow: " + input_name + "\n" + (last_err[-1800:] if last_err else "")
        self._log("HLS failed all candidates | input=" + input_name)
        return False

    def start(self, with_audio=True, restart=True):
        with self.lock:
            if restart:
                self.stop_locked()
            if self.proc is not None and self.proc.poll() is None:
                return True
            if not CAMERA_ENABLED:
                raise RuntimeError("מצלמה כבויה בהגדרת TRIPSERVER_ENABLE_WEBCAM.")
            video, audio, devices = selected_ffmpeg_devices()
            if not video:
                raise RuntimeError("לא נמצא התקן וידאו ב־FFmpeg. בדוק הרשאות מצלמה או הגדר TRIPSERVER_FFMPEG_VIDEO_DEVICE.")
            try:
                CAMERA_MANAGER.stop()
            except Exception:
                pass
            try:
                CAMERA_AUDIO_STREAM.stop()
            except Exception:
                pass
            audio_rows = preferred_audio_candidates(devices) if with_audio and CAMERA_AUDIO_ENABLED else []
            if with_audio and audio_rows:
                last_audio_error = ""
                for row in audio_rows[:8]:
                    audio_name = row.get("name") or ""
                    if not audio_name:
                        continue
                    if self._start_ffmpeg_locked(video, audio_name):
                        return True
                    last_audio_error = self.last_error
                # Keep the camera available even when audio fails, but preserve the audio error.
                if self._start_ffmpeg_locked(video, ""):
                    self.last_error = "האודיו נכשל ולכן הופעל וידאו בלבד. שגיאת אודיו אחרונה:\n" + last_audio_error
                    return True
                raise RuntimeError(last_audio_error or self.last_error or "שידור מאוחד נכשל.")
            if self._start_ffmpeg_locked(video, ""):
                return True
            raise RuntimeError(self.last_error or "שידור מאוחד נכשל.")

    def stop_locked(self):
        proc = self.proc
        self.proc = None
        if proc is not None and proc.poll() is None:
            try:
                proc.terminate()
                proc.wait(timeout=4)
            except Exception:
                try:
                    proc.kill()
                except Exception:
                    pass
        self.started_at = 0.0
        self.mode = "idle"

    def stop(self):
        with self.lock:
            self.stop_locked()
            self._log("HLS stopped")

    def status(self):
        with self.lock:
            active = bool(self.proc is not None and self.proc.poll() is None)
            playlist = os.path.join(CAMERA_HLS_DIR, "stream.m3u8")
            return {
                "active": active,
                "mode": self.mode,
                "with_audio": self.with_audio,
                "video_device": self.video_device,
                "audio_device": self.audio_device,
                "started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.started_at)) if self.started_at else "",
                "playlist_exists": os.path.exists(playlist),
                "playlist_size": os.path.getsize(playlist) if os.path.exists(playlist) else 0,
                "dir": CAMERA_HLS_DIR,
                "log_path": CAMERA_HLS_LOG_PATH,
                "last_error": self.last_error,
            }


CAMERA_HLS_STREAM = CameraHlsStreamManager()
CAMERA_AUDIO_STREAM = CameraAudioStreamManager()
CAMERA_RECORDER = CameraRecordingManager()


def list_camera_recordings(limit=30):
    try:
        os.makedirs(CAMERA_RECORDINGS_DIR, exist_ok=True)
        items = []
        for path in glob.glob(os.path.join(CAMERA_RECORDINGS_DIR, "*.mp4")):
            try:
                items.append({
                    "name": os.path.basename(path),
                    "path": path,
                    "size": os.path.getsize(path),
                    "mtime": os.path.getmtime(path),
                })
            except Exception:
                pass
        items.sort(key=lambda x: x.get("mtime", 0), reverse=True)
        return items[:limit]
    except Exception:
        return []



def append_camera_setup_log(message):
    os.makedirs(BASE_DIR, exist_ok=True)
    line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}\n"
    try:
        with open(CAMERA_SETUP_LOG_PATH, "a", encoding="utf-8") as fh:
            fh.write(line)
    except Exception:
        pass
    return line


def read_camera_setup_log_tail(lines=80):
    try:
        lines = max(20, min(300, int(lines)))
    except Exception:
        lines = 80
    if not os.path.exists(CAMERA_SETUP_LOG_PATH):
        return ""
    try:
        with open(CAMERA_SETUP_LOG_PATH, "r", encoding="utf-8", errors="replace") as fh:
            data = fh.readlines()
        return "".join(data[-lines:])
    except Exception as exc:
        return f"שגיאה בקריאת לוג התקנה: {exc}"


def opencv_import_status():
    try:
        import cv2  # noqa: F401
        return True, getattr(cv2, "__version__", "")
    except Exception as exc:
        return False, str(exc)


def camera_setup_status():
    ok, info = opencv_import_status()
    ffmpeg_path = find_ffmpeg_executable() or ""
    with CAMERA_SETUP_LOCK:
        state = dict(CAMERA_SETUP_STATE)
    return {
        "opencv": ok,
        "opencv_info": info,
        "ffmpeg": bool(ffmpeg_path),
        "ffmpeg_path": ffmpeg_path,
        "python_executable": sys.executable,
        "pip_command": f'"{sys.executable}" -m pip install --upgrade {CAMERA_OPENCV_PACKAGE}',
        "opencv_package": CAMERA_OPENCV_PACKAGE,
        "setup_log_path": CAMERA_SETUP_LOG_PATH,
        "marker_path": CAMERA_SETUP_MARKER_PATH,
        "auto_install_enabled": CAMERA_AUTO_INSTALL_OPENCV,
        "auto_install_delay_seconds": CAMERA_AUTO_INSTALL_DELAY_SECONDS,
        "installer": state,
    }


def ensure_pip_available():
    """Make pip available in the same Python interpreter when possible."""
    check = subprocess.run([sys.executable, "-m", "pip", "--version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, shell=False)
    if check.returncode == 0:
        append_camera_setup_log("pip available: " + (check.stdout or check.stderr or "").strip())
        return True
    append_camera_setup_log("pip not available, trying ensurepip --upgrade")
    try:
        ep = subprocess.run([sys.executable, "-m", "ensurepip", "--upgrade"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, shell=False)
        append_camera_setup_log("ensurepip return code: " + str(ep.returncode))
        if ep.stdout:
            append_camera_setup_log("ensurepip stdout:\n" + ep.stdout[-6000:])
        if ep.stderr:
            append_camera_setup_log("ensurepip stderr:\n" + ep.stderr[-6000:])
        return ep.returncode == 0
    except Exception as exc:
        append_camera_setup_log("ensurepip exception: " + repr(exc))
        return False


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


def run_opencv_install_worker(mode="manual"):
    with CAMERA_SETUP_LOCK:
        CAMERA_SETUP_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "ok": False, "last_message": "מתקין OpenCV...", "mode": mode})
    append_camera_setup_log("OpenCV install requested | mode=" + str(mode))
    append_camera_setup_log("Python executable: " + sys.executable)
    if not ensure_pip_available():
        msg = "pip לא זמין וגם ensurepip לא הצליח. אי אפשר להתקין OpenCV אוטומטית."
        append_camera_setup_log("ERROR: " + msg)
        with CAMERA_SETUP_LOCK:
            CAMERA_SETUP_STATE.update({"running": False, "finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "ok": False, "last_message": msg, "mode": mode})
        return
    cmd = [sys.executable, "-m", "pip", "install", "--upgrade", CAMERA_OPENCV_PACKAGE]
    append_camera_setup_log("Command: " + " ".join(cmd))
    ok = False
    msg = ""
    try:
        completed = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=CAMERA_OPENCV_INSTALL_TIMEOUT_SECONDS,
            shell=False,
        )
        append_camera_setup_log("pip return code: " + str(completed.returncode))
        if completed.stdout:
            append_camera_setup_log("pip stdout:\n" + completed.stdout[-12000:])
        if completed.stderr:
            append_camera_setup_log("pip stderr:\n" + completed.stderr[-12000:])
        ok, info = opencv_import_status()
        if completed.returncode == 0 and ok:
            msg = "OpenCV הותקן וזוהה: " + info
            append_camera_setup_log("SUCCESS: " + msg)
            write_camera_setup_marker(msg)
        elif completed.returncode == 0 and not ok:
            msg = "pip הסתיים בהצלחה אבל import cv2 עדיין נכשל: " + info
            append_camera_setup_log("WARNING: " + msg)
        else:
            msg = "pip נכשל. בדוק את הלוג במסך ההתקנה."
            append_camera_setup_log("ERROR: " + msg)
            ok = False
    except subprocess.TimeoutExpired:
        msg = f"התקנת OpenCV עברה את מגבלת הזמן ({CAMERA_OPENCV_INSTALL_TIMEOUT_SECONDS} שניות)."
        append_camera_setup_log("TIMEOUT: " + msg)
    except Exception as exc:
        msg = "שגיאה בהרצת pip: " + repr(exc)
        append_camera_setup_log("EXCEPTION: " + msg)
    with CAMERA_SETUP_LOCK:
        CAMERA_SETUP_STATE.update({"running": False, "finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "ok": bool(ok), "last_message": msg, "mode": mode})


def start_opencv_install_background(mode="manual"):
    with CAMERA_SETUP_LOCK:
        if CAMERA_SETUP_STATE.get("running"):
            return False, "התקנת OpenCV כבר רצה כרגע."
        CAMERA_SETUP_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "ok": False, "last_message": "מתחיל התקנה...", "mode": mode})
    t = threading.Thread(target=run_opencv_install_worker, kwargs={"mode": mode}, name="tripserver-opencv-installer", daemon=True)
    t.start()
    return True, "התקנת OpenCV התחילה ברקע. אפשר לרענן את מסך המצלמה/התקנה ולבדוק לוג."


def maybe_start_camera_auto_install(reason="startup"):
    if not CAMERA_AUTO_INSTALL_OPENCV:
        append_camera_setup_log("Auto install disabled by TRIPSERVER_CAMERA_AUTO_INSTALL_OPENCV")
        return False, "התקנה אוטומטית כבויה."
    ok, info = opencv_import_status()
    if ok:
        with CAMERA_SETUP_LOCK:
            CAMERA_SETUP_STATE.update({"ok": True, "last_message": "OpenCV כבר זמין: " + str(info), "mode": "ready"})
        write_camera_setup_marker("already available: " + str(info))
        return False, "OpenCV כבר מותקן."
    with CAMERA_SETUP_LOCK:
        CAMERA_SETUP_STATE["auto_requested"] = True
    def delayed_auto():
        try:
            if CAMERA_AUTO_INSTALL_DELAY_SECONDS > 0:
                time.sleep(CAMERA_AUTO_INSTALL_DELAY_SECONDS)
            ok2, info2 = opencv_import_status()
            if ok2:
                write_camera_setup_marker("available before auto install: " + str(info2))
                with CAMERA_SETUP_LOCK:
                    CAMERA_SETUP_STATE.update({"ok": True, "last_message": "OpenCV כבר זמין: " + str(info2), "mode": "ready"})
                return
            append_camera_setup_log("Auto install triggered | reason=" + str(reason) + " | cv2 import failed: " + str(info2))
            start_opencv_install_background(mode="auto:" + str(reason))
        except Exception as exc:
            append_camera_setup_log("Auto install scheduler exception: " + repr(exc))
    t = threading.Thread(target=delayed_auto, name="tripserver-opencv-auto-installer", daemon=True)
    t.start()
    return True, "התקנת OpenCV אוטומטית תתחיל ברקע."


def render_camera_setup_page():
    allowed, reason = camera_access_allowed()
    status = camera_setup_status()
    inst = status.get("installer", {})
    running = bool(inst.get("running"))
    opencv_badge = "ok" if status.get("opencv") else "warn"
    ffmpeg_badge = "ok" if status.get("ffmpeg") else "warn"
    run_badge = "warn" if running else ("ok" if inst.get("ok") else "idle")
    install_disabled = "disabled" if (not allowed or running or status.get("opencv")) else ""
    allowed_block = "" if allowed else f"<div class='warn'>גישה חסומה: {html_lib.escape(reason)}</div>"
    log_tail = html_lib.escape(read_camera_setup_log_tail(120)) or "אין עדיין לוג התקנה."
    return f"""
<!DOCTYPE html><html lang="he" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>התקנת רכיבי מצלמה</title>
<style>
body{{margin:0;background:#07111f;color:white;font-family:Arial,'Segoe UI',sans-serif;padding:18px}}.wrap{{max-width:1050px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#0f766e,#0f172a 62%,#111827);border:1px solid #14b8a6;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.7}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}}.card{{background:#111827;border:1px solid #334155;border-radius:20px;padding:16px;margin-top:14px}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin:8px 6px 0 0}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.warn{{background:#7f1d1d;color:#fecaca;border:1px solid #f87171}}.badge.idle{{background:#334155;color:#e2e8f0;border:1px solid #64748b}}button,.btn{{border:0;border-radius:14px;padding:13px 16px;font-weight:950;color:white;background:#2563eb;margin:8px 8px 0 0;cursor:pointer;display:inline-block}}button:disabled{{background:#475569;cursor:not-allowed;opacity:.55}}.warn{{background:#7f1d1d;border:1px solid #f87171;color:#fecaca;border-radius:16px;padding:13px;line-height:1.65;margin:12px 0}}.good{{background:#052e2b;border:1px solid #14b8a6;border-radius:16px;padding:13px;color:#ccfbf1;line-height:1.65;margin:12px 0}}.note{{background:#0b1220;border:1px solid #334155;border-radius:16px;padding:13px;color:#cbd5e1;line-height:1.65;margin:12px 0}}code,pre{{direction:ltr;text-align:left;white-space:pre-wrap;word-break:break-word}}pre{{background:#020617;border:1px solid #334155;color:#fde68a;border-radius:12px;padding:12px;overflow:auto;max-height:440px}}@media(max-width:700px){{body{{padding:13px}}h1{{font-size:28px}}}}
</style></head><body><div class="wrap"><div class="hero"><a href="/admin/camera">⬅ חזרה למצלמה</a><h1>🧩 התקנת רכיבי מצלמה</h1><p>OpenCV מותקן אוטומטית חד־פעמית ברקע כאשר פאנל האדמין עולה. FFmpeg לא מותקן כאן; הוא רק מזוהה מהנתיב שכבר קיים אצלך.</p><span class="badge {opencv_badge}">OpenCV: {'מותקן' if status.get('opencv') else 'חסר'}</span><span class="badge {ffmpeg_badge}">FFmpeg: {'זמין' if status.get('ffmpeg') else 'לא נמצא'}</span><span class="badge {run_badge}">Installer: {'רץ עכשיו' if running else ('הצלחה אחרונה' if inst.get('ok') else 'ממתין')}</span></div>{allowed_block}
<div class="grid"><div class="card"><h2>התקנה אוטומטית חד־פעמית</h2><p>אם OpenCV חסר, הפאנל מריץ לבד את הפקודה:</p><pre>{html_lib.escape(status.get('pip_command',''))}</pre><div class="note">אין כפתור התקנה נוסף. ההתקנה מופעלת ברקע אחרי עליית הפאנל, כדי שתוכל לעשות את זה מרחוק בלי CMD ידני.</div>{'<div class="good">OpenCV כבר מותקן וזמין. אין צורך להתקין שוב.</div>' if status.get('opencv') else ''}</div>
<div class="card"><h2>סטטוס</h2><p><b>Python:</b></p><pre>{html_lib.escape(status.get('python_executable',''))}</pre><p><b>OpenCV:</b> {html_lib.escape(str(status.get('opencv_info','')))}</p><p><b>FFmpeg:</b></p><pre>{html_lib.escape(status.get('ffmpeg_path') or 'לא נמצא')}</pre><p><b>הודעה אחרונה:</b><br>{html_lib.escape(str(inst.get('last_message') or 'אין'))}</p><p><b>התחיל:</b> {html_lib.escape(str(inst.get('started_at') or ''))}<br><b>הסתיים:</b> {html_lib.escape(str(inst.get('finished_at') or ''))}</p><p><a class="btn" href="/camera/setup">רענן</a><a class="btn" href="/camera/setup/status.json" target="_blank">JSON טכני</a></p></div></div>
<div class="card"><h2>לוג התקנה</h2><p>נתיב לוג: <code>{html_lib.escape(CAMERA_SETUP_LOG_PATH)}</code></p><pre>{log_tail}</pre></div></div></body></html>
"""

def camera_av_status():
    video, audio, devices = selected_ffmpeg_devices()
    return {
        "ffmpeg": bool(find_ffmpeg_executable()),
        "ffmpeg_path": find_ffmpeg_executable() or "",
        "video_device": video,
        "audio_device": audio,
        "devices": devices,
        "audio_stream": CAMERA_AUDIO_STREAM.status(),
        "recording": CAMERA_RECORDER.status(),
        "recordings_dir": CAMERA_RECORDINGS_DIR,
        "audio_log_path": CAMERA_AUDIO_LOG_PATH,
        "hls_stream": CAMERA_HLS_STREAM.status(),
        "hls_log_path": CAMERA_HLS_LOG_PATH,
    }


def camera_stream_generator():
    started = time.time()
    try:
        while True:
            if CAMERA_MAX_STREAM_SECONDS > 0 and (time.time() - started) > CAMERA_MAX_STREAM_SECONDS:
                break
            frame = CAMERA_MANAGER.read_jpeg()
            yield b"--frame\r\nContent-Type: image/jpeg\r\nCache-Control: no-cache\r\n\r\n" + frame + b"\r\n"
            time.sleep(CAMERA_FRAME_INTERVAL_SECONDS)
    except GeneratorExit:
        pass
    except Exception as exc:
        CAMERA_MANAGER.last_error = str(exc)
    finally:
        # Release the webcam when the browser closes/stops the stream so the LED turns off.
        CAMERA_MANAGER.stop()


def render_admin_camera():
    allowed, reason = camera_access_allowed()
    engine = camera_engine_status()
    av = camera_av_status()
    cam_status = CAMERA_MANAGER.status()
    rec_status = av.get("recording", {})
    hls_status = av.get("hls_stream", {})
    devices = av.get("devices", {}) or {}
    allowed_block = "" if allowed else f"<div class='warn'>גישה חסומה: {html_lib.escape(reason)}</div>"
    engine_block = ""
    if not engine.get("opencv"):
        engine_block = "<div class='warn'><b>OpenCV לא מותקן.</b><br>הפאנל מנסה להתקין אותו אוטומטית. אפשר לראות את הלוג דרך: <a class='btn' href='/camera/setup'>🧩 סטטוס רכיבי מצלמה</a></div>"
    if not av.get("ffmpeg"):
        engine_block += "<div class='warn'><b>FFmpeg לא נמצא.</b><br>שידור מאוחד וידאו+אודיו והקלטות דורשים FFmpeg ב־PATH או ב־<code>C:\\ffmpeg\\bin\\ffmpeg.exe</code>.</div>"
    hls_text = "פעיל" if hls_status.get("active") else "כבוי"
    hls_cls = "ok" if hls_status.get("active") else "idle"
    audio_text = "כלול" if hls_status.get("active") and hls_status.get("with_audio") else ("וידאו בלבד" if hls_status.get("active") else "כבוי")
    audio_cls = "ok" if hls_status.get("active") and hls_status.get("with_audio") else "idle"
    rec_text = "מקליט" if rec_status.get("active") else "לא מקליט"
    rec_cls = "rec" if rec_status.get("active") else "idle"
    disabled_hls = "disabled" if (not allowed or not av.get("ffmpeg")) else ""
    disabled_video = "disabled" if (not allowed or not engine.get("opencv")) else ""
    disabled_av = "disabled" if (not allowed or not av.get("ffmpeg")) else ""
    recordings = list_camera_recordings(12)
    rec_rows = "".join(
        f"<div class='file-row'><div><b>{html_lib.escape(item['name'])}</b><br><span>{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(item['mtime']))} · {format_bytes(item['size'])}</span></div><a class='mini' href='/camera/recordings/{html_lib.escape(item['name'])}' target='_blank'>פתח</a></div>"
        for item in recordings
    ) or "<div class='muted'>אין עדיין הקלטות בתיקייה.</div>"
    video_devices = ", ".join(devices.get("video", [])) or "לא זוהו דרך FFmpeg"
    audio_devices = ", ".join(devices.get("audio", [])) or "לא זוהו דרך FFmpeg"
    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>מצלמת חדר</title>
<style>
body{{margin:0;background:#07111f;color:white;font-family:Arial,'Segoe UI',sans-serif;padding:18px}}.wrap{{max-width:1120px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#0f766e,#0f172a 62%,#111827);border:1px solid #14b8a6;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}h2{{margin:0 0 10px}}p{{color:#cbd5e1;line-height:1.7}}.grid{{display:grid;grid-template-columns:1.45fr .9fr;gap:16px}}.card{{background:#111827;border:1px solid #334155;border-radius:20px;padding:16px;margin-top:14px}}.cam-box{{background:#020617;border:1px solid #334155;border-radius:22px;overflow:hidden;min-height:280px;display:flex;align-items:center;justify-content:center}}#hlsVideo,#cameraView{{width:100%;height:auto;background:#020617;display:none}}.placeholder{{color:#94a3b8;text-align:center;padding:28px;line-height:1.7}}button,.btn{{border:0;border-radius:14px;padding:13px 16px;font-weight:950;color:white;background:#2563eb;margin:8px 8px 0 0;cursor:pointer;display:inline-block}}button.stop{{background:#dc2626}}button.gray,.btn.gray{{background:#475569}}button.snap{{background:#0f766e}}button.record{{background:#ea580c}}button:disabled{{background:#475569;cursor:not-allowed;opacity:.55}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin:8px 6px 0 0}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.idle{{background:#334155;color:#e2e8f0;border:1px solid #64748b}}.badge.rec{{background:#7c2d12;color:#fed7aa;border:1px solid #fb923c}}.warn{{background:#7f1d1d;border:1px solid #f87171;color:#fecaca;border-radius:16px;padding:13px;line-height:1.65;margin:12px 0}}.note{{background:#0b1220;border:1px solid #334155;border-radius:16px;padding:13px;color:#cbd5e1;line-height:1.65;margin:12px 0}}.good{{background:#052e2b;border:1px solid #14b8a6;border-radius:16px;padding:13px;color:#ccfbf1;line-height:1.65;margin:12px 0}}code,pre{{direction:ltr;text-align:left;white-space:pre-wrap}}pre{{background:#020617;border:1px solid #334155;color:#fde68a;border-radius:12px;padding:10px;overflow:auto}}.row{{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid #263548;padding:9px 0;color:#cbd5e1}}.row b{{color:white}}.file-row{{display:flex;justify-content:space-between;gap:12px;align-items:center;border-bottom:1px solid #263548;padding:10px 0;color:#cbd5e1}}.file-row span,.muted{{color:#94a3b8}}.mini{{background:#1d4ed8;color:white;border-radius:10px;padding:8px 10px;font-weight:900;white-space:nowrap}}label{{display:block;color:#e2e8f0;margin-top:8px;font-weight:800}}input[type=checkbox]{{transform:scale(1.25);margin-left:8px}}@media(max-width:850px){{.grid{{grid-template-columns:1fr}}body{{padding:13px}}h1{{font-size:28px}}.file-row{{align-items:flex-start}}}}
</style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>📷 מצלמת חדר</h1><p>v28 חוזר לברירת מחדל יציבה: וידאו דרך OpenCV/MJPEG, כי אצל EMEET SmartCam C965 הווידאו דרך FFmpeg DirectShow נכשל. האודיו מוזרם בנפרד דרך FFmpeg, וה־HLS המאוחד נשאר רק ככלי בדיקה מתקדם.</p><p><a class="btn gray" href="/camera/setup">סטטוס רכיבי מצלמה</a> <a class="btn gray" href="/admin/logs">לוגים</a></p><span class="badge {hls_cls}">שידור מאוחד: {hls_text}</span><span class="badge {audio_cls}">אודיו: {audio_text}</span><span class="badge {rec_cls}">הקלטה: {rec_text}</span></div>{allowed_block}{engine_block}
<div class="grid"><div><div class="card"><h2>שידור חי וידאו + אודיו</h2><div class="cam-box"><video id="hlsVideo" controls playsinline webkit-playsinline></video><img id="cameraView" alt="שידור מצלמה"><div id="placeholder" class="placeholder">השידור כבוי.<br>לחץ “הפעל וידאו + אודיו”. הווידאו יעבוד דרך OpenCV, והאודיו דרך FFmpeg.</div></div><audio id="audioPlayer" controls playsinline style="width:100%;margin-top:12px;display:none"></audio><div id="cameraMsg" class="note" style="display:none"></div><div><button onclick="startStableMjpegAudio()" {disabled_video}>הפעל וידאו + אודיו</button><button class="stop" onclick="stopStableStream()">כבה וידאו + אודיו</button><button class="snap" onclick="openSnapshot()" {disabled_video}>פתח צילום רגעי</button><button class="gray" onclick="resetCameraDevices()">שחרור/איפוס מצלמה</button><button class="gray" onclick="location.reload()">רענן סטטוס</button></div><div class="note">מתקדם בלבד: אם תרצה לבדוק שוב HLS מאוחד דרך FFmpeg, השתמש בכפתור הבא. אצל המצלמה שלך הוא כנראה נכשל ולכן לא ברירת מחדל.</div><button class="gray" onclick="startUnifiedStream(true)" {disabled_hls}>בדיקה מתקדמת: HLS מאוחד</button><button class="gray" onclick="startMjpegOnly()" {disabled_video}>וידאו בלבד</button></div>
<div class="card"><h2>⏺️ הקלטה למחשב</h2><p>הקלטות נשמרות מקומית בתיקייה: <code>{html_lib.escape(CAMERA_RECORDINGS_DIR)}</code></p><form method="post" action="/camera/record/start"><label><input type="checkbox" name="with_audio" value="1" checked>כלול אודיו בהקלטה</label><button class="record" type="submit" {disabled_av}>התחל הקלטה</button></form><form method="post" action="/camera/record/stop"><button class="stop" type="submit">עצור הקלטה</button></form><div class="good">משך הקלטה מקסימלי להגנה מדיסק מלא: {CAMERA_RECORD_MAX_SECONDS//60} דקות.</div></div>
<div class="card"><h2>🎞️ הקלטות אחרונות</h2>{rec_rows}<p><a class="btn gray" href="/camera/recordings">פתח רשימת הקלטות מלאה</a></p></div></div>
<div><div class="card"><h2>סטטוס טכני</h2><div class="row"><b>OpenCV</b><span>{'מותקן' if engine.get('opencv') else 'לא מותקן'}</span></div><div class="row"><b>FFmpeg</b><span>{'זמין' if av.get('ffmpeg') else 'לא נמצא'}</span></div><div class="row"><b>HLS פעיל</b><span>{hls_text}</span></div><div class="row"><b>HLS מצב</b><span>{html_lib.escape(hls_status.get('mode') or '')}</span></div><div class="row"><b>וידאו HLS</b><span>{html_lib.escape(hls_status.get('video_device') or av.get('video_device') or '')}</span></div><div class="row"><b>אודיו HLS</b><span>{html_lib.escape(hls_status.get('audio_device') or av.get('audio_device') or '')}</span></div><div class="row"><b>לוג HLS</b><span>{html_lib.escape(hls_status.get('log_path') or CAMERA_HLS_LOG_PATH)}</span></div><div class="row"><b>Camera index</b><span>{CAMERA_INDEX}</span></div><div class="row"><b>רזולוציה</b><span>{CAMERA_WIDTH}×{CAMERA_HEIGHT}</span></div><div class="row"><b>Frames MJPEG</b><span>{cam_status.get('frames')}</span></div><div class="row"><b>קובץ הקלטה פעיל</b><span>{html_lib.escape(rec_status.get('file_name') or 'אין')}</span></div><div class="row"><b>גודל הקלטה</b><span>{format_bytes(rec_status.get('file_size') or 0)}</span></div><div class="row"><b>שגיאה אחרונה</b><span>{html_lib.escape(hls_status.get('last_error') or cam_status.get('last_error') or rec_status.get('last_error') or 'אין')}</span></div></div>
<div class="card"><h2>התקנים שזוהו</h2><p><b>וידאו:</b><br>{html_lib.escape(video_devices)}</p><p><b>אודיו:</b><br>{html_lib.escape(audio_devices)}</p><p class="muted">אם נבחר התקן לא נכון, אפשר להגדיר במערכת: <code>TRIPSERVER_FFMPEG_VIDEO_DEVICE</code> / <code>TRIPSERVER_FFMPEG_AUDIO_DEVICE</code>.</p><p><a class="btn gray" href="/camera/audio-test.json" target="_blank">בדיקת אודיו טכנית</a><a class="btn gray" href="/camera/hls/status.json" target="_blank">JSON HLS</a></p></div>
<div class="note"><b>הערת שימוש:</b> זה כולל אודיו והקלטה. ודא שכל מי שנמצא בבית יודע שהמערכת קיימת ומתי היא פעילה.</div></div></div></div>
<script>
function setMsg(id, text, isError=false){{ const el=document.getElementById(id); if(!el)return; el.style.display=text?'block':'none'; el.textContent=text||''; el.className=isError?'warn':'note'; }}
async function startStableMjpegAudio(){{
  setMsg('cameraMsg','מפעיל וידאו יציב דרך OpenCV ואודיו דרך FFmpeg...');
  const audio=document.getElementById('audioPlayer');
  const video=document.getElementById('hlsVideo');
  const img=document.getElementById('cameraView');
  const ph=document.getElementById('placeholder');
  try{{ await fetch('/camera/hls/stop', {{method:'POST', cache:'no-store'}}); }}catch(e){{}}
  video.pause(); video.removeAttribute('src'); video.load(); video.style.display='none';
  // Start audio immediately from the user click, before async video prep can cause iPhone/Safari to block playback.
  try{{
    audio.style.display='block';
    audio.src='/camera/audio-stream?restart=1&ts='+Date.now();
    audio.load();
    const p=audio.play();
    if(p && p.catch) p.catch(e=>setMsg('cameraMsg','הווידאו יופעל. אם אין אודיו, לחץ Play בנגן האודיו. '+e,false));
  }}catch(e){{ setMsg('cameraMsg','הווידאו יופעל, אך האודיו לא התחיל: '+e,false); }}
  try{{ await fetch('/camera/prepare-video', {{method:'POST', cache:'no-store'}}); }}catch(e){{}}
  img.onload=()=>{{ph.style.display='none'; img.style.display='block'; setMsg('cameraMsg','וידאו פעיל. האודיו בנגן הנפרד. אם אין קול, לחץ Play בנגן האודיו או פתח בדיקת אודיו טכנית.');}};
  img.onerror=()=>setMsg('cameraMsg','שגיאת וידאו. לחץ שחרור/איפוס מצלמה ונסה שוב.',true);
  img.src='/camera/stream?ts='+Date.now();
}}
async function stopStableStream(){{
  try{{ await fetch('/camera/hls/stop', {{method:'POST', cache:'no-store'}}); }}catch(e){{}}
  try{{ await fetch('/camera/audio-stop', {{method:'POST', cache:'no-store'}}); }}catch(e){{}}
  try{{ await fetch('/camera/stop', {{method:'POST', cache:'no-store'}}); }}catch(e){{}}
  const audio=document.getElementById('audioPlayer'); audio.pause(); audio.removeAttribute('src'); audio.load(); audio.style.display='none';
  const video=document.getElementById('hlsVideo'); video.pause(); video.removeAttribute('src'); video.load(); video.style.display='none';
  const img=document.getElementById('cameraView'); img.style.display='none'; img.removeAttribute('src');
  document.getElementById('placeholder').style.display='block'; setMsg('cameraMsg','הווידאו והאודיו כובו.');
}}

async function startUnifiedStream(withAudio){{
  setMsg('cameraMsg','מפעיל שידור מאוחד דרך FFmpeg...');
  const video=document.getElementById('hlsVideo'); const img=document.getElementById('cameraView'); const ph=document.getElementById('placeholder');
  try{{
    img.style.display='none'; img.removeAttribute('src');
    const res=await fetch('/camera/hls/start?audio='+(withAudio?'1':'0'), {{method:'POST', cache:'no-store'}});
    const data=await res.json().catch(()=>({{ok:false,error:'תגובה לא קריאה מהשרת'}}));
    if(!res.ok || !data.ok) throw new Error(data.error || ('HTTP '+res.status));
    ph.style.display='none'; video.style.display='block';
    video.src='/camera/hls/stream.m3u8?ts='+Date.now(); video.load();
    try{{ await video.play(); setMsg('cameraMsg', data.with_audio ? 'שידור וידאו+אודיו פעיל.' : 'וידאו פעיל, אבל האודיו נכשל. בדוק לוג HLS.'); }}
    catch(e){{ setMsg('cameraMsg','השידור הופעל. באייפון לחץ Play בנגן אם הוא לא התחיל לבד. '+e, false); }}
  }}catch(e){{ setMsg('cameraMsg','שגיאת שידור מאוחד: '+e, true); }}
}}
async function stopUnifiedStream(){{
  try{{ await fetch('/camera/hls/stop', {{method:'POST', cache:'no-store'}}); }}catch(e){{}}
  const video=document.getElementById('hlsVideo'); video.pause(); video.removeAttribute('src'); video.load(); video.style.display='none';
  const img=document.getElementById('cameraView'); img.style.display='none'; img.removeAttribute('src');
  document.getElementById('placeholder').style.display='block'; setMsg('cameraMsg','השידור כובה.');
}}
async function startMjpegOnly(){{
  setMsg('cameraMsg','מפעיל גיבוי וידאו בלבד...');
  const video=document.getElementById('hlsVideo'); video.pause(); video.removeAttribute('src'); video.load(); video.style.display='none';
  const img=document.getElementById('cameraView'); const ph=document.getElementById('placeholder');
  try{{ await fetch('/camera/prepare-video', {{method:'POST', cache:'no-store'}}); }}catch(e){{}}
  img.onload=()=>{{ph.style.display='none'; img.style.display='block'; setMsg('cameraMsg','וידאו בלבד פעיל.');}};
  img.onerror=()=>setMsg('cameraMsg','שגיאת וידאו בלבד. לחץ שחרור/איפוס מצלמה ונסה שוב.',true);
  img.src='/camera/stream?ts='+Date.now();
}}
function openSnapshot(){{ window.open('/camera/snapshot?ts='+Date.now(), '_blank'); }}
async function resetCameraDevices(){{
  await stopUnifiedStream();
  try{{ await fetch('/camera/audio-stop', {{method:'POST', cache:'no-store'}}); }}catch(e){{}}
  try{{ const res=await fetch('/camera/reset', {{method:'POST', cache:'no-store'}}); const data=await res.json(); setMsg('cameraMsg', data.message || 'איפוס בוצע.'); }}catch(e){{ setMsg('cameraMsg','שגיאת איפוס: '+e,true); }}
}}
</script>
</body></html>
"""

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 admin" href="/admin/camera"><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(auto-fit,minmax(180px,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="/admin/watchdog">🛡️ Watchdog</a>
<a class="btn-link" href="/admin/logs">📜 לוגים</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 class="status-card"><b>🛡️ Watchdog</b><span>בדיקה חיצונית כל דקה והפעלה אוטומטית אם האדמין נפל.</span></div>
<div class="status-card"><b>📜 לוגים</b><span>צפייה בלוגי restart ו־Watchdog מתוך הפאנל.</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" onclick="showScreen('watchdog')"><div class="icon">🛡️</div><h2>Watchdog</h2><p>שמירת הפאנל חי: בדיקה שקטה כל כמה דקות, בלי חלון שחור קופץ, שחרור פורט תקוע והפעלה מחדש אם נפל.</p></button>
<a class="main-card" href="/admin/camera" style="text-decoration:none"><div class="icon">📷</div><h2>מצלמת חדר</h2><p>הפעלת מצלמת החדר, אודיו והקלטות — רק בלחיצה ידנית.</p></a>
<a class="main-card" href="/admin/logs" style="text-decoration:none"><div class="icon">📜</div><h2>לוגים</h2><p>צפייה בלוגי אתחול פאנל ו־Watchdog מתוך הדפדפן.</p></a>
<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-watchdog" class="screen">
<div class="section-head"><div class="section-title"><h2>🛡️ Watchdog</h2><p>שכבת ביטחון חיצונית שמחזירה את פאנל האדמין לאוויר אם הוא נפל.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/admin/watchdog">מסך Watchdog מלא</a><a class="btn-link" href="/admin/self-test">בדיקה עצמית</a></div></div>
<div class="tool-panel active safe-zone"><div class="good"><strong>מטרה:</strong> לא ללכת פיזית למחשב אם השרת נפל. Windows יריץ סקריפט שקט דרך pythonw.exe כל כמה דקות, ואם /ping.json לא עונה — הוא ישחרר פורט תקוע במידת הצורך ויפעיל מחדש את admin_server.py.</div><div class="form-grid"><div class="upload-box"><h4>התקנה / עדכון</h4><form action="/watchdog/install" method="post" onsubmit="return confirm('להתקין או לעדכן את משימת ה־Watchdog?')"><button>התקן / עדכן Watchdog</button></form><p>נכתב אל C:\\TripServer\\tripserver_admin_watchdog.py ומותקן כ־Task Scheduler בשם TripServerAdminWatchdog.</p></div><div class="upload-box"><h4>בדיקה והרצה</h4><form action="/watchdog/run-once" method="post" onsubmit="return confirm('להריץ Watchdog פעם אחת עכשיו?')"><button class="orange">הרץ Watchdog עכשיו</button></form><div class="preview-links"><a href="/admin/watchdog">פתח סטטוס Watchdog</a></div><p>הרצה חד־פעמית לא מחליפה קבצים. אם השרת חי היא רק תצא תקין.</p></div><div class="upload-box"><h4>הסרה</h4><form action="/watchdog/uninstall" method="post" onsubmit="return confirm('להסיר את משימת ה־Watchdog המתוזמנת?')"><button class="danger">הסר Watchdog</button></form><p>מסיר רק את המשימה המתוזמנת. קובץ הסקריפט והלוג נשארים לצורך בדיקה.</p></div></div></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><a class="btn-link" href="/admin/watchdog">Watchdog</a><a class="btn-link" href="/admin/logs">לוגים</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><a href="/admin/logs">📜 צפה בלוגים</a></div><p><b>הערה:</b> לא חובה אחרי התקנת Watchdog. בדיקת אתחול מיועדת רק לבדיקה יזומה או אחרי עדכון/שחזור אדמין.<br>גיבויים: 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','watchdog','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))

    try:
        wd_code = build_watchdog_code(ADMIN_SERVER_PATH, sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR)
        wd_ok, wd_info = validate_watchdog_source(wd_code)
        add_self_test_result(results, "Watchdog", "קוד Watchdog חיצוני עובר קומפילציה", wd_ok, wd_info)
        wd_status = watchdog_status(include_log=False)
        add_self_test_result(results, "Watchdog", "סטטוס Watchdog ניתן לקריאה", bool(wd_status.get("source_ok")), f"script_exists={wd_status.get('script_exists')} task_installed={wd_status.get('task_installed')}")
        if os.name == "nt":
            add_self_test_result(results, "Watchdog", "Task Scheduler נתמך במחשב זה", bool(wd_status.get("task_supported")), wd_status.get("task_status", ""), severity="warning")
        else:
            add_self_test_result(results, "Watchdog", "Task Scheduler בסביבה הנוכחית", False, "לא Windows — אצלך בשרת Windows זה אמור להיות נתמך.", severity="warning")
    except Exception as exc:
        add_self_test_result(results, "Watchdog", "בדיקת Watchdog", 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", "/admin/watchdog", "/admin/logs", "/admin/logs.json", "/admin/camera", "/camera/stream", "/camera/snapshot.jpg", "/camera/prepare-video", "/camera/reset", "/camera/stop", "/camera/status.json", "/camera/audio-stream", "/camera/audio-test.json", "/camera/audio-stop", "/camera/record/start", "/camera/record/stop", "/camera/recordings",
            "/health", "/ping.json", "/upload_admin_server", "/restart_admin_server", "/restore_admin_server_backup", "/watchdog/install", "/watchdog/uninstall", "/watchdog/run-once",
            "/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")
        add_self_test_result(results, "ממשק", "כפתור Watchdog קיים בפאנל", "/admin/watchdog" in admin_html and "screen-watchdog" in admin_html, "נבדק href=/admin/watchdog ומסך screen-watchdog")
        add_self_test_result(results, "ממשק", "כפתור לוגים קיים בפאנל", "/admin/logs" in admin_html, "נבדק href=/admin/logs")
        add_self_test_result(results, "ממשק", "כפתור מצלמת חדר קיים בפאנל", "/admin/camera" in admin_html and "מצלמת חדר" in admin_html, "נבדק href=/admin/camera")
        add_self_test_result(results, "ממשק", "כפתור אתחול מתקדם לא מוצג כפעולת חובה", "בדיקת אתחול פאנל — מתקדם" in admin_html and "לא חובה אחרי התקנת Watchdog" in admin_html, "נבדק ניסוח UX")
    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))

    try:
        logs_html = render_admin_logs()
        add_self_test_result(results, "ממשק", "מסך לוגים נבנה ללא שגיאה", "מרכז לוגים" in logs_html and "לוג Watchdog" in logs_html, "OK")
    except Exception as exc:
        add_self_test_result(results, "ממשק", "מסך לוגים נבנה ללא שגיאה", False, repr(exc))

    try:
        camera_html = render_admin_camera()
        add_self_test_result(results, "מצלמה", "מסך מצלמת חדר נבנה ללא שגיאה", "מצלמת חדר" in camera_html and "/camera/stream" in camera_html and "/camera/reset" in camera_html, "OK")
        cam_engine = camera_engine_status()
        add_self_test_result(results, "מצלמה", "OpenCV זמין להפעלת מצלמה", bool(cam_engine.get("opencv")), "התקנה אוטומטית ברקע פעילה; בדוק /camera/setup" if not cam_engine.get("opencv") else f"OpenCV {cam_engine.get('opencv_version','')}", severity="warning")
        setup_html = render_camera_setup_page()
        add_self_test_result(results, "מצלמה", "מסך סטטוס רכיבי מצלמה נבנה תקין", "התקנה אוטומטית" in setup_html and "pip install" in setup_html, "OK")
        add_self_test_result(results, "מצלמה", "מצלמה מוגנת לרשת פרטית/Tailscale בלבד", is_private_or_loopback_ip("100.64.1.2") and not is_private_or_loopback_ip("8.8.8.8"), "Tailscale מותר, ציבורי חסום")
    except Exception as exc:
        add_self_test_result(results, "מצלמה", "בדיקת מסך/מנוע מצלמה", 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,
        "watchdog": watchdog_status(include_log=False, include_task=False),
        "logs": {key: {"path": meta["path"], "exists": os.path.exists(meta["path"])} for key, meta in LOG_FILE_REGISTRY.items()},
        "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><a href="/admin/watchdog">🛡️ Watchdog</a><a href="/admin/logs">📜 לוגים</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("/ping")
def ping():
    return "OK", 200, {"Cache-Control": "no-store"}


@app.route("/ping.json")
def ping_json():
    # Ultra-light endpoint for restart helper and Watchdog.
    # It must never parse presentations, inspect files, call schtasks, enumerate cameras, or run FFmpeg.
    return {"ok": True, "service": "TripServer Admin", "version": ADMIN_PANEL_VERSION, "started_at": ADMIN_STARTED_AT, "port": ADMIN_PORT}


@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("/admin/logs")
def admin_logs():
    return render_admin_logs()


@app.route("/admin/logs.json")
def admin_logs_json():
    return build_logs_payload(request.args.get("lines", LOG_TAIL_DEFAULT_LINES))


@app.route("/camera/hls/start", methods=["POST"])
def camera_hls_start():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return {"ok": False, "error": reason}, 403
    if not find_ffmpeg_executable():
        return {"ok": False, "error": "FFmpeg לא נמצא. נדרש FFmpeg לשידור מאוחד."}, 503
    with_audio = request.args.get("audio", "1") != "0"
    try:
        ok = CAMERA_HLS_STREAM.start(with_audio=with_audio, restart=True)
        status = CAMERA_HLS_STREAM.status()
        return {"ok": bool(ok), "playlist": "/camera/hls/stream.m3u8", "with_audio": bool(status.get("with_audio")), "status": status}
    except Exception as exc:
        return {"ok": False, "error": str(exc), "status": CAMERA_HLS_STREAM.status()}, 500


@app.route("/camera/hls/stop", methods=["POST"])
def camera_hls_stop():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return {"ok": False, "error": reason}, 403
    CAMERA_HLS_STREAM.stop()
    return {"ok": True, "status": CAMERA_HLS_STREAM.status()}


@app.route("/camera/hls/status.json")
def camera_hls_status_json():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return {"ok": False, "error": reason}, 403
    return {"ok": True, "status": CAMERA_HLS_STREAM.status(), "devices": ffmpeg_dshow_devices()}


@app.route("/camera/hls/<path:filename>")
def camera_hls_file(filename):
    allowed, reason = camera_access_allowed()
    if not allowed:
        return reason, 403
    safe = safe_zip_relpath(filename)
    if not safe or "/" in safe or not safe.lower().endswith((".m3u8", ".ts")):
        return "invalid hls file", 400
    resp = send_from_directory(CAMERA_HLS_DIR, safe, conditional=False)
    resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
    resp.headers["Pragma"] = "no-cache"
    if safe.lower().endswith(".m3u8"):
        resp.headers["Content-Type"] = "application/vnd.apple.mpegurl; charset=utf-8"
    elif safe.lower().endswith(".ts"):
        resp.headers["Content-Type"] = "video/mp2t"
    return resp


@app.route("/camera/setup")
def camera_setup_page():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return admin_status_page("גישה נחסמה", reason, ok=False, back_url="/admin/camera"), 403
    return render_ca