
# TripServer update compatibility marker.
# The currently installed legacy uploader scans only the first 400,000 characters.
# Required identity tokens: Flask @app.route app.run BASE_DIR ADMIN_PORT restart_process_later upload_admin_server run_self_test_suite 
# -*- coding: utf-8 -*-

from flask import Flask, request, redirect, send_from_directory, make_response, Response, send_file
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
import json
import urllib.request
import urllib.parse
import uuid
import hashlib
import math
import struct
import mimetypes
import tempfile
from pathlib import Path
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Dict, List, Mapping, Optional, Sequence, Tuple

app = Flask(__name__)

ADMIN_PANEL_VERSION = "TripServer Admin GOLD v96 — Netlify Host Validation Fix"
ADMIN_BUILD_ID = "EITAN-ADMIN-GOLD-v96-20260717-01"
# Legacy installer compatibility markers only. The currently installed pre-GOLD
# validator requires these two historical text tokens before it will accept an
# admin update. They do not define functions, routes, tasks, files or runtime behavior.
LEGACY_INSTALLER_COMPATIBILITY_MARKERS = ("build_watchdog_code", "watchdog_status")
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
CONTROL_PORT = 8010
CONTROL_DIR = os.path.join(BASE_DIR, "control_server")
CONTROL_SCRIPT_PATH = os.path.join(CONTROL_DIR, "tripserver_control_server.py")
CONTROL_PENDING_ADMIN_PATH = os.path.join(CONTROL_DIR, "pending_admin_server.py")
CONTROL_REQUEST_PATH = os.path.join(CONTROL_DIR, "request.json")
CONTROL_STATUS_PATH = os.path.join(CONTROL_DIR, "status.json")
CONTROL_LOG_PATH = os.path.join(CONTROL_DIR, "tripserver_control_server.log")
CONTROL_BOOTSTRAP_LOG_PATH = os.path.join(BASE_DIR, "tripserver_control_bootstrap.log")
CONTROL_TASK_NAME = "TripServerControlServer"
ADMIN_FAILSAFE_STATUS_PATH = os.path.join(BASE_DIR, "admin_failsafe_status.json")
PRESENTATION_MUSIC_UPLOAD_STATUS_PATH = os.path.join(BASE_DIR, "presentation_music_upload_status.json")
PENDING_MUSIC_UPLOAD_DIR = os.path.join(BASE_DIR, "tmp", "pending_music_uploads")

# Local File Manager roots are discovered at runtime on the Windows server.
# Access is intentionally limited to discovered Downloads folders and TripServer folders.
LOCAL_FILE_ROOTS_CACHE = None
LOCAL_FILE_ROOTS_CACHE_TS = 0
LOCAL_FILE_ALLOWED_ROOT_NAMES = {"downloads", "tripserver"}
LOCAL_FILE_TEXT_EXTENSIONS = {".txt", ".log", ".json", ".md", ".py", ".html", ".htm", ".css", ".js", ".csv", ".bat", ".ps1", ".xml"}
LOCAL_FILE_PREVIEW_MAX_BYTES = 1024 * 1024
LOCAL_FILE_ZIP_MAX_BYTES = 120 * 1024 * 1024
LOCAL_FILE_LIST_LIMIT = 700


FOLDERS = {
    "JAPAN": os.path.join(BASE_DIR, "JAPAN"),
    "USA": os.path.join(BASE_DIR, "USA"),
    "USA_TV": os.path.join(BASE_DIR, "USA_TV"),
    "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", "USA_TV", "Moneyapp", "MoneyHome", "TripForm"}

# Preferred entry file for each app root. This prevents /USA_TV/ from opening a presentation by accident
# when the folder contains more than one HTML file.
APP_ENTRY_FILES = {
    "JAPAN": ["index.html"],
    "USA": ["index.html"],
    "USA_TV": ["usa2027-tv.html", "index.html"],
    "Moneyapp": ["index.html"],
    "MoneyHome": ["index.html"],
    "TripForm": ["index.html"],
}

# Netlify export builder.
# Uses the same current trip files that the admin panel serves from C:\TripServer\JAPAN and C:\TripServer\USA.
# The generated ZIP is rewritten only for deployment paths: budget app URL and presentation launch URL.
NETLIFY_BUDGET_APP_URL = "https://beautiful-kitsune-dafd66.netlify.app/"
NETLIFY_EXPORT_DIR = os.path.join(BASE_DIR, "tmp", "netlify_exports")
# Canonical USA Netlify-only budget slide image.
# Extracted from the approved manual Netlify bundle; it contains no overlaid budget amount.
# It is written only into the temporary Netlify build and never replaces the private-server image.
# Canonical USA Netlify-only budget slide HTML.
# The background image is shared; the no-amount behavior is defined by this slide markup, not by the image.
USA_NETLIFY_BUDGET_SLIDE_HTML = '<section class="slide budget" data-key="budget">\n<div class="slide-shell">\n<div class="media-panel">\n<img alt="תקציב הטיול" class="hero-image" data-local-base="media/usa-presentation/images/budget" loading="lazy" referrerpolicy="no-referrer" src="media/usa-presentation/images/budget.jpg"/>\n<div class="image-overlay"></div>\n<div class="image-caption">תכנון תקציבי</div>\n</div>\n<div class="info-panel">\n<div class="badge">💰 תקציב</div>\n<h1>תקציב — בלי דרמה</h1>\n<div class="region">תמונת מצב תקציבית</div>\n<p class="vibe">השקף הזה משאיר את המספרים בקובץ הטיול, ובמצגת מציג רק את התמונה הכללית.</p>\n<ul class="summary-list"><li>המסגרת התקציבית מנוהלת בקובץ הטיול ובאפליקציית התקציב.</li><li>המעקב מחולק ללינה, פארקים, נסיעות, אוכל, קניות וקרוז.</li><li>המטרה: ליהנות מהטיול בלי להפוך כל עצירה לחישוב.</li></ul>\n<div class="fun-note">את המספרים המדויקים משאירים למעקב התקציב — כאן נשארים באווירה של טיול.</div>\n</div>\n</div>\n</section>'

NETLIFY_BUILD_DIR = os.path.join(BASE_DIR, "tmp", "netlify_build")
NETLIFY_STATIC_EXTENSIONS = {
    ".html", ".htm", ".css", ".js", ".json", ".txt", ".md", ".svg", ".xml", ".map",
    ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".ico",
    ".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma", ".webm", ".mp4", ".m4v",
    ".woff", ".woff2", ".ttf"
}
NETLIFY_SKIP_DIRS = {"__pycache__", ".git", ".svn", ".hg", "backups", "backup", "tmp", "temp", "node_modules"}
NETLIFY_SKIP_FILES = {"thumbs.db", ".ds_store", "desktop.ini"}
NETLIFY_CONTROL_FILES = {"_redirects", "_headers"}

# USA Netlify is the phone/computer deployment. TV files live under USA_TV and must never
# leak into the USA deploy bundle. A stale TV presentation inside C:\TripServer\USA used
# to make validation fail because it referenced images-tv paths that are intentionally absent.
NETLIFY_USA_EXCLUDED_FILES = {
    "usa2027-presentation-tv.html",
    "usa2027-tv.html",
}
NETLIFY_USA_EXCLUDED_PREFIXES = {
    "media/usa-presentation/images-tv/",
}

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_PRESENTATION_ROOTS = {
    "usa_presentation": "USA",
    "usa_presentation_tv": "USA_TV",
}

USA_EXTRA_HTML_ROOTS = {
    "usa_trip_tv": "USA_TV",
}


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"}
USA_VIDEO_EXTENSIONS = {".mp4", ".m4v"}
USA_MEDIA_EXTENSIONS = USA_IMAGE_EXTENSIONS | USA_VIDEO_EXTENSIONS
USA_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma", ".webm"}
USA_MEDIA_MANIFEST_NAME = "media-manifest.json"
USA_MEDIA_TIMELINE_REPORT_NAME = "media-timeline-report.json"
USA_MEDIA_PATCH_VERSION = "2026-07-13-v44-inline-source-of-truth"
USA_VIDEO_MAX_SINGLE_FILE_BYTES = int(os.environ.get("TRIPSERVER_USA_VIDEO_MAX_BYTES", str(220 * 1024 * 1024)))
USA_MEDIA_ZIP_MAX_BYTES = int(os.environ.get("TRIPSERVER_USA_MEDIA_ZIP_MAX_BYTES", str(750 * 1024 * 1024)))
USA_MEDIA_ZIP_MAX_UNCOMPRESSED_BYTES = int(os.environ.get("TRIPSERVER_USA_MEDIA_ZIP_MAX_UNCOMPRESSED_BYTES", str(1500 * 1024 * 1024)))
USA_MEDIA_ZIP_MAX_SINGLE_FILE_BYTES = int(os.environ.get("TRIPSERVER_USA_MEDIA_ZIP_MAX_SINGLE_FILE_BYTES", str(240 * 1024 * 1024)))
USA_MEDIA_ZIP_MAX_FILES = 220

USA_IMAGE_TARGETS = {
    "phone": {
        "label": "מסך פלאפון",
        "root_key": "USA",
        "description": "מצגת ארה״ב למסכי אייפון/אנדרואיד",
        "folder": os.path.join("media", "usa-presentation", "images"),
        "rel_prefix": "media/usa-presentation/images",
        "presentation_key": "usa_presentation",
        "backup_tag": "USA_images_phone",
    },
    "tv": {
        "label": "מסך טלוויזיה",
        "description": "מצגת ארה״ב למסך TV / LG C3 77 4K",
        "root_key": "USA_TV",
        "folder": os.path.join("media", "usa-presentation", "images"),
        "rel_prefix": "media/usa-presentation/images",
        "presentation_key": "usa_presentation_tv",
        "backup_tag": "USA_TV_images",
    },
}

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"),
    ],
}

# Presentation music sync rules.
# v34.14 keeps existing music upload routes as the single source of truth.
# Music buttons in the UI now upload the file, measure it, and synchronize the relevant presentation section.
# Japan main song syncs all japan-experience slides. USA part 1/2 sync only their own PART_*_SECONDS values.
PRESENTATION_MUSIC_SYNC_RULES = {
    "japan_experience_music": {
        "label": "יפן קיץ 26 — שיר מצגת ראשית",
        "trip": "JAPAN",
        "presentation": "japan-experience.html",
        "music_targets": JAPAN_MUSIC_TARGETS["japan_experience_music"],
        "scope": "כל שקפי מצגת יפן",
        "future_behavior": "פעיל כעת: העלאת שיר מצגת יפן מודדת את האורך לפני הטמעה. אם ממוצע השקף יוצא מתחת ל־5 שניות או מעל ל־10 שניות, מוצג מסך אישור לפני שמירת השיר וסנכרון המצגת.",
        "enabled_now": True,
    },
    "japan_flight_music": {
        "label": "יפן קיץ 26 — מוזיקת פתיח טיסה",
        "trip": "JAPAN",
        "presentation": "japan-flight-intro.html",
        "music_targets": JAPAN_MUSIC_TARGETS["japan_flight_music"],
        "scope": "פתיח הטיסה בלבד",
        "future_behavior": "העלאת שיר תישמר לפתיח הטיסה בלבד; לא מסנכרנת את מצגת יפן הראשית.",
        "enabled_now": False,
    },
    "usa_music_part1": {
        "label": "ארצות הברית קיץ 27 — שיר חלק 1",
        "trip": "USA",
        "presentation": "usa2027-presentation.html / usa2027-presentation-tv.html",
        "music_targets": [os.path.join("USA", USA_MUSIC_FILES["usa_music"]), os.path.join("USA_TV", USA_MUSIC_FILES["usa_music"])],
        "scope": "שקפים 1–23 בלבד",
        "future_behavior": "פעיל כעת: העלאת שיר חלק 1 מסנכרנת רק את מקטע 1 ולא משנה את מקטע 2.",
        "enabled_now": True,
    },
    "usa_music_part2": {
        "label": "ארצות הברית קיץ 27 — שיר חלק 2",
        "trip": "USA",
        "presentation": "usa2027-presentation.html / usa2027-presentation-tv.html",
        "music_targets": [os.path.join("USA", USA_MUSIC_FILES["usa_music_part2"]), os.path.join("USA_TV", USA_MUSIC_FILES["usa_music_part2"])],
        "scope": "שקפים 24–44 בלבד",
        "future_behavior": "פעיל כעת: העלאת שיר חלק 2 מסנכרנת רק את מקטע 2 ולא משנה את מקטע 1.",
        "enabled_now": True,
    },
}


# Japan main presentation music timing guardrails.
# The uploaded file is first converted into a temporary pending MP3, measured, and only then embedded.
# If the detected song length would create an average slide duration outside this range, the admin asks for confirmation.
JAPAN_EXPERIENCE_MIN_AVG_SLIDE_SECONDS = 5.0
JAPAN_EXPERIENCE_MAX_AVG_SLIDE_SECONDS = 10.0
JAPAN_EXPERIENCE_LOOP_MIN_SLIDE_SECONDS = 5.0
PENDING_MUSIC_MAX_AGE_SECONDS = 60 * 60

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", ".mp4", ".m4v", ".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
ADMIN_MAX_PACKAGE_UPLOAD_BYTES = 8 * 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")


# 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.

    Windows scheduled restart tasks should run without a visible console.
    On non-Windows or if pythonw.exe is unavailable, 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 חדש, שחזור גיבוי ופעולות מחשב."},
    "control": {"label": "לוג שרת בקרה", "path": CONTROL_LOG_PATH, "note": "שרת הבקרה החיצוני שמפעיל ומעדכן את האדמין הראשי."},
    "control_bootstrap": {"label": "לוג התקנת שרת בקרה", "path": CONTROL_BOOTSTRAP_LOG_PATH, "note": "כתיבת והפעלת שרת הבקרה."},
}
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": "ריסטארט למחשב",
}
ADMIN_UPDATE_FAILSAFE_RESTART_SECONDS = int(os.environ.get("TRIPSERVER_ADMIN_UPDATE_FAILSAFE_RESTART_SECONDS", "180"))
ADMIN_UPDATE_FAILSAFE_ENABLED = os.environ.get("TRIPSERVER_ADMIN_UPDATE_FAILSAFE", "1").strip().lower() not in {"0", "false", "no", "off"}


def _json_safe_write(path, payload):
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        tmp = path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as fh:
            json.dump(payload, fh, ensure_ascii=False, indent=2)
        os.replace(tmp, path)
        return True
    except Exception:
        return False


def read_admin_failsafe_status():
    try:
        if os.path.exists(ADMIN_FAILSAFE_STATUS_PATH):
            with open(ADMIN_FAILSAFE_STATUS_PATH, "r", encoding="utf-8") as fh:
                data = json.load(fh)
                if isinstance(data, dict):
                    return data
    except Exception:
        pass
    return {"state": "unknown", "message": "עדיין אין נתוני Fail Safe שמורים."}


def write_admin_failsafe_status(state, message, **extra):
    payload = {
        "state": state,
        "message": message,
        "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
        "admin_version": ADMIN_PANEL_VERSION,
    }
    payload.update(extra)
    _json_safe_write(ADMIN_FAILSAFE_STATUS_PATH, payload)
    return payload


def is_no_pending_shutdown_message(output):
    txt = (output or "").lower()
    return ("1116" in txt) or ("no shutdown was in progress" in txt) or ("אין" in txt and "כיבוי" in txt)


def render_failsafe_status_card():
    data = read_admin_failsafe_status()
    state = str(data.get("state") or "unknown")
    message = str(data.get("message") or "")
    updated = str(data.get("updated_at") or "")
    if state in {"armed"}:
        icon = "🟠"
        title = "Fail Safe חמוש"
    elif state in {"cancelled", "auto_cancelled", "none"}:
        icon = "🟢"
        title = "Fail Safe לא פעיל"
    elif state in {"arm_failed", "cancel_failed"}:
        icon = "🔴"
        title = "Fail Safe דורש בדיקה"
    else:
        icon = "⚪"
        title = "Fail Safe ללא סטטוס"
    safe_msg = html_lib.escape(message or "אין ריסטארט פעיל כרגע או שעדיין אין בדיקת סטטוס.")
    safe_updated = html_lib.escape(updated)
    return '<div class="status-card"><b>%s %s</b><span>%s<br><small>%s</small></span></div>' % (icon, title, safe_msg, safe_updated)

# 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 find_ffprobe_executable():
    candidates = [
        os.environ.get("FFPROBE_PATH"),
        "ffprobe",
        r"C:\ffmpeg\bin\ffprobe.exe",
        r"C:\Program Files\ffmpeg\bin\ffprobe.exe",
        r"C:\Program Files (x86)\ffmpeg\bin\ffprobe.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 hidden_subprocess_flags(extra_flags=0):
    """Return process flags that suppress visible Windows console windows."""
    flags = int(extra_flags or 0)
    if os.name == "nt":
        flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
    return flags


def run_hidden_subprocess(cmd, timeout=20, **overrides):
    kwargs = dict(capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, shell=False)
    kwargs.update(overrides)
    kwargs["creationflags"] = hidden_subprocess_flags(kwargs.get("creationflags", 0))
    return subprocess.run(cmd, **kwargs)


def format_seconds(value):
    try:
        total = int(round(float(value)))
        minutes, seconds = divmod(total, 60)
        hours, minutes = divmod(minutes, 60)
        if hours:
            return f"{hours}:{minutes:02d}:{seconds:02d}"
        return f"{minutes}:{seconds:02d}"
    except Exception:
        return str(value)


def probe_audio_duration_seconds(path):
    ffprobe = find_ffprobe_executable()
    if not ffprobe:
        return {"ok": False, "error": r"ffprobe לא נמצא. ודא שקיים C:\ffmpeg\bin\ffprobe.exe", "ffprobe": ""}
    cmd = [
        ffprobe, "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        path,
    ]
    try:
        cp = run_hidden_subprocess(cmd, timeout=20)
        raw = ((cp.stdout or "") + "\n" + (cp.stderr or "")).strip()
        if cp.returncode != 0:
            return {"ok": False, "error": raw or f"ffprobe failed rc={cp.returncode}", "return_code": cp.returncode, "ffprobe": ffprobe}
        value = None
        for line in raw.splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                value = float(line)
                break
            except Exception:
                pass
        if value is None or value <= 0:
            return {"ok": False, "error": "ffprobe לא החזיר משך שיר תקין", "raw": raw, "ffprobe": ffprobe}
        return {"ok": True, "duration_seconds": value, "duration_display": format_seconds(value), "raw": raw, "ffprobe": ffprobe}
    except subprocess.TimeoutExpired:
        return {"ok": False, "error": "ffprobe חרג מזמן הבדיקה", "ffprobe": ffprobe}
    except Exception as exc:
        return {"ok": False, "error": repr(exc), "ffprobe": ffprobe}


def music_key_to_rule_key(trip, music_key):
    trip = (trip or "").upper()
    if trip == "JAPAN":
        return music_key if music_key in PRESENTATION_MUSIC_SYNC_RULES else None
    if trip == "USA":
        if music_key == "usa_music":
            return "usa_music_part1"
        if music_key == "usa_music_part2":
            return "usa_music_part2"
    return None


def usa_folder_for_presentation_key(presentation_key):
    root_key = USA_PRESENTATION_ROOTS.get(presentation_key, "USA")
    return FOLDERS.get(root_key, FOLDERS["USA"])


def usa_folder_for_extra_html_key(extra_key):
    root_key = USA_EXTRA_HTML_ROOTS.get(extra_key, "USA")
    return FOLDERS.get(root_key, FOLDERS["USA"])


def usa_folder_for_image_target(target_variant):
    target_variant, info = usa_image_target_info(target_variant)
    return FOLDERS.get(info.get("root_key") or "USA", FOLDERS["USA"])


def usa_music_absolute_targets(rel_path):
    # USA phone and USA_TV use the same relative media path inside different roots.
    return [
        os.path.join(FOLDERS["USA"], rel_path),
        os.path.join(FOLDERS["USA_TV"], rel_path),
    ]


def save_audio_upload_to_absolute_targets(uploaded_file, target_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_abs_{stamp}_{os.getpid()}_{safe_name}")
    converted_tmp = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"audio_abs_{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 target_path in target_paths:
            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": os.path.relpath(target_path, BASE_DIR), "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 read_presentation_music_upload_status():
    try:
        if os.path.exists(PRESENTATION_MUSIC_UPLOAD_STATUS_PATH):
            with open(PRESENTATION_MUSIC_UPLOAD_STATUS_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
            return data if isinstance(data, dict) else {}
    except Exception:
        return {}
    return {}


def write_presentation_music_upload_status(rule_key, status):
    data = read_presentation_music_upload_status()
    data[rule_key or "unknown"] = status
    history = data.get("_history")
    if not isinstance(history, list):
        history = []
    history.insert(0, status)
    data["_history"] = history[:30]
    ensure_parent_dir(PRESENTATION_MUSIC_UPLOAD_STATUS_PATH)
    tmp = PRESENTATION_MUSIC_UPLOAD_STATUS_PATH + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    os.replace(tmp, PRESENTATION_MUSIC_UPLOAD_STATUS_PATH)


def primary_saved_audio_path(save_result):
    if not isinstance(save_result, dict):
        return None
    if save_result.get("path"):
        return save_result.get("path")
    targets = save_result.get("targets")
    if isinstance(targets, list) and targets:
        first = targets[0]
        if isinstance(first, dict):
            return first.get("path")
    return None


def all_saved_audio_paths(save_result):
    if not isinstance(save_result, dict):
        return []
    if save_result.get("path"):
        return [save_result.get("path")]
    targets = save_result.get("targets")
    if isinstance(targets, list):
        return [item.get("path") for item in targets if isinstance(item, dict) and item.get("path")]
    return []




def read_text_file_utf8(path):
    with open(path, "r", encoding="utf-8", errors="ignore") as f:
        return f.read()


def write_text_file_utf8(path, new_text):
    """Write UTF-8 text atomically without creating a second backup.

    Callers that already created a transaction backup use this helper so a path
    correction cannot fail halfway through or leave a truncated presentation.
    """
    ensure_parent_dir(path)
    tmp_path = path + ".tmp"
    try:
        with open(tmp_path, "w", encoding="utf-8", newline="") as f:
            f.write(new_text)
            f.flush()
            try:
                os.fsync(f.fileno())
            except OSError:
                pass
        os.replace(tmp_path, path)
    except Exception:
        try:
            if os.path.exists(tmp_path):
                os.remove(tmp_path)
        except Exception:
            pass
        raise


def write_text_file_utf8_atomic_with_backup(path, new_text, backup_group):
    if not os.path.exists(path):
        raise FileNotFoundError(path)
    backup_path = backup_existing_file(path, backup_group)
    tmp_path = path + ".tmp"
    with open(tmp_path, "w", encoding="utf-8", newline="") as f:
        f.write(new_text)
    os.replace(tmp_path, path)
    return backup_path


def calculate_scaled_slide_durations_ms(old_durations, target_total_ms):
    return calculate_scaled_slide_durations_ms_with_min(old_durations, target_total_ms, None)


def calculate_scaled_slide_durations_ms_with_min(old_durations, target_total_ms, minimum_per_slide_ms=None):
    if not old_durations:
        raise ValueError("לא נמצאו זמני שקפים לחישוב.")
    target_total_ms = int(round(float(target_total_ms)))
    if target_total_ms <= 0:
        raise ValueError("משך יעד לא תקין לסנכרון שקפים.")
    current_total = sum(int(x) for x in old_durations)
    if current_total <= 0:
        raise ValueError("סך זמני השקפים הנוכחי אינו תקין.")
    count = len(old_durations)
    if minimum_per_slide_ms is None:
        minimum = 1200 if target_total_ms >= count * 1200 else max(250, target_total_ms // max(1, count))
    else:
        minimum = int(round(float(minimum_per_slide_ms)))
        if minimum <= 0:
            raise ValueError("רצפת זמן שקף אינה תקינה.")
        if target_total_ms < count * minimum:
            raise ValueError("משך היעד קצר מדי ביחס לרצפת זמן השקפים שנבחרה.")
    scale = target_total_ms / float(current_total)
    new_durations = [max(minimum, int(round(int(x) * scale))) for x in old_durations]
    diff = target_total_ms - sum(new_durations)
    if diff > 0:
        new_durations[-1] += diff
    elif diff < 0:
        remaining = -diff
        for i in range(len(new_durations) - 1, -1, -1):
            room = max(0, new_durations[i] - minimum)
            if room <= 0:
                continue
            take = min(room, remaining)
            new_durations[i] -= take
            remaining -= take
            if remaining <= 0:
                break
        if remaining > 0:
            if minimum_per_slide_ms is None:
                new_durations[-1] = max(1, new_durations[-1] - remaining)
            else:
                raise ValueError("לא ניתן להגיע למשך היעד בלי לרדת מתחת לרצפת זמן השקף.")
    if sum(new_durations) != target_total_ms:
        new_durations[-1] += target_total_ms - sum(new_durations)
    if any(int(x) <= 0 for x in new_durations):
        raise ValueError("חישוב זמני השקפים יצר ערך לא תקין.")
    if minimum_per_slide_ms is not None and any(int(x) < int(round(float(minimum_per_slide_ms))) for x in new_durations):
        raise ValueError("חישוב זמני השקפים ירד מתחת לרצפת זמן השקף שנבחרה.")
    return new_durations


def get_japan_experience_slide_durations_ms():
    path = os.path.join(FOLDERS["JAPAN"], JAPAN_PRESENTATION_FILES["japan_experience"])
    if not os.path.exists(path):
        raise FileNotFoundError(path)
    html = read_text_file_utf8(path)
    match = re.search(r"const\s+SLIDES\s*=\s*(\[.*?\]);", html, flags=re.S)
    if not match:
        raise ValueError("לא נמצא מערך SLIDES במצגת יפן. אסור לנחש זמני שקפים.")
    block = match.group(1)
    old_durations = [int(m.group(1)) for m in re.finditer(r'"duration"\s*:\s*(\d+)', block)]
    if not old_durations:
        raise ValueError("לא נמצאו ערכי duration במערך SLIDES של מצגת יפן.")
    return {"path": path, "html": html, "match": match, "block": block, "durations": old_durations}


def sync_japan_experience_presentation_to_target_duration(duration_seconds, sync_mode="matched_music_length", source_duration_seconds=None, minimum_per_slide_seconds=None):
    data = get_japan_experience_slide_durations_ms()
    html = data["html"]
    match = data["match"]
    block = data["block"]
    old_durations = data["durations"]
    target_total_ms = int(round(float(duration_seconds) * 1000))
    min_ms = None if minimum_per_slide_seconds is None else int(round(float(minimum_per_slide_seconds) * 1000))
    new_durations = calculate_scaled_slide_durations_ms_with_min(old_durations, target_total_ms, min_ms)
    index = {"value": 0}

    def replace_duration(m):
        i = index["value"]
        index["value"] += 1
        return '"duration":' + str(new_durations[i])

    new_block = re.sub(r'"duration"\s*:\s*\d+', replace_duration, block)
    if index["value"] != len(new_durations):
        raise ValueError("מספר ערכי duration שהוחלפו אינו תואם למספר השקפים.")
    source_text = ""
    if source_duration_seconds is not None:
        source_text = f" source song: {float(source_duration_seconds):.6f}s;"
    floor_text = ""
    if minimum_per_slide_seconds is not None:
        floor_text = f" min slide: {float(minimum_per_slide_seconds):.3f}s;"
    stamp = (
        f"// TripServer music sync v34.20 confirm: japan-experience.html; mode: {sync_mode};"
        f"{source_text} target total: {float(duration_seconds):.6f}s; slides: {len(new_durations)};"
        f" old total: {sum(old_durations)/1000:.3f}s; new total: {sum(new_durations)/1000:.3f}s;{floor_text}"
    )
    new_html = html[:match.start(1)] + new_block + html[match.end(1):]
    if "// TripServer music sync v34.20 confirm: japan-experience.html" in new_html:
        new_html = re.sub(r"// TripServer music sync v34\.20 confirm: japan-experience\.html[^\n]*\n", stamp + "\n", new_html, count=1)
    elif "// TripServer music sync v34.19 safe: japan-experience.html" in new_html:
        new_html = re.sub(r"// TripServer music sync v34\.19 safe: japan-experience\.html[^\n]*\n", stamp + "\n", new_html, count=1)
    elif "// TripServer music sync v34.14: japan-experience.html" in new_html:
        new_html = re.sub(r"// TripServer music sync v34\.14: japan-experience\.html[^\n]*\n", stamp + "\n", new_html, count=1)
    elif "// TripServer music sync:" in new_html:
        new_html = re.sub(r"// TripServer music sync:[^\n]*\n", stamp + "\n", new_html, count=1)
    else:
        new_html = new_html[:match.start()] + stamp + "\n" + new_html[match.start():]
    if new_html == html:
        raise ValueError("לא בוצע שינוי בפועל במצגת יפן.")
    backup_path = write_text_file_utf8_atomic_with_backup(data["path"], new_html, "JAPAN_presentation_music_sync")
    verify = get_japan_experience_slide_durations_ms()
    verify_durations = verify.get("durations") or []
    if len(verify_durations) != len(new_durations) or sum(verify_durations) != sum(new_durations):
        raise ValueError("אימות לאחר כתיבה נכשל: זמני השקפים בקובץ אינם תואמים לחישוב.")
    return {
        "ok": True,
        "path": data["path"],
        "backup": backup_path,
        "sha256": file_sha256(data["path"]),
        "slides": len(new_durations),
        "old_total_seconds": sum(old_durations) / 1000.0,
        "new_total_seconds": sum(new_durations) / 1000.0,
        "duration_seconds": float(duration_seconds),
        "source_duration_seconds": None if source_duration_seconds is None else float(source_duration_seconds),
        "min_slide_seconds": None if minimum_per_slide_seconds is None else float(minimum_per_slide_seconds),
        "average_slide_seconds": (sum(new_durations) / 1000.0) / max(1, len(new_durations)),
        "sync_mode": sync_mode,
        "message": f"מצגת יפן עודכנה: {len(new_durations)} שקפים, סך חדש {format_seconds(sum(new_durations)/1000.0)}.",
    }


def sync_japan_experience_presentation_to_music_duration(duration_seconds):
    """Synchronize japan-experience.html exactly to the uploaded song duration.

    v34.20 keeps this exact-sync behavior only after the user explicitly approves it
    or when the measured average slide time is already inside the safe 5–10 second range.
    """
    result = sync_japan_experience_presentation_to_target_duration(
        duration_seconds,
        sync_mode="matched_music_length",
        source_duration_seconds=duration_seconds,
        minimum_per_slide_seconds=None,
    )
    result["message"] = f"מצגת יפן סונכרנה לפי שיר באורך {format_seconds(duration_seconds)} ({float(duration_seconds):.3f} שניות)."
    return result


def sync_usa_presentation_part_to_music_duration(music_key, duration_seconds):
    """Synchronize one USA song part transactionally across phone and TV.

    The two-part music structure remains unchanged:
    - PART_1_SECONDS controls slides 1-23.
    - PART_2_SECONDS controls slides 24-44.

    Before either presentation is changed, the prospective timeline is calculated with
    all currently installed videos. Videos keep their full duration and image slides
    receive the remaining song time equally. If the new song is too short, nothing is
    written. If a later write/verification step fails, every changed presentation is
    restored to its exact previous contents.
    """
    key = music_key_to_rule_key("USA", music_key) or music_key
    if key == "usa_music_part1":
        const_name = "PART_1_SECONDS"
        label = "ארה״ב חלק 1"
        scope = "שקפים 1–23"
    elif key == "usa_music_part2":
        const_name = "PART_2_SECONDS"
        label = "ארה״ב חלק 2"
        scope = "שקפים 24–44"
    else:
        raise ValueError("סוג שיר ארה״ב לא מוכר לסנכרון.")

    seconds = float(duration_seconds)
    if not math.isfinite(seconds) or seconds <= 0:
        raise ValueError("משך שיר לא תקין לסנכרון ארה״ב.")
    seconds_text = f"{seconds:.6f}"
    candidates = []

    # Phase 1: build and validate both prospective files without changing disk.
    for presentation_key in ("usa_presentation", "usa_presentation_tv"):
        filename = USA_PRESENTATION_FILES.get(presentation_key)
        if not filename:
            continue
        path = os.path.join(usa_folder_for_presentation_key(presentation_key), filename)
        if not os.path.exists(path):
            continue
        html = read_text_file_utf8(path)
        pattern = re.compile(r"(const\s+" + re.escape(const_name) + r"\s*=\s*)([0-9]+(?:\.[0-9]+)?)(\s*;)")
        match = pattern.search(html)
        if not match:
            raise ValueError(f"לא נמצא {const_name} בתוך {filename}. אסור לנחש זמני מצגת.")
        old_value = float(match.group(2))
        new_html = pattern.sub(r"\g<1>" + seconds_text + r"\g<3>", html, count=1)
        if const_name == "PART_1_SECONDS":
            new_html = re.sub(r"//\s*שקפים\s*1-23:[^\n]*", f"// שקפים 1-23: usa-roadtrip-theme, {seconds_text} שניות.", new_html, count=1)
        else:
            new_html = re.sub(r"//\s*שקפים\s*24-44:[^\n]*", f"// שקפים 24-44: usa-part2-theme, {seconds_text} שניות.", new_html, count=1)
        stamp = f"// TripServer music sync v34.33: {label} ({scope}) סונכרן לפי שיר באורך {seconds_text} שניות."
        same_part_stamp = re.compile(r"// TripServer music sync v(?:34\.14|34\.33): " + re.escape(label) + r"[^\n]*\n")
        if same_part_stamp.search(new_html):
            new_html = same_part_stamp.sub(stamp + "\n", new_html, count=1)
        else:
            # Keep the exact position stable: insert next to the timing constant.
            refreshed_match = pattern.search(new_html)
            insert_at = refreshed_match.start() if refreshed_match else match.start()
            new_html = new_html[:insert_at] + stamp + "\n" + new_html[insert_at:]
        if new_html == html:
            raise ValueError(f"לא בוצע שינוי בפועל ב־{filename}.")

        target_variant = "tv" if presentation_key == "usa_presentation_tv" else "phone"
        prospective_info, prospective_timeline, prospective_videos = calculate_usa_timeline_for_html(target_variant, new_html)
        candidates.append({
            "presentation_key": presentation_key,
            "target_variant": target_variant,
            "filename": filename,
            "path": path,
            "old_html": html,
            "new_html": new_html,
            "old_seconds": old_value,
            "prospective_timeline": prospective_timeline,
            "prospective_video_count": len(prospective_videos),
        })

    if not candidates:
        raise FileNotFoundError("לא נמצאה מצגת ארה״ב לסנכרון: usa2027-presentation.html או usa2027-presentation-tv.html")

    # Phase 2: write all files, then regenerate/verify their media metadata.
    written = []
    results = []
    try:
        for item in candidates:
            backup_path = write_text_file_utf8_atomic_with_backup(item["path"], item["new_html"], "USA_presentation_music_sync")
            item["backup"] = backup_path
            written.append(item)

        for item in candidates:
            media_refresh = refresh_usa_media_after_timing_change(item["target_variant"])
            results.append({
                "ok": True,
                "path": item["path"],
                "backup": item.get("backup"),
                "sha256": file_sha256(item["path"]),
                "media_timeline_refresh": media_refresh,
                "presentation_key": item["presentation_key"],
                "filename": item["filename"],
                "part": key,
                "const_name": const_name,
                "scope": scope,
                "old_seconds": item["old_seconds"],
                "new_seconds": seconds,
                "duration_seconds": seconds,
                "video_count": item["prospective_video_count"],
                "timeline_total_seconds": item["prospective_timeline"].total_seconds,
                "message": f"{item['filename']}: {const_name} עודכן מ־{item['old_seconds']:.6f} ל־{seconds_text} שניות.",
            })
    except Exception:
        # Exact rollback of every presentation touched in this transaction.
        for item in reversed(written):
            try:
                tmp = item["path"] + ".music_sync_rollback.tmp"
                with open(tmp, "w", encoding="utf-8", newline="") as fh:
                    fh.write(item["old_html"])
                os.replace(tmp, item["path"])
            except Exception:
                pass
        for item in candidates:
            try:
                write_usa_media_metadata(item["target_variant"])
            except Exception:
                pass
        raise

    return {
        "ok": True,
        "part": key,
        "label": label,
        "scope": scope,
        "duration_seconds": seconds,
        "results": results,
        "message": f"{label} סונכרן לפי שיר באורך {format_seconds(seconds)} ({seconds:.3f} שניות).",
    }

def rollback_saved_audio_upload(save_result):
    """Restore every audio target from its pre-upload backup after sync failure."""
    restored = []
    failed = []
    if not isinstance(save_result, dict):
        return {"ok": False, "restored": restored, "failed": ["save_result לא תקין"]}
    targets = save_result.get("targets") or []
    for item in targets:
        if not isinstance(item, dict) or not item.get("path"):
            continue
        path = item.get("path")
        backup = item.get("backup")
        try:
            if backup and os.path.isfile(backup):
                ensure_parent_dir(path)
                tmp = path + ".audio_rollback.tmp"
                shutil.copy2(backup, tmp)
                os.replace(tmp, path)
                restored.append({"path": path, "from": backup, "action": "restored_backup"})
            else:
                if os.path.exists(path):
                    os.remove(path)
                restored.append({"path": path, "from": "", "action": "removed_new_file"})
        except Exception as exc:
            failed.append({"path": path, "error": repr(exc)})
    return {"ok": not failed, "restored": restored, "failed": failed}


def register_existing_music_upload_duration(trip, music_key, save_result):
    rule_key = music_key_to_rule_key(trip, music_key)
    rule = PRESENTATION_MUSIC_SYNC_RULES.get(rule_key or "", {})
    path = primary_saved_audio_path(save_result)
    paths = all_saved_audio_paths(save_result)
    status = {
        "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
        "trip": (trip or "").upper(),
        "music_key": music_key,
        "rule_key": rule_key or "",
        "label": rule.get("label", ""),
        "scope": rule.get("scope", ""),
        "presentation": rule.get("presentation", ""),
        "paths": paths,
        "primary_path": path or "",
        "duration_ok": False,
        "duration_seconds": None,
        "duration_display": "",
        "sync_performed": False,
        "sync_note": "v34.14: העלאת שיר קיימת מודדת את האורך ומסנכרנת את המצגת/המקטע הרלוונטיים כאשר כלל הסנכרון פעיל.",
    }
    if not path or not os.path.exists(path):
        status["error"] = "קובץ השיר נשמר אך לא נמצא נתיב קובץ למדידת אורך."
        if rule_key:
            write_presentation_music_upload_status(rule_key, status)
        return status
    probe = probe_audio_duration_seconds(path)
    status["ffprobe"] = probe.get("ffprobe", "")
    if probe.get("ok"):
        status["duration_ok"] = True
        status["duration_seconds"] = probe.get("duration_seconds")
        status["duration_display"] = probe.get("duration_display")
        try:
            sync_result = None
            if (trip or "").upper() == "JAPAN" and music_key == "japan_experience_music":
                sync_result = sync_japan_experience_presentation_to_music_duration(probe.get("duration_seconds"))
                status["sync_note"] = "בוצע סנכרון בפועל: כל שקפי מצגת יפן סונכרנו לפי אורך השיר החדש."
            elif (trip or "").upper() == "USA" and rule_key in {"usa_music_part1", "usa_music_part2"}:
                sync_result = sync_usa_presentation_part_to_music_duration(rule_key, probe.get("duration_seconds"))
                status["sync_note"] = "בוצע סנכרון בפועל: מקטע השיר הרלוונטי עודכן גם במצגת ארה״ב הרגילה וגם במצגת TV כאשר הקבצים קיימים."
            elif (trip or "").upper() == "JAPAN" and music_key == "japan_flight_music":
                status["sync_note"] = "מוזיקת פתיח הטיסה נשמרה ונמדדה. לא שונה japan-experience.html כי פתיח הטיסה הוא מקטע נפרד."
            if sync_result:
                status["sync_performed"] = True
                if isinstance(sync_result, dict) and sync_result.get("results"):
                    status["sync_results"] = sync_result.get("results")
                    status["sync_summary"] = sync_result.get("message", "")
                else:
                    status["sync_results"] = [sync_result]
                    status["sync_summary"] = sync_result.get("message", "") if isinstance(sync_result, dict) else ""
        except Exception as sync_exc:
            status["sync_performed"] = False
            status["sync_error"] = repr(sync_exc)
            if (trip or "").upper() == "USA":
                rollback = rollback_saved_audio_upload(save_result)
                status["audio_rollback"] = rollback
                status["audio_rollback_performed"] = bool(rollback.get("ok"))
                if rollback.get("ok"):
                    status["sync_note"] = "סנכרון המצגת נכשל ולכן קובצי המוזיקה החדשים בוטלו ושוחזרו אוטומטית לגרסה הקודמת."
                else:
                    status["sync_note"] = "סנכרון המצגת נכשל. בוצע ניסיון שחזור מוזיקה, אך לפחות יעד אחד דורש בדיקה ידנית."
            else:
                status["sync_note"] = "השיר נשמר ונמדד, אך סנכרון המצגת נכשל. אין אישור שהמצגת מסונכרנת."
    else:
        status["error"] = probe.get("error") or probe.get("raw") or "ffprobe לא הצליח לזהות משך שיר."
    if rule_key:
        write_presentation_music_upload_status(rule_key, status)
    return status


def music_upload_duration_details(status):
    if not isinstance(status, dict):
        return ""
    lines = ["סטטוס אורך שיר:"]
    if status.get("label"):
        lines.append(f"שיר/מקטע: {status.get('label')}")
    if status.get("scope"):
        lines.append(f"תחום סנכרון עתידי: {status.get('scope')}")
    if status.get("duration_ok"):
        lines.append(f"משך שזוהה: {status.get('duration_display')} ({float(status.get('duration_seconds') or 0):.3f} שניות)")
        if status.get("sync_performed"):
            lines.append(f"סנכרון שקפים: בוצע בפועל עבור {status.get('label') or 'המצגת'}.")
            for item in status.get("sync_results") or []:
                if isinstance(item, dict):
                    lines.append(f"מצגת: {item.get('path','')}")
                    if item.get('const_name'):
                        lines.append(f"מקטע: {item.get('scope','')} | קבוע שעודכן: {item.get('const_name')} | סך חדש: {float(item.get('new_seconds') or item.get('duration_seconds') or 0):.3f} שניות")
                    else:
                        lines.append(f"שקפים: {item.get('slides','')} | סך חדש: {float(item.get('new_total_seconds') or 0):.3f} שניות")
                    if item.get("backup"):
                        lines.append(f"גיבוי מצגת קודם: {item.get('backup')}")
        elif status.get("sync_error"):
            lines.append("סנכרון שקפים: נכשל.")
            lines.append(f"שגיאת סנכרון: {status.get('sync_error')}")
            if status.get("audio_rollback_performed"):
                lines.append("שחזור מוזיקה: בוצע בהצלחה; קובצי השיר הקודמים הוחזרו.")
            elif status.get("audio_rollback"):
                lines.append("שחזור מוזיקה: לא הושלם במלואו; יש לבדוק את פירוט השחזור.")
                lines.append(json.dumps(status.get("audio_rollback"), ensure_ascii=False, indent=2))
        else:
            lines.append("סנכרון שקפים: לא בוצע בשלב הזה.")
    else:
        lines.append("אזהרה: השיר נשמר, אבל לא זוהה משך תקין ולכן אין אישור לסנכרון.")
        if status.get("error"):
            lines.append(f"שגיאה: {status.get('error')}")
    if status.get("ffprobe"):
        lines.append(f"ffprobe: {status.get('ffprobe')}")
    if status.get("primary_path"):
        lines.append(f"נתיב שנבדק: {status.get('primary_path')}")
    lines.append("קובץ סטטוס: " + PRESENTATION_MUSIC_UPLOAD_STATUS_PATH)
    return "\n".join(lines)


def render_music_upload_status_rows():
    data = read_presentation_music_upload_status()
    rows = []
    for key, rule in PRESENTATION_MUSIC_SYNC_RULES.items():
        st = data.get(key) if isinstance(data, dict) else None
        if isinstance(st, dict) and st.get("timestamp"):
            if st.get("duration_ok"):
                if st.get("sync_performed"):
                    badge = f"<span class='badge safe'>סונכרן: {html_lib.escape(str(st.get('duration_display') or ''))}</span>"
                    detail = f"משך זוהה וסנכרון בוצע בפועל עבור {html_lib.escape(str(st.get('label') or rule.get('label') or 'המצגת'))}."
                elif st.get("sync_error"):
                    badge = "<span class='badge warn'>נמדד, סנכרון נכשל</span>"
                    detail = html_lib.escape(str(st.get("sync_error") or "שגיאת סנכרון"))
                else:
                    badge = f"<span class='badge safe'>נמדד: {html_lib.escape(str(st.get('duration_display') or ''))}</span>"
                    detail = "משך זוהה. עדיין לא בוצע סנכרון שקפים."
            else:
                badge = "<span class='badge warn'>שגיאת מדידה</span>"
                detail = html_lib.escape(str(st.get("error") or "לא זוהה משך"))
            timestamp = html_lib.escape(str(st.get("timestamp") or ""))
        else:
            badge = "<span class='badge warn'>אין העלאה אחרונה</span>"
            detail = "טרם נרשם סטטוס העלאה לשיר הזה."
            timestamp = "—"
        rows.append(f"<tr><td><b>{html_lib.escape(rule.get('label',''))}</b></td><td>{badge}</td><td>{timestamp}</td><td>{detail}</td></tr>")
    return "".join(rows)


def media_engine_status():
    ffmpeg_path = find_ffmpeg_executable() or ""
    ffprobe_path = find_ffprobe_executable() or ""
    status = {"pillow": False, "ffmpeg": bool(ffmpeg_path), "ffmpeg_path": ffmpeg_path, "ffprobe": bool(ffprobe_path), "ffprobe_path": ffprobe_path}
    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 = run_hidden_subprocess(cmd, 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 cleanup_old_pending_music_uploads():
    try:
        os.makedirs(PENDING_MUSIC_UPLOAD_DIR, exist_ok=True)
        now = time.time()
        for name in os.listdir(PENDING_MUSIC_UPLOAD_DIR):
            path = os.path.join(PENDING_MUSIC_UPLOAD_DIR, name)
            try:
                if os.path.isfile(path) and now - os.path.getmtime(path) > PENDING_MUSIC_MAX_AGE_SECONDS:
                    os.remove(path)
            except Exception:
                pass
    except Exception:
        pass


def save_pending_audio_upload(uploaded_file, trip, music_key):
    cleanup_old_pending_music_uploads()
    os.makedirs(PENDING_MUSIC_UPLOAD_DIR, exist_ok=True)
    token = uuid.uuid4().hex
    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(PENDING_MUSIC_UPLOAD_DIR, f"{token}_{stamp}_{safe_name}")
    pending_mp3 = os.path.join(PENDING_MUSIC_UPLOAD_DIR, f"{token}.mp3")
    meta_path = os.path.join(PENDING_MUSIC_UPLOAD_DIR, f"{token}.json")
    uploaded_file.save(source_tmp)
    try:
        media = convert_or_copy_audio_to_mp3(source_tmp, pending_mp3, uploaded_file.filename or "")
        meta = {
            "token": token,
            "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
            "created_epoch": time.time(),
            "trip": (trip or "").upper(),
            "music_key": music_key,
            "original_name": uploaded_file.filename or "",
            "pending_path": pending_mp3,
            "size": os.path.getsize(pending_mp3),
            "sha256": file_sha256(pending_mp3),
            "media": media,
        }
        with open(meta_path, "w", encoding="utf-8") as f:
            json.dump(meta, f, ensure_ascii=False, indent=2)
        return meta
    except Exception:
        for p in (pending_mp3, meta_path):
            try:
                if os.path.exists(p):
                    os.remove(p)
            except Exception:
                pass
        raise
    finally:
        try:
            if os.path.exists(source_tmp):
                os.remove(source_tmp)
        except Exception:
            pass


def read_pending_music_upload(token):
    token = (token or "").strip()
    if not re.fullmatch(r"[0-9a-fA-F]{32}", token):
        raise ValueError("אסימון העלאה זמנית לא תקין.")
    meta_path = os.path.join(PENDING_MUSIC_UPLOAD_DIR, f"{token}.json")
    if not os.path.exists(meta_path):
        raise FileNotFoundError("העלאת השיר הזמנית לא נמצאה. ייתכן שפג תוקפה או שהיא כבר טופלה.")
    with open(meta_path, "r", encoding="utf-8") as f:
        meta = json.load(f)
    if not isinstance(meta, dict) or meta.get("token") != token:
        raise ValueError("קובץ ההעלאה הזמנית אינו תקין.")
    pending_path = meta.get("pending_path") or os.path.join(PENDING_MUSIC_UPLOAD_DIR, f"{token}.mp3")
    if not os.path.exists(pending_path):
        raise FileNotFoundError("קובץ השיר הזמני לא נמצא.")
    if time.time() - float(meta.get("created_epoch") or 0) > PENDING_MUSIC_MAX_AGE_SECONDS:
        discard_pending_music_upload(token)
        raise FileNotFoundError("פג תוקף העלאת השיר הזמנית. העלה את השיר מחדש.")
    meta["pending_path"] = pending_path
    return meta


def discard_pending_music_upload(token):
    token = (token or "").strip()
    if not re.fullmatch(r"[0-9a-fA-F]{32}", token):
        return
    for suffix in (".mp3", ".json"):
        path = os.path.join(PENDING_MUSIC_UPLOAD_DIR, token + suffix)
        try:
            if os.path.exists(path):
                os.remove(path)
        except Exception:
            pass


def commit_pending_audio_to_multiple_targets(pending_meta, base_folder, rel_paths, backup_group):
    pending_path = pending_meta.get("pending_path")
    if not pending_path or not os.path.exists(pending_path):
        raise FileNotFoundError("קובץ השיר הזמני לא נמצא ולכן לא ניתן להטמיע אותו.")
    sha = file_sha256(pending_path)
    size = os.path.getsize(pending_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(pending_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, "media": pending_meta.get("media"), "pending_token": pending_meta.get("token")}


def build_japan_music_duration_status(music_key, save_result, probe, sync_result=None, sync_note="", sync_error=None, approval_action=""):
    rule_key = music_key_to_rule_key("JAPAN", music_key)
    rule = PRESENTATION_MUSIC_SYNC_RULES.get(rule_key or "", {})
    status = {
        "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
        "trip": "JAPAN",
        "music_key": music_key,
        "rule_key": rule_key or "",
        "label": rule.get("label", ""),
        "scope": rule.get("scope", ""),
        "presentation": rule.get("presentation", ""),
        "paths": all_saved_audio_paths(save_result),
        "primary_path": primary_saved_audio_path(save_result) or "",
        "duration_ok": bool(probe and probe.get("ok")),
        "duration_seconds": (probe or {}).get("duration_seconds"),
        "duration_display": (probe or {}).get("duration_display", ""),
        "ffprobe": (probe or {}).get("ffprobe", ""),
        "sync_performed": bool(sync_result),
        "sync_note": sync_note,
        "approval_action": approval_action,
    }
    if sync_result:
        status["sync_results"] = [sync_result]
        status["sync_summary"] = sync_result.get("message", "") if isinstance(sync_result, dict) else ""
    if sync_error:
        status["sync_error"] = repr(sync_error)
    if rule_key:
        write_presentation_music_upload_status(rule_key, status)
    return status


def japan_experience_music_analysis(duration_seconds):
    data = get_japan_experience_slide_durations_ms()
    slide_count = len(data.get("durations") or [])
    if slide_count <= 0:
        raise ValueError("לא נמצאו שקפים במצגת יפן.")
    duration = float(duration_seconds)
    average = duration / float(slide_count)
    min_total = slide_count * JAPAN_EXPERIENCE_MIN_AVG_SLIDE_SECONDS
    max_total = slide_count * JAPAN_EXPERIENCE_MAX_AVG_SLIDE_SECONDS
    if average < JAPAN_EXPERIENCE_MIN_AVG_SLIDE_SECONDS:
        state = "too_short"
    elif average > JAPAN_EXPERIENCE_MAX_AVG_SLIDE_SECONDS:
        state = "too_long"
    else:
        state = "ok"
    return {
        "state": state,
        "slide_count": slide_count,
        "song_seconds": duration,
        "song_display": format_seconds(duration),
        "average_slide_seconds": average,
        "minimum_total_seconds": min_total,
        "maximum_total_seconds": max_total,
        "loop_total_seconds": min_total,
        "presentation_path": data.get("path", ""),
    }


def render_japan_music_confirmation_page(token, analysis, probe, pending_meta):
    state = analysis.get("state")
    avg = float(analysis.get("average_slide_seconds") or 0)
    slide_count = int(analysis.get("slide_count") or 0)
    song_display = html_lib.escape(str(analysis.get("song_display") or ""))
    original_name = html_lib.escape(str(pending_meta.get("original_name") or ""))
    loop_total = html_lib.escape(format_seconds(analysis.get("loop_total_seconds") or 0))
    token_safe = html_lib.escape(token)
    if state == "too_short":
        title = "נדרש אישור לפני הטמעת השיר"
        badge = "אזהרה: השיר קצר מדי ביחס למספר השקפים"
        message = (
            f"השיר שזוהה באורך {song_display}. במצגת יש {slide_count} שקפים, "
            f"ולכן זמן ממוצע לשקף יהיה {avg:.2f} שניות — פחות מהמינימום שהוגדר: "
            f"{JAPAN_EXPERIENCE_MIN_AVG_SLIDE_SECONDS:.0f} שניות לשקף."
        )
        options = f"""
        <form action=\"/confirm_japan_experience_music_upload/{token_safe}\" method=\"post\"><input type=\"hidden\" name=\"action\" value=\"loop_min_5\"><button class=\"primary\">הטמע את השיר בלופ ושמור לפחות 5 שניות לכל שקף</button></form>
        <form action=\"/confirm_japan_experience_music_upload/{token_safe}\" method=\"post\"><input type=\"hidden\" name=\"action\" value=\"accept_as_is\"><button class=\"warn\">הטמע בכל זאת וסנכרן לפי אורך השיר המקורי</button></form>
        <form action=\"/confirm_japan_experience_music_upload/{token_safe}\" method=\"post\"><input type=\"hidden\" name=\"action\" value=\"cancel\"><button class=\"danger\">אל תטמיע את השיר הזה</button></form>
        """
        note = f"בחירת הלופ תאריך את המצגת ל־{loop_total} לפחות. קובץ האודיו עצמו יישמר, ותגית ה־audio במצגת תמשיך לנגן אותו בלופ."
    elif state == "too_long":
        title = "נדרש אישור לפני הטמעת השיר"
        badge = "אזהרה: השיר ארוך מדי ביחס למספר השקפים"
        message = (
            f"השיר שזוהה באורך {song_display}. במצגת יש {slide_count} שקפים, "
            f"ולכן זמן ממוצע לשקף יהיה {avg:.2f} שניות — מעל המקסימום שהוגדר: "
            f"{JAPAN_EXPERIENCE_MAX_AVG_SLIDE_SECONDS:.0f} שניות לשקף."
        )
        options = f"""
        <form action=\"/confirm_japan_experience_music_upload/{token_safe}\" method=\"post\"><input type=\"hidden\" name=\"action\" value=\"accept_as_is\"><button class=\"warn\">הטמע בכל זאת וסנכרן לפי אורך השיר</button></form>
        <form action=\"/confirm_japan_experience_music_upload/{token_safe}\" method=\"post\"><input type=\"hidden\" name=\"action\" value=\"cancel\"><button class=\"danger\">אל תטמיע את השיר הזה</button></form>
        """
        note = "במצב הזה אין אפשרות לופ, כי הבעיה אינה שיר קצר אלא שיר ארוך מדי ביחס למצגת."
    else:
        return admin_status_page("אין צורך באישור", "משך השיר נמצא בטווח התקין. העלה שוב אם נדרש.", ok=True)
    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>{html_lib.escape(title)}</title>
<style>
body{{margin:0;font-family:Arial,'Segoe UI',sans-serif;background:linear-gradient(180deg,#0f172a,#020617);color:white;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}}
.card{{width:min(820px,94vw);background:#111827;border:1px solid #334155;border-radius:28px;padding:26px;box-shadow:0 24px 90px rgba(0,0,0,.45)}}
.badge{{display:inline-flex;background:#7c2d12;color:#fed7aa;border:1px solid #fb923c;border-radius:999px;padding:8px 14px;font-weight:900;margin-bottom:12px}}
h1{{margin:0 0 12px;font-size:clamp(28px,5vw,42px)}}
p{{color:#e5e7eb;line-height:1.7;font-size:17px}}
.meta{{background:#020617;border:1px solid #334155;border-radius:18px;padding:14px;margin:14px 0;color:#bfdbfe;line-height:1.7}}
.actions{{display:grid;gap:10px;margin-top:16px}}
button,a{{border:0;border-radius:14px;padding:14px 16px;color:white;font-weight:950;cursor:pointer;text-decoration:none;font-size:16px;width:100%}}
.primary{{background:#16a34a}}.warn{{background:#ca8a04}}.danger{{background:#dc2626}}.back{{display:inline-flex;width:auto;background:#334155;margin-top:12px}}
form{{margin:0}}
.usa-image-target-panel{{display:none;margin-top:16px}}.usa-image-target-panel.active{{display:block}}.image-target-choice .sub-card{{border-color:rgba(96,165,250,.28)}}.tool-head.compact{{margin-top:10px;padding-top:4px}}.tool-head.compact h3{{margin-top:0}}code{{direction:ltr;display:inline-block;background:rgba(15,23,42,.65);border:1px solid rgba(148,163,184,.25);border-radius:8px;padding:2px 6px;color:#dbeafe}}
</style></head><body><div class=\"card\"><span class=\"badge\">{html_lib.escape(badge)}</span><h1>{html_lib.escape(title)}</h1><p>{html_lib.escape(message)}</p><div class=\"meta\"><b>קובץ:</b> {original_name or '—'}<br><b>משך שיר:</b> {song_display}<br><b>שקפים:</b> {slide_count}<br><b>ממוצע צפוי לשקף:</b> {avg:.2f} שניות<br><b>סטטוס:</b> השיר עדיין לא הוטמע בנתיבי המצגת. הוא נשמר זמנית בלבד עד לבחירה.</div><p>{html_lib.escape(note)}</p><div class=\"actions\">{options}</div><a class=\"back\" href=\"/admin#japan\">חזרה בלי לבצע שינוי</a></div></body></html>
"""


def handle_japan_experience_music_upload_with_confirmation(uploaded_file):
    pending_meta = None
    try:
        pending_meta = save_pending_audio_upload(uploaded_file, "JAPAN", "japan_experience_music")
        probe = probe_audio_duration_seconds(pending_meta.get("pending_path"))
        if not probe.get("ok"):
            discard_pending_music_upload(pending_meta.get("token"))
            details = media_details_text(pending_meta.get("media"))
            details += "\n\nשגיאת ffprobe:\n" + str(probe.get("error") or probe.get("raw") or "לא ידוע")
            return admin_status_page("השיר לא הוטמע — בדיקת אורך נכשלה", "המערכת לא הצליחה לזהות משך תקין ולכן לא שמרה את השיר בנתיבי המצגת ולא שינתה את המצגת.", ok=False, details=details), 200
        analysis = japan_experience_music_analysis(probe.get("duration_seconds"))
        if analysis.get("state") in {"too_short", "too_long"}:
            return render_japan_music_confirmation_page(pending_meta.get("token"), analysis, probe, pending_meta), 200
        return commit_japan_experience_pending_music(pending_meta.get("token"), "accept_as_is")
    except Exception as exc:
        if pending_meta and pending_meta.get("token"):
            discard_pending_music_upload(pending_meta.get("token"))
        return admin_status_page("שגיאה בהכנת שיר יפן", "השיר לא הוטמע ולא בוצע שינוי במצגת.", ok=False, details=repr(exc)), 500


def commit_japan_experience_pending_music(token, action):
    try:
        action = (action or "").strip()
        pending_meta = read_pending_music_upload(token)
        if pending_meta.get("trip") != "JAPAN" or pending_meta.get("music_key") != "japan_experience_music":
            return admin_status_page("העלאה זמנית לא תואמת", "הקובץ הזמני אינו שייך לשיר מצגת יפן הראשית.", ok=False), 400
        probe = probe_audio_duration_seconds(pending_meta.get("pending_path"))
        if not probe.get("ok"):
            discard_pending_music_upload(token)
            return admin_status_page("השיר לא הוטמע — בדיקת אורך נכשלה", "הקובץ הזמני קיים, אבל ffprobe לא הצליח לזהות את משך השיר בזמן האישור. לא בוצע שינוי.", ok=False, details=str(probe.get("error") or probe.get("raw") or "לא ידוע")), 200
        analysis = japan_experience_music_analysis(probe.get("duration_seconds"))
        allowed = {"accept_as_is", "cancel"}
        if analysis.get("state") == "too_short":
            allowed.add("loop_min_5")
        if action not in allowed:
            return admin_status_page("בחירת סנכרון לא תקינה", "האפשרות שנשלחה אינה תואמת למצב השיר.", ok=False), 400
        if action == "cancel":
            discard_pending_music_upload(token)
            status = build_japan_music_duration_status("japan_experience_music", {"targets": []}, probe, sync_result=None, sync_note="המשתמש ביטל את ההעלאה אחרי אזהרת זמן שקפים. השיר לא הוטמע והמצגת לא שונתה.", approval_action="cancel")
            return admin_status_page("העלאת השיר בוטלה", "השיר לא הוטמע בנתיבי המצגת והמצגת לא שונתה.", ok=True, details=music_upload_duration_details(status))
        save_result = commit_pending_audio_to_multiple_targets(pending_meta, FOLDERS["JAPAN"], JAPAN_MUSIC_TARGETS["japan_experience_music"], "JAPAN_music")
        if action == "loop_min_5":
            target_seconds = max(float(analysis.get("loop_total_seconds") or 0), float(probe.get("duration_seconds") or 0))
            sync_result = sync_japan_experience_presentation_to_target_duration(
                target_seconds,
                sync_mode="short_song_audio_loop_min_5_seconds_per_slide",
                source_duration_seconds=probe.get("duration_seconds"),
                minimum_per_slide_seconds=JAPAN_EXPERIENCE_LOOP_MIN_SLIDE_SECONDS,
            )
            sync_note = "השיר הוטמע, אך לא דחס את המצגת. המצגת הוארכה כך שלכל שקף יש לפחות 5 שניות, והשיר יתנגן בלופ."
            title = "השיר הוטמע בלופ והמצגת סונכרנה בבטחה"
            message = "השיר נשמר בנתיבי המצגת. בגלל שהוא קצר מדי, זמני השקפים נקבעו לרצפת 5 שניות לשקף והמוזיקה תמשיך בלופ."
        else:
            sync_result = sync_japan_experience_presentation_to_music_duration(probe.get("duration_seconds"))
            if analysis.get("state") == "too_short":
                sync_note = "השיר הוטמע לאחר אישור מפורש למרות שהוא יוצר פחות מ־5 שניות בממוצע לשקף."
                title = "השיר הוטמע לפי האורך המקורי"
                message = "השיר נשמר והמצגת סונכרנה לפי אורך השיר המקורי, בהתאם לאישור שניתן במסך האזהרה."
            elif analysis.get("state") == "too_long":
                sync_note = "השיר הוטמע לאחר אישור מפורש למרות שהוא יוצר מעל 10 שניות בממוצע לשקף."
                title = "השיר הארוך הוטמע לפי האורך המקורי"
                message = "השיר נשמר והמצגת סונכרנה לפי אורך השיר הארוך, בהתאם לאישור שניתן במסך האזהרה."
            else:
                sync_note = "השיר נמצא בטווח התקין 5–10 שניות בממוצע לשקף, ולכן הוטמע וסונכרן ללא אזהרה."
                title = "מוזיקת יפן הועלתה והמצגת סונכרנה"
                message = "השיר נשמר, משך השיר זוהה, וזמני השקפים עודכנו לפי האורך החדש."
        status = build_japan_music_duration_status("japan_experience_music", save_result, probe, sync_result=sync_result, sync_note=sync_note, approval_action=action)
        discard_pending_music_upload(token)
        details = "נשמר אל:\n" + "\n".join([item["path"] for item in save_result["targets"]]) + f"\n\nSHA256 MP3 סופי:\n{save_result['sha256']}"
        details += "\n\n" + media_details_text(save_result.get("media"))
        details += "\n\n" + music_upload_duration_details(status)
        return admin_status_page(title, message, ok=True, details=details)
    except (ValueError, FileNotFoundError) as exc:
        discard_pending_music_upload(token)
        status = 404 if isinstance(exc, FileNotFoundError) else 400
        return admin_status_page("העלאה זמנית לא זמינה", str(exc), ok=False), status
    except Exception as exc:
        try:
            discard_pending_music_upload(token)
        except Exception:
            pass
        return admin_status_page("שגיאה באישור הטמעת שיר", "השיר לא הוטמע או שהפעולה לא הושלמה במלואה. בדוק את הפירוט לפני ניסיון חוזר.", ok=False, details=repr(exc)), 500


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("duration_seconds") is not None:
        lines.append(f"משך סרטון: {media.get('duration_display') or format_seconds(media.get('duration_seconds'))} ({float(media.get('duration_seconds') or 0):.3f} שניות)")
    if media.get("codec"):
        lines.append(f"קידוד וידאו: {media.get('codec')}")
    if media.get("width") and media.get("height"):
        lines.append(f"רזולוציית וידאו: {media.get('width')}×{media.get('height')}")
    if media.get("ffmpeg"):
        lines.append(f"FFmpeg: {media.get('ffmpeg')}")
    return "\n".join(lines)



# ================= USA VIDEO MEDIA ENGINE v34.33 =================
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
VIDEO_EXTENSIONS = {".mp4", ".m4v"}
MEDIA_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
MANIFEST_NAME = USA_MEDIA_MANIFEST_NAME
PATCH_VERSION = USA_MEDIA_PATCH_VERSION
MIN_IMAGE_SECONDS = 0.05
EPSILON = 0.035


class MediaError(RuntimeError):
    """Raised when media or presentation validation fails."""


@dataclass(frozen=True)
class PresentationPart:
    start: int
    end: int
    seconds: float
    label: str


@dataclass(frozen=True)
class PresentationInfo:
    slide_bases: Tuple[str, ...]
    parts: Tuple[PresentationPart, ...]
    part1_last_slide_number: int


@dataclass(frozen=True)
class TimelineEntry:
    index: int
    base: str
    kind: str
    media_seconds: float
    timeline_seconds: float
    start: float
    end: float
    part_index: int


@dataclass(frozen=True)
class TimelineResult:
    entries: Tuple[TimelineEntry, ...]
    total_seconds: float
    warnings: Tuple[str, ...]


# --------------------------- MP4 duration parser ---------------------------

def _read_u32(f) -> int:
    data = f.read(4)
    if len(data) != 4:
        raise MediaError("קובץ הווידאו נקטע בזמן קריאת מבנה MP4.")
    return struct.unpack(">I", data)[0]


def _read_u64(f) -> int:
    data = f.read(8)
    if len(data) != 8:
        raise MediaError("קובץ הווידאו נקטע בזמן קריאת מבנה MP4.")
    return struct.unpack(">Q", data)[0]


def _iter_boxes(f, start: int, end: int):
    pos = start
    while pos + 8 <= end:
        f.seek(pos)
        size = _read_u32(f)
        box_type = f.read(4)
        header = 8
        if size == 1:
            size = _read_u64(f)
            header = 16
        elif size == 0:
            size = end - pos
        if size < header or pos + size > end:
            break
        yield box_type, pos + header, pos + size
        pos += size


def mp4_duration_seconds(path: Path) -> float:
    """Read duration from the ISO BMFF mvhd atom without ffmpeg."""
    path = Path(path)
    if not path.is_file():
        raise MediaError(f"קובץ וידאו לא נמצא: {path.name}")
    file_size = path.stat().st_size
    if file_size < 24:
        raise MediaError(f"קובץ וידאו קטן/פגום: {path.name}")

    with path.open("rb") as f:
        moov = None
        for box_type, content_start, box_end in _iter_boxes(f, 0, file_size):
            if box_type == b"moov":
                moov = (content_start, box_end)
                break
        if not moov:
            raise MediaError(f"לא נמצא אטום moov בקובץ {path.name}. יש לשמור MP4 תקין עם faststart.")

        for box_type, content_start, box_end in _iter_boxes(f, moov[0], moov[1]):
            if box_type != b"mvhd":
                continue
            f.seek(content_start)
            version_flags = f.read(4)
            if len(version_flags) != 4:
                break
            version = version_flags[0]
            if version == 1:
                f.seek(content_start + 4 + 8 + 8)
                timescale = _read_u32(f)
                duration = _read_u64(f)
            else:
                f.seek(content_start + 4 + 4 + 4)
                timescale = _read_u32(f)
                duration = _read_u32(f)
            if timescale <= 0 or duration <= 0:
                raise MediaError(f"משך וידאו לא תקין בקובץ {path.name}.")
            seconds = duration / timescale
            if not math.isfinite(seconds) or seconds <= 0 or seconds > 24 * 60 * 60:
                raise MediaError(f"משך וידאו לא סביר בקובץ {path.name}: {seconds}")
            return float(seconds)

    raise MediaError(f"לא נמצא משך וידאו תקין בקובץ {path.name}.")


# ------------------------- Presentation inspection ------------------------

_SLIDE_SECTION_RE = re.compile(
    r"<section\b(?=[^>]*\bclass=[\"'][^\"']*\bslide\b[^\"']*[\"'])[^>]*>(.*?)</section>",
    re.IGNORECASE | re.DOTALL,
)
_DATA_LOCAL_BASE_RE = re.compile(r"\bdata-local-base=[\"']([^\"']+)[\"']", re.IGNORECASE)
_IMG_SRC_RE = re.compile(r"<img\b[^>]*\bsrc=[\"']([^\"']+)[\"']", re.IGNORECASE | re.DOTALL)


def _base_from_slide_html(section_html: str, index: int) -> str:
    match = _DATA_LOCAL_BASE_RE.search(section_html)
    if match:
        return Path(match.group(1).replace("\\", "/")).name
    src = _IMG_SRC_RE.search(section_html)
    if src:
        return Path(src.group(1).split("?", 1)[0].replace("\\", "/")).stem
    return f"slide-{index + 1:02d}"


def inspect_presentation_html(html: str) -> PresentationInfo:
    slide_sections = list(_SLIDE_SECTION_RE.finditer(html))
    slide_bases = tuple(_base_from_slide_html(m.group(1), i) for i, m in enumerate(slide_sections))
    if not slide_bases:
        # Fallback for malformed/minified HTML: count class=slide openings.
        starts = list(re.finditer(r"<(?:section|div)\b[^>]*\bclass=[\"'][^\"']*\bslide\b", html, re.I))
        slide_bases = tuple(f"slide-{i + 1:02d}" for i in range(len(starts)))
    if not slide_bases:
        raise MediaError("לא נמצאו שקפים בקובץ המצגת.")

    def number(name: str) -> float:
        m = re.search(rf"\bconst\s+{re.escape(name)}\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*;", html)
        if not m:
            raise MediaError(f"לא נמצא המשתנה {name} בקובץ המצגת.")
        return float(m.group(1))

    part1_seconds = number("PART_1_SECONDS")
    part2_seconds = number("PART_2_SECONDS")
    part1_last = int(round(number("PART_1_LAST_SLIDE_NUMBER")))
    if not 1 <= part1_last < len(slide_bases):
        raise MediaError(
            f"חלוקת השירים אינה תקינה: PART_1_LAST_SLIDE_NUMBER={part1_last}, מספר שקפים={len(slide_bases)}."
        )

    parts = (
        PresentationPart(0, part1_last - 1, part1_seconds, "שיר ראשון"),
        PresentationPart(part1_last, len(slide_bases) - 1, part2_seconds, "שיר שני"),
    )
    return PresentationInfo(slide_bases=slide_bases, parts=parts, part1_last_slide_number=part1_last)


def inspect_presentation_file(path: Path) -> PresentationInfo:
    return inspect_presentation_html(Path(path).read_text(encoding="utf-8"))


# ---------------------------- Timeline allocator ---------------------------

def allocate_timeline(
    slide_bases: Sequence[str],
    parts: Sequence[PresentationPart],
    video_durations: Mapping[str, float],
    *,
    min_image_seconds: float = MIN_IMAGE_SECONDS,
) -> TimelineResult:
    if not slide_bases:
        raise MediaError("אין שקפים לחישוב ציר זמן.")
    normalized_videos = {str(k).lower(): float(v) for k, v in video_durations.items()}
    durations = [0.0] * len(slide_bases)
    media_seconds = [0.0] * len(slide_bases)
    kinds = ["image"] * len(slide_bases)
    warnings: List[str] = []

    for part_index, part in enumerate(parts):
        indices = list(range(part.start, part.end + 1))
        if not indices:
            continue
        video_indices = []
        for i in indices:
            duration = normalized_videos.get(slide_bases[i].lower())
            if duration is not None:
                if not math.isfinite(duration) or duration <= 0:
                    raise MediaError(f"משך וידאו לא תקין בשקף {i + 1}: {duration}")
                video_indices.append(i)
                media_seconds[i] = duration
                kinds[i] = "video"
        image_indices = [i for i in indices if i not in video_indices]
        video_total = sum(media_seconds[i] for i in video_indices)

        if not video_indices:
            equal = part.seconds / len(indices)
            for i in indices:
                durations[i] = equal
            continue

        if video_total > part.seconds + EPSILON:
            raise MediaError(
                f"{part.label}: אורך הסרטונים המצטבר {video_total:.3f} שנ׳ ארוך ממשך השיר "
                f"{part.seconds:.3f} שנ׳. אי אפשר להציג את כולם עד הסוף בלי לחרוג מהמוזיקה."
            )

        remaining = max(0.0, part.seconds - video_total)
        if image_indices:
            per_image = remaining / len(image_indices)
            if per_image < min_image_seconds:
                raise MediaError(
                    f"{part.label}: לאחר ניכוי הסרטונים נשארו רק {remaining:.3f} שנ׳ "
                    f"ל־{len(image_indices)} שקפי תמונה ({per_image:.3f} שנ׳ לשקף)."
                )
            for i in video_indices:
                durations[i] = media_seconds[i]
            for i in image_indices:
                durations[i] = per_image
            if per_image < 1.0:
                warnings.append(
                    f"{part.label}: שקפי התמונות יוצגו {per_image:.2f} שנ׳ בלבד בגלל כמות/אורך הסרטונים."
                )
        else:
            # All slides are videos. Preserve complete playback and spread any spare song time
            # as a last-frame hold. This keeps the total exactly equal to the song.
            hold = remaining / len(video_indices)
            for i in video_indices:
                durations[i] = media_seconds[i] + hold
            if hold > EPSILON:
                warnings.append(
                    f"{part.label}: כל השקפים הם סרטונים; לאחר כל סרטון תישאר תמונת הסיום עוד {hold:.2f} שנ׳."
                )

        # Correct floating-point residue on the final slide of the part.
        residue = part.seconds - sum(durations[i] for i in indices)
        durations[indices[-1]] += residue

    entries: List[TimelineEntry] = []
    cursor = 0.0
    part_for_index = {}
    for p_i, part in enumerate(parts):
        for i in range(part.start, part.end + 1):
            part_for_index[i] = p_i
    for i, base in enumerate(slide_bases):
        start = cursor
        cursor += durations[i]
        entries.append(
            TimelineEntry(
                index=i,
                base=base,
                kind=kinds[i],
                media_seconds=media_seconds[i],
                timeline_seconds=durations[i],
                start=start,
                end=cursor,
                part_index=part_for_index.get(i, 0),
            )
        )

    expected = sum(p.seconds for p in parts)
    if abs(cursor - expected) > 0.005:
        raise MediaError(f"כשל פנימי בחישוב ציר הזמן: {cursor:.6f} במקום {expected:.6f} שנ׳.")
    return TimelineResult(entries=tuple(entries), total_seconds=expected, warnings=tuple(warnings))


# ------------------------------ Media manifest -----------------------------

def media_files_by_base(media_dir: Path) -> Dict[str, Path]:
    media_dir = Path(media_dir)
    result: Dict[str, Path] = {}
    if not media_dir.is_dir():
        return result
    # Videos win only when an image sibling was not removed by an older admin version.
    candidates = sorted((p for p in media_dir.iterdir() if p.is_file() and p.suffix.lower() in MEDIA_EXTENSIONS), key=lambda p: p.name.lower())
    for path in candidates:
        key = path.stem.lower()
        previous = result.get(key)
        if previous is None or path.suffix.lower() in VIDEO_EXTENSIONS:
            result[key] = path
    return result


def build_manifest(media_dir: Path, slide_bases: Optional[Sequence[str]] = None) -> dict:
    files = media_files_by_base(media_dir)
    allowed = {b.lower() for b in slide_bases} if slide_bases else None
    items = []
    for key, path in sorted(files.items()):
        if allowed is not None and key not in allowed:
            continue
        ext = path.suffix.lower()
        item = {
            "base": path.stem,
            "file": path.name,
            "type": "video" if ext in VIDEO_EXTENSIONS else "image",
        }
        if ext in VIDEO_EXTENSIONS:
            item["duration"] = round(mp4_duration_seconds(path), 6)
        items.append(item)
    return {
        "version": 2,
        "patchVersion": PATCH_VERSION,
        "generatedAt": datetime.now().astimezone().isoformat(timespec="seconds"),
        "items": items,
    }


def write_manifest(media_dir: Path, slide_bases: Optional[Sequence[str]] = None) -> Path:
    media_dir = Path(media_dir)
    media_dir.mkdir(parents=True, exist_ok=True)
    manifest = build_manifest(media_dir, slide_bases)
    target = media_dir / MANIFEST_NAME
    tmp = target.with_suffix(".json.tmp")
    tmp.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
    os.replace(tmp, target)
    return target


def video_duration_map(media_dir: Path, slide_bases: Optional[Sequence[str]] = None) -> Dict[str, float]:
    manifest = build_manifest(media_dir, slide_bases)
    return {
        item["base"]: float(item["duration"])
        for item in manifest["items"]
        if item["type"] == "video"
    }


# --------------------------- Presentation patching -------------------------

CSS_PATCH = r"""
/* EITAN_VIDEO_MEDIA_CSS_START — generated by admin */
.hero-video{
  width:100%;height:100%;object-fit:cover;display:block;
  filter:saturate(1.12) contrast(1.08) brightness(.96);
  background:#020617;
}
.hero-video::-webkit-media-controls{display:none!important}
/* EITAN_VIDEO_MEDIA_CSS_END */
""".strip()

JS_RUNTIME_PATCH = r"""
  /* EITAN_VIDEO_MEDIA_RUNTIME_START — generated by admin */
  const EITAN_VIDEO_MEDIA_PATCH_VERSION = '2026-07-12-video-sync-v1';
  const EITAN_MEDIA_MANIFEST_NAME = 'media-manifest.json';
  const eitanManifestUrls = new Set();
  const eitanVideoDurations = new Map();
  let eitanSlideStarts = new Array(slides.length).fill(0);
  let eitanSlideEnds = new Array(slides.length).fill(0);
  let eitanSlideTimelineDurations = new Array(slides.length).fill(0);
  let eitanMediaPrepared = false;
  let eitanMediaPreparePromise = null;

  function eitanBaseName(value) {
    const clean = String(value || '').split('?')[0].replace(/\\/g, '/');
    const name = clean.slice(clean.lastIndexOf('/') + 1);
    return name.replace(/\.[^.]+$/, '');
  }

  function eitanBaseForSlide(slide, slideIndex) {
    const img = slide && slide.querySelector ? slide.querySelector('img.hero-image') : null;
    if (img) return eitanBaseName(img.dataset.localBase || img.getAttribute('src') || '');
    const media = slide && slide.querySelector ? slide.querySelector('video.hero-video') : null;
    if (media) return String(media.dataset.mediaBase || eitanBaseName(media.dataset.mediaSource || media.src));
    return `slide-${slideIndex + 1}`;
  }

  function eitanBuildTimeline(videoDurationsByIndex = new Map()) {
    const durations = new Array(slides.length).fill(0);
    const starts = new Array(slides.length).fill(0);
    const ends = new Array(slides.length).fill(0);
    const tiny = 0.05;

    parts.forEach((part, partIndex) => {
      const indices = [];
      for (let i = part.start; i <= part.end; i++) indices.push(i);
      const videoIndices = indices.filter(i => videoDurationsByIndex.has(i));
      const imageIndices = indices.filter(i => !videoDurationsByIndex.has(i));
      const videoTotal = videoIndices.reduce((sum, i) => sum + Number(videoDurationsByIndex.get(i) || 0), 0);

      if (!videoIndices.length) {
        const equal = part.seconds / Math.max(1, indices.length);
        indices.forEach(i => { durations[i] = equal; });
      } else {
        if (videoTotal > part.seconds + 0.035) {
          throw new Error(`${part.label}: משך הסרטונים (${videoTotal.toFixed(2)} שנ׳) ארוך ממשך השיר (${part.seconds.toFixed(2)} שנ׳).`);
        }
        const remaining = Math.max(0, part.seconds - videoTotal);
        if (imageIndices.length) {
          const perImage = remaining / imageIndices.length;
          if (perImage < tiny) {
            throw new Error(`${part.label}: לא נותר זמן שימושי לשקפי התמונות לאחר ניכוי הסרטונים.`);
          }
          videoIndices.forEach(i => { durations[i] = Number(videoDurationsByIndex.get(i)); });
          imageIndices.forEach(i => { durations[i] = perImage; });
        } else {
          const hold = remaining / Math.max(1, videoIndices.length);
          videoIndices.forEach(i => { durations[i] = Number(videoDurationsByIndex.get(i)) + hold; });
        }
        const residue = part.seconds - indices.reduce((sum, i) => sum + durations[i], 0);
        durations[indices[indices.length - 1]] += residue;
      }
    });

    let cursor = 0;
    durations.forEach((duration, i) => {
      starts[i] = cursor;
      cursor += duration;
      ends[i] = cursor;
    });
    if (Math.abs(cursor - totalDuration) > 0.01) {
      throw new Error(`כשל בחישוב הסנכרון: ${cursor.toFixed(3)} במקום ${totalDuration.toFixed(3)} שנ׳.`);
    }
    eitanSlideTimelineDurations = durations;
    eitanSlideStarts = starts;
    eitanSlideEnds = ends;
    window.__USA_PRESENTATION_TIMELINE__ = durations.map((duration, i) => ({
      slide: i + 1,
      base: eitanBaseForSlide(slides[i], i),
      type: videoDurationsByIndex.has(i) ? 'video' : 'image',
      mediaSeconds: Number(videoDurationsByIndex.get(i) || 0),
      timelineSeconds: duration,
      start: starts[i],
      end: ends[i]
    }));
  }

  eitanBuildTimeline();

  function eitanManifestUrlForImage(img) {
    const source = img.dataset.localBase || img.getAttribute('src') || '';
    const slash = source.replace(/\\/g, '/').lastIndexOf('/');
    if (slash < 0) return EITAN_MEDIA_MANIFEST_NAME;
    return source.slice(0, slash + 1) + EITAN_MEDIA_MANIFEST_NAME;
  }

  function eitanReadInlineManifest() {
    const node = document.getElementById('usa2027-inline-media-manifest');
    if (!node) return { token:'missing', items:[] };
    try {
      const data = JSON.parse(node.textContent || '{}');
      return { token:String(data.token || 'unknown'), items:Array.isArray(data.items) ? data.items : [] };
    } catch(e) {
      return { token:'invalid', items:[] };
    }
  }

  async function eitanReadManifest(url) {
    const data = eitanReadInlineManifest();
    const baseUrl = new URL('.', document.baseURI || window.location.href).href;
    return { url:baseUrl, items:data.items.map(item => {
      if (!item || !item.url) return item;
      return {...item, file:item.url};
    }) };
  }

  function eitanWaitVideoMetadata(video, timeoutMs = 10000) {
    if (Number.isFinite(video.duration) && video.duration > 0) return Promise.resolve(video.duration);
    return new Promise((resolve, reject) => {
      let timer;
      const done = () => {
        clearTimeout(timer);
        video.removeEventListener('loadedmetadata', onReady);
        video.removeEventListener('durationchange', onReady);
        video.removeEventListener('error', onError);
      };
      const onReady = () => {
        if (Number.isFinite(video.duration) && video.duration > 0) {
          const value = video.duration;
          done();
          resolve(value);
        }
      };
      const onError = () => { done(); reject(new Error(`וידאו לא נטען: ${video.dataset.mediaBase || ''}`)); };
      timer = setTimeout(() => { done(); reject(new Error(`תם הזמן לטעינת נתוני הווידאו: ${video.dataset.mediaBase || ''}`)); }, timeoutMs);
      video.addEventListener('loadedmetadata', onReady);
      video.addEventListener('durationchange', onReady);
      video.addEventListener('error', onError);
      try { video.load(); } catch(e) {}
    });
  }

  async function preparePresentationMedia() {
    if (eitanMediaPrepared) return true;
    if (eitanMediaPreparePromise) return eitanMediaPreparePromise;
    eitanMediaPreparePromise = (async () => {
      const images = [...document.querySelectorAll('img.hero-image')];
      const manifestResults = [await eitanReadManifest('inline')];
      const itemByBase = new Map();
      manifestResults.forEach(result => {
        result.items.forEach(item => {
          if (!item || !item.base || item.type !== 'video' || !item.file) return;
          const assetUrl = new URL(item.url || item.file, document.baseURI || window.location.href).href;
          itemByBase.set(String(item.base).toLowerCase(), { ...item, assetUrl });
        });
      });

      const videoDurationsByIndex = new Map();
      for (let i = 0; i < slides.length; i++) {
        const slide = slides[i];
        const img = slide.querySelector('img.hero-image');
        if (!img) continue;
        const base = eitanBaseForSlide(slide, i);
        const item = itemByBase.get(String(base).toLowerCase());
        if (!item) continue;

        const video = document.createElement('video');
        video.className = 'hero-image hero-video';
        video.muted = true;
        video.defaultMuted = true;
        video.autoplay = false;
        video.loop = false;
        video.controls = false;
        video.playsInline = true;
        video.setAttribute('playsinline', '');
        video.setAttribute('webkit-playsinline', '');
        video.preload = 'metadata';
        video.dataset.mediaBase = base;
        video.dataset.mediaSource = item.assetUrl;
        video.setAttribute('aria-label', img.getAttribute('alt') || base);

        let selectedSrc = item.assetUrl;
        try {
          if (typeof getCachedObjectUrl === 'function') {
            const cached = await getCachedObjectUrl(item.assetUrl);
            if (cached) selectedSrc = cached;
          }
        } catch(e) {}
        video.src = selectedSrc;
        img.replaceWith(video);

        let duration = Number(item.duration || 0);
        try {
          const actual = await eitanWaitVideoMetadata(video, 10000);
          if (Number.isFinite(actual) && actual > 0) duration = actual;
        } catch(e) {
          if (!(Number.isFinite(duration) && duration > 0)) throw e;
        }
        if (!(Number.isFinite(duration) && duration > 0)) {
          throw new Error(`משך הווידאו לא תקין בשקף ${i + 1}.`);
        }
        eitanVideoDurations.set(i, duration);
        videoDurationsByIndex.set(i, duration);
      }

      eitanBuildTimeline(videoDurationsByIndex);
      eitanMediaPrepared = true;
      window.__USA_PRESENTATION_MEDIA_READY__ = true;
      return true;
    })();
    try {
      return await eitanMediaPreparePromise;
    } catch (e) {
      eitanMediaPreparePromise = null;
      throw e;
    }
  }

  function pauseAllPresentationVideos(except = null) {
    document.querySelectorAll('video.hero-video').forEach(video => {
      if (video !== except) {
        try { video.pause(); } catch(e) {}
      }
    });
  }

  function syncPresentationVideo(globalSeconds) {
    if (!eitanMediaPrepared) return;
    const activeIndex = timeToSlide(globalSeconds);
    document.querySelectorAll('video.hero-video').forEach(video => {
      const slide = video.closest('.slide');
      const slideIndex = slides.indexOf(slide);
      if (slideIndex !== activeIndex) {
        try { video.pause(); } catch(e) {}
        return;
      }
      const mediaDuration = Number(eitanVideoDurations.get(slideIndex) || video.duration || 0);
      const local = Math.max(0, globalSeconds - Number(eitanSlideStarts[slideIndex] || 0));
      const playbackPoint = Math.max(0, Math.min(Math.max(0, mediaDuration - 0.04), local));
      try {
        if (Math.abs((video.currentTime || 0) - playbackPoint) > 0.45) video.currentTime = playbackPoint;
        if (local < mediaDuration - 0.04 && playing) {
          const promise = video.play();
          if (promise && typeof promise.catch === 'function') promise.catch(() => {});
        } else {
          video.pause();
          if (mediaDuration > 0 && local >= mediaDuration - 0.04) video.currentTime = Math.max(0, mediaDuration - 0.04);
        }
      } catch(e) {}
    });
  }
  /* EITAN_VIDEO_MEDIA_RUNTIME_END */
""".rstrip()

TIMING_FUNCTIONS_PATCH = r"""
  function slideDurationInPart(part) {
    const indices = [];
    for (let i = part.start; i <= part.end; i++) indices.push(i);
    return indices.reduce((sum, i) => sum + Number(eitanSlideTimelineDurations[i] || 0), 0) / Math.max(1, indices.length);
  }

  function slideToGlobalTime(slideIndex) {
    const safe = Math.max(0, Math.min(slides.length - 1, Number(slideIndex) || 0));
    return Number(eitanSlideStarts[safe] || 0);
  }

  function timeToSlide(seconds) {
    const target = Math.max(0, Math.min(totalDuration, Number(seconds) || 0));
    let low = 0;
    let high = eitanSlideEnds.length - 1;
    while (low < high) {
      const mid = Math.floor((low + high) / 2);
      if (target < eitanSlideEnds[mid] - 0.0005) high = mid;
      else low = mid + 1;
    }
    return Math.max(0, Math.min(slides.length - 1, low));
  }
""".rstrip()


def _replace_once(html: str, pattern: str, replacement: str, description: str, flags: int = 0) -> str:
    updated, count = re.subn(pattern, replacement, html, count=1, flags=flags)
    if count != 1:
        raise MediaError(f"לא הצלחתי לעדכן את {description} בקובץ המצגת (נמצאו {count} התאמות).")
    return updated


def patch_presentation_html(html: str) -> Tuple[str, bool]:
    if "EITAN_VIDEO_MEDIA_RUNTIME_START" in html:
        return html, False

    # CSS
    if "</head>" not in html.lower():
        raise MediaError("קובץ המצגת חסר תגית </head>.")
    html = re.sub(r"</head>", f"<style>\n{CSS_PATCH}\n</style>\n</head>", html, count=1, flags=re.I)

    # Insert runtime immediately after totalDuration so all timing functions can use it.
    total_pattern = r"(\bconst\s+totalDuration\s*=\s*parts\.reduce\([^;]+;\s*)"
    html = _replace_once(
        html,
        total_pattern,
        lambda m: m.group(1) + "\n" + JS_RUNTIME_PATCH + "\n",
        "מנוע המדיה",
        flags=re.S,
    )

    timing_pattern = (
        r"\s*function\s+slideDurationInPart\s*\(part\)\s*\{.*?\}\s*"
        r"function\s+slideToGlobalTime\s*\(slideIndex\)\s*\{.*?\}\s*"
        r"function\s+timeToSlide\s*\(seconds\)\s*\{.*?\}\s*"
        r"(?=function\s+getAudioDrivenSeconds)"
    )
    html = _replace_once(html, timing_pattern, "\n" + TIMING_FUNCTIONS_PATCH + "\n\n  ", "פונקציות הסנכרון", flags=re.S)

    # Start media preparation without awaiting it. This is intentional: the existing iPhone/Safari
    # audio priming code must remain in the same user-gesture task. Awaiting video metadata before
    # audio.play() would cause Safari to block the music.
    start_anchor = r"(\s*await\s+applyCachedAudioSources\(\);)"
    start_code = r"""\1

    let eitanMediaStartError = null;
    setLoader(3, 'בודק תמונות וסרטונים…');
    const eitanMediaStartPromise = preparePresentationMedia().catch(e => {
      eitanMediaStartError = e;
      return false;
    });"""
    html = _replace_once(html, start_anchor, start_code, "התחלת טעינת המדיה")

    # Join the existing preload barrier only after the original audio-prime calls have run.
    preload_pattern = r"(await\s+Promise\.allSettled\(\[)(.*?)(\]\);)"
    preload_replacement = r"\1\n      eitanMediaStartPromise,\2\3"
    html = _replace_once(html, preload_pattern, preload_replacement, "שילוב טעינת המדיה במחסום הטעינה", flags=re.S)

    media_error_check = r"""

    if (eitanMediaStartError) {
      playing = false;
      musicEnabled = false;
      pauseAllAudio();
      pauseAllPresentationVideos();
      const message = eitanMediaStartError && eitanMediaStartError.message
        ? eitanMediaStartError.message
        : String(eitanMediaStartError || 'כשל מדיה');
      window.__USA_PRESENTATION_MEDIA_ERROR__ = message;
      setLoader(0, 'שגיאת מדיה: ' + message);
      if (startWithMusic) {
        startWithMusic.disabled = false;
        startWithMusic.textContent = '⚠️ בדוק את קבצי המדיה';
      }
      showToast(message);
      return;
    }
"""
    preload_done_pattern = r"(await\s+Promise\.allSettled\(\[.*?\]\);)"
    html = _replace_once(html, preload_done_pattern, lambda m: m.group(1) + media_error_check, "בדיקת תוצאת טעינת המדיה", flags=re.S)

    # Keep video aligned on every clock frame.
    sync_anchor = r"(if\s*\(nextPart\s*!==\s*currentPartIndex\)\s*\{\s*alignAudioToTime\(seconds,\s*musicEnabled\)\.catch\(\(\)\s*=>\s*\{\}\);\s*\})"
    html = _replace_once(html, sync_anchor, r"\1\n    syncPresentationVideo(seconds);", "סנכרון הסרטון", flags=re.S)

    # Manual seek must also update the video while paused.
    seek_pattern = r"(function\s+seekToSlide\s*\(slideIndex\)\s*\{.*?\bupdate\(\);)"
    html = _replace_once(html, seek_pattern, r"\1\n    syncPresentationVideo(targetTime);", "סנכרון בעת מעבר ידני", flags=re.S)

    # Pause and finish stop videos too.
    pause_pattern = r"(function\s+pausePresentation\s*\(\)\s*\{.*?pauseAllAudio\(\);)"
    html = _replace_once(html, pause_pattern, r"\1\n    pauseAllPresentationVideos();", "עצירת סרטונים בהשהיה", flags=re.S)

    finish_pattern = r"(function\s+finishPresentation\s*\(\)\s*\{.*?pauseAllAudio\(\);)"
    html = _replace_once(html, finish_pattern, r"\1\n    pauseAllPresentationVideos();", "עצירת סרטונים בסיום", flags=re.S)

    # Offline cache: prepare DOM first and include videos + manifests.
    cache_prepare_pattern = r"(async\s+function\s+prepareOfflineCache\s*\(\)\s*\{.*?try\s*\{\s*if\s*\(navigator\.storage.*?\}\s*catch\(e\)\s*\{\}\s*)(const\s+urls\s*=\s*allOfflineAssetUrls\(\);)"
    html = _replace_once(
        html,
        cache_prepare_pattern,
        r"\1try { await preparePresentationMedia(); } catch(e) { setOfflineStatus(e.message || String(e)); prepareOfflineCacheBtn.disabled = false; return; }\n    \2",
        "הכנת סרטונים לשמירה אופליין",
        flags=re.S,
    )

    offline_pattern = r"function\s+allOfflineAssetUrls\s*\(\)\s*\{.*?\n\s*\}"
    offline_replacement = r"""function allOfflineAssetUrls() {
    // מקור אמת יחיד: HTML המצגת + רשימת המדיה המוטמעת.
    // אין לסרוק את ה-DOM בנוסף, אחרת תמונה ישנה שהוחלפה בסרטון
    // נספרת לצד הסרטון ונוצר פער שקרי בין קובץ הטיול למצגת.
    const inline = eitanReadInlineManifest();
    const declared = inline.items
      .map(item => item && (item.url || item.file))
      .filter(src => src && !/^data:/i.test(src))
      .map(absoluteAssetUrl);
    return [...new Set([canonicalPresentationUrl(), ...declared])];
  }"""
    html = _replace_once(html, offline_pattern, offline_replacement, "רשימת המדיה לשמירה אופליין", flags=re.S)

    # Human-facing loading wording only; no functional dependency.
    html = html.replace("טוען תמונות ומוזיקה…", "טוען מדיה ומוזיקה…")
    html = html.replace("מכין מוזיקה וטוען תמונות…", "מכין מוזיקה וטוען מדיה…")
    html = html.replace("טוען תמונות ראשונות…", "טוען מדיה ראשונה…")

    return html, True


def patch_presentation_file(path: Path, *, backup: bool = True) -> bool:
    path = Path(path)
    if not path.is_file():
        raise MediaError(f"קובץ המצגת לא נמצא: {path}")
    original = path.read_text(encoding="utf-8")
    updated, changed = patch_presentation_html(original)
    if not changed:
        return False
    if backup:
        backup_dir = path.parent / ".admin_backups"
        backup_dir.mkdir(parents=True, exist_ok=True)
        stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
        shutil.copy2(path, backup_dir / f"{path.name}.{stamp}.bak")
    temp = path.with_suffix(path.suffix + ".tmp")
    temp.write_text(updated, encoding="utf-8")
    # Re-inspect before replacing to ensure core data survived.
    inspect_presentation_html(updated)
    os.replace(temp, path)
    return True


def write_timeline_report(presentation_path: Path, media_dir: Path, output_path: Optional[Path] = None) -> Path:
    info = inspect_presentation_file(presentation_path)
    videos = video_duration_map(media_dir, info.slide_bases)
    timeline = allocate_timeline(info.slide_bases, info.parts, videos)
    if output_path is None:
        output_path = Path(media_dir) / "media-timeline-report.json"
    payload = {
        "patchVersion": PATCH_VERSION,
        "presentation": str(presentation_path),
        "mediaDirectory": str(media_dir),
        "totalSeconds": timeline.total_seconds,
        "warnings": list(timeline.warnings),
        "entries": [asdict(entry) for entry in timeline.entries],
    }
    Path(output_path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    return Path(output_path)

# ================= END USA VIDEO MEDIA ENGINE =================

def normalize_usa_image_target(target_variant):
    target_variant = (target_variant or "phone").strip().lower()
    if target_variant in {"mobile", "iphone", "phone", "פלאפון", "טלפון"}:
        return "phone"
    if target_variant in {"tv", "television", "screen", "טלוויזיה", "טלויזיה"}:
        return "tv"
    if target_variant not in USA_IMAGE_TARGETS:
        raise ValueError("יעד מדיה למצגת ארה״ב לא תקין. יש לבחור פלאפון או טלוויזיה.")
    return target_variant


def usa_image_target_info(target_variant):
    target_variant = normalize_usa_image_target(target_variant)
    return target_variant, USA_IMAGE_TARGETS[target_variant]


def usa_image_dir_for_target(target_variant):
    target_variant, info = usa_image_target_info(target_variant)
    return os.path.join(usa_folder_for_image_target(target_variant), info["folder"])


def ensure_usa_presentation_uses_image_target(target_variant):
    """Make sure each USA presentation points to the media folder that exists in its own root."""
    target_variant, info = usa_image_target_info(target_variant)
    presentation_key = info.get("presentation_key")
    filename = USA_PRESENTATION_FILES.get(presentation_key)
    if not filename:
        return {"updated": False, "reason": "presentation_key_not_found", "target": target_variant}
    path = os.path.join(usa_folder_for_presentation_key(presentation_key), filename)
    if not os.path.isfile(path):
        return {"updated": False, "reason": "presentation_file_missing", "target": target_variant, "path": path}
    html = read_text_file_utf8(path)
    desired = info["rel_prefix"] + "/"
    new_html = html
    # Old experimental TV path and the accidental doubled media path are both invalid for the USA_TV folder shown on disk.
    for wrong in ("media/usa-presentation/images-tv/", "media/usa-presentation/media/images/"):
        new_html = new_html.replace(wrong, desired)
    if new_html == html:
        return {"updated": False, "reason": "already_correct", "target": target_variant, "path": path}
    backup = backup_existing_file(path, f"USA_presentation_image_path_{target_variant}")
    write_text_file_utf8(path, new_html)
    return {"updated": True, "target": target_variant, "path": path, "backup": backup, "sha256": file_sha256(path)}



def usa_presentation_path_for_target(target_variant):
    target_variant, info = usa_image_target_info(target_variant)
    presentation_key = info.get("presentation_key")
    filename = USA_PRESENTATION_FILES.get(presentation_key)
    if not filename:
        raise ValueError("לא נמצא מיפוי קובץ מצגת ליעד המדיה.")
    return os.path.join(usa_folder_for_presentation_key(presentation_key), filename)


def usa_media_files_for_target(target_variant):
    return media_files_by_base(Path(usa_image_dir_for_target(target_variant)))


def probe_video_duration_seconds(path):
    probe = probe_audio_duration_seconds(path)
    if probe.get("ok"):
        value = float(probe.get("duration_seconds") or 0)
        if value > 0:
            return value
    return mp4_duration_seconds(Path(path))


def probe_video_stream_info(path):
    ffprobe = find_ffprobe_executable()
    if not ffprobe:
        return {"ok": False, "codec": "unknown", "width": 0, "height": 0, "ffprobe": ""}
    cmd = [
        ffprobe, "-v", "error", "-select_streams", "v:0",
        "-show_entries", "stream=codec_name,width,height,pix_fmt",
        "-of", "json", path,
    ]
    try:
        cp = run_hidden_subprocess(cmd, timeout=30)
        if cp.returncode != 0:
            return {"ok": False, "codec": "unknown", "error": (cp.stderr or cp.stdout or "")[-1000:], "ffprobe": ffprobe}
        data = json.loads(cp.stdout or "{}")
        stream = (data.get("streams") or [{}])[0]
        return {
            "ok": bool(stream),
            "codec": str(stream.get("codec_name") or "unknown").lower(),
            "width": int(stream.get("width") or 0),
            "height": int(stream.get("height") or 0),
            "pix_fmt": str(stream.get("pix_fmt") or ""),
            "ffprobe": ffprobe,
        }
    except Exception as exc:
        return {"ok": False, "codec": "unknown", "error": repr(exc), "ffprobe": ffprobe}


def normalize_video_to_mp4(source_path, target_path, original_name=""):
    """Create a browser-safe MP4. Preserve H.264 by remuxing; transcode only when required."""
    ensure_parent_dir(target_path)
    size = os.path.getsize(source_path) if os.path.exists(source_path) else 0
    if size <= 0:
        raise ValueError("קובץ הסרטון ריק.")
    if size > USA_VIDEO_MAX_SINGLE_FILE_BYTES:
        raise ValueError(f"קובץ הסרטון גדול מדי: {format_bytes(size)}. מגבלה: {format_bytes(USA_VIDEO_MAX_SINGLE_FILE_BYTES)}")

    info = probe_video_stream_info(source_path)
    ffmpeg = find_ffmpeg_executable()
    tmp_target = target_path + ".video.tmp.mp4"
    mode = "copied_mp4"
    if ffmpeg:
        codec = info.get("codec") or "unknown"
        if codec == "h264":
            cmd = [ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-i", source_path,
                   "-map", "0:v:0", "-an", "-c:v", "copy", "-movflags", "+faststart", tmp_target]
            mode = "remuxed_h264_faststart"
        else:
            cmd = [ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-i", source_path,
                   "-map", "0:v:0", "-an", "-c:v", "libx264", "-preset", "medium", "-crf", "20",
                   "-pix_fmt", "yuv420p", "-movflags", "+faststart", tmp_target]
            mode = "transcoded_h264_faststart"
        cp = run_hidden_subprocess(cmd, timeout=600)
        if cp.returncode != 0 or not os.path.exists(tmp_target) or os.path.getsize(tmp_target) <= 0:
            try:
                if os.path.exists(tmp_target):
                    os.remove(tmp_target)
            except Exception:
                pass
            raise ValueError("FFmpeg לא הצליח להכין MP4 תקין: " + ((cp.stderr or cp.stdout or "שגיאה לא ידועה")[-1600:]))
        os.replace(tmp_target, target_path)
    else:
        # MP4/M4V are both ISO-BMFF containers. Validate structure before copying.
        mp4_duration_seconds(Path(source_path))
        tmp_copy = target_path + ".copy.tmp"
        shutil.copy2(source_path, tmp_copy)
        os.replace(tmp_copy, target_path)
        mode = "copied_validated_mp4_without_ffmpeg"

    duration = probe_video_duration_seconds(target_path)
    if duration <= 0:
        raise ValueError("לא זוהה משך תקין לסרטון לאחר השמירה.")
    final_info = probe_video_stream_info(target_path)
    return {
        "mode": mode,
        "original_size": size,
        "final_size": os.path.getsize(target_path),
        "saved_bytes": max(0, size - os.path.getsize(target_path)),
        "duration_seconds": duration,
        "duration_display": format_seconds(duration),
        "codec": final_info.get("codec") or info.get("codec") or "unknown",
        "width": final_info.get("width") or info.get("width") or 0,
        "height": final_info.get("height") or info.get("height") or 0,
        "ffmpeg": ffmpeg or "not_found",
    }


def usa_media_sibling_paths(media_dir, media_key):
    return [os.path.join(media_dir, media_key + ext) for ext in sorted(USA_MEDIA_EXTENSIONS)]


def calculate_usa_timeline_for_html(target_variant, html_text):
    """Calculate a prospective USA timeline against current media without writing files."""
    target_variant, _target_info = usa_image_target_info(target_variant)
    info = inspect_presentation_html(html_text)
    allowed = {base.lower() for base in info.slide_bases}
    media_map = media_files_by_base(Path(usa_image_dir_for_target(target_variant)))
    videos = {}
    for base, path in media_map.items():
        if base in allowed and Path(path).suffix.lower() in USA_VIDEO_EXTENSIONS:
            videos[base] = probe_video_duration_seconds(str(path))
    timeline = allocate_timeline(info.slide_bases, info.parts, videos)
    return info, timeline, videos


def validate_usa_media_prospective(target_variant, overrides=None):
    target_variant, target_info = usa_image_target_info(target_variant)
    presentation_path = usa_presentation_path_for_target(target_variant)
    if not os.path.isfile(presentation_path):
        raise FileNotFoundError(f"קובץ המצגת לא נמצא ולכן אי אפשר לאמת סנכרון: {presentation_path}")
    info = inspect_presentation_file(Path(presentation_path))
    allowed = {base.lower() for base in info.slide_bases}
    media_map = media_files_by_base(Path(usa_image_dir_for_target(target_variant)))
    for key, path in (overrides or {}).items():
        k = str(key).lower()
        if k not in allowed:
            raise ValueError(f"שם המדיה {key} אינו תואם לשקף במצגת.")
        if path is None:
            media_map.pop(k, None)
        else:
            media_map[k] = Path(path)
    videos = {}
    for base, path in media_map.items():
        if base in allowed and Path(path).suffix.lower() in USA_VIDEO_EXTENSIONS:
            videos[base] = probe_video_duration_seconds(str(path))
    timeline = allocate_timeline(info.slide_bases, info.parts, videos)
    return info, timeline, videos


def _usa_audio_urls_from_presentation_html(html_text):
    """Return active presentation audio URLs in deterministic document order.

    The active HTML is the contract. Do not assume a fixed track count: collect audio
    element sources and canonical entries from the music-segment JSON. Editing-source
    files that merely exist in the music directory are inventoried by the live-source
    gate, but are not declared active unless the presentation references them.
    """
    supported = {str(x).lower() for x in USA_AUDIO_EXTENSIONS}
    found = []
    def add(raw):
        value = html_lib.unescape(str(raw or '')).strip().replace('\\', '/')
        if not value or value.startswith(('http://','https://','//','data:','blob:','#')):
            return
        value = value.split('?',1)[0].split('#',1)[0].lstrip('/')
        if not value.lower().startswith('media/usa-presentation/music/'):
            return
        if Path(value).suffix.lower() not in supported:
            return
        if value not in found:
            found.append(value)
    for match in re.finditer(r'<(?:audio|source)\b[^>]*?\bsrc=["\']([^"\']+)["\']', html_text, re.I):
        add(match.group(1))
    for match in re.finditer(r'<script\b[^>]*?\bid=["\']usa2027-music-segments["\'][^>]*>([\s\S]*?)</script>', html_text, re.I):
        try:
            payload = json.loads(match.group(1))
        except Exception:
            continue
        stack = [payload]
        while stack:
            node = stack.pop()
            if isinstance(node, dict):
                if 'canonical' in node:
                    add(node.get('canonical'))
                stack.extend(node.values())
            elif isinstance(node, list):
                stack.extend(node)
    return found


def write_inline_usa_media_manifest(target_variant, info=None, videos=None):
    """Embed the authoritative active-media contract in the presentation HTML.

    Slide media is resolved from the target's own media directory. Active audio is
    discovered from the presentation itself, so the contract supports any number of
    tracks without treating editing-source files as active playback files.
    """
    target_variant, target_info = usa_image_target_info(target_variant)
    presentation_path = usa_presentation_path_for_target(target_variant)
    if not os.path.isfile(presentation_path):
        return {"ok": False, "reason": "presentation_missing", "path": presentation_path}
    if info is None or videos is None:
        info, _timeline, videos = validate_usa_media_prospective(target_variant)
    media_map = media_files_by_base(Path(usa_image_dir_for_target(target_variant)))
    rel_prefix = target_info.get("rel_prefix") or "media/usa-presentation/images"
    items = []
    for idx, base in enumerate(info.slide_bases):
        path = media_map.get(str(base).lower())
        if path is None:
            raise FileNotFoundError(f"חסרה מדיה פעילה לשקף {idx + 1}: {base} ({target_variant})")
        ext = Path(path).suffix.lower()
        item = {"slide": idx + 1, "base": base, "type": "video" if ext in USA_VIDEO_EXTENSIONS else "image", "url": f"{rel_prefix}/{Path(path).name}"}
        if ext in USA_VIDEO_EXTENSIONS:
            item["duration"] = round(float(videos.get(str(base).lower()) or probe_video_duration_seconds(str(path))), 6)
        items.append(item)
    root_dir = usa_folder_for_image_target(target_variant)
    html_before_manifest = read_text_file_utf8(presentation_path)
    audio_urls = _usa_audio_urls_from_presentation_html(html_before_manifest)
    if not audio_urls:
        raise RuntimeError(f"לא נמצאו הפניות אודיו פעילות במצגת ({target_variant})")
    for audio_url in audio_urls:
        audio_path = os.path.join(root_dir, *audio_url.split("/"))
        if not os.path.isfile(audio_path) or os.path.getsize(audio_path) <= 0:
            raise FileNotFoundError(f"חסר קובץ מוזיקה פעיל: {audio_url} ({target_variant})")
        items.append({"type":"audio","base":Path(audio_url).stem,"url":audio_url})
    canonical = json.dumps(items, ensure_ascii=False, separators=(",", ":"))
    token = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
    payload = json.dumps({"schema":1,"token":token,"items":items}, ensure_ascii=False, separators=(",", ":"))
    script = f'<script id="usa2027-inline-media-manifest" type="application/json">{payload}</script>'
    html = read_text_file_utf8(presentation_path)
    rx = re.compile(r'<script\b[^>]*\bid=["\']usa2027-inline-media-manifest["\'][^>]*>[\s\S]*?</script>', re.I)
    if rx.search(html):
        updated = rx.sub(script, html, count=1)
    else:
        updated = re.sub(r'(<script\b)', script + r'\n\1', html, count=1, flags=re.I)
    if updated != html:
        write_text_file_utf8_atomic_with_backup(presentation_path, updated, "USA_inline_media_manifest")
    verify = read_text_file_utf8(presentation_path)
    if payload not in verify:
        raise RuntimeError("האימות נכשל: רשימת המדיה המוטמעת לא נשמרה במצגת.")
    return {"ok":True,"path":presentation_path,"token":token,"count":len(items),"sha256":file_sha256(presentation_path)}


def write_usa_media_metadata(target_variant):
    target_variant, target_info = usa_image_target_info(target_variant)
    presentation_path = usa_presentation_path_for_target(target_variant)
    media_dir = usa_image_dir_for_target(target_variant)
    if not os.path.isfile(presentation_path):
        return {"ok": False, "reason": "presentation_missing", "path": presentation_path}
    info, timeline, videos = validate_usa_media_prospective(target_variant)
    manifest_path = write_manifest(Path(media_dir), info.slide_bases)
    report_path = write_timeline_report(Path(presentation_path), Path(media_dir), Path(media_dir) / USA_MEDIA_TIMELINE_REPORT_NAME)
    inline_manifest = write_inline_usa_media_manifest(target_variant, info, videos)
    return {
        "ok": True,
        "target": target_variant,
        "manifest": str(manifest_path),
        "inline_manifest": inline_manifest,
        "report": str(report_path),
        "video_count": len(videos),
        "total_seconds": timeline.total_seconds,
        "warnings": list(timeline.warnings),
    }


def ensure_usa_presentation_media_engine(target_variant):
    target_variant, target_info = usa_image_target_info(target_variant)
    path_update = ensure_usa_presentation_uses_image_target(target_variant)
    presentation_path = usa_presentation_path_for_target(target_variant)
    if not os.path.isfile(presentation_path):
        raise FileNotFoundError(presentation_path)
    changed = patch_presentation_file(Path(presentation_path), backup=True)
    metadata = write_usa_media_metadata(target_variant)
    return {
        "target": target_variant,
        "presentation": presentation_path,
        "patched_now": bool(changed),
        "patch_version": USA_MEDIA_PATCH_VERSION,
        "path_update": path_update,
        "metadata": metadata,
    }


def refresh_usa_media_after_timing_change(target_variant):
    """Keep reports current after PART_1_SECONDS/PART_2_SECONDS changes without changing music logic."""
    try:
        presentation_path = usa_presentation_path_for_target(target_variant)
        if not os.path.isfile(presentation_path):
            return {"ok": False, "reason": "presentation_missing"}
        html = read_text_file_utf8(presentation_path)
        has_videos = any(Path(p).suffix.lower() in USA_VIDEO_EXTENSIONS for p in usa_media_files_for_target(target_variant).values())
        if "EITAN_VIDEO_MEDIA_RUNTIME_START" not in html and not has_videos:
            return {"ok": True, "skipped": True, "reason": "no_video_media"}
        return ensure_usa_presentation_media_engine(target_variant)
    except Exception as exc:
        return {"ok": False, "error": repr(exc)}


def _restore_media_transaction(media_dir, affected_keys, rollback_dir):
    for key in affected_keys:
        for path in usa_media_sibling_paths(media_dir, key):
            try:
                if os.path.exists(path):
                    os.remove(path)
            except Exception:
                pass
    if os.path.isdir(rollback_dir):
        for name in os.listdir(rollback_dir):
            src = os.path.join(rollback_dir, name)
            if os.path.isfile(src):
                shutil.copy2(src, os.path.join(media_dir, name))




USA_MEDIA_ACTIVE_EXTRA_EXTENSIONS = {".json"}
# Optional manual copy tool only. Phone and TV remain separate media targets and
# ordinary HTML/system updates must never overwrite one with the other.
USA_MEDIA_COPY_SOURCE = "USA"
USA_MEDIA_COPY_TARGET = "USA_TV"


def _usa_media_is_active_file(path):
    name = Path(path).name.lower()
    if name.startswith(".") or name.endswith(".bak") or ".before_" in name or name.endswith(".tmp"):
        return False
    ext = Path(path).suffix.lower()
    return ext in (set(USA_MEDIA_EXTENSIONS) | set(AUDIO_UPLOAD_EXTENSIONS) | USA_MEDIA_ACTIVE_EXTRA_EXTENSIONS)


def _usa_media_tree_inventory(root):
    root = Path(root)
    rows = {}
    if not root.is_dir():
        return rows
    for path in root.rglob("*"):
        if path.is_file() and _usa_media_is_active_file(path):
            rel = path.relative_to(root).as_posix()
            rows[rel] = {"path": str(path), "size": path.stat().st_size, "sha256": file_sha256(str(path))}
    return rows


def sync_usa_phone_media_to_tv(prune=True, rebuild=True):
    """Explicitly copy phone media to TV after a user-requested manual action.

    This is not run by ordinary trip/presentation update packages. The two targets remain
    separate so device-specific media can be maintained without accidental overwrites.
    """
    src_root = Path(FOLDERS[USA_MEDIA_COPY_SOURCE]) / "media" / "usa-presentation"
    dst_root = Path(FOLDERS[USA_MEDIA_COPY_TARGET]) / "media" / "usa-presentation"
    if not src_root.is_dir():
        raise FileNotFoundError(f"תיקיית מקור המדיה אינה קיימת: {src_root}")
    prune = bool(prune)
    source = _usa_media_tree_inventory(src_root)
    if not source:
        raise RuntimeError("לא נמצאו קובצי מדיה פעילים בתיקיית USA")
    # Require the two active songs before touching TV.
    for rel in ("music/usa-roadtrip-theme.mp3", "music/usa-part2-theme.mp3"):
        if rel not in source:
            raise FileNotFoundError(f"חסר במקור האמת: {rel}")

    stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
    backup_root = Path(BASE_DIR) / "backups" / "USA_TV_media_mirror" / stamp
    backup_root.parent.mkdir(parents=True, exist_ok=True)
    if dst_root.exists():
        shutil.copytree(dst_root, backup_root)
    else:
        backup_root.mkdir(parents=True, exist_ok=True)
    work = Path(tempfile.mkdtemp(prefix="usa_tv_media_mirror_", dir=ADMIN_UPLOAD_TMP_DIR))
    staged = work / "usa-presentation"
    try:
        for rel, row in source.items():
            target = staged / Path(rel)
            target.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(row["path"], target)
            if file_sha256(str(target)) != row["sha256"]:
                raise RuntimeError("media_stage_hash_mismatch:" + rel)
        # Replace the whole TV media tree atomically enough for Windows: rename old, then new.
        old = dst_root.with_name(dst_root.name + ".mirror_old_" + stamp)
        dst_root.parent.mkdir(parents=True, exist_ok=True)
        if old.exists():
            shutil.rmtree(old, ignore_errors=True)
        if dst_root.exists():
            os.replace(str(dst_root), str(old))
        try:
            shutil.copytree(staged, dst_root)
        except Exception:
            if dst_root.exists(): shutil.rmtree(dst_root, ignore_errors=True)
            if old.exists(): os.replace(str(old), str(dst_root))
            raise
        shutil.rmtree(old, ignore_errors=True)

        if rebuild:
            phone_engine = ensure_usa_presentation_media_engine("phone")
            tv_engine = ensure_usa_presentation_media_engine("tv")
        else:
            phone_engine = tv_engine = None
        src_after = _usa_media_tree_inventory(src_root)
        dst_after = _usa_media_tree_inventory(dst_root)
        # Generated JSON reports may differ by target; compare actual media/audio only.
        def comparable(inv):
            return {k:v["sha256"] for k,v in inv.items() if Path(k).suffix.lower() != ".json"}
        if comparable(src_after) != comparable(dst_after):
            raise RuntimeError("USA_TV_media_mirror_verification_failed")
        return {
            "ok": True,
            "source": str(src_root), "target": str(dst_root),
            "active_source_files": len(src_after), "active_target_files": len(dst_after),
            "media_hashes_equal": True, "backup": str(backup_root),
            "prune_requested": prune,
            "phone_engine": phone_engine, "tv_engine": tv_engine,
        }
    except Exception:
        try:
            if dst_root.exists(): shutil.rmtree(dst_root, ignore_errors=True)
            if backup_root.exists(): shutil.copytree(backup_root, dst_root)
        except Exception:
            pass
        raise
    finally:
        shutil.rmtree(work, ignore_errors=True)


def validate_usa_media_source_alignment():
    src = Path(FOLDERS["USA"]) / "media" / "usa-presentation"
    dst = Path(FOLDERS["USA_TV"]) / "media" / "usa-presentation"
    a, b = _usa_media_tree_inventory(src), _usa_media_tree_inventory(dst)
    def c(inv): return {k:v["sha256"] for k,v in inv.items() if Path(k).suffix.lower() != ".json"}
    missing_tv = sorted(set(c(a)) - set(c(b)))
    extra_tv = sorted(set(c(b)) - set(c(a)))
    mismatched = sorted(k for k in set(c(a)) & set(c(b)) if c(a)[k] != c(b)[k])
    return {"ok": not missing_tv and not extra_tv and not mismatched,
            "source_count":len(a),"target_count":len(b),"missing_tv":missing_tv,
            "extra_tv":extra_tv,"mismatched":mismatched}

def save_usa_presentation_media(media_key, uploaded_file, target_variant="phone"):
    target_variant, target_info = usa_image_target_info(target_variant)
    if media_key not in USA_IMAGE_KEYS:
        return None
    original_name = uploaded_file.filename or ""
    ext = file_ext(original_name)
    if ext not in USA_MEDIA_EXTENSIONS:
        raise ValueError("סיומת לא נתמכת. העלה תמונה או סרטון MP4/M4V.")

    media_dir = usa_image_dir_for_target(target_variant)
    os.makedirs(media_dir, exist_ok=True)
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    work_dir = tempfile.mkdtemp(prefix=f"usa_media_{target_variant}_{media_key}_", dir=ADMIN_UPLOAD_TMP_DIR)
    rollback_dir = os.path.join(work_dir, "rollback")
    os.makedirs(rollback_dir, exist_ok=True)
    source_path = os.path.join(work_dir, secure_filename(original_name) or (media_key + ext))
    uploaded_file.save(source_path)
    uploaded_size = os.path.getsize(source_path)
    if uploaded_size <= 0:
        shutil.rmtree(work_dir, ignore_errors=True)
        raise ValueError("קובץ המדיה ריק.")
    if ext in USA_IMAGE_EXTENSIONS and uploaded_size > USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES:
        shutil.rmtree(work_dir, ignore_errors=True)
        raise ValueError(f"קובץ התמונה גדול מדי: {format_bytes(uploaded_size)}. מגבלה: {format_bytes(USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES)}")

    try:
        if ext in USA_VIDEO_EXTENSIONS:
            prepared = os.path.join(work_dir, media_key + ".mp4")
            media = normalize_video_to_mp4(source_path, prepared, original_name)
            canonical_ext = ".mp4"
            kind = "video"
        else:
            prepared = os.path.join(work_dir, media_key + ".jpg")
            media = convert_or_copy_image_to_jpg(source_path, prepared, original_name)
            canonical_ext = ".jpg"
            kind = "image"

        info, prospective_timeline, videos = validate_usa_media_prospective(target_variant, {media_key: prepared})
        for sibling in usa_media_sibling_paths(media_dir, media_key):
            if os.path.isfile(sibling):
                shutil.copy2(sibling, os.path.join(rollback_dir, os.path.basename(sibling)))
                backup_existing_file(sibling, target_info["backup_tag"] + "_media_single")

        target_path = os.path.join(media_dir, media_key + canonical_ext)
        for sibling in usa_media_sibling_paths(media_dir, media_key):
            if os.path.exists(sibling):
                os.remove(sibling)
        tmp_target = target_path + ".tmp"
        shutil.copy2(prepared, tmp_target)
        os.replace(tmp_target, target_path)

        engine = ensure_usa_presentation_media_engine(target_variant)
        return {
            "target": target_variant,
            "requested_target": target_variant,
            "mirrored_to_tv": None,
            "target_label": target_info["label"],
            "key": media_key,
            "kind": kind,
            "rel": os.path.join(target_info["rel_prefix"], os.path.basename(target_path)).replace(os.sep, "/"),
            "path": target_path,
            "mode": media.get("mode", kind),
            "media": media,
            "sha256": file_sha256(target_path),
            "size": os.path.getsize(target_path),
            "video_count": len(videos),
            "timeline_total_seconds": prospective_timeline.total_seconds,
            "timeline_warnings": list(prospective_timeline.warnings),
            "presentation_media_engine": engine,
        }
    except Exception:
        _restore_media_transaction(media_dir, [media_key], rollback_dir)
        try:
            write_usa_media_metadata(target_variant)
        except Exception:
            pass
        raise
    finally:
        shutil.rmtree(work_dir, ignore_errors=True)


def save_usa_presentation_image(image_key, uploaded_file, target_variant="phone"):
    """Backward-compatible name: now accepts either an image or MP4/M4V video."""
    return save_usa_presentation_media(image_key, uploaded_file, target_variant)


def save_usa_presentation_media_zip(uploaded_file, target_variant="phone"):
    target_variant, target_info = usa_image_target_info(target_variant)
    media_dir = usa_image_dir_for_target(target_variant)
    os.makedirs(media_dir, exist_ok=True)
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    work_dir = tempfile.mkdtemp(prefix=f"usa_media_zip_{target_variant}_", dir=ADMIN_UPLOAD_TMP_DIR)
    zip_path = os.path.join(work_dir, "upload.zip")
    extract_dir = os.path.join(work_dir, "extract")
    prepared_dir = os.path.join(work_dir, "prepared")
    rollback_dir = os.path.join(work_dir, "rollback")
    os.makedirs(extract_dir, exist_ok=True)
    os.makedirs(prepared_dir, exist_ok=True)
    os.makedirs(rollback_dir, exist_ok=True)
    uploaded_file.save(zip_path)
    if os.path.getsize(zip_path) > USA_MEDIA_ZIP_MAX_BYTES:
        shutil.rmtree(work_dir, ignore_errors=True)
        raise ValueError(f"קובץ ZIP גדול מהמגבלה: {format_bytes(USA_MEDIA_ZIP_MAX_BYTES)}")

    imported = []
    skipped = []
    prepared = {}
    affected = []
    try:
        with zipfile.ZipFile(zip_path, "r") as zf:
            infos = [i for i in zf.infolist() if not i.is_dir()]
            if len(infos) > USA_MEDIA_ZIP_MAX_FILES:
                raise ValueError(f"יותר מדי קבצים ב־ZIP: {len(infos)}")
            total = sum(max(0, i.file_size) for i in infos)
            if total > USA_MEDIA_ZIP_MAX_UNCOMPRESSED_BYTES:
                raise ValueError("הגודל לאחר חילוץ גדול מהמגבלה.")
            seen = set()
            for info in infos:
                raw_name = (info.filename or "").replace("\\", "/")
                base_name = os.path.basename(raw_name)
                if not base_name or base_name.startswith(".") or raw_name.startswith("__MACOSX/"):
                    continue
                stem, ext = os.path.splitext(base_name)
                stem = stem.strip()
                ext = ext.lower()
                if ext not in USA_MEDIA_EXTENSIONS:
                    skipped.append(base_name + " — פורמט לא נתמך")
                    continue
                if stem not in USA_IMAGE_KEYS:
                    skipped.append(base_name + " — השם אינו תואם לשקף")
                    continue
                if stem in seen:
                    raise ValueError(f"נמצאה כפילות עבור השקף {stem} בתוך ה־ZIP.")
                if info.file_size > USA_MEDIA_ZIP_MAX_SINGLE_FILE_BYTES:
                    skipped.append(base_name + " — גדול ממגבלת הקובץ הכללית")
                    continue
                if ext in USA_VIDEO_EXTENSIONS and info.file_size > USA_VIDEO_MAX_SINGLE_FILE_BYTES:
                    skipped.append(base_name + " — הסרטון גדול מדי")
                    continue
                if ext in USA_IMAGE_EXTENSIONS and info.file_size > USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES:
                    skipped.append(base_name + " — התמונה גדולה מדי")
                    continue
                seen.add(stem)
                source = os.path.join(extract_dir, stem + ext)
                with zf.open(info, "r") as src, open(source, "wb") as dst:
                    shutil.copyfileobj(src, dst)
                if ext in USA_VIDEO_EXTENSIONS:
                    dest = os.path.join(prepared_dir, stem + ".mp4")
                    media = normalize_video_to_mp4(source, dest, base_name)
                    kind = "video"
                else:
                    dest = os.path.join(prepared_dir, stem + ".jpg")
                    media = convert_or_copy_image_to_jpg(source, dest, base_name)
                    kind = "image"
                prepared[stem] = (dest, kind, media)
                affected.append(stem)

        if not prepared:
            raise ValueError("לא נמצאו ב־ZIP קובצי תמונה או וידאו עם שמות שקפים תקינים.")
        overrides = {key: item[0] for key, item in prepared.items()}
        info, prospective_timeline, videos = validate_usa_media_prospective(target_variant, overrides)

        for key in affected:
            for sibling in usa_media_sibling_paths(media_dir, key):
                if os.path.isfile(sibling):
                    shutil.copy2(sibling, os.path.join(rollback_dir, os.path.basename(sibling)))
                    backup_existing_file(sibling, target_info["backup_tag"] + "_media_zip")
        for key, (prepared_path, kind, media) in prepared.items():
            for sibling in usa_media_sibling_paths(media_dir, key):
                if os.path.exists(sibling):
                    os.remove(sibling)
            target = os.path.join(media_dir, os.path.basename(prepared_path))
            tmp_target = target + ".tmp"
            shutil.copy2(prepared_path, tmp_target)
            os.replace(tmp_target, target)
            if kind == "video":
                imported.append(f"{os.path.basename(target)} — סרטון {media.get('duration_display','')} ({format_bytes(media.get('final_size',0))})")
            else:
                imported.append(f"{os.path.basename(target)} — תמונה {format_bytes(media.get('original_size',0))} → {format_bytes(media.get('final_size',0))}")

        engine = ensure_usa_presentation_media_engine(target_variant)
        existing = media_files_by_base(Path(media_dir))
        missing = sorted(key for key in USA_IMAGE_KEYS if key.lower() not in existing)
        return imported, skipped, missing, {
            "target": target_variant,
            "requested_target": target_variant,
            "target_label": target_info["label"],
            "mirrored_to_tv": None,
            "video_count": len(videos),
            "timeline_total_seconds": prospective_timeline.total_seconds,
            "timeline_warnings": list(prospective_timeline.warnings),
            "presentation_media_engine": engine,
        }
    except Exception:
        _restore_media_transaction(media_dir, affected, rollback_dir)
        try:
            write_usa_media_metadata(target_variant)
        except Exception:
            pass
        raise
    finally:
        shutil.rmtree(work_dir, ignore_errors=True)


def save_usa_presentation_images_zip(uploaded_file, target_variant="phone"):
    """Backward-compatible name: ZIP may now contain images and MP4/M4V videos."""
    return save_usa_presentation_media_zip(uploaded_file, target_variant)


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["USA_TV"], "media", "usa-presentation", "music"), exist_ok=True)
os.makedirs(os.path.join(FOLDERS["USA_TV"], "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 _safe_static_path(directory, file_name):
    root = os.path.abspath(directory)
    candidate = os.path.abspath(os.path.join(root, file_name))
    if candidate != root and not candidate.startswith(root + os.sep):
        raise ValueError("נתיב קובץ לא מורשה")
    return candidate


def _parse_single_http_range(value, size):
    if not value or not value.startswith("bytes="):
        return None
    spec = value[6:].split(",", 1)[0].strip()
    if "-" not in spec:
        raise ValueError("טווח HTTP לא תקין")
    left, right = spec.split("-", 1)
    if left == "":
        suffix = int(right)
        if suffix <= 0:
            raise ValueError("טווח suffix לא תקין")
        start = max(0, size - suffix)
        end = size - 1
    else:
        start = int(left)
        end = int(right) if right else size - 1
    if start < 0 or start >= size or end < start:
        raise ValueError("טווח מחוץ לקובץ")
    return start, min(end, size - 1)


def _stream_file_range(path, start, length, chunk_size=1024 * 1024):
    remaining = length
    with open(path, "rb") as fh:
        fh.seek(start)
        while remaining > 0:
            chunk = fh.read(min(chunk_size, remaining))
            if not chunk:
                break
            remaining -= len(chunk)
            yield chunk


def serve_file_no_cache(directory, file_name):
    try:
        full_path = _safe_static_path(directory, file_name)
        if not os.path.isfile(full_path):
            return "File not found", 404
        ext = file_ext(full_path)
        if ext in USA_VIDEO_EXTENSIONS:
            size = os.path.getsize(full_path)
            content_type = mimetypes.guess_type(full_path)[0] or "video/mp4"
            range_header = request.headers.get("Range", "")
            try:
                parsed = _parse_single_http_range(range_header, size) if range_header else None
            except Exception:
                response = Response(status=416)
                response.headers["Content-Range"] = f"bytes */{size}"
                response.headers["Accept-Ranges"] = "bytes"
                return no_cache_response(response)
            if parsed:
                start, end = parsed
                length = end - start + 1
                body = () if request.method == "HEAD" else _stream_file_range(full_path, start, length)
                response = Response(body, status=206, mimetype=content_type, direct_passthrough=True)
                response.headers["Content-Range"] = f"bytes {start}-{end}/{size}"
                response.headers["Content-Length"] = str(length)
                response.headers["Accept-Ranges"] = "bytes"
                return no_cache_response(response)
            response = make_response(send_file(full_path, mimetype=content_type, conditional=True, as_attachment=False))
            response.headers["Accept-Ranges"] = "bytes"
            return no_cache_response(response)
        try:
            response = make_response(send_from_directory(directory, file_name, conditional=False, max_age=0))
        except TypeError:
            response = make_response(send_from_directory(directory, file_name))
        return no_cache_response(response)
    except ValueError:
        return "Forbidden", 403



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 folder_key_for_path(folder_path):
    try:
        abs_path = os.path.abspath(folder_path)
        for key, value in FOLDERS.items():
            if os.path.abspath(value) == abs_path:
                return key
    except Exception:
        pass
    return None


def find_html_file(folder_path):
    # Use explicit entry files first, especially for USA_TV where the folder can also contain
    # usa2027-presentation-tv.html. The quick trip route must open the trip file, not the presentation.
    if not os.path.isdir(folder_path):
        return None
    folder_key = folder_key_for_path(folder_path)
    for preferred in APP_ENTRY_FILES.get(folder_key, []):
        if os.path.exists(os.path.join(folder_path, preferred)):
            return preferred

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

    html_files = sorted(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 preferred_main_upload_filename(folder):
    # One authoritative filename per app root.
    # Critical for USA_TV: the quick-open button and the /USA_TV/ route both expect the TV trip file,
    # not index.html and not the TV presentation. This prevents upload/open path drift.
    if folder == "USA_TV":
        return "usa2027-tv.html"
    return "index.html"


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 = preferred_main_upload_filename(folder)
    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 = usa_folder_for_presentation_key(presentation_key)
    os.makedirs(usa_folder, exist_ok=True)
    filename = USA_PRESENTATION_FILES[presentation_key]
    target_path = os.path.join(usa_folder, filename)
    target_variant = "tv" if presentation_key == "usa_presentation_tv" else "phone"
    result = atomic_save_upload(uploaded_file, target_path, backup_group="USA_TV_presentations" if presentation_key == "usa_presentation_tv" else "USA_presentations")
    try:
        engine = ensure_usa_presentation_media_engine(target_variant)
        result["presentation_media_engine"] = engine
    except Exception:
        backup = result.get("backup")
        try:
            if backup and os.path.isfile(backup):
                shutil.copy2(backup, target_path)
            elif os.path.exists(target_path):
                os.remove(target_path)
        except Exception:
            pass
        raise
    return filename, result


def save_usa_music(music_key, uploaded_file):
    if music_key not in USA_MUSIC_FILES:
        return None
    rel_path = USA_MUSIC_FILES[music_key]
    result = save_audio_upload_to_absolute_targets(uploaded_file, usa_music_absolute_targets(rel_path), backup_group="USA_music_all_screens")
    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 = preferred_main_upload_filename("USA_TV") if extra_key == "usa_trip_tv" else USA_EXTRA_HTML_FILES[extra_key]
    target_path = os.path.join(usa_folder_for_extra_html_key(extra_key), filename)
    result = atomic_save_upload(uploaded_file, target_path, backup_group="USA_TV_extra_html" if extra_key in USA_EXTRA_HTML_ROOTS else "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 normalize_netlify_trip_key(trip):
    trip = (trip or "").strip().upper()
    if trip in {"USA", "US", "AMERICA"}:
        return "USA"
    if trip in {"JAPAN", "JP"}:
        return "JAPAN"
    return ""


def netlify_output_filename(trip):
    trip = normalize_netlify_trip_key(trip)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    if trip == "USA":
        return f"USA2027_NETLIFY_DEPLOY_FROM_ADMIN_{stamp}.zip"
    if trip == "JAPAN":
        return f"JAPAN2026_NETLIFY_DEPLOY_FROM_ADMIN_{stamp}.zip"
    return f"NETLIFY_DEPLOY_{stamp}.zip"


def netlify_is_static_file(path):
    base = os.path.basename(path or "").lower()
    if base in NETLIFY_SKIP_FILES:
        return False
    if base in NETLIFY_CONTROL_FILES:
        return True
    return file_ext(path) in NETLIFY_STATIC_EXTENSIONS


def netlify_should_copy_relpath(rel, profile=""):
    rel = (rel or "").replace("\\", "/").lstrip("./")
    low = rel.lower()
    if profile == "USA_NETLIFY_PHONE":
        if os.path.basename(low) in NETLIFY_USA_EXCLUDED_FILES:
            return False
        if any(low.startswith(prefix) for prefix in NETLIFY_USA_EXCLUDED_PREFIXES):
            return False
    return True


def netlify_copy_static_tree(source_root, target_root, prefix="", profile=""):
    copied = []
    if not os.path.isdir(source_root):
        raise FileNotFoundError(source_root)
    for dirpath, dirnames, filenames in os.walk(source_root):
        dirnames[:] = [d for d in dirnames if d.lower() not in NETLIFY_SKIP_DIRS and not d.startswith(".")]
        rel_dir = os.path.relpath(dirpath, source_root)
        if rel_dir == ".":
            rel_dir = ""
        for name in filenames:
            src = os.path.join(dirpath, name)
            if not netlify_is_static_file(src):
                continue
            source_rel = os.path.join(rel_dir, name) if rel_dir else name
            source_rel = source_rel.replace("\\", "/")
            if not netlify_should_copy_relpath(source_rel, profile=profile):
                continue
            rel = source_rel
            if prefix:
                rel = prefix.strip("/") + "/" + rel
            safe = safe_zip_relpath(rel)
            if not safe:
                continue
            dst = os.path.join(target_root, *safe.split("/"))
            ensure_parent_dir(dst)
            shutil.copy2(src, dst)
            copied.append(safe)
    return sorted(copied, key=lambda x: x.lower())



def netlify_replace_attrs(html_text, replacer):
    attr_re = re.compile(r"(?P<attr>\b(?:href|src|poster|data-href)\s*=\s*)(?P<quote>[\"'])(?P<url>.*?)(?P=quote)", re.I | re.S)
    def repl(match):
        url = match.group("url")
        new_url = replacer(url)
        if new_url is None:
            return match.group(0)
        return match.group("attr") + match.group("quote") + new_url + match.group("quote")
    return attr_re.sub(repl, html_text or "")


def netlify_is_budget_url(url):
    u = str(url or "").strip().lower()
    return (
        "beautiful-kitsune-dafd66.netlify.app" in u
        or "moneyapp" in u
        or "/moneyapp" in u
        or "trip-budget" in u
        or "vacation-budget" in u
    )


def netlify_patch_trip_html(trip, html_text):
    trip = normalize_netlify_trip_key(trip)
    text = html_text or ""

    def attr_replacer(url):
        u = str(url or "").strip()
        low = u.lower()
        if netlify_is_budget_url(u):
            return NETLIFY_BUDGET_APP_URL
        if trip == "USA" and "usa2027-presentation" in low:
            return "/USA/usa2027-presentation.html"
        if trip == "JAPAN" and ("japan-flight-intro.html" in low or "japan-experience.html" in low):
            return "./japan-flight-intro.html?from=trip"
        return None

    text = netlify_replace_attrs(text, attr_replacer)
    # Also cover common JavaScript button patterns such as window.open('...').
    if trip == "USA":
        text = re.sub(r"https?://[^'\"\s<>]+/USA/usa2027-presentation\.html(?:\?[^'\"\s<>]*)?", "/USA/usa2027-presentation.html", text, flags=re.I)
        text = re.sub(r"(?<![\w./-])(?:\./)?usa2027-presentation\.html(?:\?[^'\"\s<>]*)?", "/USA/usa2027-presentation.html", text, flags=re.I)
    elif trip == "JAPAN":
        text = re.sub(r"https?://[^'\"\s<>]+/JAPAN/japan-flight-intro\.html(?:\?[^'\"\s<>]*)?", "./japan-flight-intro.html?from=trip", text, flags=re.I)
        text = re.sub(r"https?://[^'\"\s<>]+/JAPAN/japan-experience\.html(?:\?[^'\"\s<>]*)?", "./japan-flight-intro.html?from=trip", text, flags=re.I)
    text = re.sub(r"https?://[^'\"\s<>]+/Moneyapp/?(?:index\.html)?(?:\?[^'\"\s<>]*)?", NETLIFY_BUDGET_APP_URL, text, flags=re.I)
    text = re.sub(r"https?://beautiful-kitsune-dafd66\.netlify\.app(?:/index\.html)?/?", NETLIFY_BUDGET_APP_URL, text, flags=re.I)
    return text


def netlify_patch_all_html_files(build_root, trip):
    patched = []
    trip = normalize_netlify_trip_key(trip)
    for dirpath, _, filenames in os.walk(build_root):
        for name in filenames:
            if file_ext(name) not in {".html", ".htm"}:
                continue
            path = os.path.join(dirpath, name)
            try:
                with open(path, "r", encoding="utf-8", errors="replace") as fh:
                    text = fh.read()
                original = text
                # Remove private server links from generated Netlify bundles.
                if trip == "USA":
                    text = re.sub(r"https?://work-room\.tail978ec2\.ts\.net/USA/", "/USA/", text, flags=re.I)
                    text = re.sub(r"https?://[^'\"\s<>]+/USA/", "/USA/", text, flags=re.I)
                elif trip == "JAPAN":
                    text = re.sub(r"https?://work-room\.tail978ec2\.ts\.net/JAPAN/", "./", text, flags=re.I)
                    text = re.sub(r"https?://[^'\"\s<>]+/JAPAN/", "./", text, flags=re.I)
                text = re.sub(r"https?://work-room\.tail978ec2\.ts\.net/Moneyapp/?(?:index\.html)?", NETLIFY_BUDGET_APP_URL, text, flags=re.I)
                text = re.sub(r"https?://[^'\"\s<>]+/Moneyapp/?(?:index\.html)?", NETLIFY_BUDGET_APP_URL, text, flags=re.I)
                if os.path.relpath(path, build_root).replace("\\", "/") in {"index.html", "USA/index.html"}:
                    text = netlify_patch_trip_html(trip, text)
                if text != original:
                    with open(path, "w", encoding="utf-8", newline="") as fh:
                        fh.write(text)
                    patched.append(os.path.relpath(path, build_root).replace("\\", "/"))
            except Exception:
                raise
    return sorted(patched, key=lambda x: x.lower())


def netlify_write_usa_root_index(build_root):
    path = os.path.join(build_root, "index.html")
    html = """<!DOCTYPE html>
<html lang=\"he\" dir=\"rtl\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>טיול ארצות הברית 2027</title><meta http-equiv=\"refresh\" content=\"0; url=/USA/\"><script>location.replace('/USA/');</script></head><body style=\"font-family:Arial,sans-serif;direction:rtl;text-align:center;padding:40px\"><h1>טיול ארצות הברית 2027</h1><p>מעביר לקובץ הטיול...</p><p><a href=\"/USA/\">פתח טיול ארה״ב</a></p></body></html>"""
    with open(path, "w", encoding="utf-8", newline="") as fh:
        fh.write(html)
    return "index.html"


def netlify_ensure_japan_top_anchor(build_root):
    path = os.path.join(build_root, "index.html")
    if not os.path.isfile(path):
        return False
    with open(path, "r", encoding="utf-8", errors="replace") as fh:
        text = fh.read()
    if 'id="top"' in text or "id='top'" in text:
        return False
    new_text = re.sub(r"(<body\b[^>]*>)", r"\1\n<div id=\"top\" style=\"position:absolute;top:0\"></div>", text, count=1, flags=re.I)
    if new_text == text:
        new_text = '<div id="top" style="position:absolute;top:0"></div>\n' + text
    with open(path, "w", encoding="utf-8", newline="") as fh:
        fh.write(new_text)
    return True


def netlify_write_usa_redirects(build_root):
    """Ensure USA Netlify bundles have a root control file for the /USA entry path."""
    path = os.path.join(build_root, "_redirects")
    if os.path.isfile(path):
        return False
    redirects = "\n".join([
        "/USA /USA/ 301",
        "/USA/* /USA/:splat 200",
        ""
    ])
    with open(path, "w", encoding="utf-8", newline="") as fh:
        fh.write(redirects)
    return True


def netlify_write_japan_redirects(build_root):
    """Ensure Japan Netlify bundles include the same redirect compatibility file used by the manual Netlify ZIPs."""
    path = os.path.join(build_root, "_redirects")
    if os.path.isfile(path):
        return False
    redirects = "\n".join([
        "/JAPAN/japan-flight-intro.html /japan-flight-intro.html 200",
        "/JAPAN/japan-experience.html /japan-experience.html 200",
        "/JAPAN/ /index.html 200",
        "/JAPAN/* /index.html 200",
        "/japan-flight-intro.html /japan-flight-intro.html 200",
        "/japan-experience.html /japan-experience.html 200",
        ""
    ])
    with open(path, "w", encoding="utf-8", newline="") as fh:
        fh.write(redirects)
    return True


def netlify_local_ref_to_path(build_root, html_rel, raw_url):
    url = str(raw_url or "").strip()
    if not url or url.startswith("#") or url.startswith("?"):
        return None
    low = url.lower()
    if low.startswith(("http://", "https://", "mailto:", "tel:", "sms:", "javascript:", "data:", "blob:", "about:", "//")):
        return None
    clean = url.split("#", 1)[0].split("?", 1)[0].strip()
    if not clean:
        return None
    if clean.startswith("/"):
        rel = clean.strip("/")
    else:
        base = os.path.dirname(html_rel)
        rel = os.path.normpath(os.path.join(base, clean)).replace("\\", "/")
    rel = rel.strip("/")
    if not rel or rel == ".":
        rel = "index.html"
    return rel


def netlify_path_exists(build_root, rel):
    safe = safe_zip_relpath(rel)
    if not safe:
        return False
    path = os.path.join(build_root, *safe.split("/"))
    if os.path.isfile(path):
        return True
    if os.path.isdir(path) and os.path.isfile(os.path.join(path, "index.html")):
        return True
    if safe.endswith("/") and os.path.isfile(os.path.join(build_root, *safe.split("/"), "index.html")):
        return True
    return False


def netlify_discover_usa_music_assets(build_root, presentation_text=""):
    """Discover and validate every USA phone-presentation audio asset dynamically.

    The music folder, not a fixed two-file list, is the source of truth for packaged
    audio inventory. Every local audio URL referenced by the presentation must exist.
    Every supported audio file present in the folder is counted, hashed later by the
    ZIP verifier, and must be non-empty.
    """
    music_rel_root = "USA/media/usa-presentation/music"
    music_root = os.path.join(build_root, *music_rel_root.split("/"))
    errors, warnings, files = [], [], []
    if not os.path.isdir(music_root):
        return {"ok": False, "errors": ["חסרה תיקיית המוזיקה של מצגת ארה״ב: " + music_rel_root], "warnings": [], "files": [], "referenced": []}

    for dirpath, _, names in os.walk(music_root):
        for name in sorted(names, key=str.lower):
            full = os.path.join(dirpath, name)
            ext = file_ext(name)
            if ext not in USA_AUDIO_EXTENSIONS:
                continue
            rel = os.path.relpath(full, build_root).replace("\\", "/")
            size = os.path.getsize(full)
            if size <= 0:
                errors.append("קובץ מוזיקה ריק: " + rel)
            files.append({"path": rel, "size": size})

    quoted_audio_re = re.compile(r"[\"']([^\"']+\.(?:mp3|wav|m4a|aac|flac|ogg|opus|wma|webm)(?:[?#][^\"']*)?)[\"']", re.I)
    referenced = []
    for raw in quoted_audio_re.findall(str(presentation_text or "")):
        rel = netlify_local_ref_to_path(build_root, "USA/usa2027-presentation.html", raw)
        if not rel:
            continue
        if not rel.startswith("USA/media/usa-presentation/music/"):
            warnings.append("המצגת מפנה לקובץ אודיו מחוץ לתיקיית המוזיקה התקנית: " + raw)
        referenced.append(rel)
        if not netlify_path_exists(build_root, rel):
            errors.append("קובץ מוזיקה שמוצהר במצגת חסר: " + raw + " => " + rel)

    if referenced and not files:
        errors.append("המצגת מצהירה על מוזיקה אך תיקיית המוזיקה אינה מכילה קובצי אודיו תקינים")

    packaged = {item["path"] for item in files}
    for rel in sorted(set(referenced)):
        if rel not in packaged and rel.startswith(music_rel_root + "/"):
            errors.append("קובץ מוזיקה מוצהר אינו חלק ממלאי תיקיית המוזיקה: " + rel)

    referenced_set = set(referenced)
    unreferenced = sorted(packaged - referenced_set)
    if unreferenced:
        warnings.append("קובצי מוזיקה קיימים בתיקייה אך אינם מזוהים כהפניה ישירה במצגת: " + ", ".join(unreferenced))

    return {
        "ok": not errors,
        "errors": sorted(set(errors)),
        "warnings": sorted(set(warnings)),
        "files": files,
        "count": len(files),
        "referenced": sorted(set(referenced)),
        "unreferenced": unreferenced,
    }


def netlify_validate_build(build_root, trip):
    trip = normalize_netlify_trip_key(trip)
    errors, warnings, checked_refs = [], [], []
    required = ["index.html"]
    if trip == "USA":
        required += ["USA/index.html", "USA/usa2027-presentation.html", "_redirects"]
    elif trip == "JAPAN":
        required += ["japan-flight-intro.html", "japan-experience.html", "_redirects"]
    for rel in required:
        if not netlify_path_exists(build_root, rel):
            errors.append("חסר קובץ חובה: " + rel)
    attr_re = re.compile(r"\b(?:href|src|poster|data-href)\s*=\s*[\"']([^\"']+)[\"']", re.I)
    for dirpath, _, filenames in os.walk(build_root):
        for name in filenames:
            if file_ext(name) not in {".html", ".htm"}:
                continue
            path = os.path.join(dirpath, name)
            html_rel = os.path.relpath(path, build_root).replace("\\", "/")
            with open(path, "r", encoding="utf-8", errors="replace") as fh:
                text = fh.read()
            if "work-room.tail978ec2.ts.net" in text or "tail978ec2" in text:
                errors.append("נשאר קישור לשרת הפרטי בתוך: " + html_rel)
            declared_media_urls = set()
            inline_match = re.search(r'<script\b[^>]*\bid=["\']usa2027-inline-media-manifest["\'][^>]*>([\s\S]*?)</script>', text, re.I)
            if inline_match:
                try:
                    inline_payload = json.loads(inline_match.group(1))
                    declared_media_urls = {str(x.get("url") or "").replace("\\", "/").lstrip("/") for x in (inline_payload.get("items") or []) if isinstance(x, dict)}
                except Exception:
                    errors.append("Manifest מדיה מוטמע אינו JSON תקין בתוך: " + html_rel)
            for raw_url in attr_re.findall(text):
                rel = netlify_local_ref_to_path(build_root, html_rel, raw_url)
                if not rel:
                    continue
                # Ignore non-file route style links that Netlify cannot validate statically, except known trip paths.
                if not os.path.splitext(rel)[1] and not rel.endswith("/"):
                    candidate_dir = os.path.join(build_root, *rel.split("/"))
                    if not os.path.isdir(candidate_dir):
                        continue
                checked_refs.append(html_rel + " -> " + rel)
                if not netlify_path_exists(build_root, rel):
                    # Presentation image src is a visual fallback. When the inline manifest
                    # declares a video for the same slide base, that video is the active asset.
                    raw_norm = str(raw_url or "").replace("\\", "/").split("?",1)[0].split("#",1)[0].lstrip("/")
                    raw_stem = os.path.splitext(raw_norm)[0].lower()
                    active_sibling = next((u for u in declared_media_urls if os.path.splitext(u)[0].lower() == raw_stem and file_ext(u) in USA_VIDEO_EXTENSIONS), None)
                    active_rel = netlify_local_ref_to_path(build_root, html_rel, active_sibling) if active_sibling else None
                    if active_rel and netlify_path_exists(build_root, active_rel):
                        continue
                    errors.append("קישור פנימי חסר: " + html_rel + " -> " + raw_url + " => " + rel)
    if trip == "USA":
        presentation_rel = "USA/usa2027-presentation.html"
        presentation_path = os.path.join(build_root, "USA", "usa2027-presentation.html")
        required_phone_assets = [
            "USA/media/usa-presentation/images/budget.jpg",
        ]
        for rel in required_phone_assets:
            if not netlify_path_exists(build_root, rel):
                errors.append("חסר קובץ מצגת ארה״ב עדכני: " + rel)
        if os.path.isfile(presentation_path):
            with open(presentation_path, "r", encoding="utf-8", errors="replace") as fh:
                presentation_text = fh.read()
            music_inventory = netlify_discover_usa_music_assets(build_root, presentation_text)
            errors.extend(music_inventory.get("errors") or [])
            warnings.extend(music_inventory.get("warnings") or [])
            normalized_text = presentation_text.replace("\\", "/")
            if "media/usa-presentation/images-tv/" in normalized_text:
                errors.append("מצגת הפלאפון מפנה בטעות לתיקיית images-tv: " + presentation_rel)
            if "usa2027-presentation-tv.html" in normalized_text.lower():
                errors.append("מצגת הפלאפון מכילה הפניה למצגת TV: " + presentation_rel)
            budget_ref_re = re.compile(r"<section\b[^>]*data-key=['\"]budget['\"][\s\S]*?<img\b[^>]*src=['\"]([^'\"]+)['\"]", re.I)
            budget_match = budget_ref_re.search(normalized_text)
            if not budget_match:
                errors.append("לא נמצא שקף תקציב עם תמונת budget במצגת ארה״ב")
            else:
                budget_src = budget_match.group(1).split("?", 1)[0].split("#", 1)[0]
                if budget_src != "media/usa-presentation/images/budget.jpg":
                    errors.append("שקף התקציב אינו משתמש בתמונת התקציב המשותפת: " + budget_src)
            budget_section_match = re.search(r'<section\b[^>]*data-key=[\'"]budget[\'"][^>]*>[\s\S]*?</section>', normalized_text, re.I)
            if not budget_section_match:
                errors.append("לא נמצא שקף התקציב המלא במצגת ארה״ב")
            else:
                budget_section = budget_section_match.group(0)
                required_budget_texts = (
                    "תקציב — בלי דרמה",
                    "השקף הזה משאיר את המספרים בקובץ הטיול",
                    "המסגרת התקציבית מנוהלת בקובץ הטיול ובאפליקציית התקציב",
                    "את המספרים המדויקים משאירים למעקב התקציב",
                )
                for required_text in required_budget_texts:
                    if required_text not in budget_section:
                        errors.append("שקף התקציב של Netlify אינו גרסת ללא-סכומים; חסר מלל מאושר: " + required_text)
                forbidden_budget_texts = ("$81,297", "₪243,890", "$51,430", "₪154,290", "$11,622", "$10,000")
                for forbidden_text in forbidden_budget_texts:
                    if forbidden_text in budget_section:
                        errors.append("שקף התקציב של Netlify עדיין מכיל סכום אסור: " + forbidden_text)
                if budget_section != USA_NETLIFY_BUDGET_SLIDE_HTML:
                    errors.append("שקף התקציב ב-ZIP אינו זהה לשקף ללא-הסכומים המאושר של Netlify")
            private_presentation_path = os.path.join(FOLDERS["USA"], "usa2027-presentation.html")
            if not os.path.isfile(private_presentation_path):
                errors.append("מצגת השרת הפרטי חסרה ולכן לא ניתן לאמת סנכרון עם Netlify")
            else:
                with open(private_presentation_path, "r", encoding="utf-8", errors="replace") as fh:
                    private_presentation_text = fh.read()
                dual_sync = _v57_validate_dual_presentation_sync(private_presentation_text, presentation_text)
                if not dual_sync.get("ok"):
                    errors.extend(dual_sync.get("errors") or [])
        # TV belongs to C:\TripServer\USA_TV and is deliberately excluded from the phone Netlify ZIP.
        for forbidden in ("USA/usa2027-presentation-tv.html", "USA/usa2027-tv.html"):
            if netlify_path_exists(build_root, forbidden):
                errors.append("קובץ TV הוכנס בטעות לחבילת Netlify של הפלאפון: " + forbidden)

    result = {"ok": not errors, "errors": sorted(set(errors)), "warnings": sorted(set(warnings)), "checked_refs": len(checked_refs)}
    if trip == "USA" and "music_inventory" in locals():
        result["usa_music"] = {
            "count": music_inventory.get("count", 0),
            "files": music_inventory.get("files", []),
            "referenced": music_inventory.get("referenced", []),
            "unreferenced": music_inventory.get("unreferenced", []),
        }
    return result


def zip_directory_contents(source_dir, zip_path):
    ensure_parent_dir(zip_path)
    if os.path.exists(zip_path):
        os.remove(zip_path)
    files = []
    with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        for dirpath, _, filenames in os.walk(source_dir):
            for name in sorted(filenames, key=lambda x: x.lower()):
                full = os.path.join(dirpath, name)
                rel = os.path.relpath(full, source_dir).replace("\\", "/")
                safe = safe_zip_relpath(rel)
                if not safe:
                    continue
                zf.write(full, safe)
                files.append(safe)
    return files


def build_netlify_deploy_zip(trip):
    trip = normalize_netlify_trip_key(trip)
    if trip not in {"USA", "JAPAN"}:
        raise ValueError("trip_not_supported")
    source_root = FOLDERS[trip]
    if not os.path.isdir(source_root):
        raise FileNotFoundError(source_root)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    os.makedirs(NETLIFY_BUILD_DIR, exist_ok=True)
    os.makedirs(NETLIFY_EXPORT_DIR, exist_ok=True)
    build_root = os.path.join(NETLIFY_BUILD_DIR, trip.lower() + "_" + stamp)
    if os.path.exists(build_root):
        shutil.rmtree(build_root)
    os.makedirs(build_root, exist_ok=True)
    copied = []
    if trip == "USA":
        copied.extend(netlify_copy_static_tree(source_root, build_root, prefix="USA", profile="USA_NETLIFY_PHONE"))
        netlify_write_usa_root_index(build_root)
        netlify_write_usa_redirects(build_root)
    else:
        copied.extend(netlify_copy_static_tree(source_root, build_root, prefix=""))
        netlify_ensure_japan_top_anchor(build_root)
        netlify_write_japan_redirects(build_root)
    patched = netlify_patch_all_html_files(build_root, trip)
    if trip == "USA":
        presentation_path = os.path.join(build_root, "USA", "usa2027-presentation.html")
        if not os.path.isfile(presentation_path):
            raise RuntimeError("usa_netlify_presentation_missing")
        with open(presentation_path, "r", encoding="utf-8", errors="strict") as fh:
            presentation_html = fh.read()
        # Netlify only: replace the ENTIRE budget slide with the approved no-amount
        # slide. The shared budget.jpg is copied unchanged from the private server.
        budget_slide_re = re.compile(
            r'<section\b[^>]*class=[\'"][^\'"]*\bbudget\b[^\'"]*[\'"][^>]*data-key=[\'"]budget[\'"][^>]*>[\s\S]*?</section>',
            re.I,
        )
        presentation_html, count = budget_slide_re.subn(
            lambda _m: USA_NETLIFY_BUDGET_SLIDE_HTML,
            presentation_html,
            count=1,
        )
        if count != 1:
            raise RuntimeError("usa_netlify_budget_slide_full_replace_failed")
        with open(presentation_path, "w", encoding="utf-8", newline="") as fh:
            fh.write(presentation_html)
        patched.append("USA/usa2027-presentation.html#budget-slide-approved-no-amount")
    validation = netlify_validate_build(build_root, trip)
    if not validation.get("ok"):
        raise RuntimeError("netlify_validation_failed:\n" + "\n".join(validation.get("errors") or []))
    zip_path = os.path.join(NETLIFY_EXPORT_DIR, netlify_output_filename(trip))
    files = zip_directory_contents(build_root, zip_path)
    details = {
        "trip": trip,
        "source_root": source_root,
        "build_root": build_root,
        "zip_path": zip_path,
        "files": len(files),
        "copied": len(copied),
        "patched_html": patched,
        "validation": validation,
        "budget_app_url": NETLIFY_BUDGET_APP_URL,
        "presentation_link": "/USA/usa2027-presentation.html" if trip == "USA" else "./japan-flight-intro.html?from=trip",
        "size_bytes": os.path.getsize(zip_path) if os.path.exists(zip_path) else 0,
    }
    return zip_path, details


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_TV"], "usa2027-tv.html"))
    add("מצגת ארה״ב", os.path.join(FOLDERS["USA"], "usa2027-presentation.html"))
    add("מצגת ארה״ב TV", os.path.join(FOLDERS["USA_TV"], "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("שיר ארה״ב חלק 1 — TV", os.path.join(FOLDERS["USA_TV"], "media", "usa-presentation", "music", "usa-roadtrip-theme.mp3"))
    add("שיר ארה״ב חלק 2 — TV", os.path.join(FOLDERS["USA_TV"], "media", "usa-presentation", "music", "usa-part2-theme.mp3"))
    add("תקציב טיולים", os.path.join(FOLDERS["Moneyapp"], "index.html"))
    add("תקציב משפחתי", os.path.join(FOLDERS["MoneyHome"], "index.html"))
    add("טופס פתיחת טיול", os.path.join(FOLDERS["TripForm"], "index.html"))

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

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

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

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

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

    return True, "OK"


def extract_admin_server_from_zip_upload(zip_path, work_dir):
    """Validate a controlled admin update ZIP and extract only the admin Python file.

    Supported package format:
    - root admin_server.py, or
    - exactly one root-level .py file that passes TripServer admin validation.

    For safety, this route does not execute arbitrary scripts from the ZIP.
    """
    size = os.path.getsize(zip_path)
    if size <= 0:
        return False, "קובץ ה־ZIP ריק.", None
    if size > ADMIN_MAX_PACKAGE_UPLOAD_BYTES:
        return False, f"קובץ ה־ZIP גדול מדי: {size:,} bytes. המגבלה היא {ADMIN_MAX_PACKAGE_UPLOAD_BYTES:,} bytes.", None

    try:
        with zipfile.ZipFile(zip_path, "r") as zf:
            infos = zf.infolist()
            if not infos:
                return False, "קובץ ה־ZIP ריק.", None

            unsafe = []
            py_candidates = []
            for info in infos:
                name = (info.filename or "").replace("\\", "/")
                clean = name.strip("/")
                if not clean or info.is_dir():
                    continue
                if clean.startswith("/") or ".." in clean.split("/"):
                    unsafe.append(name)
                    continue
                # Keep package execution intentionally disabled; only extract admin Python.
                if clean.lower() == "admin_server.py" or ("/" not in clean and clean.lower().endswith(".py")):
                    py_candidates.append(clean)

            if unsafe:
                return False, "קובץ ה־ZIP כולל נתיבים לא בטוחים: " + ", ".join(unsafe[:6]), None
            if "admin_server.py" in py_candidates:
                selected = "admin_server.py"
            elif len(py_candidates) == 1:
                selected = py_candidates[0]
            else:
                return False, "ZIP עדכון אדמין חייב לכלול admin_server.py בשורש, או קובץ Python יחיד בשורש החבילה.", None

            os.makedirs(work_dir, exist_ok=True)
            extracted_path = os.path.join(work_dir, "admin_server_from_zip.py")
            with zf.open(selected, "r") as src, open(extracted_path, "wb") as dst:
                shutil.copyfileobj(src, dst)

        valid, details = validate_admin_server_upload(extracted_path)
        if not valid:
            try: os.remove(extracted_path)
            except Exception: pass
            return False, "admin_server.py שבתוך ה־ZIP נדחה:\n" + details, None
        return True, f"ZIP תקין. נבחר קובץ: {selected}", extracted_path
    except zipfile.BadZipFile:
        return False, "קובץ ZIP לא תקין או פגום.", None
    except Exception as exc:
        return False, "שגיאה בבדיקת ZIP:\n" + repr(exc), None


def admin_status_page(title, message, ok=True, details="", back_url="/admin"):
    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 ""
    safe_back_url = str(back_url or "/admin").strip()
    if not safe_back_url.startswith("/") or safe_back_url.startswith("//"):
        safe_back_url = "/admin"
    safe_back_url = html_lib.escape(safe_back_url, quote=True)
    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="{safe_back_url}">חזרה</a>
<form action="/restart_admin_server" method="post" onsubmit="return confirm('זו בדיקת אתחול מתקדמת. להפעיל מחדש את הפאנל עכשיו?')"><button class="restart">בדיקת אתחול פאנל — מתקדם</button></form>
<div class="advanced-note">מיועד לבדיקה יזומה בלבד אחרי עדכון/שחזור קובץ אדמין.</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_control_bootstrap(message):
    try:
        os.makedirs(BASE_DIR, exist_ok=True)
        with open(CONTROL_BOOTSTRAP_LOG_PATH, "a", encoding="utf-8") as fh:
            fh.write(time.strftime("%Y-%m-%d %H:%M:%S") + " | control-bootstrap | " + str(message) + "\n")
    except Exception:
        pass


def quiet_pythonw_path():
    exe = sys.executable or "python"
    base = os.path.basename(exe).lower()
    if base == "python.exe":
        candidate = os.path.join(os.path.dirname(exe), "pythonw.exe")
        if os.path.exists(candidate):
            return candidate
    return exe



# Control Server implementation is defined once in the v92+ runtime layer below.

def control_server_is_alive():
    try:
        with urllib.request.urlopen(f"http://127.0.0.1:{CONTROL_PORT}/ping.json", timeout=1.0) as resp:
            return resp.status == 200
    except Exception:
        return False



# Bootstrap and request writing are supplied by the single active v92+ implementation below.

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. Control Server 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-v29 | ' + 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, creationflags=(getattr(subprocess, 'CREATE_NO_WINDOW', 0) if os.name == 'nt' else 0))",
        "        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, creationflags=(getattr(subprocess, 'CREATE_NO_WINDOW', 0) if os.name == 'nt' else 0))",
        "            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, 'CREATE_NO_WINDOW', 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-v29 | 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-v29 | 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(2.5)",
        "    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; Control Server 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"
    quiet_python = get_pythonw_executable(python_exe)
    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 = hidden_subprocess_flags(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0))
        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("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([quiet_python, script_path], cwd=work_dir, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=(False if os.name == "nt" else True), creationflags=hidden_subprocess_flags(), 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, creationflags=hidden_subprocess_flags())
    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, creationflags=hidden_subprocess_flags())
    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 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)
        return helper_path + " | task=" + str(task_info.get("task")) + " | python=" + str(task_info.get("python"))
    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 = hidden_subprocess_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)
        return helper_path + " | fallback=detached-popen"


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>צפייה נוחה בלוגי האדמין, עדכונים ופעולות מערכת בלי לפתוח קבצים ידנית במחשב. המסך לקריאה בלבד ולא מוחק או משנה שום דבר.</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 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, creationflags=hidden_subprocess_flags())
    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, creationflags=hidden_subprocess_flags())
    # 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 arm_admin_update_failsafe_restart():
    """Schedule a Windows reboot before applying an admin update."""
    if not ADMIN_UPDATE_FAILSAFE_ENABLED:
        msg = "Fail Safe כבוי בהגדרת TRIPSERVER_ADMIN_UPDATE_FAILSAFE."
        write_admin_failsafe_status("arm_failed", msg)
        return False, msg
    allowed, reason = power_action_allowed()
    if not allowed:
        write_admin_failsafe_status("arm_failed", reason)
        return False, reason
    delay = normalize_power_delay(ADMIN_UPDATE_FAILSAFE_RESTART_SECONDS)
    try:
        subprocess.run(["shutdown", "/a"], capture_output=True, text=True, shell=False, creationflags=hidden_subprocess_flags())
    except Exception:
        pass
    try:
        label, final_delay, output = run_power_command("reboot", delay)
        msg = f"{label} Fail Safe נקבע לעוד {final_delay} שניות."
        write_admin_failsafe_status("armed", msg, delay_seconds=final_delay, windows_output=output or "OK")
        return True, f"{msg} פלט Windows: {output or 'OK'}"
    except Exception as exc:
        msg = "Fail Safe לא הצליח לקבוע ריסטארט Windows: " + repr(exc)
        write_admin_failsafe_status("arm_failed", msg)
        return False, msg


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,
            creationflags=hidden_subprocess_flags(),
        )
        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 = hidden_subprocess_flags(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 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 = hidden_subprocess_flags(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 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 = hidden_subprocess_flags(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 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, creationflags=hidden_subprocess_flags())
    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, creationflags=hidden_subprocess_flags())
        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,
            creationflags=hidden_subprocess_flags(),
        )
        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")
    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)




# ================= PRESENTATION CONTROL CENTER v45 =================
# Generic presentation QA layer. New trips can be added to this registry without
# duplicating the analysis engine.
PRESENTATION_CONTROL_TARGETS = {
    "usa_phone": {
        "label": "ארצות הברית 2027 — פלאפון / מחשב",
        "trip": "USA",
        "root_key": "USA",
        "presentation": "usa2027-presentation.html",
        "media_rel": os.path.join("media", "usa-presentation", "images"),
        "music_rel": [
            os.path.join("media", "usa-presentation", "music", "usa-roadtrip-theme.mp3"),
            os.path.join("media", "usa-presentation", "music", "usa-part2-theme.mp3"),
        ],
        "open_url": "/USA/usa2027-presentation.html",
    },
    "usa_tv": {
        "label": "ארצות הברית 2027 — טלוויזיה",
        "trip": "USA",
        "root_key": "USA_TV",
        "presentation": "usa2027-presentation-tv.html",
        "media_rel": os.path.join("media", "usa-presentation", "images"),
        "music_rel": [
            os.path.join("media", "usa-presentation", "music", "usa-roadtrip-theme.mp3"),
            os.path.join("media", "usa-presentation", "music", "usa-part2-theme.mp3"),
        ],
        "open_url": "/USA_TV/usa2027-presentation-tv.html",
    },
}


def _pc_target_paths(target_key):
    cfg = PRESENTATION_CONTROL_TARGETS.get(target_key)
    if not cfg:
        raise KeyError(f"יעד מצגת לא מוכר: {target_key}")
    root = FOLDERS.get(cfg["root_key"], os.path.join(BASE_DIR, cfg["root_key"]))
    return cfg, root, os.path.join(root, cfg["presentation"]), os.path.join(root, cfg["media_rel"])


def _pc_file_info(path):
    exists = os.path.isfile(path)
    return {
        "path": path,
        "exists": exists,
        "size": os.path.getsize(path) if exists else 0,
        "mtime": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(path))) if exists else "",
    }


def _pc_extract_inline_manifest(html):
    m = re.search(r'<script\b[^>]*\bid=["\']usa2027-inline-media-manifest["\'][^>]*>([\s\S]*?)</script>', html, re.I)
    if not m:
        return None, "לא נמצאה רשימת מדיה מוטמעת במצגת."
    try:
        payload = json.loads(html_lib.unescape(m.group(1).strip()))
    except Exception as exc:
        return None, "רשימת המדיה המוטמעת אינה JSON תקין: " + repr(exc)
    if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
        return None, "מבנה רשימת המדיה המוטמעת אינו תקין."
    return payload, ""


def _pc_local_url_path(root, presentation_path, url):
    raw = str(url or "").strip()
    if not raw or raw.startswith(("data:", "blob:", "http://", "https://", "//")):
        return None
    raw = raw.split("#", 1)[0].split("?", 1)[0].replace("/", os.sep)
    if raw.startswith(os.sep):
        # /USA/... or /USA_TV/... : remove the app-root component.
        bits = [x for x in raw.split(os.sep) if x]
        if bits and bits[0].upper() in {"USA", "USA_TV", "JAPAN"}:
            bits = bits[1:]
        return os.path.normpath(os.path.join(root, *bits))
    return os.path.normpath(os.path.join(os.path.dirname(presentation_path), raw))


def _pc_duplicate_ids(html):
    ids = re.findall(r'\bid=["\']([^"\']+)["\']', html, re.I)
    seen, dup = set(), []
    for value in ids:
        if value in seen and value not in dup:
            dup.append(value)
        seen.add(value)
    return dup


def _pc_broken_anchors(html):
    ids = set(re.findall(r'\bid=["\']([^"\']+)["\']', html, re.I))
    refs = set(re.findall(r'\bhref=["\']#([^"\']+)["\']', html, re.I))
    return sorted(x for x in refs if x and x not in ids)



def _pc_probe_music_for_timeline(cfg, root):
    """Return actual audio durations from files. QA must never trust stale HTML constants."""
    results = []
    for rel in cfg.get("music_rel") or []:
        path = os.path.join(root, rel)
        item = _pc_file_info(path)
        if item["exists"]:
            probe = probe_audio_duration_seconds(path)
            item["duration"] = float(probe.get("duration_seconds") or 0) if probe.get("ok") else None
            item["probe_ok"] = bool(probe.get("ok"))
            item["error"] = probe.get("error", "") or probe.get("raw", "")
            item["ffprobe"] = probe.get("ffprobe", "")
        else:
            item["duration"] = None
            item["probe_ok"] = False
            item["error"] = "קובץ המוזיקה חסר"
        results.append(item)
    return results


def _pc_parts_with_actual_audio(info, music_results):
    """Build timeline parts from probed MP3 durations while preserving slide boundaries."""
    if len(music_results) != len(info.parts) or not all(x.get("probe_ok") and (x.get("duration") or 0) > 0 for x in music_results):
        return info.parts
    return tuple(
        PresentationPart(part.start, part.end, float(music_results[i]["duration"]), part.label)
        for i, part in enumerate(info.parts)
    )


def _pc_audio_mismatch_detail(info, music_results, tolerance=0.25):
    rows=[]
    ok=True
    if len(music_results) != len(info.parts):
        return False, "מספר קובצי המוזיקה אינו תואם למספר חלקי המצגת."
    for i,(part,item) in enumerate(zip(info.parts,music_results),1):
        actual=float(item.get("duration") or 0)
        if not item.get("probe_ok") or actual <= 0:
            ok=False
            rows.append(f"חלק {i}: לא ניתן למדוד את קובץ המוזיקה")
            continue
        delta=abs(float(part.seconds)-actual)
        if delta > tolerance:
            ok=False
        rows.append(f"חלק {i}: בקוד {part.seconds:.3f} שנ׳ · בקובץ {actual:.3f} שנ׳ · פער {delta:.3f}")
    return ok, " | ".join(rows)

def _pc_base_build_presentation_control_report(target_key="usa_phone"):
    cfg, root, presentation_path, media_dir = _pc_target_paths(target_key)
    report = {
        "target": target_key,
        "label": cfg["label"],
        "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
        "presentation": _pc_file_info(presentation_path),
        "parts": [], "checks": [], "videos": [], "missing": [], "warnings": [],
        "offline": {}, "totals": {}, "ok": False,
    }
    def check(name, ok, detail="", level=None):
        report["checks"].append({"name": name, "ok": bool(ok), "detail": str(detail or ""), "level": level or ("ok" if ok else "error")})
    if not os.path.isfile(presentation_path):
        check("קובץ מצגת", False, presentation_path)
        return report
    try:
        html = read_text_file_utf8(presentation_path)
        info = inspect_presentation_html(html)
        music_results = _pc_probe_music_for_timeline(cfg, root)
        actual_parts = _pc_parts_with_actual_audio(info, music_results)
        audio_match_ok, audio_match_detail = _pc_audio_mismatch_detail(info, music_results)
        check("מבנה המצגת", True, f"נמצאו {len(info.slide_bases)} שקפים; חלק 1 מסתיים בשקף {info.part1_last_slide_number}.")
        check("משך השירים בקוד תואם לקבצים בפועל", audio_match_ok, audio_match_detail, None if audio_match_ok else "error")
    except Exception as exc:
        check("מבנה המצגת", False, repr(exc))
        return report

    media_map = media_files_by_base(Path(media_dir))
    video_durations = {}
    missing_slides = []
    duplicate_siblings = []
    total_media_bytes = 0
    for base in info.slide_bases:
        key = str(base).lower()
        path = media_map.get(key)
        siblings = [p for p in usa_media_sibling_paths(media_dir, key) if os.path.isfile(p)]
        if len(siblings) > 1:
            duplicate_siblings.append({"base": base, "files": siblings})
        if path is None:
            missing_slides.append(base)
            continue
        total_media_bytes += os.path.getsize(path)
        if Path(path).suffix.lower() in VIDEO_EXTENSIONS:
            try:
                duration = probe_video_duration_seconds(str(path))
                video_durations[key] = float(duration)
                report["videos"].append({"base": base, "file": os.path.basename(path), "duration": float(duration), "size": os.path.getsize(path)})
            except Exception as exc:
                report["warnings"].append(f"משך הסרטון {base} לא נקרא: {exc}")
    report["missing"] = missing_slides
    check("קובצי מדיה לכל השקפים", not missing_slides, "הכול קיים" if not missing_slides else "חסרים: " + ", ".join(missing_slides[:12]))
    check("אין תמונה וסרטון כפולים לאותו שקף", not duplicate_siblings, "" if not duplicate_siblings else "; ".join(x["base"] for x in duplicate_siblings))

    try:
        timeline = allocate_timeline(info.slide_bases, actual_parts, video_durations)
        for part_index, part in enumerate(actual_parts):
            entries = [e for e in timeline.entries if e.part_index == part_index]
            videos = [e for e in entries if e.kind == "video"]
            images = [e for e in entries if e.kind == "image"]
            video_total = sum(e.media_seconds for e in videos)
            image_total = sum(e.timeline_seconds for e in images)
            per_image = (image_total / len(images)) if images else 0.0
            status = "green" if (not images or per_image >= 8.0) else ("yellow" if per_image >= 5.0 else "red")
            report["parts"].append({
                "label": part.label, "song_seconds": part.seconds,
                "slide_count": len(entries), "video_count": len(videos), "video_seconds": video_total,
                "image_count": len(images), "image_seconds": image_total, "per_image_seconds": per_image,
                "reserve_to_5_seconds": max(0.0, image_total - len(images) * 5.0),
                "status": status,
            })
        report["timeline_total"] = timeline.total_seconds
        report["warnings"].extend(timeline.warnings)
        check("חישוב ציר הזמן", True, f"סה״כ {timeline.total_seconds:.3f} שניות; סרטונים נשמרים במלוא אורכם.")
    except Exception as exc:
        check("חישוב ציר הזמן", False, repr(exc))

    for item in music_results:
        if item.get("exists"):
            total_media_bytes += item.get("size", 0)
    report["music"] = music_results
    music_ok = len(music_results) == 2 and all(x.get("exists") and x.get("probe_ok") for x in music_results)
    check("שני קובצי המוזיקה", music_ok, "; ".join(os.path.basename(x["path"]) + (f" ✓ {x.get('duration',0):.3f} שנ׳" if x.get("probe_ok") else " חסר/לא מדיד") for x in music_results))

    duplicates = _pc_duplicate_ids(html)
    broken_anchors = _pc_broken_anchors(html)
    check("אין מזהי HTML כפולים", not duplicates, ", ".join(duplicates[:12]))
    check("אין עוגנים פנימיים שבורים", not broken_anchors, ", ".join(broken_anchors[:12]))
    forbidden = []
    body = _v51_extract_js_function_body(html, "allOfflineAssetUrls") or ""
    offline_union = "querySelectorAll" in body and ("eitanReadInlineManifest" in body or "eitanManifestUrls" in body)
    check("Registry אופליין אינו סופר מדיה כפולה", not offline_union,
          "" if not offline_union else "הפונקציה מאחדת DOM עם Registry ועלולה לספור גם תמונה ישנה וגם סרטון מחליף.")

    for token in ("navigator.serviceWorker", "offline-manifest.json", "usa-offline-sw.js"):
        if token in html:
            forbidden.append(token)
    check("אין תלות במנגנוני אופליין שנכשלו בעבר", not forbidden, ", ".join(forbidden))

    inline, inline_error = _pc_extract_inline_manifest(html)
    offline_missing = []
    inline_count = 0
    if inline:
        items = inline.get("items") or []
        inline_count = len(items)
        for item in items:
            local_path = _pc_local_url_path(root, presentation_path, item.get("url"))
            if local_path and not os.path.isfile(local_path):
                offline_missing.append({"url": item.get("url"), "path": local_path})
        check("רשימת אופליין מוטמעת", True, f"{inline_count} פריטי מדיה; token={inline.get('token','—')}")
        check("כל קובצי רשימת האופליין קיימים בשרת", not offline_missing, "" if not offline_missing else ", ".join(str(x["url"]) for x in offline_missing[:12]))
    else:
        check("רשימת אופליין מוטמעת", False, inline_error)
    report["offline"] = {"inline_count": inline_count, "missing": offline_missing, "expected_with_html": inline_count + 1 if inline_count else 0}
    report["audio_timeline_source"] = "ffprobe" if all(x.get("probe_ok") for x in music_results) else "html_fallback"
    report["declared_song_seconds"] = [float(p.seconds) for p in info.parts]
    report["actual_song_seconds"] = [float(x.get("duration") or 0) for x in music_results]
    report["totals"] = {
        "slide_count": len(info.slide_bases), "video_count": len(video_durations),
        "image_count": len(info.slide_bases) - len(video_durations),
        "media_bytes": total_media_bytes, "presentation_bytes": os.path.getsize(presentation_path),
    }
    report["ok"] = all(c["ok"] or c.get("level") == "warning" for c in report["checks"])
    return report


def _pc_h(value):
    return html_lib.escape(str(value if value is not None else ""))


def _pc_base_render_presentation_control_center():
    target = request.args.get("target", "usa_phone")
    if target not in PRESENTATION_CONTROL_TARGETS:
        target = "usa_phone"
    report = build_presentation_control_report(target)
    target_buttons = "".join(
        f'<a class="target {"active" if key==target else ""}" href="/admin/presentations?target={_pc_h(key)}">{_pc_h(cfg["label"])}</a>'
        for key, cfg in PRESENTATION_CONTROL_TARGETS.items()
    )
    status_text = "כל הבדיקות עברו" if report["ok"] else "נמצאו נושאים שדורשים טיפול"
    status_class = "ok" if report["ok"] else "bad"
    part_cards = []
    for part in report.get("parts", []):
        cls = part.get("status", "red")
        part_cards.append(f'''<article class="part {cls}"><h2>{_pc_h(part['label'])}</h2>
        <div class="metric"><b>{part['song_seconds']:.2f}</b><span>שניות בשיר</span></div>
        <div class="metric"><b>{part['video_count']}</b><span>סרטונים · {part['video_seconds']:.2f} שנ׳</span></div>
        <div class="metric"><b>{part['image_count']}</b><span>שקפי תמונה</span></div>
        <div class="hero-number">{part['per_image_seconds']:.2f} שנ׳</div><p>זמן לכל שקף תמונה</p>
        <small>רזרבה עד רצפת 5 שניות: {part['reserve_to_5_seconds']:.2f} שניות</small></article>''')
    check_rows = "".join(f'''<tr><td>{"✅" if c['ok'] else "❌"}</td><td><b>{_pc_h(c['name'])}</b></td><td>{_pc_h(c['detail']) or "—"}</td></tr>''' for c in report.get("checks", []))
    video_rows = "".join(f'''<tr><td>{_pc_h(v['base'])}</td><td>{_pc_h(v['file'])}</td><td>{v['duration']:.2f} שנ׳</td><td>{format_bytes(v['size'])}</td></tr>''' for v in report.get("videos", [])) or '<tr><td colspan="4">אין סרטונים במצגת זו.</td></tr>'
    warnings = "".join(f"<li>{_pc_h(x)}</li>" for x in report.get("warnings", [])) or "<li>לא נמצאו אזהרות.</li>"
    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>:root{{--bg:#07111f;--panel:#101c31;--line:#2b3d58;--text:#f8fafc;--muted:#a8b3c7;--blue:#60a5fa;--green:#22c55e;--yellow:#f59e0b;--red:#ef4444}}*{{box-sizing:border-box}}body{{margin:0;background:radial-gradient(circle at 85% 0,#173563,transparent 28rem),var(--bg);color:var(--text);font-family:Arial,'Segoe UI',sans-serif}}.wrap{{width:min(1180px,94%);margin:auto;padding:26px 0 60px}}a{{color:#bfdbfe}}.head{{background:linear-gradient(135deg,#172554,#0f172a);border:1px solid var(--line);border-radius:28px;padding:24px;box-shadow:0 20px 55px #0005}}.head h1{{margin:0 0 8px;font-size:clamp(28px,5vw,46px)}}.head p{{color:var(--muted);line-height:1.6}}.actions,.targets{{display:flex;gap:9px;flex-wrap:wrap;margin-top:14px}}.actions a,.target{{text-decoration:none;border-radius:999px;padding:10px 14px;font-weight:900;background:#243653;border:1px solid #466080}}.target.active{{background:#2563eb;border-color:#93c5fd}}.summary{{margin:18px 0;border-radius:20px;padding:16px;font-weight:900}}.summary.ok{{background:#123524;border:1px solid #23804a}}.summary.bad{{background:#421d25;border:1px solid #a83245}}.parts{{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:14px}}.part{{background:var(--panel);border:2px solid var(--line);border-radius:24px;padding:18px}}.part.green{{border-color:#15803d}}.part.yellow{{border-color:#d97706}}.part.red{{border-color:#dc2626}}.part h2{{margin:0 0 14px}}.metric{{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid var(--line)}}.metric b{{font-size:20px}}.metric span,.part p,.part small{{color:var(--muted)}}.hero-number{{font-size:44px;font-weight:950;color:#bfdbfe;margin-top:15px}}.panel{{background:var(--panel);border:1px solid var(--line);border-radius:22px;padding:18px;margin-top:16px;overflow:auto}}table{{width:100%;border-collapse:collapse}}th,td{{padding:10px;border-bottom:1px solid var(--line);text-align:right;vertical-align:top}}th{{color:#bfdbfe}}code{{direction:ltr;unicode-bidi:isolate}}ul{{line-height:1.8}}@media(max-width:650px){{th,td{{font-size:12px;padding:7px 4px}}.hero-number{{font-size:36px}}}}</style></head><body><main class="wrap"><section class="head"><h1>🎛️ מרכז בקרת המצגות</h1><p>ניתוח חי של ציר הזמן, המדיה, קבצים חסרים ומוכנות אופליין. הנתונים נקראים מהקבצים הפעילים בשרת ולא מרשימה קשיחה.</p><div class="actions"><a href="/">🏠 ראשי</a><a href="/admin">📁 אדמין</a><a href="{_pc_h(PRESENTATION_CONTROL_TARGETS[target]['open_url'])}" target="_blank">▶️ פתח מצגת</a><a href="/admin/presentations?target={_pc_h(target)}">🔄 הרץ שוב</a><a href="/admin/presentations/report.json?target={_pc_h(target)}" target="_blank">JSON</a></div><div class="targets">{target_buttons}</div></section>
<div class="summary {status_class}">{"✅" if report['ok'] else "⚠️"} {status_text} · נבדק ב־{_pc_h(report['generated_at'])}</div><section class="parts">{''.join(part_cards) or '<article class="part red">לא ניתן לחשב את ציר הזמן.</article>'}</section>
<section class="panel"><h2>בדיקת תקינות</h2><table><thead><tr><th>מצב</th><th>בדיקה</th><th>פירוט</th></tr></thead><tbody>{check_rows}</tbody></table></section>
<section class="panel"><h2>סרטונים פעילים</h2><table><thead><tr><th>שקף</th><th>קובץ</th><th>משך</th><th>גודל</th></tr></thead><tbody>{video_rows}</tbody></table></section>
<section class="panel"><h2>מוכנות לשמירה מקומית</h2><p>ברשימה המוטמעת: <b>{report.get('offline',{}).get('inline_count',0)}</b> פריטי מדיה. יחד עם HTML המצגת: <b>{report.get('offline',{}).get('expected_with_html',0)}</b> קבצים צפויים. בדיקה זו מאמתת שהקבצים קיימים בשרת; מצב המטמון במכשיר עצמו נבדק מתוך המצגת.</p></section>
<section class="panel"><h2>אזהרות</h2><ul>{warnings}</ul></section></main></body></html>'''


@app.route("/admin/presentations")
def admin_presentation_control_center():
    return render_presentation_control_center()


@app.route("/admin/presentations/report.json")
def admin_presentation_control_report_json():
    target = request.args.get("target", "usa_phone")
    if target not in PRESENTATION_CONTROL_TARGETS:
        target = "usa_phone"
    return Response(json.dumps(build_presentation_control_report(target), ensure_ascii=False, indent=2), content_type="application/json; charset=utf-8")

@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)}
.control{background:linear-gradient(135deg,#7c3aed,#2563eb)}
.control{background:linear-gradient(135deg,#7c3aed,#2563eb,#0891b2)}
.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-current{display:block;margin-top:10px;color:#93c5fd;font-size:12.5px;font-weight:900}.upload-status{display:block;margin-top:8px;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 usa" href="/admin#trips"><div class="icon">🧳</div><h2>טיולים</h2><p>יפן קיץ 26, ארצות הברית קיץ 27 וכל טיול עתידי במקום אחד.</p></a>
<a class="card control" href="/admin/presentations"><div class="icon">🎛️</div><h2>בקרת מצגות</h2><p>זמן לכל תמונה, סרטונים, קבצים חסרים, אופליין ובדיקת תקינות חיה.</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 local_no_window_flags():
    return hidden_subprocess_flags()


def local_existing_dir(path):
    try:
        return bool(path and os.path.isdir(path))
    except Exception:
        return False


def local_norm_path(path):
    return os.path.abspath(os.path.normpath(os.path.expandvars(os.path.expanduser(str(path or "")))))


def local_add_unique_path(items, label, path, key_hint, writable=True):
    if not local_existing_dir(path):
        return
    norm = local_norm_path(path)
    low = norm.lower()
    if any(item["path"].lower() == low for item in items):
        return
    items.append({"label": label, "path": norm, "key_hint": key_hint, "readonly": False, "writable": bool(writable)})


def discover_download_folders():
    items = []
    bases = []
    for env in ("USERPROFILE", "HOME"):
        val = os.environ.get(env)
        if val:
            bases.append(val)
    hd = os.environ.get("HOMEDRIVE")
    hp = os.environ.get("HOMEPATH")
    if hd and hp:
        bases.append(hd + hp)
    for env in ("OneDrive", "OneDriveConsumer", "OneDriveCommercial"):
        val = os.environ.get(env)
        if val:
            bases.append(val)
    if os.name == "nt":
        # Scan Windows user profiles. This catches cases where the service runs under another user.
        users_root = r"C:\Users"
        try:
            for name in os.listdir(users_root):
                if name.lower() in {"public", "default", "default user", "all users"}:
                    continue
                p = os.path.join(users_root, name)
                if os.path.isdir(p):
                    bases.append(p)
                    bases.append(os.path.join(p, "OneDrive"))
                    bases.append(os.path.join(p, "OneDrive - Personal"))
        except Exception:
            pass
    checked = set()
    for base in bases:
        if not base:
            continue
        base = local_norm_path(base)
        if base.lower() in checked:
            continue
        checked.add(base.lower())
        for sub in ("Downloads", "הורדות"):
            local_add_unique_path(items, "הורדות במחשב", os.path.join(base, sub), "downloads")
        # Some OneDrive setups store the localized display name but keep physical folder as Downloads.
        local_add_unique_path(items, "הורדות OneDrive", os.path.join(base, "Downloads"), "downloads")

    if os.name == "nt":
        # v34.17: also detect Downloads folders placed directly on data drives,
        # for example D:\הורדות as seen on the user's computer. This is shallow
        # and safe: it only adds existing folders named Downloads / הורדות.
        for letter in "CDEFGHIJKLMNOPQRSTUVWXYZ":
            drive = f"{letter}:\\"
            for sub in ("הורדות", "Downloads"):
                candidate = os.path.join(drive, sub)
                local_add_unique_path(items, f"הורדות בכונן {letter}:", candidate, "downloads")
            try:
                for name in os.listdir(drive):
                    clean = str(name or "").strip().lower()
                    if clean in {"downloads", "הורדות"}:
                        local_add_unique_path(items, f"הורדות בכונן {letter}:", os.path.join(drive, name), "downloads")
            except Exception:
                pass
    return items


def tripserver_score(path):
    score = 0
    try:
        names = set(os.listdir(path))
    except Exception:
        return 0
    for name in ("admin_server.py", "control_server", "JAPAN", "USA", "Moneyapp", "MoneyHome", "TripForm"):
        if name in names:
            score += 1
    return score


def discover_tripserver_folders():
    items = []
    candidates = []
    # The running admin script location is the strongest signal.
    try:
        candidates.append(os.path.dirname(os.path.abspath(__file__)))
    except Exception:
        pass
    candidates.append(BASE_DIR)
    candidates.append(os.getcwd())
    # Common locations on the server computer.
    for env in ("USERPROFILE", "HOME", "OneDrive", "OneDriveConsumer", "OneDriveCommercial"):
        base = os.environ.get(env)
        if base:
            candidates.extend([
                os.path.join(base, "TripServer"),
                os.path.join(base, "Desktop", "TripServer"),
                os.path.join(base, "Documents", "TripServer"),
                os.path.join(base, "Downloads", "TripServer"),
            ])
    if os.name == "nt":
        for drive in ("C:\\", "D:\\", "E:\\"):
            candidates.append(os.path.join(drive, "TripServer"))
            try:
                # Shallow scan only; avoids hanging the server while still finding typical placements.
                for name in os.listdir(drive):
                    if "tripserver" in name.lower() or name.lower() in {"server", "trip", "trips"}:
                        candidates.append(os.path.join(drive, name))
            except Exception:
                pass
        try:
            for user in os.listdir(r"C:\Users"):
                up = os.path.join(r"C:\Users", user)
                if os.path.isdir(up):
                    candidates.extend([
                        os.path.join(up, "Desktop", "TripServer"),
                        os.path.join(up, "Documents", "TripServer"),
                        os.path.join(up, "Downloads", "TripServer"),
                    ])
        except Exception:
            pass
    # Add parent folders around the running script if they look like TripServer.
    try:
        cur = os.path.dirname(os.path.abspath(__file__))
        for _ in range(3):
            candidates.append(cur)
            cur = os.path.dirname(cur)
    except Exception:
        pass
    seen = set()
    scored = []
    for c in candidates:
        if not c:
            continue
        n = local_norm_path(c)
        low = n.lower()
        if low in seen or not local_existing_dir(n):
            continue
        seen.add(low)
        score = tripserver_score(n)
        if score >= 1 or os.path.basename(n).lower() == "tripserver":
            scored.append((score, n))
    scored.sort(key=lambda x: (-x[0], len(x[1])))
    for idx, (score, path) in enumerate(scored[:8], start=1):
        label = "TripServer" if idx == 1 else f"TripServer נוסף {idx}"
        local_add_unique_path(items, label, path, "tripserver")
    return items


def build_local_file_roots(force=False):
    global LOCAL_FILE_ROOTS_CACHE, LOCAL_FILE_ROOTS_CACHE_TS
    now = time.time()
    if (not force) and LOCAL_FILE_ROOTS_CACHE and (now - LOCAL_FILE_ROOTS_CACHE_TS < 60):
        return LOCAL_FILE_ROOTS_CACHE
    roots = {}
    all_items = []
    all_items.extend(discover_download_folders())
    all_items.extend(discover_tripserver_folders())
    if not all_items:
        # Safe fallback: expose only the folder where the admin is running.
        try:
            all_items.append({"label": "תיקיית האדמין", "path": os.path.dirname(os.path.abspath(__file__)), "key_hint": "tripserver", "readonly": False, "writable": True})
        except Exception:
            pass
    counts = {"downloads": 0, "tripserver": 0, "root": 0}
    for item in all_items:
        hint = item.get("key_hint") or "root"
        counts[hint] = counts.get(hint, 0) + 1
        key = hint if counts[hint] == 1 else f"{hint}_{counts[hint]}"
        roots[key] = {"label": item["label"], "path": item["path"], "readonly": False, "writable": True}
    LOCAL_FILE_ROOTS_CACHE = roots
    LOCAL_FILE_ROOTS_CACHE_TS = now
    return roots


def normalize_local_root_key(root_key):
    roots = build_local_file_roots()
    root_key = (root_key or "").strip().lower()
    if root_key in roots:
        return root_key
    if "downloads" in roots:
        return "downloads"
    if "tripserver" in roots:
        return "tripserver"
    return next(iter(roots.keys())) if roots else "downloads"


def local_root_path(root_key):
    roots = build_local_file_roots()
    root_key = normalize_local_root_key(root_key)
    if root_key not in roots:
        raise ValueError("לא נמצאה תיקיית בסיס מאושרת.")
    return os.path.abspath(os.path.normpath(roots[root_key]["path"]))


def safe_local_path(root_key, rel_path=""):
    root = local_root_path(root_key)
    rel_path = (rel_path or "").replace("\\", os.sep).replace("/", os.sep).strip()
    if rel_path and (os.path.isabs(rel_path) or re.match(r"^[A-Za-z]:", rel_path)):
        raise ValueError("נתיב מוחלט נחסם. ניתן לגלוש רק בתוך התיקיות המאושרות.")
    target = os.path.abspath(os.path.normpath(os.path.join(root, rel_path)))
    try:
        common = os.path.commonpath([root, target])
    except Exception:
        raise ValueError("נתיב לא תקין.")
    if common != root:
        raise ValueError("ניסיון יציאה מתיקיית הבסיס נחסם.")
    return target


def rel_for_local(root_key, full_path):
    root = local_root_path(root_key)
    try:
        rel = os.path.relpath(full_path, root)
        return "" if rel == "." else rel.replace("\\", "/")
    except Exception:
        return ""


def local_file_kind(path):
    if os.path.isdir(path):
        return "dir"
    ext = os.path.splitext(path)[1].lower()
    if ext in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}:
        return "image"
    if ext in LOCAL_FILE_TEXT_EXTENSIONS:
        return "text"
    return "file"


def local_file_icon(kind):
    return {"dir": "📁", "image": "🖼️", "text": "📄", "file": "📦"}.get(kind, "📦")



def local_safe_name(name):
    name = os.path.basename((name or "").replace("\\", "/")).strip()
    name = re.sub(r"[\x00-\x1f<>:\"/\\|?*]", "_", name)
    name = name.strip(" .")
    if name in {"", ".", ".."}:
        raise ValueError("שם קובץ/תיקייה לא תקין.")
    return name

def local_unique_destination(path):
    if not os.path.exists(path):
        return path
    folder = os.path.dirname(path)
    base, ext = os.path.splitext(os.path.basename(path))
    for i in range(1, 1000):
        candidate = os.path.join(folder, f"{base}_copy{i}{ext}")
        if not os.path.exists(candidate):
            return candidate
    raise ValueError("לא ניתן ליצור שם יעד ייחודי.")


def local_copy_item(src, dest_dir):
    target = local_unique_destination(os.path.join(dest_dir, os.path.basename(src)))
    if os.path.isdir(src):
        shutil.copytree(src, target)
    else:
        shutil.copy2(src, target)
    return target


def local_move_item(src, dest_dir):
    target = local_unique_destination(os.path.join(dest_dir, os.path.basename(src)))
    shutil.move(src, target)
    return target


def add_path_to_zip(zf, root_key, full_path, base_arc=""):
    if os.path.isfile(full_path):
        arc = base_arc or rel_for_local(root_key, full_path)
        zf.write(full_path, arc)
        return os.path.getsize(full_path), 1
    total = 0
    count = 0
    for dirpath, dirnames, filenames in os.walk(full_path):
        dirnames[:] = [d for d in dirnames if d not in {"$RECYCLE.BIN", "System Volume Information", "__pycache__"}]
        for filename in filenames:
            p = os.path.join(dirpath, filename)
            try:
                size = os.path.getsize(p)
                rel = rel_for_local(root_key, p)
                zf.write(p, rel)
                total += size
                count += 1
            except Exception:
                continue
    return total, count


def local_file_href(endpoint="/admin/local-files", root_key=None, rel_path=None, **extra):
    """Build HTML-safe admin local-file URLs with proper percent-encoding.
    This is required for Hebrew names, spaces and characters like & in filenames.
    """
    params = {}
    if root_key is not None:
        params["root"] = str(root_key)
    if rel_path is not None and str(rel_path) != "":
        params["path"] = str(rel_path).replace("\\", "/")
    for k, v in extra.items():
        if v is not None:
            params[k] = str(v)
    url = endpoint
    if params:
        url += "?" + urllib.parse.urlencode(params, doseq=True)
    return html_lib.escape(url, quote=True)


def render_admin_file_manager(root_key="downloads", rel_path=""):
    root_key = normalize_local_root_key(root_key)
    roots = build_local_file_roots()
    try:
        current = safe_local_path(root_key, rel_path)
    except Exception as exc:
        return admin_status_page("נתיב נחסם", "מנהל הקבצים לא פתח את הנתיב המבוקש.", ok=False, details=str(exc)), 400

    if not os.path.exists(current):
        return admin_status_page("תיקייה לא קיימת", "התיקייה המבוקשת לא קיימת במחשב השרת.", ok=False, details=current), 404
    if not os.path.isdir(current):
        return redirect(html_lib.unescape(local_file_href("/admin/local-files/preview", root_key, rel_for_local(root_key, current))))

    rel_path = rel_for_local(root_key, current)
    breadcrumbs = []
    if rel_path and rel_path != ".":
        parts = [p for p in rel_path.split("/") if p and p != "."]
        acc = []
        breadcrumbs.append(f'<a href="{local_file_href("/admin/local-files", root_key)}">שורש</a>')
        for part in parts:
            acc.append(part)
            breadcrumbs.append(f'<a href="{local_file_href("/admin/local-files", root_key, "/".join(acc))}">{html_lib.escape(part)}</a>')
    else:
        breadcrumbs.append("שורש")

    rows = []
    parent_link = ""
    if rel_path and rel_path not in (".", ""):
        parent = os.path.dirname(rel_path.replace("/", os.sep)).replace("\\", "/")
        parent_link = f'<tr><td></td><td><a class="file-name" href="{local_file_href("/admin/local-files", root_key, parent)}">⬆️ תיקייה למעלה</a></td><td>תיקייה</td><td></td><td></td><td></td></tr>'
    try:
        entries = []
        for name in os.listdir(current):
            if name in {"$RECYCLE.BIN", "System Volume Information"}:
                continue
            path = os.path.join(current, name)
            try:
                is_dir = os.path.isdir(path)
                st = os.stat(path)
                entries.append((not is_dir, name.lower(), name, path, st))
            except Exception:
                continue
        entries.sort()
        limited = entries[:LOCAL_FILE_LIST_LIMIT]
        for _not_dir, _sort, name, path, st in limited:
            rel = rel_for_local(root_key, path)
            kind = local_file_kind(path)
            icon = local_file_icon(kind)
            safe_name = html_lib.escape(name)
            safe_rel = html_lib.escape(rel, quote=True)
            modified = time.strftime("%Y-%m-%d %H:%M", time.localtime(st.st_mtime))
            size = "—" if os.path.isdir(path) else format_bytes(st.st_size)
            checkbox = f'<input type="checkbox" name="path" value="{safe_rel}">'
            if os.path.isdir(path):
                main = f'<a class="file-name" href="{local_file_href("/admin/local-files", root_key, rel)}">{icon} {safe_name}</a>'
                actions = '<span class="muted">פתח</span>'
            else:
                main = f'<span class="file-name">{icon} {safe_name}</span>'
                actions = f'<a href="{local_file_href("/admin/local-files/download", root_key, rel)}">הורד</a> '
                if kind == "text" and st.st_size <= LOCAL_FILE_PREVIEW_MAX_BYTES:
                    actions += f' · <a href="{local_file_href("/admin/local-files/preview", root_key, rel)}">תצוגה</a> · <a href="{local_file_href("/admin/local-files/edit", root_key, rel)}">עריכה</a>'
            rows.append(f'<tr><td class="select-cell">{checkbox}</td><td>{main}</td><td>{html_lib.escape(kind)}</td><td>{size}</td><td>{modified}</td><td>{actions}</td></tr>')
        if len(entries) > LOCAL_FILE_LIST_LIMIT:
            rows.append(f'<tr><td></td><td colspan="5" class="muted">מוצגים {LOCAL_FILE_LIST_LIMIT} פריטים ראשונים מתוך {len(entries)}. השתמש בתיקיות משנה כדי לצמצם.</td></tr>')
    except Exception as exc:
        rows.append(f'<tr><td></td><td colspan="5">שגיאה בקריאת תיקייה: {html_lib.escape(repr(exc))}</td></tr>')

    root_buttons = []
    for key, info in roots.items():
        cls = "active-root" if key == root_key else ""
        root_buttons.append(f'<a class="root-btn {cls}" href="{local_file_href("/admin/local-files", key)}" title="{html_lib.escape(info["path"], quote=True)}">{html_lib.escape(info["label"])}</a>')

    dest_options = []
    for key, info in roots.items():
        selected = " selected" if key == root_key else ""
        dest_options.append(f'<option value="{html_lib.escape(key, quote=True)}"{selected}>{html_lib.escape(info["label"])} — {html_lib.escape(info["path"])}</option>')

    body_rows = parent_link + "\n".join(rows)
    safe_current = html_lib.escape(current)
    root_key_safe = html_lib.escape(root_key, quote=True)
    rel_path_safe = html_lib.escape(rel_path if rel_path != "." else "", quote=True)
    crumbs = " / ".join(breadcrumbs)
    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>
:root{{--bg:#07111f;--panel:#101c31;--line:#253650;--text:#f8fafc;--muted:#a8b3c7;--brand:#3b82f6;--danger:#dc2626;--ok:#16a34a}}
*{{box-sizing:border-box}}body{{margin:0;font-family:Arial,'Segoe UI',sans-serif;background:linear-gradient(180deg,#07111f,#0f172a);color:var(--text)}}.wrap{{width:min(1220px,94%);margin:auto;padding:22px 0 50px}}a{{color:#bfdbfe}}.hero,.box{{background:rgba(16,28,49,.92);border:1px solid var(--line);border-radius:24px;padding:18px;margin:14px 0;box-shadow:0 18px 50px rgba(0,0,0,.24)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:var(--muted);line-height:1.6}}.root-bar,.ops{{display:flex;gap:10px;flex-wrap:wrap;margin:14px 0}}.root-btn,.btn{{display:inline-flex;text-decoration:none;border:1px solid var(--line);background:#172033;border-radius:999px;padding:10px 13px;font-weight:900;color:#dbeafe}}.root-btn.active-root{{background:#2563eb;color:white;border-color:#60a5fa}}.path{{direction:ltr;text-align:left;background:#020617;border:1px solid #334155;border-radius:16px;padding:12px;color:#fde68a;overflow:auto}}table{{width:100%;border-collapse:collapse;margin-top:12px}}th,td{{border-bottom:1px solid #253650;padding:10px;text-align:right;vertical-align:middle}}th{{color:#cbd5e1}}.file-name{{font-weight:900;text-decoration:none;color:#fff}}.muted{{color:var(--muted)}}.select-cell{{width:44px;text-align:center}}button,input,select,textarea{{font-family:inherit}}button{{border:0;background:#2563eb;color:white;padding:11px 14px;border-radius:12px;font-weight:950;cursor:pointer}}button.danger{{background:#dc2626}}button.ok{{background:#16a34a}}button:hover{{filter:brightness(1.08)}}input[type=text],input[type=file],select,textarea{{background:#020617;color:#fff;border:1px solid #334155;border-radius:12px;padding:10px;max-width:100%}}.note{{background:#0f172a;border:1px solid #334155;border-radius:16px;padding:12px;color:#cbd5e1;line-height:1.6}}.mini-form{{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0}}.danger-note{{border-color:#7f1d1d;background:rgba(127,29,29,.25)}}@media(max-width:760px){{table,thead,tbody,tr,td,th{{display:block}}thead{{display:none}}tr{{border:1px solid #253650;border-radius:18px;margin:10px 0;padding:8px}}td{{border:0;padding:7px}}.select-cell{{width:auto;text-align:right}}}}
</style></head><body><div class="wrap">
<div class="hero"><a class="btn" href="/admin">⬅ חזרה לפאנל</a><h1>📁 קבצי מחשב — גישה מלאה לתיקיות מאושרות</h1><p>הפאנל סורק את מחשב השרת ומציג תיקיות הורדות ו־TripServer שנמצאו בפועל. בתוך התיקיות האלה אפשר לקרוא, להוריד, להעלות, למחוק, ליצור תיקייה, לערוך טקסט, להעתיק ולהעביר קבצים.</p><div class="root-bar">{''.join(root_buttons)}<a class="root-btn" href="/admin/local-files?refresh=1">🔄 סרוק מחדש</a></div><div class="path">{safe_current}</div><p class="muted">{crumbs}</p></div>
<div class="note"><b>חשוב:</b> זה עדיין לא נותן ל־ChatGPT גישה ישירה למחשב שלך. אבל זה כן נותן לך דרך לנהל קבצים במחשב השרת מתוך הפאנל, ליצור ZIP ולהעלות אליי לצ׳אט כשצריך ניתוח.</div>
<div class="box">
<h2>פעולות בתיקייה הנוכחית</h2>
<form class="mini-form" action="/admin/local-files/upload" method="post" enctype="multipart/form-data"><input type="hidden" name="root" value="{root_key_safe}"><input type="hidden" name="path" value="{rel_path_safe}"><input type="file" name="files" multiple><button class="ok" type="submit">⬆ העלה לכאן</button></form>
<form class="mini-form" action="/admin/local-files/mkdir" method="post"><input type="hidden" name="root" value="{root_key_safe}"><input type="hidden" name="path" value="{rel_path_safe}"><input type="text" name="folder_name" placeholder="שם תיקייה חדשה"><button type="submit">📁 צור תיקייה</button></form>
</div>
<div class="box"><form action="/admin/local-files/bulk" method="post"><input type="hidden" name="root" value="{root_key_safe}"><input type="hidden" name="base_path" value="{rel_path_safe}">
<div class="ops"><button name="action" value="zip" type="submit">⬇ ZIP למסומנים</button><button class="danger" name="action" value="delete" type="submit" onclick="return confirm('למחוק את כל הפריטים המסומנים?')">🗑 מחק מסומנים</button><button name="action" value="copy" type="submit">📋 העתק מסומנים אל היעד</button><button name="action" value="move" type="submit" onclick="return confirm('להעביר את הפריטים המסומנים?')">📦 העבר מסומנים אל היעד</button><button name="action" value="rename" type="submit">✏️ שנה שם למסומן אחד</button><input type="text" name="new_name" placeholder="שם חדש למסומן אחד"><select name="dest_root">{''.join(dest_options)}</select><input type="text" name="dest_path" placeholder="נתיב יעד יחסי. ריק = שורש התיקייה שנבחרה"></div>
<table><thead><tr><th></th><th>שם</th><th>סוג</th><th>גודל</th><th>עודכן</th><th>פעולות</th></tr></thead><tbody>{body_rows}</tbody></table></form></div>
</div></body></html>
"""



def render_usa_image_upload_cards(target_variant="phone"):
    target_variant, target_info = usa_image_target_info(target_variant)
    safe_target = html_lib.escape(target_variant, quote=True)
    target_label = html_lib.escape(target_info["label"], quote=True)
    current_files = media_files_by_base(Path(usa_image_dir_for_target(target_variant)))
    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)
        input_id = html_lib.escape("media-file-" + target_variant + "-" + image_key, quote=True)
        current = current_files.get(image_key.lower())
        if current:
            if current.suffix.lower() in USA_VIDEO_EXTENSIONS:
                try:
                    duration = probe_video_duration_seconds(str(current))
                    current_text = f"קיים כעת: 🎬 {current.name} · {duration:.2f} שניות"
                except Exception:
                    current_text = f"קיים כעת: 🎬 {current.name} · משך לא זוהה"
            else:
                current_text = f"קיים כעת: 🖼️ {current.name}"
        else:
            current_text = "לא נמצא כרגע קובץ מדיה לשקף"
        safe_current = html_lib.escape(current_text, quote=True)
        cards.append("""
<form class="image-upload-card" data-search="%s" action="/upload_usa_media" method="post" enctype="multipart/form-data">
<input type="hidden" name="target" value="%s">
<input type="hidden" name="image_key" value="%s">
<input class="image-file-input" id="%s" type="file" name="file" accept=".jpg,.jpeg,.png,.webp,.bmp,.tif,.tiff,.mp4,.m4v,image/*,video/mp4" onchange="if(this.files && this.files.length){this.closest('form').querySelector('.upload-status').textContent='מעלה ומאמת מדיה ל־%s...';this.closest('form').submit();}">
<label for="%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-current">%s</span>
<span class="upload-status">לחץ ובחר תמונה או סרטון MP4 עבור %s</span>
</label>
</form>
""" % (search_text, safe_target, safe_key, input_id, target_label, input_id, safe_key, safe_title, safe_desc, safe_current, target_label))
    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);pointer-events:none}.hero>*{position:relative;z-index:1}.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;position:relative;z-index:2}.hero-actions form{margin:0}.hero-actions button{margin:0;border-radius:999px;padding:11px 15px;box-shadow:none}.btn-link,.back-home{position:relative;z-index:3;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:disabled{opacity:.55;cursor:not-allowed;filter:none;box-shadow:none}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">
<form action="/power/cancel" method="post" onsubmit="return confirm('לבטל ריסטארט Fail Safe / כיבוי או ריסטארט מתוזמן של Windows?')"><button class="orange">בטל ריסטארט Fail Safe</button></form>
<a class="btn-link" href="/admin/status">📊 סטטוס קבצים</a>
<a class="btn-link" href="/admin/platform">🛡️ מרכז בקרת איכות</a>
<a class="btn-link" href="/admin/health">🩺 בדיקת מערכת</a>
<a class="btn-link" href="/admin/self-test">🧪 בדיקה עצמית מלאה</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="upload-box" style="margin:14px 0;border:2px solid #2563eb"><h3>📦 עדכון גורף — ZIP אחד</h3><p>העלה חבילת עדכון אחת. האדמין בודק Manifest, מזהה כל רכיב לפי מזהה קשיח, מציג תוכנית התקנה, מגבה ומעדכן את כולם יחד.</p><div class="preview-links"><a href="/admin/update-package">פתח מתקין עדכונים גורף</a><a href="/admin/usa-media-alignment">בדוק וסנכרן מדיית USA ↔ TV</a></div></div>
<div class="status-strip">
<div class="status-card"><b>📁 העלאה בטוחה</b><span>שמירה זמנית, גיבוי, החלפה אטומית, גודל ו־SHA אחרי שמירה.</span></div>
""" + render_failsafe_status_card() + """
<div class="status-card"><b>🧭 מבנה היררכי</b><span>מסך ראשי → תחום → תת־תחום → פעולה.</span></div>
<div class="status-card"><b>🖥️ מחשב</b><span>כיבוי/ריסטארט עם טיימר, אישור כתוב וביטול.</span></div>
<div class="status-card"><b>📜 לוגים</b><span>צפייה בלוגי האדמין, עדכונים ופעולות מערכת מתוך הפאנל.</span></div>
</div>
<div class="main-grid">
<button class="main-card" onclick="showScreen('trips')"><div class="icon">🧳</div><h2>טיולים</h2><p>כל הטיולים במקום אחד: יפן קיץ 26, ארצות הברית קיץ 27, ובהמשך כל יעד חדש.</p></button>
<a class="main-card" href="/admin/usa-music-segments" style="text-decoration:none;color:inherit"><div class="icon">🎵</div><h2>מקטעי מוזיקה</h2><p>הוספת שירים, שיוך שקפים וחישוב זמן לכל תמונה בכל שיר.</p></a>
<button class="main-card" onclick="showScreen('netlify')"><div class="icon">🌐</div><h2>Netlify Builder</h2><p>בניית חבילת Deploy מסונכרנת מהמקור הפעיל: טיול, מצגות, תמונות, סרטונים, שירים ו־Manifest.</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>
<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>צפייה בלוגי האדמין, עדכונים ופעולות מערכת מתוך הדפדפן.</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>
<a class="main-card" href="/admin/local-files" style="text-decoration:none"><div class="icon">📁</div><h2>קבצי מחשב</h2><p>גישה מלאה לתיקיות הורדות ו־TripServer שהתגלו במחשב: קריאה, העלאה, מחיקה, העתקה, העברה ו־ZIP.</p></a>
<a class="main-card" href="/admin/live-usa-audit" style="text-decoration:none;border-color:rgba(34,197,94,.55)"><div class="icon">🔍</div><h2>ביקורת מערכת USA</h2><p>יצירת חבילת מקור אמת חיה: טיולים, מצגות, מדיה, אדמין פעיל ו־Netlify שנבנה דרך המנגנון עצמו.</p></a>
</div>
</div>

<div id="screen-netlify" class="screen">
<div class="section-head"><div class="section-title"><h2>🌐 Netlify</h2><p>בחר טיול, והפאנל יבנה ZIP מוכן להעלאה ל־Netlify מתוך הקבצים העדכניים שנמצאים כעת בתיקיות הטיול בשרת.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button></div></div>
<div class="sub-grid">
<div class="sub-card" onclick="window.location.href='/admin/netlify/builder'"><h3>🧭 בחר טיול ובנה ZIP עדכני</h3><p>המערכת סורקת את תיקיות הטיולים הפעילות, שואלת עבור איזה טיול ליצור חבילה, ומרכיבה אותה מחדש מקובץ הטיול, המצגות, התמונות, הסרטונים והשירים העדכניים באותו רגע.</p></div>
<div class="sub-card" onclick="window.location.href='/admin/netlify/download/USA'"><h3>🇺🇸 בנייה מהירה — ארצות הברית 27</h3><p>בונה מחדש חבילת iPhone/מחשב מהמקור הפעיל. רק נתיבי הפריסה וקישור אפליקציית התקציב משתנים. שקף התקציב זהה וללא סכומים בכל הגרסאות.</p></div>
<div class="sub-card" onclick="window.location.href='/admin/netlify/download/JAPAN'"><h3>🇯🇵 בנייה מהירה — יפן 26</h3><p>בונה מחדש חבילה מלאה מתוך קובצי יפן הפעילים, כולל המצגות והמדיה העדכניות.</p></div>
</div>
<div class="mini-note" style="margin-top:14px"><b>Netlify הוא תוצר Build ולא מקור אמת:</b> אין צורך לתחזק ידנית קובץ Netlify נפרד. בכל לחיצה החבילה נוצרת מחדש מהגרסה הפעילה בשרת, נבדקת, ומקבלת build-manifest.json עם חתימות.</div>
</div>

<div id="screen-trips" 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="sub-grid">
<div class="sub-card" onclick="showScreen('japan')"><h3>🇯🇵 יפן קיץ 26</h3><p>קובץ טיול, מצגות, תמונות ומוזיקה של טיול יפן 2026.</p></div>
<div class="sub-card" onclick="showScreen('usa')"><h3>🇺🇸 ארצות הברית קיץ 27</h3><p>קובץ טיול, מצגת, TV, מדיה לשקפים ומוזיקה של טיול ארצות הברית 2027.</p></div>
</div>
<div class="mini-note" style="margin-top:14px">פורמט עבודה להמשך: כל טיול עתידי יופיע כאן ככרטיס חדש עם שם יעד + עונה/שנה, ואז יוביל למסך משנה באותו מבנה של קובץ טיול, מצגות, מדיה ובדיקות.</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('trips')">⬅ חזרה לרשימת הטיולים</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/','_blank')"><h3>🔎 בדיקה מהירה — טיול</h3><p>פתיחת קובץ הטיול יפן בחלון חדש.</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="preview-links"><a href="/JAPAN/" target="_blank">פתח קובץ טיול יפן</a><a href="/JAPAN/japan-flight-intro.html" target="_blank">פתח פתיח טיסה</a><a href="/JAPAN/japan-experience.html" target="_blank">פתח מצגת יפן</a></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 safe-zone"><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" required><button>בדוק שיר והמשך להטמעה</button></form><p><b>פעולה בטוחה:</b> השיר נשמר זמנית, ffprobe מזהה את האורך, ורק אם ממוצע השקף בטווח 5–10 שניות הוא מוטמע ומסונכרן אוטומטית. אם הוא קצר מדי או ארוך מדי, יוצג מסך אישור לפני שמירת השיר בנתיבי המצגת ולפני שינוי japan-experience.html.</p><div class="preview-links"><a href="/admin/presentation-music" target="_blank">סטטוס סנכרון מוזיקה</a><a href="/JAPAN/japan-experience.html" target="_blank">פתח מצגת יפן</a></div></div><div class="upload-box safe-zone"><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" required><button>העלה מוזיקת פתיח טיסה</button></form><p>פתיח הטיסה הוא מקטע נפרד. השיר נשמר ונמדד, אבל לא משנה את japan-experience.html.</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('trips')">⬅ חזרה לרשימת הטיולים</button><a class="btn-link" href="/USA/" target="_blank">פתח קובץ טיול ארה״ב</a><a class="btn-link" href="/USA_TV/usa2027-tv.html" target="_blank">פתח קובץ טיול TV</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 class="sub-card" onclick="window.open('/USA/','_blank')"><h3>🔎 בדיקה מהירה — טיול פלאפון/מחשב</h3><p>פתיחת קובץ הטיול הראשי של ארה״ב.</p></div>
<div class="sub-card" onclick="window.open('/USA_TV/usa2027-tv.html','_blank')"><h3>📺 בדיקה מהירה — טיול TV</h3><p>פתיחת קובץ הטיול של ארה״ב למסך טלוויזיה.</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="preview-links"><a href="/admin/platform/release-matrix" target="_blank">🔄 מטריצת סנכרון ארה״ב</a><a href="/USA/" target="_blank">פתח קובץ טיול ארה״ב — פלאפון/מחשב</a><a href="/USA_TV/usa2027-tv.html" target="_blank">פתח קובץ טיול ארה״ב — TV</a><a href="/USA/usa2027-presentation.html" target="_blank">פתח מצגת ארה״ב</a><a href="/USA_TV/usa2027-presentation-tv.html" target="_blank">פתח מצגת TV</a></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_TV/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>מדיית USA היא מקור האמת היחיד. כל העלאה נשמרת ב־USA ומסונכרנת אוטומטית, באותם שמות ובאותם נתיבים פנימיים, גם ל־USA_TV.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div>
<div class="good"><b>מקור אמת יחיד:</b> המדיה הפעילה נמצאת תחת <code>C:/TripServer/USA/media/usa-presentation</code>. האדמין משכפל ומאמת אותה אוטומטית תחת <code>C:/TripServer/USA_TV/media/usa-presentation</code>, תוך שמירת נתיבי TV הקיימים.</div>
<div class="sub-grid image-target-choice"><div class="sub-card" onclick="selectUsaImageTarget('phone')"><h3>📱 מסך פלאפון</h3><p>תמונות מותאמות למצגת אייפון/אנדרואיד. יעד: usa2027-presentation.html.</p></div><div class="sub-card" onclick="selectUsaImageTarget('tv')"><h3>📺 מסך טלוויזיה</h3><p>תמונות מותאמות למצגת TV / LG C3. יעד: usa2027-presentation-tv.html.</p></div></div>
<div id="usa-images-phone-section" class="usa-image-target-panel">
<div class="tool-head compact"><div><h3>📱 מדיה לשקפים — מסך פלאפון</h3><p>העלה ZIP מלא או קובץ תמונה/סרטון בודד. הקבצים יישמרו רק בתיקיית הפלאפון.</p></div></div>
<div class="form-grid"><div class="upload-box"><h4>ZIP מדיה מלא — פלאפון</h4><form action="/upload_usa_media_zip" method="post" enctype="multipart/form-data"><input type="hidden" name="target" value="phone"><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. ניתן לשלב JPG/PNG/WEBP עם MP4/M4V באותו ZIP.</p></div><div class="upload-box"><h4>בדיקת מצגת פלאפון</h4><p>אחרי העלאה פתח את המצגת הרגילה ובדוק שאין cache ישן.</p><div class="preview-links"><a href="/USA/usa2027-presentation.html?from=admin-phone-images" target="_blank">פתח מצגת פלאפון</a><a href="/admin/status" target="_blank">סטטוס מדיה</a></div></div></div>
<div class="filter-bar"><input id="imageSearchPhone" type="text" placeholder="חיפוש שקף לפלאפון: יום 18, Disney Springs, קרוז..." oninput="filterImages('phone')"><div class="jump-days"><button onclick="jumpImage('phone','intro')">פתיחה</button><button onclick="jumpImage('phone','day-01')">יום 1</button><button onclick="jumpImage('phone','day-18')">יום 18</button><button onclick="jumpImage('phone','day-28')">קרוז</button><button onclick="jumpImage('phone','budget')">תקציב</button><button onclick="jumpImage('phone','summary')">סיום</button></div></div>
<div class="image-upload-grid" id="usaImageGridPhone">
""" + render_usa_image_upload_cards("phone") + """
</div>
</div>
<div id="usa-images-tv-section" class="usa-image-target-panel">
<div class="tool-head compact"><div><h3>📺 מדיה לשקפים — מסך טלוויזיה</h3><p>תאימות לאחור בלבד: גם העלאה מכאן תנותב למקור USA ותסונכרן אוטומטית ל־USA_TV. אין יותר מדיית TV עצמאית.</p></div></div>
<div class="form-grid"><div class="upload-box"><h4>ZIP מדיה מלא — טלוויזיה</h4><form action="/upload_usa_media_zip" method="post" enctype="multipart/form-data"><input type="hidden" name="target" value="tv"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP תמונות/סרטונים לטלוויזיה</button></form><p>ניתן לכלול תמונות וסרטוני MP4/M4V בשמות השקפים. השמירה מתבצעת ל־C:/TripServer/USA_TV/media/usa-presentation/images.</p></div><div class="upload-box"><h4>בדיקת מצגת TV</h4><p>אחרי העלאה פתח את מצגת הטלוויזיה ובדוק שאין cache ישן.</p><div class="preview-links"><a href="/USA_TV/usa2027-presentation-tv.html?from=admin-tv-images" target="_blank">פתח מצגת TV</a><a href="/admin/status" target="_blank">סטטוס מדיה</a></div></div></div>
<div class="filter-bar"><input id="imageSearchTv" type="text" placeholder="חיפוש שקף לטלוויזיה: יום 18, Disney Springs, קרוז..." oninput="filterImages('tv')"><div class="jump-days"><button onclick="jumpImage('tv','intro')">פתיחה</button><button onclick="jumpImage('tv','day-01')">יום 1</button><button onclick="jumpImage('tv','day-18')">יום 18</button><button onclick="jumpImage('tv','day-28')">קרוז</button><button onclick="jumpImage('tv','budget')">תקציב</button><button onclick="jumpImage('tv','summary')">סיום</button></div></div>
<div class="image-upload-grid" id="usaImageGridTv">
""" + render_usa_image_upload_cards("tv") + """
</div>
</div>
</div>
<div id="tool-usa-music" class="tool-panel">
<div class="tool-head"><div><h3>🎵 מוזיקת מצגת ארה״ב</h3><p>העלאת שיר קיימת + מדידת אורך + סנכרון המקטע הרלוונטי במצגת הרגילה ובמצגת TV.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div>
<div class="good"><b>כלל עבודה פעיל:</b> למצגת ארה״ב יש שני שירים. שיר חלק 1 מסנכרן רק שקפים 1–23. שיר חלק 2 מסנכרן רק שקפים 24–44. לא משנים את המקטע השני כשמעלים את הראשון, ולהפך.</div>
<div class="form-grid"><div class="upload-box safe-zone"><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" required><button>העלה שיר חלק 1 וסנכרן שקפים 1–23</button></form><p>הקובץ נשמר כ־media/usa-presentation/music/usa-roadtrip-theme.mp3 גם ב־USA וגם ב־USA_TV, ואז PART_1_SECONDS מתעדכן בשתי המצגות.</p></div><div class="upload-box safe-zone"><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" required><button>העלה שיר חלק 2 וסנכרן שקפים 24–44</button></form><p>הקובץ נשמר כ־media/usa-presentation/music/usa-part2-theme.mp3 גם ב־USA וגם ב־USA_TV, ואז PART_2_SECONDS מתעדכן בשתי המצגות.</p></div></div><div class="preview-links"><a href="/admin/presentation-music" target="_blank">סטטוס סנכרון מוזיקה</a><a href="/USA/usa2027-presentation.html" target="_blank">פתח מצגת ארה״ב</a><a href="/USA_TV/usa2027-presentation-tv.html" target="_blank">פתח מצגת TV</a></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 id="budgetAppInstallForm" action="/upload/Moneyapp" method="post" enctype="multipart/form-data" onsubmit="return submitBudgetAppInstall(event)"><input id="budgetAppInstallFile" type="file" name="file" accept=".html,.htm,text/html" required><button id="budgetAppInstallButton" type="submit">התקן ואמת אפליקציית תקציב חופשה</button></form><div id="budgetAppInstallClientStatus" class="mini-note" style="display:none;margin-top:10px"></div><p>הקובץ ייבדק לפני החלפה ויישמר כ־Moneyapp/index.html. האדמין מאמת חוזה זהות קבוע ויכולות נדרשות, ולכן גרסאות עתידיות תקינות מתקבלות בלי לשנות מספר גרסה קשיח באדמין.</p><div class="preview-links"><a href="/admin/budget-app/status">בדוק גרסה מותקנת</a><a href="/Moneyapp/?fresh=1" target="_blank">פתח תקציב חופשה</a></div></div><div class="upload-box"><h4>🏠 אפליקציית תקציב חודשי / משפחתי</h4><form action="/upload/MoneyHome" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה תקציב חודשי</button></form><p>יישמר כ־MoneyHome/index.html.</p><div class="preview-links"><a href="/MoneyHome/" target="_blank">פתח תקציב חודשי</a></div></div></div>
</div>

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

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

<div id="screen-admin-tools" class="screen"><div class="good"><b>חדש:</b> לעדכוני מערכת מרובי קבצים השתמש ב־<a href="/admin/update-package">מתקין ZIP גורף</a>. העלאת admin ישירה נשארה למסלול חירום/תחזוקה בלבד.</div>
<div class="section-head"><div class="section-title"><h2>⚙️ ניהול אפליקציית האדמין</h2><p>כאן מעדכנים את הקובץ שמפעיל את הפאנל עצמו. נתמך קובץ Python ישיר, ומגרסה זו גם ZIP עדכון מבוקר.</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/logs">לוגים</a></div></div>
<div class="tool-panel active"><div class="warning"><strong>פעולה רגישה:</strong> עדכון admin_server.py מחליף את המנוע שמריץ את הפאנל. לפני שליחת העדכון מופעל Fail Safe: ריסטארט Windows מתוכנן לעוד 3 דקות, כדי שגם אם הפאנל ייתקע המחשב יחזור לבד.</div><div class="good">הזרימה: העלאה → בדיקת Python → בדיקת סימני TripServer → גיבוי → חימוש Fail Safe → שליחה לשרת הבקרה → החלפה ואתחול. אם הכול חזר תקין — אפשר ללחוץ על ביטול ריסטארט Fail Safe. אם הוא כבר בוטל אוטומטית, תופיע הודעה רגועה שאין ריסטארט פעיל.</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 חדש? לפני העדכון ייקבע ריסטארט Fail Safe לעוד 3 דקות. אם הכול יעבוד — לבטל מהמסך הראשי.')"><input type="file" name="file" accept=".py,.zip,text/x-python,text/plain,application/zip"><button class="orange">העלה admin חדש / ZIP עדכון עם Fail Safe</button></form><p>ניתן להעלות קובץ Python ישיר. החל מגרסה זו נתמך גם ZIP מבוקר הכולל admin_server.py בשורש החבילה. ZIP אינו מריץ סקריפטים חופשיים — בכוונה, כדי לא לסכן את השליטה מרחוק.</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><a href="/admin/control">🛰️ שרת בקרה</a></div><p><b>הערה:</b> בדיקת אתחול מיועדת רק לבדיקה יזומה או אחרי עדכון/שחזור אדמין.<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 selectUsaImageTarget(target){
  document.querySelectorAll('.usa-image-target-panel').forEach(el=>el.classList.remove('active'));
  const panel=document.getElementById(target==='tv'?'usa-images-tv-section':'usa-images-phone-section');
  if(panel){panel.classList.add('active'); setTimeout(()=>panel.scrollIntoView({behavior:'smooth',block:'start'}),60);}
}
function filterImages(target){
  const isTv=target==='tv';
  const inputId=isTv?'imageSearchTv':'imageSearchPhone';
  const gridId=isTv?'usaImageGridTv':'usaImageGridPhone';
  const q=(document.getElementById(inputId)?.value||'').toLowerCase().trim();
  document.querySelectorAll('#'+gridId+' .image-upload-card').forEach(card=>{card.style.display=!q||card.dataset.search.includes(q)?'block':'none';});
}
function jumpImage(target,key){
  const gridId=target==='tv'?'usaImageGridTv':'usaImageGridPhone';
  const card=document.querySelector('#'+gridId+' input[name="image_key"][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);}
}
async function submitBudgetAppInstall(event){
  event.preventDefault();
  const form=event.currentTarget;
  const fileInput=document.getElementById('budgetAppInstallFile');
  const button=document.getElementById('budgetAppInstallButton');
  const status=document.getElementById('budgetAppInstallClientStatus');
  if(!fileInput || !fileInput.files || !fileInput.files.length){
    status.style.display='block'; status.textContent='לא נבחר קובץ להתקנה.'; return false;
  }
  button.disabled=true;
  status.style.display='block';
  status.textContent='מעלה את הקובץ לשרת, מתקין ומאמת...';
  try{
    const body=new FormData();
    body.append('file', fileInput.files[0], fileInput.files[0].name);
    const response=await fetch('/upload/Moneyapp?client_ts='+Date.now(), {method:'POST', body, cache:'no-store', credentials:'same-origin', redirect:'follow'});
    const html=await response.text();
    if(!html){ throw new Error('השרת החזיר תשובה ריקה (HTTP '+response.status+').'); }
    document.open(); document.write(html); document.close();
  }catch(error){
    status.textContent='ההעלאה לא הגיעה למסך תוצאה: '+(error && error.message ? error.message : String(error));
    button.disabled=false;
  }
  return false;
}
window.addEventListener('load',()=>{const h=(location.hash||'').replace('#',''); if(['trips','japan','usa','budgets','tripform','power','admin-tools'].includes(h)) showScreen(h); if(h.startsWith('usa-')){showScreen('usa'); setTimeout(()=>showTool('usa',h),80);} if(h.startsWith('japan-')){showScreen('japan'); setTimeout(()=>showTool('japan',h),80);}});
</script>
</body>
</html>
"""



def render_probe_form(rule_key):
    return f"""
    <form action="/probe_presentation_music/{html_lib.escape(rule_key)}" method="post" enctype="multipart/form-data">
      <input type="file" name="file" accept="{html_lib.escape(MEDIA_AUDIO_ACCEPT)}" required>
      <button>בדוק אורך בלבד</button>
    </form>
    <span>בדיקה זמנית בלבד: הקובץ נמחק בסיום, אין שמירה ואין שינוי למצגת.</span>
    """


def render_presentation_music_panel():
    rows = []
    for key, rule in PRESENTATION_MUSIC_SYNC_RULES.items():
        targets = "<br>".join(html_lib.escape(str(x)) for x in (rule.get("music_targets") or []))
        rows.append(f"""
        <tr>
          <td><b>{html_lib.escape(rule.get('label',''))}</b><br><span>{html_lib.escape(key)}</span></td>
          <td>{html_lib.escape(rule.get('scope',''))}</td>
          <td>{html_lib.escape(rule.get('presentation',''))}</td>
          <td class='ltr'>{targets}</td>
          <td>{html_lib.escape(rule.get('future_behavior',''))}</td>
          <td><span class='badge safe'>העלאה פעילה דרך מסך הטיול</span><br><span>בדיקת אורך זמנית נשארה ככלי עזר בלבד:</span><br>{render_probe_form(key)}</td>
        </tr>
        """)
    ffprobe = find_ffprobe_executable() or "לא נמצא"
    ffprobe_badge = "safe" if find_ffprobe_executable() else "warn"
    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:linear-gradient(180deg,#07111f,#0f172a);color:white;font-family:Arial,'Segoe UI',sans-serif;padding:20px}}.wrap{{width:min(1100px,94vw);margin:auto}}.hero{{background:linear-gradient(135deg,#1e3a8a,#0f172a);border:1px solid #334155;border-radius:28px;padding:22px;margin-bottom:16px}}h1{{margin:0 0 8px;font-size:clamp(28px,5vw,42px)}}p{{color:#cbd5e1;line-height:1.65}}a{{display:inline-flex;text-decoration:none;color:white;border:1px solid #334155;background:#172033;border-radius:999px;padding:10px 14px;font-weight:900;margin:6px 0}}.note{{background:#0b1220;border:1px solid #334155;border-radius:18px;padding:14px;margin:14px 0;color:#dbeafe;line-height:1.7}}table{{width:100%;border-collapse:collapse;background:#101c31;border:1px solid #334155;border-radius:20px;overflow:hidden}}th,td{{padding:13px;border-bottom:1px solid #334155;text-align:right;vertical-align:top}}th{{background:#0b1220;color:#bfdbfe}}td span{{color:#94a3b8;font-size:12px}}.ltr{{direction:ltr;text-align:left;word-break:break-word}}.badge{{display:inline-flex;border-radius:999px;padding:6px 10px;font-weight:900;margin:4px 0}}.safe{{background:#14532d;color:#bbf7d0;border:1px solid #22c55e}}.warn{{background:#7c2d12;color:#fed7aa;border:1px solid #f97316}}input[type=file]{{max-width:260px;color:#dbeafe;margin:8px 0}}button{{border:0;border-radius:999px;padding:10px 14px;background:#2563eb;color:white;font-weight:900;cursor:pointer}}button:hover{{filter:brightness(1.1)}}@media(max-width:760px){{body{{padding:14px}}table,thead,tbody,tr,td,th{{display:block}}thead{{display:none}}tr{{border:1px solid #334155;border-radius:16px;margin:12px 0;padding:8px}}td,th{{border:0}}.ltr{{direction:ltr;text-align:right}}}}
</style></head><body><div class="wrap"><div class="hero"><a href="/admin#trips">⬅ חזרה לפאנל</a><h1>🎵 סנכרון מוזיקה למצגות — העלאה קיימת + סנכרון פעיל</h1><p>גרסה זו מתחברת להעלאות השירים הקיימות: יפן מצגת ראשית מודדת את השיר לפני הטמעה ומבקשת אישור אם קצב השקפים יוצא מטווח 5–10 שניות. ארה״ב חלק 1 מסנכרן רק שקפים 1–23, וחלק 2 מסנכרן רק שקפים 24–44. פתיח הטיסה נשמר ונמדד ללא שינוי למצגת הראשית.</p><span class="badge {ffprobe_badge}">ffprobe: {html_lib.escape(ffprobe)}</span></div><div class="note"><b>סדר הפיתוח:</b> v34.14 לא מוסיף מנגנון העלאה חדש. הוא משתמש בהעלאות הקיימות ומוסיף עליהן מדידה, מסך אישור לפני הטמעה כשצריך, וסנכרון בפועל רק לאחר בחירת המשתמש. טפסי בדיקת אורך זמניים נשארו כלי עזר בלבד ואינם מחליפים את כפתורי ההעלאה.</div><h2>סטטוס העלאות אחרונות</h2><table><thead><tr><th>שיר</th><th>סטטוס</th><th>זמן עדכון</th><th>פירוט</th></tr></thead><tbody>{render_music_upload_status_rows()}</tbody></table><h2>כללי סנכרון פעילים ובדיקת אורך זמנית</h2><table><thead><tr><th>שיר / יעד</th><th>תחום סנכרון</th><th>מצגת</th><th>נתיבי מוזיקה</th><th>התנהגות עתידית</th><th>בדיקה זמנית</th></tr></thead><tbody>{''.join(rows)}</tbody></table></div></body></html>
"""


@app.route("/admin/presentation-music")
def admin_presentation_music():
    return render_presentation_music_panel()


@app.route("/probe_presentation_music/<rule_key>", methods=["POST"])
def probe_presentation_music(rule_key):
    if rule_key not in PRESENTATION_MUSIC_SYNC_RULES:
        return admin_status_page("סוג שיר לא מוכר", "לא נמצא כלל סנכרון מתאים לשיר שנבחר.", ok=False), 404
    file = request.files.get("file")
    if not file or file.filename == "":
        return admin_status_page("לא נבחר קובץ", "בחר קובץ אודיו לבדיקה זמנית.", ok=False), 400
    if request.content_length and request.content_length > AUDIO_UPLOAD_MAX_BYTES + 1024 * 1024:
        return admin_status_page("קובץ מוזיקה גדול מדי", f"קובץ האודיו גדול מהמגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}.", ok=False), 413
    probe_dir = os.path.join(BASE_DIR, "tmp", "music_probe")
    os.makedirs(probe_dir, exist_ok=True)
    safe_name = secure_filename(file.filename or "audio") or "audio"
    target = os.path.join(probe_dir, time.strftime("probe_%Y%m%d_%H%M%S_") + safe_name)
    try:
        file.save(target)
        result = probe_audio_duration_seconds(target)
        rule = PRESENTATION_MUSIC_SYNC_RULES.get(rule_key, {})
        if result.get("ok"):
            details = (
                f"שיר/מקטע: {rule.get('label','')}\n"
                f"תחום סנכרון עתידי: {rule.get('scope','')}\n"
                f"משך שזוהה: {result.get('duration_display')} ({result.get('duration_seconds'):.3f} שניות)\n"
                f"ffprobe: {result.get('ffprobe')}\n\n"
                "בדיקה זמנית בלבד: הקובץ לא נשמר קבוע, ולא בוצע שינוי במצגת."
            )
            return admin_status_page("בדיקת אורך שיר הצליחה", "ffprobe זיהה את משך השיר. לא בוצע סנכרון ולא נשמר שיר קבוע.", ok=True, details=details)
        details = (
            f"שיר/מקטע: {rule.get('label','')}\n"
            f"ffprobe: {result.get('ffprobe') or 'לא נמצא'}\n"
            f"שגיאה: {result.get('error') or result.get('raw') or 'לא ידוע'}\n\n"
            "לא בוצע סנכרון ולא נשמר שיר קבוע."
        )
        return admin_status_page("בדיקת אורך שיר נכשלה", "לא ניתן לזהות את משך השיר עם ffprobe.", ok=False, details=details), 400
    finally:
        try:
            if os.path.exists(target):
                os.remove(target)
        except Exception:
            pass


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, Control Server and Fail Safe status.
    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, creationflags=hidden_subprocess_flags())
        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:
        bootstrap_ok, bootstrap_info = bootstrap_control_server("self-test")
        alive = control_server_is_alive()
        add_self_test_result(results, "עדכון אדמין", "שרת בקרה חיצוני Control Server פעיל", bool(alive), bootstrap_info if not alive else "פורט 8010 עונה")
    except Exception as exc:
        add_self_test_result(results, "עדכון אדמין", "בדיקת Control Server", False, repr(exc))

    try:
        fs = read_admin_failsafe_status()
        state = str(fs.get("state", "unknown"))
        bad_states = {"arm_failed", "cancel_failed"}
        ok = state not in bad_states
        msg = fs.get("message", "") or "OK"
        add_self_test_result(results, "עדכון אדמין", "סטטוס Fail Safe ניתן לקריאה", ok, f"state={state} | {msg}")
    except Exception as exc:
        add_self_test_result(results, "עדכון אדמין", "סטטוס Fail Safe ניתן לקריאה", 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/presentation-music", "/admin/logs", "/admin/logs.json", "/probe_presentation_music/<rule_key>", "/confirm_japan_experience_music_upload/<token>", "/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",
            "/upload/<folder>", "/upload_presentation/<presentation_key>", "/upload_usa_presentation/<presentation_key>",
            "/upload_usa_image", "/upload_usa_media", "/upload_usa_images_zip", "/upload_usa_media_zip", "/upload_usa_extra_html/<extra_key>",
            "/upload_japan_music/<music_key>", "/upload_japan_images_zip", "/upload_trip_package/<trip>",
            "/admin/netlify/download/<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, "ממשק", "כפתור טיולים מרכזי קיים בפאנל", "showScreen('trips')" in admin_html and "יפן קיץ 26" in admin_html and "ארצות הברית קיץ 27" in admin_html, "המסך הראשי מוביל למסך טיולים ולא מציג יעדים כקטגוריות ראשיות")
        add_self_test_result(results, "ממשק", "מרכז בקרת האיכות נגיש בלי כרטיס כפול במסך הראשי", 'class="main-card presentation-control-card"' not in admin_html and "Trip Platform QA" not in admin_html and 'href="/admin/platform"' in admin_html and "מרכז בקרת איכות" in admin_html, "נבדקו הסרת הכרטיס הכפול והגישה הקבועה מבר הפעולות העליון")
        add_self_test_result(results, "ממשק", "מרכז בקרת המצגות מחובר למסלולי HTML ו-JSON", callable(globals().get("admin_presentation_control_center")) and callable(globals().get("admin_presentation_control_report_json")), "/admin/presentations + /admin/presentations/report.json")
        add_self_test_result(results, "ממשק", "קישורי פתיחה מהירה לקבצי טיול קיימים", "פתח קובץ טיול יפן" in admin_html and "פתח קובץ טיול ארה״ב" in admin_html and "/USA_TV/usa2027-tv.html" in admin_html, "נבדקו קישורי פתיחה מהירה ל־JAPAN, USA ו־USA_TV")
        add_self_test_result(results, "נתיבים", "USA_TV נשמר ונפתח באותו שם קבוע", preferred_main_upload_filename("USA_TV") == "usa2027-tv.html" and APP_ENTRY_FILES.get("USA_TV", [""])[0] == "usa2027-tv.html" and "/USA_TV/usa2027-tv.html" in admin_html and "/upload_usa_extra_html/usa_trip_tv" in admin_html, "save=USA_TV/usa2027-tv.html; open=/USA_TV/usa2027-tv.html")
        add_self_test_result(results, "ממשק", "מסך Netlify קיים ומחובר להורדת ZIP", "showScreen('netlify')" in admin_html and "/admin/netlify/builder" in admin_html and "/admin/netlify/download/USA" in admin_html and "/admin/netlify/download/JAPAN" in admin_html, "נבדקו כרטיס Netlify ושתי פעולות הורדה")
        add_self_test_result(results, "סנכרון", "מטריצת ששת היעדים רשומה באדמין", "USA_RELEASE_MATRIX_V57" in globals() and len(USA_RELEASE_MATRIX_V57) == 6, "נדרשים בדיוק שישה יעדי ארה״ב")
        add_self_test_result(results, "סנכרון", "שער השוואת מצגות עם/ללא סכומים פעיל", callable(globals().get("_v57_validate_dual_presentation_sync")) and "_v57_validate_dual_presentation_sync" in netlify_validate_build.__code__.co_names, "Netlify נחסם אם קיים שינוי שאינו שקף התקציב או נתיב Deploy")
        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, "ממשק", "מסך מוזיקה מציג העלאה קיימת, בדיקה ואישור לפני הטמעה", "/upload_japan_music/japan_experience_music" in admin_html and "/upload_usa_music/usa_music" in admin_html and "/upload_usa_music/usa_music_part2" in admin_html and ("בדוק שיר והמשך להטמעה" in admin_html or "העלה שיר וסנכרן" in admin_html), "כפתורי ההעלאה הם הפעולה הראשית; מצגת יפן כוללת בדיקת טווח 5–10 שניות לפני הטמעה; probe נשאר כלי עזר בלבד")
        add_self_test_result(results, "ממשק", "כפתור אתחול מתקדם מוצג ככלי בדיקה בלבד", "בדיקת אתחול פאנל — מתקדם" in admin_html and (("מיועד לבדיקה יזומה בלבד" in admin_html) or ("מיועדת רק לבדיקה יזומה" in admin_html) or ("בדיקה יזומה" 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, "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")
        add_self_test_result(results, "מדיה", "ffprobe זמין לבדיקת אורך שירים", bool(media_status.get("ffprobe")), media_status.get("ffprobe_path") or r"שים ffprobe ב־C:\ffmpeg\bin\ffprobe.exe")
        add_self_test_result(results, "מדיה", "קובץ סטטוס העלאות מוזיקה מוגדר", PRESENTATION_MUSIC_UPLOAD_STATUS_PATH.endswith("presentation_music_upload_status.json"), PRESENTATION_MUSIC_UPLOAD_STATUS_PATH)
        rules_ok = isinstance(PRESENTATION_MUSIC_SYNC_RULES, dict) and {"japan_experience_music", "japan_flight_music", "usa_music_part1", "usa_music_part2"}.issubset(set(PRESENTATION_MUSIC_SYNC_RULES.keys()))
        add_self_test_result(results, "מדיה", "כללי סנכרון מוזיקה למצגות קיימים עם סטטוס אורך לאחר העלאה", rules_ok, "v34.25 מודד משך ומסנכרן את מצגת האייפון ואת מצגת ה-TV")
    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")
        add_self_test_result(results, "ארה״ב", "האדמין מקבל תמונה או MP4/M4V בכל כרטיס שקף", USA_VIDEO_EXTENSIONS == {".mp4", ".m4v"} and "/upload_usa_media" in admin_html and "video/mp4" in admin_html, str(sorted(USA_MEDIA_EXTENSIONS)))
        demo_parts = (PresentationPart(0, 2, 30.0, "חלק 1"), PresentationPart(3, 5, 24.0, "חלק 2"))
        demo = allocate_timeline(("a", "b", "c", "d", "e", "f"), demo_parts, {"a": 15.0, "e": 10.0})
        demo_ok = abs(demo.entries[0].timeline_seconds - 15.0) < 0.001 and abs(demo.entries[1].timeline_seconds - 7.5) < 0.001 and abs(demo.total_seconds - 54.0) < 0.001
        add_self_test_result(results, "ארה״ב", "סימולציית תזמון סרטונים ותמונות", demo_ok, f"total={demo.total_seconds:.3f}; image={demo.entries[1].timeline_seconds:.3f}")
        try:
            allocate_timeline(("a", "b", "c"), (PresentationPart(0, 2, 30.0, "חלק"),), {"a": 16.0, "b": 15.0})
            overflow_rejected = False
        except MediaError:
            overflow_rejected = True
        add_self_test_result(results, "ארה״ב", "סרטונים ארוכים ממשך השיר נחסמים", overflow_rejected, "OK" if overflow_rejected else "לא נחסם")
    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, טפסים, נתיבים, מדיה, מצגות, אבטחה, Control Server ו־Fail Safe. היא לא מבצעת העלאות אמיתיות, לא מאתחלת את השרת ולא מכבה את המחשב.</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():
    # v34.9: Restart helper is a legacy component. The active stable update path is
    # Control Server + Fail Safe, so Health must not mark the panel as faulty just
    # because the old helper was removed from the live workflow.
    control_alive = False
    control_info = "not checked"
    try:
        control_alive = bool(control_server_is_alive())
        control_info = "פורט 8010 עונה" if control_alive else "פורט 8010 לא עונה"
    except Exception as exc:
        control_info = repr(exc)
    try:
        failsafe_info = read_admin_failsafe_status()
    except Exception as exc:
        failsafe_info = {"state": "unknown", "message": repr(exc)}
    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,
        "control_server_ok": control_alive,
        "control_server_status": control_info,
        "failsafe_status": failsafe_info,
        "restart_helper_legacy": True,
        "restart_helper_status": "legacy hidden from Health; active path is Control Server + Fail Safe",
        "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 "לא נמצא"
    ffprobe_path = media.get("ffprobe_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/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>Control Server</b><span>{badge(payload.get("control_server_ok", False), "פעיל", "לא פעיל")}</span></div><div class="line"><b>Fail Safe</b><span>{html_lib.escape(str(payload.get("failsafe_status", {}).get("state", "unknown")))}</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 class="line"><b>ffprobe</b><span class="ltr">{html_lib.escape(ffprobe_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 דחוס / MP4-M4V → MP4 H.264</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>מסלול העדכון וההתאוששות נשען על Control Server ו־Fail Safe בלבד.</div>
<h2>מידע טכני מלא</h2><pre>{json_text}</pre>
</div></body></html>
"""





@app.route("/admin/local-files")
def admin_local_files():
    if request.args.get("refresh") == "1":
        build_local_file_roots(force=True)
    return render_admin_file_manager(request.args.get("root"), request.args.get("path") or "")


@app.route("/admin/local-files/download")
def admin_local_files_download():
    root_key = normalize_local_root_key(request.args.get("root"))
    try:
        path = safe_local_path(root_key, request.args.get("path") or "")
    except Exception as exc:
        return admin_status_page("נתיב נחסם", "הקובץ המבוקש לא נמצא בתוך התיקיות המאושרות.", ok=False, details=str(exc)), 400
    if not os.path.isfile(path):
        return admin_status_page("קובץ לא נמצא", "לא ניתן להוריד תיקייה או קובץ שאינו קיים.", ok=False, details=path), 404
    return send_file(path, as_attachment=True, download_name=os.path.basename(path))


@app.route("/admin/local-files/preview")
def admin_local_files_preview():
    root_key = normalize_local_root_key(request.args.get("root"))
    try:
        path = safe_local_path(root_key, request.args.get("path") or "")
    except Exception as exc:
        return admin_status_page("נתיב נחסם", "הקובץ המבוקש לא נמצא בתוך התיקיות המאושרות.", ok=False, details=str(exc)), 400
    if not os.path.isfile(path):
        return admin_status_page("קובץ לא נמצא", "לא ניתן להציג תיקייה או קובץ שאינו קיים.", ok=False, details=path), 404
    size = os.path.getsize(path)
    ext = os.path.splitext(path)[1].lower()
    if ext not in LOCAL_FILE_TEXT_EXTENSIONS or size > LOCAL_FILE_PREVIEW_MAX_BYTES:
        return admin_status_page("אין תצוגה מקדימה", "ניתן להציג רק קבצי טקסט קטנים. אפשר להוריד את הקובץ במקום זאת.", ok=False, details=f"{path}\n{format_bytes(size)}"), 400
    try:
        raw = open(path, "rb").read()
        try:
            content = raw.decode("utf-8")
        except UnicodeDecodeError:
            content = raw.decode("cp1255", errors="replace")
    except Exception as exc:
        return admin_status_page("שגיאה בקריאת קובץ", "לא ניתן היה לקרוא את הקובץ.", ok=False, details=repr(exc)), 500
    safe_content = html_lib.escape(content[:250000])
    rel_raw = rel_for_local(root_key, path)
    back_raw = os.path.dirname(rel_raw).replace('\\','/')
    back_url = local_file_href("/admin/local-files", root_key, back_raw)
    download_url = local_file_href("/admin/local-files/download", root_key, rel_raw)
    edit_url = local_file_href("/admin/local-files/edit", root_key, rel_raw)
    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;font-family:Arial;background:#07111f;color:#f8fafc}}.wrap{{width:min(1100px,94%);margin:auto;padding:22px}}a{{color:#bfdbfe}}pre{{direction:ltr;text-align:left;white-space:pre-wrap;background:#020617;border:1px solid #334155;border-radius:18px;padding:16px;overflow:auto;color:#fde68a}}</style></head><body><div class='wrap'><a href='{back_url}'>⬅ חזרה לתיקייה</a> · <a href='{download_url}'>הורד קובץ</a> · <a href='{edit_url}'>ערוך</a><h1>📄 {html_lib.escape(os.path.basename(path))}</h1><p>{html_lib.escape(path)}</p><pre>{safe_content}</pre></div></body></html>"""


@app.route("/admin/local-files/edit", methods=["GET", "POST"])
def admin_local_files_edit():
    root_key = normalize_local_root_key(request.values.get("root"))
    try:
        path = safe_local_path(root_key, request.values.get("path") or "")
    except Exception as exc:
        return admin_status_page("נתיב נחסם", "הקובץ אינו בתוך תיקייה מאושרת.", ok=False, details=str(exc)), 400
    if not os.path.isfile(path):
        return admin_status_page("קובץ לא נמצא", "לא ניתן לערוך תיקייה או קובץ שאינו קיים.", ok=False, details=path), 404
    ext = os.path.splitext(path)[1].lower()
    if ext not in LOCAL_FILE_TEXT_EXTENSIONS:
        return admin_status_page("עריכה חסומה", "עריכה דרך הדפדפן מותרת רק לקבצי טקסט מוכרים.", ok=False, details=path), 400
    if request.method == "POST":
        text = request.form.get("content", "")
        try:
            backup_existing_file(path, "local_file_edit")
            with open(path, "w", encoding="utf-8", newline="") as f:
                f.write(text)
            return admin_status_page("הקובץ נשמר", "קובץ הטקסט עודכן ונוצר גיבוי לפני השינוי.", ok=True, details=path)
        except Exception as exc:
            return admin_status_page("שגיאה בשמירה", "לא ניתן היה לשמור את הקובץ.", ok=False, details=repr(exc)), 500
    try:
        raw = open(path, "rb").read()
        try:
            content = raw.decode("utf-8")
        except UnicodeDecodeError:
            content = raw.decode("cp1255", errors="replace")
    except Exception as exc:
        return admin_status_page("שגיאה בקריאה", "לא ניתן היה לקרוא את הקובץ לעריכה.", ok=False, details=repr(exc)), 500
    rel_raw = rel_for_local(root_key, path)
    back_raw = os.path.dirname(rel_raw).replace('\\','/')
    back_url = local_file_href("/admin/local-files", root_key, back_raw)
    root = html_lib.escape(root_key, quote=True)
    rel = html_lib.escape(rel_raw, quote=True)
    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;font-family:Arial;background:#07111f;color:#f8fafc}}.wrap{{width:min(1200px,94%);margin:auto;padding:22px}}a{{color:#bfdbfe}}textarea{{width:100%;min-height:70vh;direction:ltr;text-align:left;background:#020617;color:#fde68a;border:1px solid #334155;border-radius:18px;padding:16px;font-family:Consolas,monospace}}button{{background:#16a34a;color:white;border:0;border-radius:14px;padding:14px 18px;font-weight:900}}</style></head><body><div class='wrap'><a href='{back_url}'>⬅ חזרה לתיקייה</a><h1>✏️ עריכת {html_lib.escape(os.path.basename(path))}</h1><p>{html_lib.escape(path)}</p><form method='post'><input type='hidden' name='root' value='{root}'><input type='hidden' name='path' value='{rel}'><textarea name='content'>{html_lib.escape(content)}</textarea><p><button type='submit' onclick="return confirm('לשמור שינוי בקובץ?')">שמור קובץ</button></p></form></div></body></html>"""


@app.route("/admin/local-files/upload", methods=["POST"])
def admin_local_files_upload():
    root_key = normalize_local_root_key(request.form.get("root"))
    try:
        folder = safe_local_path(root_key, request.form.get("path") or "")
    except Exception as exc:
        return admin_status_page("נתיב נחסם", "התיקייה אינה מאושרת.", ok=False, details=str(exc)), 400
    if not os.path.isdir(folder):
        return admin_status_page("תיקייה לא קיימת", "לא ניתן להעלות לקובץ או לתיקייה שאינה קיימת.", ok=False, details=folder), 400
    files = request.files.getlist("files")
    saved = []
    try:
        for f in files:
            if not f or not (f.filename or "").strip():
                continue
            try:
                name = local_safe_name(f.filename)
            except Exception:
                name = "upload_" + str(int(time.time()))
            target = local_unique_destination(os.path.join(folder, name))
            f.save(target)
            saved.append(os.path.basename(target))
        if not saved:
            return admin_status_page("לא נבחר קובץ", "בחר קובץ אחד לפחות להעלאה.", ok=False), 400
        return admin_status_page("העלאה הושלמה", f"נשמרו {len(saved)} קבצים בתיקייה הנוכחית.", ok=True, details="\n".join(saved))
    except Exception as exc:
        return admin_status_page("שגיאה בהעלאה", "לא ניתן היה לשמור את הקבצים.", ok=False, details=repr(exc)), 500


@app.route("/admin/local-files/mkdir", methods=["POST"])
def admin_local_files_mkdir():
    root_key = normalize_local_root_key(request.form.get("root"))
    try:
        folder = safe_local_path(root_key, request.form.get("path") or "")
    except Exception as exc:
        return admin_status_page("נתיב נחסם", "התיקייה אינה מאושרת.", ok=False, details=str(exc)), 400
    try:
        name = local_safe_name(request.form.get("folder_name") or "")
    except Exception as exc:
        return admin_status_page("שם תיקייה חסר", "כתוב שם תיקייה תקין.", ok=False, details=str(exc)), 400
    try:
        target = safe_local_path(root_key, os.path.join(rel_for_local(root_key, folder), name))
        os.makedirs(target, exist_ok=False)
        return admin_status_page("תיקייה נוצרה", "התיקייה החדשה נוצרה בהצלחה.", ok=True, details=target)
    except Exception as exc:
        return admin_status_page("שגיאה ביצירת תיקייה", "לא ניתן היה ליצור את התיקייה.", ok=False, details=repr(exc)), 500


@app.route("/admin/local-files/bulk", methods=["POST"])
def admin_local_files_bulk():
    root_key = normalize_local_root_key(request.form.get("root"))
    action = (request.form.get("action") or "zip").strip().lower()
    selected = request.form.getlist("path")
    if not selected:
        return admin_status_page("לא נבחרו פריטים", "סמן קובץ או תיקייה לפני פעולה.", ok=False), 400
    try:
        paths = [safe_local_path(root_key, rel) for rel in selected[:300]]
    except Exception as exc:
        return admin_status_page("נתיב נחסם", "אחד הנתיבים אינו מאושר.", ok=False, details=str(exc)), 400

    if action == "zip":
        stamp = time.strftime("%Y%m%d_%H%M%S")
        out_dir = os.path.join(BASE_DIR, "tmp")
        os.makedirs(out_dir, exist_ok=True)
        zip_path = os.path.join(out_dir, f"local_files_export_{root_key}_{stamp}.zip")
        total = 0
        count = 0
        try:
            with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
                for p in paths:
                    if not os.path.exists(p):
                        continue
                    added_bytes, added_count = add_path_to_zip(zf, root_key, p)
                    total += added_bytes
                    count += added_count
                    if total > LOCAL_FILE_ZIP_MAX_BYTES:
                        raise ValueError(f"ZIP גדול מדי. מגבלה: {format_bytes(LOCAL_FILE_ZIP_MAX_BYTES)}")
            if count <= 0:
                return admin_status_page("לא נוספו קבצים", "לא נמצאו קבצים תקינים לבניית ZIP.", ok=False), 400
            return send_file(zip_path, as_attachment=True, download_name=os.path.basename(zip_path))
        except Exception as exc:
            try:
                if os.path.exists(zip_path):
                    os.remove(zip_path)
            except Exception:
                pass
            return admin_status_page("שגיאה ביצירת ZIP", "לא ניתן היה ליצור חבילת קבצים להורדה.", ok=False, details=repr(exc)), 500

    if action == "delete":
        deleted = []
        errors = []
        for p in paths:
            try:
                if os.path.abspath(p) == local_root_path(root_key):
                    raise ValueError("אסור למחוק את שורש התיקייה.")
                if os.path.isdir(p):
                    shutil.rmtree(p)
                elif os.path.exists(p):
                    os.remove(p)
                deleted.append(rel_for_local(root_key, p))
            except Exception as exc:
                errors.append(f"{rel_for_local(root_key, p)}: {repr(exc)}")
        return admin_status_page("מחיקה הסתיימה", f"נמחקו {len(deleted)} פריטים. שגיאות: {len(errors)}", ok=(len(errors)==0), details="\n".join(deleted + errors))


    if action == "rename":
        if len(paths) != 1:
            return admin_status_page("שינוי שם", "בחר פריט אחד בלבד לשינוי שם.", ok=False), 400
        try:
            new_name = local_safe_name(request.form.get("new_name") or "")
            src = paths[0]
            dest = os.path.join(os.path.dirname(src), new_name)
            dest = safe_local_path(root_key, rel_for_local(root_key, dest))
            if os.path.exists(dest):
                raise ValueError("כבר קיים קובץ/תיקייה בשם הזה.")
            os.rename(src, dest)
            return admin_status_page("שם שונה", "הפריט שונה שם בהצלחה.", ok=True, details=f"{src}\n→ {dest}")
        except Exception as exc:
            return admin_status_page("שגיאה בשינוי שם", "לא ניתן היה לשנות את שם הפריט.", ok=False, details=repr(exc)), 500

    if action in {"copy", "move"}:
        dest_root = normalize_local_root_key(request.form.get("dest_root"))
        dest_rel = request.form.get("dest_path") or ""
        try:
            dest_dir = safe_local_path(dest_root, dest_rel)
            if not os.path.isdir(dest_dir):
                raise ValueError("יעד אינו תיקייה קיימת.")
        except Exception as exc:
            return admin_status_page("יעד לא תקין", "בחר תיקיית יעד קיימת בתוך אחת התיקיות המאושרות.", ok=False, details=str(exc)), 400
        done = []
        errors = []
        for p in paths:
            try:
                if action == "copy":
                    target = local_copy_item(p, dest_dir)
                else:
                    target = local_move_item(p, dest_dir)
                done.append(target)
            except Exception as exc:
                errors.append(f"{p}: {repr(exc)}")
        return admin_status_page("הפעולה הסתיימה", f"בוצעו {len(done)} פעולות. שגיאות: {len(errors)}", ok=(len(errors)==0), details="\n".join(done + errors))

    return admin_status_page("פעולה לא מוכרת", "הפעולה המבוקשת אינה נתמכת.", ok=False, details=action), 400


@app.route("/admin/local-files/zip", methods=["POST"])
def admin_local_files_zip():
    # Backward compatible endpoint: use the new bulk action.
    return admin_local_files_bulk()


@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 Control Server and restart verification.
    # 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/control")
def admin_control_page():
    bootstrap_ok, bootstrap_info = bootstrap_control_server("control-page")
    alive = control_server_is_alive()
    status_text = ""
    try:
        if os.path.exists(CONTROL_STATUS_PATH): status_text = open(CONTROL_STATUS_PATH, "r", encoding="utf-8", errors="replace").read()
    except Exception as exc: status_text = repr(exc)
    log_tail = ""
    try:
        if os.path.exists(CONTROL_LOG_PATH): log_tail = open(CONTROL_LOG_PATH, "r", encoding="utf-8", errors="replace").read()[-16000:]
    except Exception as exc: log_tail = repr(exc)
    html = f"""<!doctype html><html lang='he' dir='rtl'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>TripServer Control</title><style>body{{font-family:Arial;background:#071120;color:#eef;margin:0;padding:22px}}.box{{max-width:960px;margin:auto;background:#111827;border:1px solid #334155;border-radius:22px;padding:24px}}a,button{{display:inline-block;margin:8px;padding:14px 18px;border-radius:14px;background:#2563eb;color:white;text-decoration:none;border:0;font-size:18px}}pre{{direction:ltr;text-align:left;background:#020617;color:#facc15;padding:16px;border-radius:14px;white-space:pre-wrap;overflow:auto}}.pill{{display:inline-block;padding:8px 14px;border-radius:999px;background:{'#166534' if alive else '#991b1b'}}}</style></head><body><div class='box'><a href='/admin'>⬅ חזרה</a><h1>שרת בקרה חיצוני</h1><div class='pill'>{'פעיל' if alive else 'לא פעיל'}</div><p>Bootstrap: {html_lib.escape(str(bootstrap_ok))} | {html_lib.escape(str(bootstrap_info))}</p><form action='/restart_admin_server' method='post'><button>אתחל פאנל ראשי דרך שרת הבקרה</button></form><a href='http://127.0.0.1:{CONTROL_PORT}/status.json' target='_blank'>Status מקומי</a><a href='http://127.0.0.1:{CONTROL_PORT}/log' target='_blank'>לוג מקומי</a><h2>Status</h2><pre>{html_lib.escape(status_text or 'אין')}</pre><h2>Log</h2><pre>{html_lib.escape(log_tail or 'אין')}</pre></div></body></html>"""
    return make_response(html)

@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_camera_setup_page()


@app.route("/camera/setup/status.json")
def camera_setup_status_json():
    allowed, reason = camera_access_allowed()
    payload = camera_setup_status()
    payload["access_allowed"] = allowed
    payload["access_reason"] = reason
    return payload


@app.route("/camera/setup/opencv", methods=["POST"])
def camera_setup_opencv():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return admin_status_page("התקנת OpenCV נחסמה", reason, ok=False, back_url="/camera/setup"), 403
    confirm = (request.form.get("confirm") or "").strip()
    if confirm != CAMERA_SETUP_CONFIRM_TEXT:
        return admin_status_page("אישור חסר", f"כדי להתקין OpenCV צריך לכתוב בדיוק: {CAMERA_SETUP_CONFIRM_TEXT}", ok=False, back_url="/camera/setup"), 400
    if camera_engine_status().get("opencv"):
        return admin_status_page("OpenCV כבר מותקן", "אין צורך להריץ התקנה נוספת. אפשר לחזור למסך המצלמה.", ok=True, back_url="/admin/camera")
    started, message = start_opencv_install_background(mode="manual")
    return admin_status_page("התקנת OpenCV התחילה" if started else "התקנת OpenCV כבר רצה", message + "\n\nפתח את מסך סטטוס רכיבי מצלמה כדי לעקוב אחרי הלוג.", ok=started, back_url="/camera/setup", details=str(camera_setup_status()))


@app.route("/admin/camera")
def admin_camera():
    return render_admin_camera()


@app.route("/camera/status.json")
def camera_status_json():
    allowed, reason = camera_access_allowed()
    payload = CAMERA_MANAGER.status()
    payload.update(camera_engine_status())
    payload["access_allowed"] = allowed
    payload["access_reason"] = reason
    return payload


@app.route("/camera/snapshot.jpg")
def camera_snapshot():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return reason, 403
    if not camera_engine_status().get("opencv"):
        return "OpenCV לא מותקן. הרץ: py -m pip install opencv-python", 503
    try:
        frame = CAMERA_MANAGER.read_jpeg()
        response = make_response(frame)
        response.headers["Content-Type"] = "image/jpeg"
        response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
        return response
    except Exception as exc:
        return f"שגיאת מצלמה: {exc}", 500


@app.route("/camera/prepare-video", methods=["POST"])
def camera_prepare_video():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return reason, 403
    CAMERA_MANAGER.prepare_new_stream()
    log_camera_event(f"video prepare requested by {get_client_ip()}")
    return {"ok": True, "message": "camera video handle released and ready"}


@app.route("/camera/reset", methods=["POST"])
def camera_reset():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return reason, 403
    # Recovery button: release everything that may hold the webcam/microphone.
    try:
        CAMERA_AUDIO_STREAM.stop()
    except Exception:
        pass
    try:
        CAMERA_RECORDER.stop()
    except Exception:
        pass
    CAMERA_MANAGER.force_reset()
    log_camera_event(f"full camera reset requested by {get_client_ip()}")
    return {"ok": True, "message": "camera/audio/recording handles released"}


@app.route("/camera/stream")
def camera_stream():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return reason, 403
    if not camera_engine_status().get("opencv"):
        return "OpenCV לא מותקן. הרץ: py -m pip install opencv-python", 503
    return Response(camera_stream_generator(), mimetype="multipart/x-mixed-replace; boundary=frame", headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "X-Accel-Buffering": "no"})


@app.route("/camera/stop", methods=["POST"])
def camera_stop():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return reason, 403
    CAMERA_MANAGER.stop()
    return admin_status_page("מצלמה כובתה", "המצלמה שוחררה והזרם הופסק. אם נורית המצלמה עדיין דולקת, סגור גם את דף השידור בדפדפן ורענן.", ok=True)


@app.route("/camera/audio-stream")
def camera_audio_stream():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return reason, 403
    if not find_ffmpeg_executable():
        return "FFmpeg לא נמצא. נדרש FFmpeg כדי להזרים אודיו.", 503
    try:
        restart = request.args.get("restart") == "1"
        CAMERA_AUDIO_STREAM.start(restart=restart)
    except Exception as exc:
        return "שגיאת אודיו: " + str(exc), 500
    return Response(CAMERA_AUDIO_STREAM.stream_chunks(), mimetype="audio/mpeg", headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "X-Accel-Buffering": "no"})


@app.route("/camera/audio-test.json")
def camera_audio_test_json():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return {"ok": False, "error": reason}, 403
    ffmpeg = find_ffmpeg_executable()
    if not ffmpeg:
        return {"ok": False, "error": "FFmpeg לא נמצא"}, 503
    devices = ffmpeg_dshow_devices()
    candidates = preferred_audio_candidates(devices)
    results = []
    for row in candidates[:6]:
        name = row.get("name") or ""
        started = time.time()
        cmd = [ffmpeg, "-hide_banner", "-nostdin", "-loglevel", "warning", "-f", "dshow", "-rtbufsize", "64M", "-i", "audio=" + name, "-t", "1", "-vn", "-f", "null", "-"]
        try:
            completed = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, creationflags=hidden_subprocess_flags())
            results.append({"device": name, "source": row.get("source"), "ok": completed.returncode == 0, "returncode": completed.returncode, "seconds": round(time.time() - started, 2), "stderr_tail": (completed.stderr or "")[-1200:]})
        except Exception as exc:
            results.append({"device": name, "source": row.get("source"), "ok": False, "error": repr(exc)})
    return {"ok": any(r.get("ok") for r in results), "ffmpeg": ffmpeg, "selected": selected_ffmpeg_devices()[:2], "devices": devices, "results": results, "audio_log": CAMERA_AUDIO_LOG_PATH}


@app.route("/camera/audio-stop", methods=["POST"])
def camera_audio_stop():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return reason, 403
    CAMERA_AUDIO_STREAM.stop()
    return admin_status_page("אודיו כובה", "שידור האודיו הופסק והמיקרופון שוחרר.", ok=True)


@app.route("/camera/record/start", methods=["POST"])
def camera_record_start():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return admin_status_page("הקלטה נחסמה", reason, ok=False), 403
    try:
        with_audio = request.form.get("with_audio") == "1"
        target = CAMERA_RECORDER.start(with_audio=with_audio)
        return admin_status_page("הקלטה התחילה", f"ההקלטה נשמרת אל:\n{target}", ok=True, details=str(CAMERA_RECORDER.status()))
    except Exception as exc:
        CAMERA_RECORDER.last_error = str(exc)
        return admin_status_page("הקלטה לא התחילה", str(exc), ok=False, details=str(camera_av_status())), 500


@app.route("/camera/record/stop", methods=["POST"])
def camera_record_stop():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return admin_status_page("עצירת הקלטה נחסמה", reason, ok=False), 403
    try:
        target = CAMERA_RECORDER.stop()
        return admin_status_page("הקלטה נעצרה", f"קובץ ההקלטה:\n{target or 'לא היה קובץ פעיל'}", ok=True, details=str(CAMERA_RECORDER.status()), back_url="/admin/camera")
    except Exception as exc:
        return admin_status_page("שגיאה בעצירת הקלטה", str(exc), ok=False, back_url="/admin/camera"), 500


@app.route("/camera/recordings")
def camera_recordings_page():
    allowed, reason = camera_access_allowed()
    if not allowed:
        return reason, 403
    rows = "".join(
        f"<div style='border-bottom:1px solid #334155;padding:12px 0'><b>{html_lib.escape(item['name'])}</b><br><span style='color:#94a3b8'>{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(item['mtime']))} · {format_bytes(item['size'])}</span><br><a href='/camera/recordings/{html_lib.escape(item['name'])}' target='_blank'>פתח/נגן</a></div>"
        for item in list_camera_recordings(100)
    ) or "<p>אין הקלטות.</p>"
    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;padding:18px}}.wrap{{max-width:900px;margin:auto}}a{{color:#93c5fd}}.card{{background:#111827;border:1px solid #334155;border-radius:22px;padding:18px}}</style></head><body><div class='wrap'><a href='/admin/camera'>⬅ חזרה למצלמה</a><h1>🎞️ הקלטות מצלמה</h1><div class='card'><p>תיקייה: <code>{html_lib.escape(CAMERA_RECORDINGS_DIR)}</code></p>{rows}</div></div></body></html>"""


@app.route("/camera/recordings/<path:filename>")
def camera_recording_file(filename):
    allowed, reason = camera_access_allowed()
    if not allowed:
        return reason, 403
    base = os.path.basename(filename or "")
    if not base.lower().endswith(".mp4"):
        return "קובץ לא מורשה", 403
    return send_from_directory(CAMERA_RECORDINGS_DIR, base, as_attachment=False)





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

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

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

    os.makedirs(CONTROL_DIR, exist_ok=True)
    os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    suffix = ".zip" if is_zip else ".py"
    tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"admin_server_upload_{stamp}{suffix}")
    extracted_admin_path = None

    try:
        file.save(tmp_path)

        if is_zip:
            work_dir = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"admin_zip_extract_{stamp}_{os.getpid()}")
            valid, details, extracted_admin_path = extract_admin_server_from_zip_upload(tmp_path, work_dir)
            if not valid:
                try: os.remove(tmp_path)
                except Exception: pass
                try:
                    if work_dir and os.path.isdir(work_dir): shutil.rmtree(work_dir, ignore_errors=True)
                except Exception: pass
                return admin_status_page("העלאת ZIP אדמין נדחתה", "הקובץ הפעיל לא הוחלף. נמצאה בעיה בחבילת העדכון.", ok=False, details=details), 400
            admin_candidate_path = extracted_admin_path
            source_note = details
        else:
            valid, details = validate_admin_server_upload(tmp_path)
            if not valid:
                try: os.remove(tmp_path)
                except Exception: pass
                return admin_status_page("העלאת admin_server.py נדחתה", "הקובץ הפעיל לא הוחלף. נמצאה בעיה בקובץ שהעלית.", ok=False, details=details), 400
            admin_candidate_path = tmp_path
            source_note = "קובץ Python ישיר תקין."

        shutil.copy2(admin_candidate_path, CONTROL_PENDING_ADMIN_PATH)
        new_sha = file_sha256(CONTROL_PENDING_ADMIN_PATH)

        try: os.remove(tmp_path)
        except Exception: pass
        if extracted_admin_path:
            try:
                shutil.rmtree(os.path.dirname(extracted_admin_path), ignore_errors=True)
            except Exception:
                pass

        failsafe_ok, failsafe_info = arm_admin_update_failsafe_restart()
        if not failsafe_ok:
            return admin_status_page("Fail Safe לא הופעל", "הקובץ עבר בדיקה, אבל עדכון האדמין לא נשלח כי ריסטארט החירום לא הופעל. הקובץ הפעיל לא הוחלף.", ok=False, details=failsafe_info), 500

        bootstrap_ok, bootstrap_info = bootstrap_control_server("admin-upload")
        req = write_control_request("apply_update", reason="admin_upload_v34_3_control_server_failsafe", pending_path=CONTROL_PENDING_ADMIN_PATH)
        details = (
            f"מקור העלאה:\n{html_lib.escape(original_name)}\n\n"
            f"בדיקת מקור:\n{source_note}\n\n"
            f"קובץ ממתין:\n{CONTROL_PENDING_ADMIN_PATH}\n\n"
            f"SHA256 חדש:\n{new_sha}\n\n"
            f"Fail Safe:\n{failsafe_info}\n\n"
            f"Control Server:\nhttp://127.0.0.1:{CONTROL_PORT}/\n\n"
            f"Bootstrap:\n{bootstrap_ok} | {bootstrap_info}\n\n"
            f"Request:\n{json.dumps(req, ensure_ascii=False, indent=2)}\n\n"
            f"לוג בקרה:\n{CONTROL_LOG_PATH}\n"
            f"לוג התקנה:\n{CONTROL_BOOTSTRAP_LOG_PATH}\n\n"
            "אם הפאנל חזר לעבוד אחרי העדכון — אפשר ללחוץ על ביטול ריסטארט Fail Safe. אם הוא כבר בוטל אוטומטית, זה יוצג כמצב תקין."
        )
        return admin_status_page("עדכון נשלח לשרת הבקרה", "הקובץ החדש עבר בדיקה, ריסטארט חירום נקבע, והעדכון נשלח לשרת הבקרה החיצוני.", ok=True, details=details)
    except Exception as exc:
        try:
            if os.path.exists(tmp_path): os.remove(tmp_path)
        except Exception:
            pass
        try:
            if extracted_admin_path and os.path.exists(os.path.dirname(extracted_admin_path)):
                shutil.rmtree(os.path.dirname(extracted_admin_path), ignore_errors=True)
        except Exception:
            pass
        return admin_status_page("שגיאה בעדכון אדמין", "הקובץ הפעיל לא הוחלף.", ok=False, details=repr(exc)), 500


@app.route("/restart_admin_server", methods=["POST"])
def restart_admin_server():
    bootstrap_ok, bootstrap_info = bootstrap_control_server("manual-restart")
    req = write_control_request("restart_admin", reason="manual_panel_restart_v34_control")
    return admin_status_page("אתחול נשלח לשרת הבקרה", "שרת הבקרה החיצוני יאתחל את פאנל האדמין הראשי. המתן כמה שניות ורענן.", ok=True, details=f"Control bootstrap: {bootstrap_ok} | {bootstrap_info}\n\nRequest:\n{json.dumps(req, ensure_ascii=False, indent=2)}\n\nControl log:\n{CONTROL_LOG_PATH}")


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

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



# Budget application installer: dedicated validation and atomic replacement.
#
# Platform rule: future HTML applications identify themselves with a stable,
# machine-readable identity contract. Validators must verify the contract and
# required capabilities, never freeze acceptance to one exact release version.
EITAN_ARTIFACT_CONTRACT_SCHEMA = "eitan-tripserver-artifact/v1"
EITAN_ARTIFACT_IDENTITY_ELEMENT_ID = "eitan-platform-artifact"
EITAN_ARTIFACT_VERSION_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+\-]{0,127}$")

BUDGET_APP_LEGACY_REQUIRED_VERSION = "2026.07.15-FX-WALLETS-CHILDREN-v6"
BUDGET_APP_EXPECTED_ARTIFACT_ID = "Moneyapp"
BUDGET_APP_EXPECTED_ARTIFACT_TYPE = "vacation-budget-app"
BUDGET_APP_REQUIRED_FEATURES = {
    "foreign_budget_sources",
    "ido_private_budget",
    "michael_private_budget",
    "expense_budget_owner",
    "visible_version_badge",
}
BUDGET_APP_REQUIRED_MARKERS = {
    "foreign_budget_sources": "budgetSourcesRows",
    "ido_private_budget": "tripIdoBudgetInput",
    "michael_private_budget": "tripMichaelBudgetInput",
    "expense_budget_owner": "budgetOwner",
    "visible_version_badge": "budgetAppVersionBadge",
}


def extract_eitan_artifact_identity(text):
    """Return the embedded EITAN artifact identity JSON, or a structured error.

    The block must be placed in the HTML head and use:
    <script id="eitan-platform-artifact" type="application/json">...</script>
    """
    match = re.search(
        r'<script\b[^>]*\bid=["\']' + re.escape(EITAN_ARTIFACT_IDENTITY_ELEMENT_ID) + r'["\'][^>]*>(.*?)</script\s*>',
        str(text or ""),
        flags=re.I | re.S,
    )
    if not match:
        return None, "identity_block_missing"
    try:
        payload = json.loads(match.group(1).strip())
    except Exception as exc:
        return None, "identity_json_invalid:" + exc.__class__.__name__
    if not isinstance(payload, dict):
        return None, "identity_json_not_object"
    return payload, ""


def validate_eitan_artifact_identity(identity, expected_id=None, expected_type=None, required_features=()):
    errors = []
    if not isinstance(identity, dict):
        return ["identity_missing"]
    if identity.get("schema") != EITAN_ARTIFACT_CONTRACT_SCHEMA:
        errors.append("schema")
    artifact_id = str(identity.get("artifact_id") or "").strip()
    artifact_type = str(identity.get("artifact_type") or "").strip()
    version = str(identity.get("version") or "").strip()
    if expected_id and artifact_id != expected_id:
        errors.append("artifact_id")
    if expected_type and artifact_type != expected_type:
        errors.append("artifact_type")
    if not version or not EITAN_ARTIFACT_VERSION_PATTERN.fullmatch(version):
        errors.append("version")
    raw_features = identity.get("features")
    if not isinstance(raw_features, list) or any(not isinstance(x, str) or not x.strip() for x in raw_features):
        errors.append("features_format")
        feature_set = set()
    else:
        feature_set = {x.strip() for x in raw_features}
    missing_features = sorted(set(required_features) - feature_set)
    if missing_features:
        errors.append("features_missing:" + ",".join(missing_features))
    return errors


def inspect_budget_app_file(path):
    info = {
        "path": path,
        "exists": os.path.isfile(path),
        "valid": False,
        "markers": {},
        "contract_schema": EITAN_ARTIFACT_CONTRACT_SCHEMA,
    }
    if not info["exists"]:
        info["error"] = "file_not_found"
        return info
    try:
        raw = Path(path).read_bytes()
        text = raw.decode("utf-8", errors="replace")
        info["size"] = len(raw)
        info["sha256"] = hashlib.sha256(raw).hexdigest()
        info["html"] = "<!DOCTYPE html" in text[:500].upper() or "<html" in text[:2000].lower()

        feature_markers = {key: marker in text for key, marker in BUDGET_APP_REQUIRED_MARKERS.items()}
        identity, identity_error = extract_eitan_artifact_identity(text)
        identity_errors = validate_eitan_artifact_identity(
            identity,
            expected_id=BUDGET_APP_EXPECTED_ARTIFACT_ID,
            expected_type=BUDGET_APP_EXPECTED_ARTIFACT_TYPE,
            required_features=BUDGET_APP_REQUIRED_FEATURES,
        ) if identity is not None else [identity_error or "identity_missing"]

        contract_valid = bool(identity is not None and not identity_errors and all(feature_markers.values()))
        legacy_valid = bool(
            BUDGET_APP_LEGACY_REQUIRED_VERSION in text
            and all(feature_markers.values())
        )

        version = str((identity or {}).get("version") or (BUDGET_APP_LEGACY_REQUIRED_VERSION if legacy_valid else ""))
        info["version"] = version
        info["identity"] = identity or {}
        info["identity_errors"] = identity_errors
        info["contract_mode"] = "artifact_contract_v1" if contract_valid else ("legacy_v6" if legacy_valid else "invalid")
        info["markers"] = {"version": bool(version), **feature_markers}
        missing_markers = [key for key, present in feature_markers.items() if not present]

        info["valid"] = bool(info["html"] and (contract_valid or legacy_valid))
        if not info["html"]:
            info["error"] = "html_structure_missing"
        elif missing_markers:
            info["error"] = "missing_required_features: " + ", ".join(missing_markers)
        elif not contract_valid and not legacy_valid:
            info["error"] = "artifact_contract_invalid: " + ", ".join(identity_errors)
        return info
    except Exception as exc:
        info["error"] = repr(exc)
        return info

BUDGET_APP_RECEIPT = os.path.join(FOLDERS["Moneyapp"], "install_receipt.json")
BUDGET_APP_ATTEMPT_LOG = os.path.join(FOLDERS["Moneyapp"], "install_attempt.json")

def budget_status_html(info, receipt=None):
    ok = bool(info.get("valid"))
    title = "אפליקציית התקציב תואמת לחוזה ההתקנה" if ok else "אפליקציית התקציב אינה עומדת בחוזה התאימות"
    details = json.dumps({"installed": info, "receipt": receipt or {}}, ensure_ascii=False, indent=2)
    return admin_status_page(title, "הבדיקה קוראת ישירות את C:\\TripServer\\Moneyapp\\index.html ומאמתת זהות, גרסה ויכולות נדרשות.", ok=ok, details=details)

@app.route("/admin/budget-app/status")
def budget_app_status():
    target = os.path.join(FOLDERS["Moneyapp"], "index.html")
    info = inspect_budget_app_file(target)
    receipt = {}
    attempt = {}
    try:
        if os.path.isfile(BUDGET_APP_RECEIPT):
            receipt = json.loads(Path(BUDGET_APP_RECEIPT).read_text(encoding="utf-8"))
    except Exception as exc:
        receipt = {"error": repr(exc)}
    try:
        if os.path.isfile(BUDGET_APP_ATTEMPT_LOG):
            attempt = json.loads(Path(BUDGET_APP_ATTEMPT_LOG).read_text(encoding="utf-8"))
    except Exception as exc:
        attempt = {"error": repr(exc)}
    details = json.dumps({"installed": info, "receipt": receipt, "last_install_attempt": attempt}, ensure_ascii=False, indent=2)
    ok = bool(info.get("valid"))
    title = "אפליקציית התקציב תואמת לחוזה ההתקנה" if ok else "אפליקציית התקציב אינה עומדת בחוזה התאימות"
    return admin_status_page(title, "הבדיקה קוראת ישירות את C:\\TripServer\\Moneyapp\\index.html, מאמתת את חוזה הזהות ומציגה גם את ניסיון ההתקנה האחרון.", ok=ok, details=details), (200 if ok else 409)


def record_budget_install_attempt(stage, uploaded=None, extra=None):
    payload = {
        "at": datetime.now().isoformat(timespec="seconds"),
        "stage": stage,
        "request_path": request.path if request else "",
        "request_method": request.method if request else "",
        "content_type": request.content_type if request else "",
        "content_length": request.content_length if request else None,
        "remote_addr": request.remote_addr if request else "",
        "filename": getattr(uploaded, "filename", "") if uploaded is not None else "",
        "extra": extra or {},
    }
    try:
        os.makedirs(FOLDERS["Moneyapp"], exist_ok=True)
        Path(BUDGET_APP_ATTEMPT_LOG).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception:
        pass
    return payload

def install_budget_app_uploaded(uploaded):
    """Validate and atomically install Moneyapp through the established /upload route."""
    record_budget_install_attempt("request_received", uploaded)
    if not uploaded or not uploaded.filename:
        return admin_status_page("לא נבחר קובץ", "בחר קובץ HTML של אפליקציית התקציב.", ok=False), 400
    if file_ext(uploaded.filename) not in {".html", ".htm"}:
        return admin_status_page("קובץ לא תקין", "ניתן להתקין כאן רק קובץ HTML.", ok=False), 400
    os.makedirs(FOLDERS["Moneyapp"], exist_ok=True)
    fd, temp_path = tempfile.mkstemp(prefix="budget_upload_", suffix=".html", dir=FOLDERS["Moneyapp"])
    os.close(fd)
    target = os.path.join(FOLDERS["Moneyapp"], "index.html")
    backup = None
    try:
        uploaded.save(temp_path)
        record_budget_install_attempt("temporary_file_saved", uploaded, {"temp_path": temp_path, "size": os.path.getsize(temp_path)})
        candidate = inspect_budget_app_file(temp_path)
        if not candidate.get("valid"):
            return budget_status_html(candidate), 400
        if os.path.isfile(target):
            backup_dir = os.path.join(BASE_DIR, "backups", "Moneyapp")
            os.makedirs(backup_dir, exist_ok=True)
            backup = os.path.join(backup_dir, "index_before_budget_" + time.strftime("%Y%m%d_%H%M%S") + ".html")
            shutil.copy2(target, backup)
        os.replace(temp_path, target)
        installed = inspect_budget_app_file(target)
        if not installed.get("valid") or installed.get("sha256") != candidate.get("sha256"):
            if backup and os.path.isfile(backup):
                shutil.copy2(backup, target)
            raise RuntimeError("post_install_verification_failed")
        receipt = {
            "installed_at": datetime.now().isoformat(timespec="seconds"),
            "source_filename": uploaded.filename,
            "target": target,
            "version": installed.get("version") or "unknown",
            "contract_schema": EITAN_ARTIFACT_CONTRACT_SCHEMA,
            "contract_mode": installed.get("contract_mode"),
            "size": installed["size"],
            "sha256": installed["sha256"],
            "backup": backup,
            "upload_route": "/upload/Moneyapp",
        }
        Path(BUDGET_APP_RECEIPT).write_text(json.dumps(receipt, ensure_ascii=False, indent=2), encoding="utf-8")
        record_budget_install_attempt("installation_completed", uploaded, receipt)
        return admin_status_page("התקנת אפליקציית התקציב הושלמה", "הקובץ הוחלף דרך מנגנון ההעלאה הראשי, נקרא מחדש מהדיסק וכל התכונות הנדרשות אומתו.", ok=True, details=json.dumps(receipt, ensure_ascii=False, indent=2))
    except Exception as exc:
        return admin_status_page("התקנת אפליקציית התקציב נכשלה", "הגרסה הקודמת נשמרה או שוחזרה.", ok=False, details=repr(exc)), 500
    finally:
        try:
            if os.path.exists(temp_path): os.remove(temp_path)
        except Exception:
            pass

@app.route("/admin/budget-app/install", methods=["POST"])
def install_budget_app():
    # Backward-compatible endpoint; the UI uses /upload/Moneyapp, the proven upload family.
    return install_budget_app_uploaded(request.files.get("file"))


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


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


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




@app.route("/upload_usa_image", methods=["POST"])
@app.route("/upload_usa_media", methods=["POST"])
def upload_usa_image():
    image_key = request.form.get("image_key", "").strip()
    target_variant = request.form.get("target", "phone").strip()
    file = request.files.get("file")
    if not file or file.filename == "":
        return admin_status_page("לא נבחר קובץ", "בחר קובץ מתאים ולאחר מכן נסה שוב.", ok=False), 400
    try:
        result = save_usa_presentation_media(image_key, file, target_variant)
        if not result:
            return admin_status_page("שקף לא מוכר", "מפתח המדיה שנשלח אינו תואם לשקף קיים במצגת ארה״ב.", ok=False), 404
        kind_he = "סרטון" if result.get("kind") == "video" else "תמונה"
        details = (
            f"יעד תצוגה: {result.get('target_label', '')}\n"
            f"סוג מדיה: {kind_he}\n"
            f"יעד קובץ:\n{result['path']}\n\n"
            f"גודל:\n{result['size']:,} bytes\n\n"
            f"SHA256:\n{result['sha256']}\n\n"
            f"מצב שמירה:\n{result['mode']}\n\n"
            f"מספר סרטונים במצגת: {result.get('video_count', 0)}\n"
            f"סך ציר זמן: {result.get('timeline_total_seconds', 0):.6f} שניות"
        )
        if result.get("timeline_warnings"):
            details += "\n\nאזהרות תזמון:\n" + "\n".join(result.get("timeline_warnings") or [])
        details += "\n\n" + media_details_text(result.get("media"))
        details += "\n\nמנוע מצגת:\n" + json.dumps(result.get("presentation_media_engine") or {}, ensure_ascii=False, indent=2)
        return admin_status_page(
            f"{kind_he} מצגת ארה״ב הועלה",
            f"{kind_he} החליף את המדיה הקודמת בשקף {image_key} עבור {result.get('target_label', 'היעד')}. ציר הזמן וה־manifest של היעד שנבחר חושבו מחדש.",
            ok=True,
            details=details,
        )
    except Exception as exc:
        return admin_status_page(
            "שגיאה בהעלאת מדיה",
            "הקובץ לא נשמר והמדיה הקודמת נשארה. בדוק פורמט, שם שקף ואורך הסרטונים מול משך השיר.",
            ok=False,
            details=repr(exc),
        ), 400


@app.route("/upload_usa_images_zip", methods=["POST"])
@app.route("/upload_usa_media_zip", methods=["POST"])
def upload_usa_images_zip():
    target_variant = request.form.get("target", "phone").strip()
    file = request.files.get("file")
    if not file or file.filename == "":
        return admin_status_page("לא נבחר קובץ", "בחר קובץ מתאים ולאחר מכן נסה שוב.", ok=False), 400
    original_name = file.filename or ""
    if not original_name.lower().endswith(".zip"):
        return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק ZIP של תמונות וסרטוני מצגת.", ok=False), 400
    if request.content_length and request.content_length > USA_MEDIA_ZIP_MAX_BYTES + 2 * 1024 * 1024:
        return admin_status_page("קובץ גדול מדי", f"קובץ המדיה גדול מהמגבלה: {USA_MEDIA_ZIP_MAX_BYTES:,} bytes.", ok=False), 413
    try:
        imported, skipped, missing, target_result = save_usa_presentation_media_zip(file, target_variant)
        details = "יעד תצוגה: " + target_result.get("target_label", "") + "\n"
        details += "נשמרו בתיקיית המדיה:\n" + ("\n".join(imported) if imported else "לא נשמרו קבצים.")
        details += f"\n\nמספר סרטונים במצגת: {target_result.get('video_count', 0)}"
        details += f"\nסך ציר זמן: {target_result.get('timeline_total_seconds', 0):.6f} שניות"
        if target_result.get("timeline_warnings"):
            details += "\n\nאזהרות תזמון:\n" + "\n".join(target_result.get("timeline_warnings") or [])
        details += "\n\nמנוע מצגת:\n" + json.dumps(target_result.get("presentation_media_engine") or {}, ensure_ascii=False, indent=2)
        if skipped:
            details += "\n\nדולגו:\n" + "\n".join(skipped[:160])
        if missing:
            details += "\n\nשקפים שעדיין אין להם קובץ מדיה:\n" + "\n".join(missing[:160])
        return admin_status_page(
            "ZIP מדיה ארה״ב נטען",
            f"נשמרו {len(imported)} קובצי מדיה ל־{target_result.get('target_label', 'יעד נבחר')}. כל הסרטונים אומתו, וציר הזמן וה־manifest של היעד שנבחר חושבו מחדש.",
            ok=True,
            details=details,
        )
    except zipfile.BadZipFile:
        return admin_status_page("ZIP לא תקין", "הקובץ שהועלה אינו ZIP תקין או שלא ניתן לפתוח אותו.", ok=False), 400
    except ValueError as exc:
        return admin_status_page("ZIP לא תקין", "הקובץ לא עומד במגבלות או בתנאי הסנכרון. לא בוצע שינוי חלקי.", ok=False, details=str(exc)), 400
    except Exception as exc:
        return admin_status_page("שגיאה בהעלאת ZIP", "לא ניתן היה לעבד את קובצי המדיה. הפעולה בוטלה ושוחזרה.", ok=False, details=repr(exc)), 500


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


@app.route("/confirm_japan_experience_music_upload/<token>", methods=["POST"])
def confirm_japan_experience_music_upload(token):
    action = request.form.get("action", "")
    return commit_japan_experience_pending_music(token, action)


@app.route("/upload_japan_music/<music_key>", methods=["POST"])
def upload_japan_music(music_key):
    file = request.files.get("file")
    if not file or file.filename == "":
        return admin_status_page("לא נבחר קובץ", "בחר קובץ מתאים ולאחר מכן נסה שוב.", ok=False), 400
    if request.content_length and request.content_length > AUDIO_UPLOAD_MAX_BYTES + 1024 * 1024:
        return admin_status_page("קובץ מוזיקה גדול מדי", f"קובץ האודיו גדול מהמגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}.", ok=False), 413
    if music_key == "japan_experience_music":
        return handle_japan_experience_music_upload_with_confirmation(file)
    result = save_japan_music(music_key, file)
    if not result:
        return admin_status_page("סוג מוזיקה לא מוכר", "לא נמצא יעד שמירה מתאים לקובץ המוזיקה של יפן.", ok=False), 404
    duration_status = register_existing_music_upload_duration("JAPAN", music_key, result)
    details = "נשמר אל:\n" + "\n".join([item["path"] for item in result["targets"]]) + f"\n\nSHA256 MP3 סופי:\n{result['sha256']}"
    details += "\n\n" + media_details_text(result.get("media"))
    details += "\n\n" + music_upload_duration_details(duration_status)
    if duration_status.get("duration_ok"):
        if music_key == "japan_experience_music":
            if duration_status.get("sync_performed"):
                return admin_status_page("מוזיקת יפן הועלתה והמצגת סונכרנה", "השיר נשמר, משך השיר זוהה, וכל שקפי מצגת יפן סונכרנו לפי האורך החדש.", ok=True, details=details)
            return admin_status_page("מוזיקת יפן נשמרה — סנכרון מצגת נכשל", "השיר נשמר ומשך השיר זוהה, אך סנכרון שקפי מצגת יפן נכשל. אין אישור שהמצגת מסונכרנת.", ok=False, details=details), 200
        return admin_status_page("מוזיקת פתיח טיסה הועלתה", "הקובץ נשמר ומשך השיר זוהה. פתיח הטיסה הוא מקטע נפרד ולכן japan-experience.html לא שונה.", ok=True, details=details)
    return admin_status_page("מוזיקת יפן נשמרה — בדיקת אורך נכשלה", "השיר נשמר, אבל ffprobe לא הצליח לזהות משך תקין. אין אישור לסנכרון.", ok=False, details=details), 200


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



@app.route("/admin/netlify/download/<trip>")
def admin_netlify_download(trip):
    trip_key = normalize_netlify_trip_key(trip) or v58_normalize_generic_trip_key(trip)
    if not trip_key:
        return admin_status_page("טיול לא מוכר", "לא נמצאה תיקיית טיול פעילה עם index.html עבור הבחירה.", ok=False), 404
    try:
        zip_path, details = build_netlify_deploy_zip_v58(trip_key)
        response = send_file(
            zip_path,
            as_attachment=True,
            download_name=os.path.basename(zip_path),
            mimetype="application/zip",
            conditional=False,
            max_age=0,
        )
        response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
        response.headers["X-TripServer-Netlify-Trip"] = trip_key
        response.headers["X-TripServer-Netlify-Files"] = str(details.get("files", ""))
        return response
    except Exception as exc:
        return admin_status_page(
            "יצירת ZIP ל־Netlify נכשלה",
            "לא הצלחתי להכין את חבילת Netlify. הקבצים המקוריים לא שונו.",
            ok=False,
            details=repr(exc),
        ), 500

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


@app.route("/upload_usa_music/<music_key>", methods=["POST"])
def upload_usa_music(music_key):
    file = request.files.get("file")
    if not file or file.filename == "":
        return admin_status_page("לא נבחר קובץ", "בחר קובץ מתאים ולאחר מכן נסה שוב.", ok=False), 400
    if request.content_length and request.content_length > AUDIO_UPLOAD_MAX_BYTES + 1024 * 1024:
        return admin_status_page("קובץ מוזיקה גדול מדי", f"קובץ האודיו גדול מהמגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}.", ok=False), 413
    saved = save_usa_music(music_key, file)
    if not saved:
        return admin_status_page("סוג מוזיקה לא מוכר", "לא נמצא יעד שמירה מתאים לקובץ המוזיקה של ארה״ב.", ok=False), 404
    filename, result = saved
    duration_status = register_existing_music_upload_duration("USA", music_key, result)
    target_lines = []
    for item in result.get("targets") or []:
        if isinstance(item, dict) and item.get("path"):
            target_lines.append(item.get("path"))
    details = "נשמר אל:\n" + ("\n".join(target_lines) if target_lines else "לא זוהה יעד שמירה.")
    details += f"\n\nגודל MP3 סופי:\n{result.get('size', 0):,} bytes\n\nSHA256 MP3 סופי:\n{result.get('sha256', '')}"
    backup_lines = [item.get("backup") for item in result.get("targets") or [] if isinstance(item, dict) and item.get("backup")]
    if backup_lines:
        details += "\n\nגיבויים קודמים:\n" + "\n".join(backup_lines)
    details += "\n\n" + media_details_text(result.get("media"))
    details += "\n\n" + music_upload_duration_details(duration_status)
    if duration_status.get("duration_ok"):
        if duration_status.get("sync_performed"):
            return admin_status_page("מוזיקת ארה״ב הועלתה והמצגות סונכרנו", f"הקובץ נשמר בנתיבים הקבועים של USA ושל USA_TV: {filename}. משך השיר זוהה, ומקטע המצגת הרלוונטי סונכרן בכל מצגת קיימת.", ok=True, details=details)
        if duration_status.get("audio_rollback_performed"):
            return admin_status_page("העלאת מוזיקת ארה״ב בוטלה בבטחה", "השיר החדש לא התאים לציר הזמן הקיים או שסנכרון המצגת נכשל. המצגות לא שונו וקובצי המוזיקה הקודמים שוחזרו אוטומטית.", ok=False, details=details), 400
        return admin_status_page("סנכרון מוזיקת ארה״ב נכשל", "משך השיר זוהה, אך הסנכרון והשחזור לא הושלמו במלואם. בדוק את הפירוט לפני שימוש במצגת.", ok=False, details=details), 500
    return admin_status_page("מוזיקת ארה״ב נשמרה — בדיקת אורך נכשלה", "השיר נשמר, אבל ffprobe לא הצליח לזהות משך תקין. אין אישור לסנכרון.", ok=False, details=details), 200



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

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

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

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

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


@app.route("/power/cancel", methods=["POST"])
def power_cancel():
    allowed, reason = power_action_allowed()
    if not allowed:
        return admin_status_page("ביטול פעולת מחשב נחסם", reason, ok=False), 403
    rc, output = cancel_power_command()
    details = output or f"return code: {rc}"
    if rc == 0:
        write_admin_failsafe_status("cancelled", "ריסטארט/כיבוי מתוזמן בוטל ידנית מהפאנל.", windows_output=details)
        return admin_status_page("פעולה מתוזמנת בוטלה", "אם היה כיבוי/ריסטארט בהמתנה — הוא בוטל.", ok=True, details=details)
    if is_no_pending_shutdown_message(details):
        write_admin_failsafe_status("none", "אין כרגע ריסטארט Fail Safe פעיל. כנראה שהוא כבר בוטל אוטומטית או שלא היה ריסטארט בהמתנה.", windows_output=details)
        return admin_status_page("אין ריסטארט פעיל כרגע", "אין כרגע כיבוי/ריסטארט בהמתנה. זה תקין אם העדכון הצליח וה־Fail Safe כבר בוטל אוטומטית.", ok=True, details=details)
    write_admin_failsafe_status("cancel_failed", "ניסיון ביטול ריסטארט החזיר שגיאה שדורשת בדיקה.", windows_output=details, return_code=rc)
    return admin_status_page("ביטול פעולה מתוזמנת נכשל", "Windows החזיר שגיאה בביטול כיבוי/ריסטארט מתוזמן.", ok=False, details=details)

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


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


@app.route("/USA_TV")
@app.route("/USA_TV/")
def usa_tv():
    return serve_app("USA_TV")


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


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


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


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


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


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


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


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


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


# ================= PRESENTATION QA CENTER v47 =================
PRESENTATION_QA_VERSION = "60.0-clean"
PRESENTATION_QA_MIN_IMAGE_SECONDS = 5.0
PRESENTATION_QA_GOOD_IMAGE_SECONDS = 8.0


def _qa_transition_checks(report):
    entries = report.get("timeline") or []
    checks = []
    def add(name, ok, detail):
        checks.append({"name": name, "ok": bool(ok), "detail": detail, "level": "ok" if ok else "error", "category": "regression"})
    if not entries:
        add("ציר זמן לרגרסיה", False, "אין נתוני ציר זמן")
        return checks
    monotonic = all(entries[i]["end"] <= entries[i+1]["end"] + 1e-9 and entries[i+1]["start"] >= entries[i]["start"] for i in range(len(entries)-1))
    add("מעבר רציף בין כל השקפים", monotonic, f"נבדקו {max(0, len(entries)-1)} מעברים")
    for a, b in ((21,22),(22,23),(23,24),(24,25),(42,43),(43,44)):
        if b <= len(entries):
            left, right = entries[a-1], entries[b-1]
            ok = abs(left["end"] - right["start"]) <= 0.005 and right["start"] >= left["start"]
            add(f"מעבר {a}→{b}", ok, f"{left['end']:.3f}→{right['start']:.3f} שנ׳")
    add("ניווט ידני קדימה בזמן נגינה", all(e["start"] > 0 for e in entries[1:]), "לכל שקף אחרי הראשון קיימת נקודת seek חיובית")
    add("אין מקטעים שליליים או אפסיים", all(e["seconds"] > 0 and e["end"] > e["start"] for e in entries), "כל המקטעים מתקדמים קדימה")
    return checks


def _qa_score(report):
    groups = {}
    for c in report.get("checks", []):
        groups.setdefault(c.get("category", "structure"), []).append(c)
    weights = {"media":20,"timeline":20,"regression":20,"offline":15,"code":10,"structure":10,"performance":5}
    scores = {}
    total = 0.0
    for name, weight in weights.items():
        items = groups.get(name, [])
        failures = sum(1 for x in items if not x.get("ok") and x.get("level") != "warning")
        warnings = sum(1 for x in items if not x.get("ok") and x.get("level") == "warning")
        score = max(0, 100 - failures * 35 - warnings * 10)
        scores[name] = score
        total += score * weight / 100
    return round(total), scores


_OLD_BUILD_PRESENTATION_CONTROL_REPORT = _pc_base_build_presentation_control_report


def _pc_music_build_presentation_control_report(target_key="usa_phone"):
    report = _OLD_BUILD_PRESENTATION_CONTROL_REPORT(target_key)
    if not report.get("presentation", {}).get("exists"):
        return report
    cfg, root, presentation_path, media_dir = _pc_target_paths(target_key)
    try:
        html = read_text_file_utf8(presentation_path)
        info = inspect_presentation_html(html)
        media_map = media_files_by_base(Path(media_dir))
        video_durations = {}
        for base in info.slide_bases:
            path = media_map.get(str(base).lower())
            if path and Path(path).suffix.lower() in VIDEO_EXTENSIONS:
                video_durations[str(base).lower()] = float(probe_video_duration_seconds(str(path)))
        timeline = allocate_timeline(info.slide_bases, info.parts, video_durations)
        report["timeline"] = [{"slide":e.index+1,"base":e.base,"kind":e.kind,"seconds":e.timeline_seconds,"media_seconds":e.media_seconds,"start":e.start,"end":e.end,"part":e.part_index+1} for e in timeline.entries]
        for c in report.get("checks", []):
            n = c.get("name", "")
            if "מדיה" in n or "מוזיקה" in n: c["category"] = "media"
            elif "אופליין" in n: c["category"] = "offline"
            elif "ציר הזמן" in n: c["category"] = "timeline"
            else: c.setdefault("category", "structure")
        report["checks"].extend(_qa_transition_checks(report))
        for part in report.get("parts", []):
            ok = part.get("image_count", 0) == 0 or part.get("per_image_seconds", 0) >= PRESENTATION_QA_MIN_IMAGE_SECONDS
            report["checks"].append({"name":f"{part.get('label')}: לפחות 5 שניות לתמונה","ok":ok,"detail":f"{part.get('per_image_seconds',0):.2f} שנ׳","level":"ok" if ok else "error","category":"timeline"})
        total_bytes = report.get("totals", {}).get("media_bytes", 0) + report.get("totals", {}).get("presentation_bytes", 0)
        def est(mbps): return round(total_bytes * 8 / (mbps * 1_000_000), 1) if total_bytes else 0
        video_bytes = sum(v.get("size", 0) for v in report.get("videos", []))
        report["performance"] = {"total_bytes":total_bytes,"video_bytes":video_bytes,"estimated_seconds":{"wifi_100mbps":est(100),"wifi_30mbps":est(30),"cellular_10mbps":est(10)}}
        report["checks"].append({"name":"משקל כולל בתחום סביר לדפדפן","ok":total_bytes < 2_000_000_000,"detail":format_bytes(total_bytes),"level":"warning" if total_bytes >= 2_000_000_000 else "ok","category":"performance"})
        score, category_scores = _qa_score(report)
        report["score"] = score
        report["category_scores"] = category_scores
        report["trip_ready"] = all(x.get("ok") or x.get("level") == "warning" for x in report.get("checks", [])) and score >= 95 and report.get("audio_timeline_source") == "ffprobe"
        report["qa_version"] = PRESENTATION_QA_VERSION
        report["ok"] = all(x.get("ok") or x.get("level") == "warning" for x in report.get("checks", []))
    except Exception as exc:
        report.setdefault("warnings", []).append("QA v47: " + repr(exc))
    return report


def _qa_timeline_html(report):
    entries = report.get("timeline") or []
    if not entries:
        return '<div class="empty">לא ניתן לבנות ציר זמן.</div>'
    total = max((e["end"] for e in entries), default=1)
    blocks = []
    for e in entries:
        width = max(0.55, e["seconds"] / total * 100)
        cls = "video" if e["kind"] == "video" else "image"
        blocks.append(f'<div class="tl-item {cls}" style="flex-basis:{width:.4f}%" title="שקף {e["slide"]}: {_pc_h(e["base"])} · {e["seconds"]:.2f} שנ׳"><span>{e["slide"]}</span></div>')
    return '<div class="timeline">' + ''.join(blocks) + '</div><div class="legend"><span class="lg image"></span>תמונה <span class="lg video"></span>סרטון</div>'


def render_presentation_control_center():
    target = request.args.get("target", "usa_phone")
    if target not in PRESENTATION_CONTROL_TARGETS:
        target = "usa_phone"
    r = build_presentation_control_report(target)
    targets = ''.join(f'<a class="target {"active" if k == target else ""}" href="/admin/presentations?target={_pc_h(k)}">{_pc_h(v["label"])}</a>' for k, v in PRESENTATION_CONTROL_TARGETS.items())
    parts = ''.join(f'<article class="part {p.get("status","red")}"><h2>{_pc_h(p.get("label"))}</h2><div class="metric"><b>{p.get("song_seconds",0):.2f}</b><span>שניות בשיר</span></div><div class="metric"><b>{p.get("video_count",0)}</b><span>סרטונים · {p.get("video_seconds",0):.2f} שנ׳</span></div><div class="metric"><b>{p.get("image_count",0)}</b><span>שקפי תמונה</span></div><div class="hero-number">{p.get("per_image_seconds",0):.2f} שנ׳</div><p>זמן לכל תמונה</p><small>רזרבה מעל 5 שנ׳: {p.get("reserve_to_5_seconds",0):.2f} שנ׳</small></article>' for p in r.get("parts", []))
    checks = ''.join(f'<tr><td>{"✅" if c.get("ok") else ("⚠️" if c.get("level") == "warning" else "❌")}</td><td><b>{_pc_h(c.get("name"))}</b><small>{_pc_h(c.get("category",""))}</small></td><td>{_pc_h(c.get("detail")) or "—"}</td></tr>' for c in r.get("checks", []))
    videos = ''.join(f'<tr><td>{_pc_h(v.get("base"))}</td><td>{_pc_h(v.get("file"))}</td><td>{v.get("duration",0):.2f} שנ׳</td><td>{format_bytes(v.get("size",0))}</td></tr>' for v in r.get("videos", [])) or '<tr><td colspan="4">אין סרטונים.</td></tr>'
    perf = r.get("performance", {}); est = perf.get("estimated_seconds", {}); cs = r.get("category_scores", {})
    cards = ''.join(f'<div><b>{label}</b><strong>{cs.get(key,100)}</strong></div>' for key,label in (("media","מדיה"),("timeline","ציר זמן"),("regression","רגרסיה"),("offline","אופליין"),("structure","מבנה"),("performance","ביצועים")))
    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>מרכז QA למצגות</title><style>
:root{{--bg:#07111f;--panel:#101d31;--line:#304560;--text:#f8fafc;--muted:#b8c6d9}}*{{box-sizing:border-box}}body{{margin:0;background:radial-gradient(circle at 85% 0,#173563,transparent 30rem),var(--bg);color:var(--text);font-family:Arial,'Segoe UI',sans-serif}}.wrap{{width:min(1220px,94%);margin:auto;padding:24px 0 60px}}a{{color:#dbeafe}}.head,.panel{{background:var(--panel);border:1px solid var(--line);border-radius:24px;padding:18px;margin-bottom:15px;box-shadow:0 18px 50px #0004}}.head{{background:linear-gradient(135deg,#172554,#0f172a)}}h1{{margin:0 0 8px;font-size:clamp(29px,5vw,46px)}}p,small{{color:var(--muted)}}.actions,.targets{{display:flex;gap:8px;flex-wrap:wrap;margin-top:13px}}.actions a,.target{{text-decoration:none;padding:10px 14px;border-radius:999px;background:#243653;border:1px solid #466080;font-weight:900}}.target.active{{background:#2563eb;border-color:#93c5fd}}.ready{{display:grid;grid-template-columns:180px 1fr;gap:16px;align-items:center}}.score{{width:150px;height:150px;border-radius:50%;display:grid;place-items:center;background:conic-gradient(#22c55e calc(var(--score)*1%),#334155 0);position:relative;font-size:36px;font-weight:950}}.score:before{{content:'';position:absolute;inset:12px;border-radius:50%;background:#0f172a}}.score span{{position:relative}}.category-scores{{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px}}.category-scores div{{background:#0b1628;border:1px solid #334155;border-radius:14px;padding:10px}}.category-scores b{{display:block;color:#cbd5e1;font-size:12px}}.category-scores strong{{font-size:22px}}.parts{{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:13px}}.part{{background:var(--panel);border:2px solid var(--line);border-radius:22px;padding:17px}}.part.green{{border-color:#15803d}}.part.yellow{{border-color:#d97706}}.part.red{{border-color:#dc2626}}.metric{{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--line)}}.hero-number{{font-size:42px;color:#bfdbfe;font-weight:950;margin-top:13px}}.timeline{{display:flex;width:100%;min-height:76px;gap:2px;direction:ltr;overflow:auto;padding:7px;background:#07111f;border-radius:15px}}.tl-item{{min-width:17px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:900;color:white}}.tl-item.image{{background:#2563eb}}.tl-item.video{{background:#f97316}}.legend{{margin-top:9px;color:#cbd5e1}}.lg{{display:inline-block;width:14px;height:14px;border-radius:4px;margin:0 8px}}.lg.image{{background:#2563eb}}.lg.video{{background:#f97316}}table{{width:100%;border-collapse:collapse}}th,td{{padding:9px;border-bottom:1px solid var(--line);text-align:right;vertical-align:top}}td small{{display:block;font-size:10px;text-transform:uppercase}}.perf{{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:9px}}.perf div{{background:#0b1628;border-radius:14px;padding:12px}}.perf b{{display:block;font-size:21px}}@media(max-width:650px){{.ready{{grid-template-columns:1fr}}.score{{margin:auto}}th,td{{font-size:11px;padding:6px 3px}}}}</style></head><body><main class="wrap"><section class="head"><h1>🎛️ מרכז QA ובקרת מצגות</h1><p>Timeline, חישובי זמן, בדיקות רגרסיה, מוכנות אופליין וביצועים — מתוך הקבצים הפעילים בשרת.</p><div class="actions"><a href="/">🏠 ראשי</a><a href="/admin">📁 אדמין</a><a href="{_pc_h(PRESENTATION_CONTROL_TARGETS[target]['open_url'])}" target="_blank">▶️ פתח מצגת</a><a href="/admin/presentations?target={_pc_h(target)}">🔄 בדיקה מלאה</a><a href="/admin/presentations/report.json?target={_pc_h(target)}" target="_blank">JSON</a></div><div class="targets">{targets}</div></section><section class="panel ready"><div class="score" style="--score:{r.get('score',0)}"><span>{r.get('score',0)}</span></div><div><h2>{'✅ Trip Ready' if r.get('trip_ready') else '⚠️ נדרשת בדיקה'}</h2><p>מנוע QA v{PRESENTATION_QA_VERSION} · {len(r.get('checks',[]))} בדיקות · {_pc_h(r.get('generated_at'))}</p><div class="category-scores">{cards}</div></div></section><section class="parts">{parts or '<article class="part red">לא ניתן לחשב זמן.</article>'}</section><section class="panel"><h2>📈 Timeline מלא</h2>{_qa_timeline_html(r)}</section><section class="panel"><h2>⚡ ביצועים ומשקל</h2><div class="perf"><div><b>{format_bytes(perf.get('total_bytes',0))}</b>סה״כ</div><div><b>{format_bytes(perf.get('video_bytes',0))}</b>סרטונים</div><div><b>{est.get('wifi_100mbps','—')} שנ׳</b>Wi‑Fi 100Mbps</div><div><b>{est.get('wifi_30mbps','—')} שנ׳</b>Wi‑Fi 30Mbps</div><div><b>{est.get('cellular_10mbps','—')} שנ׳</b>10Mbps</div></div></section><section class="panel"><h2>🧪 בדיקות מלאות ורגרסיה</h2><table><thead><tr><th>מצב</th><th>בדיקה</th><th>פירוט</th></tr></thead><tbody>{checks}</tbody></table></section><section class="panel"><h2>🎬 סרטונים פעילים</h2><table><thead><tr><th>שקף</th><th>קובץ</th><th>משך</th><th>גודל</th></tr></thead><tbody>{videos}</tbody></table></section></main></body></html>'''


@app.route('/admin/presentations/audit.json')
def admin_presentation_audit_json_v47():
    target = request.args.get('target', 'usa_phone')
    if target not in PRESENTATION_CONTROL_TARGETS:
        target = 'usa_phone'
    return Response(json.dumps(build_presentation_control_report(target), ensure_ascii=False, indent=2), content_type='application/json; charset=utf-8')


# v59 — QA UX consolidation: no duplicate dashboard card; one central quality hub.
# ================= TRIP PLATFORM QA v49 =================
# Modular, read-only control layer built on the existing v48 analysis engine.
# It does not change trip or presentation files; it analyses the active server files.
TRIP_PLATFORM_QA_VERSION = "60.0-clean"
TRIP_PLATFORM_MODULES = (
    ("dashboard", "🏠", "Dashboard"),
    ("studio", "🎬", "Presentation Studio"),
    ("qa", "🔍", "QA Center"),
    ("deployment", "🚀", "Deployment"),
    ("analytics", "📈", "Analytics"),
    ("audit", "✅", "בדיקת מוכנות"),
)


def _v49_target_key():
    key = request.args.get("target", "usa_phone")
    return key if key in PRESENTATION_CONTROL_TARGETS else "usa_phone"


def _v49_reports():
    return {key: build_presentation_control_report(key) for key in PRESENTATION_CONTROL_TARGETS}


def _v49_status(report):
    if report.get("trip_ready"):
        return "ok", "מוכן"
    hard = [c for c in report.get("checks", []) if not c.get("ok") and c.get("level") != "warning"]
    return ("bad", f"{len(hard)} כשלים") if hard else ("warn", "אזהרות")


def _v49_nav(active, target):
    links=[]
    for key, icon, label in TRIP_PLATFORM_MODULES:
        cls=' active' if key==active else ''
        links.append(f'<a class="v49-nav{cls}" href="/admin/platform/{key}?target={_pc_h(target)}">{icon} {_pc_h(label)}</a>')
    return ''.join(links)


def _v49_targets(target, module):
    return ''.join(
        f'<a class="v49-target {"active" if key==target else ""}" href="/admin/platform/{module}?target={_pc_h(key)}">{_pc_h(cfg["label"])}</a>'
        for key,cfg in PRESENTATION_CONTROL_TARGETS.items()
    )


def _v49_shell(title, active, target, body, subtitle=""):
    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>{_pc_h(title)}</title><style>
:root{{--bg:#07111f;--panel:#101d31;--panel2:#0b1628;--line:#304560;--text:#f8fafc;--muted:#b8c6d9;--blue:#2563eb;--green:#22c55e;--yellow:#f59e0b;--red:#ef4444;--orange:#f97316}}*{{box-sizing:border-box}}body{{margin:0;background:radial-gradient(circle at 85% 0,#173563,transparent 30rem),var(--bg);color:var(--text);font-family:Arial,'Segoe UI',sans-serif}}a{{color:#dbeafe}}.wrap{{width:min(1280px,95%);margin:auto;padding:20px 0 60px}}.hero,.panel{{background:var(--panel);border:1px solid var(--line);border-radius:24px;padding:18px;margin-bottom:15px;box-shadow:0 18px 50px #0004}}.hero{{background:linear-gradient(135deg,#172554,#0f172a)}}h1{{margin:0 0 7px;font-size:clamp(28px,5vw,44px)}}h2{{margin-top:0}}p,small,.muted{{color:var(--muted)}}.top-actions,.v49-tabs,.targets{{display:flex;gap:8px;flex-wrap:wrap;margin-top:13px}}.top-actions a,.v49-nav,.v49-target,.btn{{text-decoration:none;padding:10px 14px;border-radius:999px;background:#243653;border:1px solid #466080;font-weight:900;color:#fff}}.v49-nav.active,.v49-target.active{{background:var(--blue);border-color:#93c5fd}}.v49-tabs{{position:sticky;top:0;z-index:20;padding:10px;background:rgba(7,17,31,.92);backdrop-filter:blur(10px);border-radius:18px}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(245px,1fr));gap:13px}}.card{{background:var(--panel);border:1px solid var(--line);border-radius:20px;padding:16px}}.card.ok{{border-color:#15803d}}.card.warn{{border-color:#d97706}}.card.bad{{border-color:#dc2626}}.big{{font-size:38px;font-weight:950;color:#bfdbfe}}.metric{{display:flex;justify-content:space-between;gap:10px;padding:8px 0;border-bottom:1px solid var(--line)}}.metric:last-child{{border-bottom:0}}table{{width:100%;border-collapse:collapse}}th,td{{padding:9px;border-bottom:1px solid var(--line);text-align:right;vertical-align:top}}th{{color:#bfdbfe}}code{{direction:ltr;unicode-bidi:isolate}}.timeline{{display:flex;width:100%;min-height:78px;gap:2px;direction:ltr;overflow:auto;padding:7px;background:#07111f;border-radius:15px}}.tl-item{{min-width:18px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:900;color:#fff}}.tl-item.image{{background:var(--blue)}}.tl-item.video{{background:var(--orange)}}.badge{{display:inline-flex;padding:5px 9px;border-radius:999px;font-size:12px;font-weight:900}}.badge.ok{{background:#14532d;color:#bbf7d0}}.badge.warn{{background:#78350f;color:#fde68a}}.badge.bad{{background:#7f1d1d;color:#fecaca}}.score{{font-size:60px;font-weight:950}}.recommend{{padding:12px 14px;background:var(--panel2);border-right:4px solid var(--blue);border-radius:12px;margin:9px 0;line-height:1.55}}.recommend.warn{{border-color:var(--yellow)}}.recommend.bad{{border-color:var(--red)}}.preview-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px}}.preview-card{{background:var(--panel2);border:1px solid var(--line);border-radius:15px;padding:12px}}.bar{{height:10px;background:#334155;border-radius:999px;overflow:hidden}}.bar span{{display:block;height:100%;background:linear-gradient(90deg,var(--blue),var(--green))}}@media(max-width:700px){{th,td{{font-size:11px;padding:6px 3px}}.v49-tabs{{position:static}}.score{{font-size:46px}}}}</style></head><body><main class="wrap"><section class="hero"><h1>{_pc_h(title)}</h1><p>{_pc_h(subtitle or 'מרכז בקרה, בדיקות ומוכנות לכל המצגות הפעילות בשרת.')}</p><div class="top-actions"><a href="/admin">📁 אדמין</a><a href="/">🏠 ראשי</a><a href="/admin/platform/audit?target={_pc_h(target)}">✅ בדיקת מוכנות מלאה</a></div><div class="targets">{_v49_targets(target, active)}</div></section><nav class="v49-tabs">{_v49_nav(active,target)}</nav>{body}</main></body></html>'''


def _v49_timeline(report):
    entries=report.get('timeline') or []
    if not entries: return '<p class="muted">לא ניתן לבנות Timeline.</p>'
    total=max((x.get('end',0) for x in entries), default=1) or 1
    blocks=[]
    for e in entries:
        w=max(.55,float(e.get('seconds',0))/total*100)
        kind='video' if e.get('kind')=='video' else 'image'
        blocks.append(f'<div class="tl-item {kind}" style="flex-basis:{w:.4f}%" title="שקף {e.get("slide")}: {_pc_h(e.get("base"))} · {e.get("seconds",0):.2f} שנ׳">{e.get("slide")}</div>')
    return '<div class="timeline">'+''.join(blocks)+'</div><p class="muted">כחול = תמונה · כתום = סרטון</p>'


def _v49_recommendations(report):
    rec=[]
    for p in report.get('parts',[]):
        sec=float(p.get('per_image_seconds',0) or 0)
        if p.get('image_count',0)==0: continue
        if sec < PRESENTATION_QA_MIN_IMAGE_SECONDS:
            rec.append(('bad',f'{p.get("label")}: נותרו רק {sec:.2f} שניות לכל תמונה. אין להוסיף סרטון נוסף.'))
        elif sec < PRESENTATION_QA_GOOD_IMAGE_SECONDS:
            rec.append(('warn',f'{p.get("label")}: הקצב גבולי ({sec:.2f} שניות לתמונה). מומלץ לקצר סרטונים לפני הוספה נוספת.'))
        else:
            rec.append(('',f'{p.get("label")}: קצב תמונות טוב — {sec:.2f} שניות לתמונה.'))
    videos=sorted(report.get('videos',[]), key=lambda x:x.get('duration',0), reverse=True)
    if videos:
        v=videos[0]
        rec.append(('',f'הסרטון הארוך ביותר הוא {v.get("base")} ({v.get("duration",0):.2f} שניות). קיצור של 5 שניות יחזיר זמן לשקפי התמונה באותו חלק.'))
    perf=report.get('performance',{})
    if perf.get('total_bytes',0)>750_000_000:
        rec.append(('warn',f'משקל החבילה גבוה: {format_bytes(perf.get("total_bytes",0))}. מומלץ לדחוס סרטונים לפני נסיעה.'))
    hard=[c for c in report.get('checks',[]) if not c.get('ok') and c.get('level')!='warning']
    if hard: rec.insert(0,('bad',f'קיימים {len(hard)} כשלים חוסמים. אין לסמן את הגרסה כ-GOLD לפני תיקונם.'))
    return rec


def _v49_dashboard():
    reports=_v49_reports(); cards=[]
    for key,r in reports.items():
        status,label=_v49_status(r); cfg=PRESENTATION_CONTROL_TARGETS[key]
        parts=''.join(f'<div class="metric"><span>{_pc_h(p.get("label"))}</span><b>{p.get("per_image_seconds",0):.2f} שנ׳/תמונה</b></div>' for p in r.get('parts',[]))
        cards.append(f'<article class="card {status}"><span class="badge {status}">{_pc_h(label)}</span><h2>{_pc_h(cfg["label"])}</h2><div class="big">{r.get("score",0)}/100</div>{parts}<div class="metric"><span>מדיה</span><b>{r.get("totals",{}).get("media_items",0)}</b></div><div class="metric"><span>משקל</span><b>{format_bytes(r.get("performance",{}).get("total_bytes",0))}</b></div><p><a href="/admin/platform/studio?target={_pc_h(key)}">פתח Studio</a> · <a href="/admin/platform/qa?target={_pc_h(key)}">בדיקות</a></p></article>')
    body='<section class="grid">'+''.join(cards)+'</section><section class="panel"><h2>כלי בקרת איכות מרכזיים</h2><p><a class="btn" href="/admin/self-test">🧪 בדיקה עצמית מלאה</a> <a class="btn" href="/admin/platform/audit">✅ Audit ומוכנות למסירה</a> <a class="btn" href="/admin/platform/release-matrix">🔄 מטריצת סנכרון</a> <a class="btn" href="/admin/platform/audit.json" target="_blank">📄 דוח JSON</a></p></section><section class="panel"><h2>פלטפורמה גנרית</h2><p>המסך קורא את Registry היעדים. הוספת טיול עתידי ל־Registry תצרף אותו לאותם מודולים ולבדיקות, ללא שכפול מנוע.</p></section>'
    return _v49_shell('Trip Platform Dashboard','dashboard','usa_phone',body,'תמונת מצב אחת לכל המצגות, האופליין, ציר הזמן ומוכנות המסירה.')


def _v49_studio():
    target=_v49_target_key(); r=build_presentation_control_report(target); cfg=PRESENTATION_CONTROL_TARGETS[target]
    parts=''.join(f'<article class="card {p.get("status","bad")}"><h2>{_pc_h(p.get("label"))}</h2><div class="big">{p.get("per_image_seconds",0):.2f}</div><p>שניות לכל תמונה</p><div class="metric"><span>שיר</span><b>{p.get("song_seconds",0):.2f}</b></div><div class="metric"><span>סרטונים</span><b>{p.get("video_count",0)} / {p.get("video_seconds",0):.2f} שנ׳</b></div><div class="metric"><span>תמונות</span><b>{p.get("image_count",0)}</b></div></article>' for p in r.get('parts',[]))
    vids=''.join(f'<article class="preview-card"><h3>שקף {_pc_h(v.get("base"))}</h3><p>{_pc_h(v.get("file"))}</p><div class="metric"><span>משך</span><b>{v.get("duration",0):.2f} שנ׳</b></div><div class="metric"><span>גודל</span><b>{format_bytes(v.get("size",0))}</b></div></article>' for v in r.get('videos',[])) or '<p>אין סרטונים פעילים.</p>'
    body=f'<section class="grid">{parts}</section><section class="panel"><h2>Timeline</h2>{_v49_timeline(r)}</section><section class="panel"><h2>Preview ומדיה פעילה</h2><p><a class="btn" href="{_pc_h(cfg["open_url"])}" target="_blank">▶️ פתח מצגת פעילה</a></p><div class="preview-grid">{vids}</div></section>'
    return _v49_shell('Presentation Studio','studio',target,body,'ציר זמן, קצב תמונות, סרטונים ותצוגה מקדימה — ללא שינוי בקובץ הפעיל.')


def _v49_qa():
    target=_v49_target_key(); r=build_presentation_control_report(target)
    rows=''.join(f'<tr><td>{"✅" if c.get("ok") else ("⚠️" if c.get("level")=="warning" else "❌")}</td><td><b>{_pc_h(c.get("name"))}</b><br><small>{_pc_h(c.get("category",""))}</small></td><td>{_pc_h(c.get("detail")) or "—"}</td></tr>' for c in r.get('checks',[]))
    body=f'<section class="panel"><h2>Run Full QA</h2><p>נבדקו {len(r.get("checks",[]))} בדיקות. ציון: <b>{r.get("score",0)}/100</b>.</p><p><a class="btn" href="/admin/platform/qa?target={_pc_h(target)}">🔄 הרץ שוב</a> <a class="btn" href="/admin/platform/repair-presentations">🛠️ סנכרן מצגות פעילות</a> <a class="btn" href="/admin/platform/report.json?target={_pc_h(target)}" target="_blank">JSON</a></p><table><thead><tr><th>מצב</th><th>בדיקה</th><th>פירוט</th></tr></thead><tbody>{rows}</tbody></table></section>'
    return _v49_shell('QA Center','qa',target,body,'בדיקות מבנה, מדיה, Timeline, רגרסיה, אופליין וביצועים.')


def _v49_deployment():
    target=_v49_target_key(); r=build_presentation_control_report(target); cfg,root,pres,media=_pc_target_paths(target)
    stale=[]
    for name in ('usa-offline-sw.js','offline-manifest.json','media-manifest.json'):
        fp=Path(root)/name
        if fp.exists(): stale.append(str(fp))
    perf=r.get('performance',{}); est=perf.get('estimated_seconds',{})
    registry=[c for c in r.get('checks',[]) if 'אופליין' in c.get('name','') or c.get('category')=='offline']
    rows=''.join(f'<tr><td>{"✅" if c.get("ok") else "❌"}</td><td>{_pc_h(c.get("name"))}</td><td>{_pc_h(c.get("detail"))}</td></tr>' for c in registry) or '<tr><td colspan="3">אין בדיקות אופליין בדוח.</td></tr>'
    stale_html=''.join(f'<li><code>{_pc_h(x)}</code></li>' for x in stale) or '<li>לא נמצאו שרידים חיצוניים ידועים.</li>'
    body=f'''<section class="grid"><article class="card"><h2>קבצים פעילים</h2><div class="metric"><span>מצגת</span><b>{"קיימת" if Path(pres).exists() else "חסרה"}</b></div><div class="metric"><span>משקל כולל</span><b>{format_bytes(perf.get("total_bytes",0))}</b></div><div class="metric"><span>Wi‑Fi 100Mbps</span><b>{est.get("wifi_100mbps","—")} שנ׳</b></div><div class="metric"><span>10Mbps</span><b>{est.get("cellular_10mbps","—")} שנ׳</b></div></article><article class="card {"warn" if stale else "ok"}"><h2>שרידי ארכיטקטורות ישנות</h2><ul>{stale_html}</ul></article></section><section class="panel"><h2>Offline Registry</h2><table><thead><tr><th>מצב</th><th>בדיקה</th><th>פירוט</th></tr></thead><tbody>{rows}</tbody></table></section>'''
    return _v49_shell('Deployment & Offline','deployment',target,body,'בדיקת קבצים, משקל, זמן הורדה, Registry ושרידים מגרסאות ישנות.')


def _v49_analytics():
    target=_v49_target_key(); r=build_presentation_control_report(target); rec=_v49_recommendations(r)
    rec_html=''.join(f'<div class="recommend {cls}">{_pc_h(text)}</div>' for cls,text in rec)
    parts=''.join(f'<article class="card"><h2>{_pc_h(p.get("label"))}</h2><div class="metric"><span>זמן סרטונים</span><b>{p.get("video_seconds",0):.2f}</b></div><div class="metric"><span>זמן לתמונה</span><b>{p.get("per_image_seconds",0):.2f}</b></div><div class="bar"><span style="width:{min(100,max(0,p.get("per_image_seconds",0)/10*100)):.1f}%"></span></div></article>' for p in r.get('parts',[]))
    body=f'<section class="grid">{parts}</section><section class="panel"><h2>המלצות אוטומטיות</h2>{rec_html or "<p>אין המלצות.</p>"}<p class="muted">המלצות אלו מבוססות על נתוני הקבצים והספים הקבועים; הן אינן משנות קבצים אוטומטית.</p></section>'
    return _v49_shell('Analytics','analytics',target,body,'ניתוח השפעת סרטונים, קצב תמונות, משקל והמלצות פעולה.')


def _v49_audit():
    reports=_v49_reports(); all_ready=all(r.get('trip_ready') for r in reports.values())
    total_checks=sum(len(r.get('checks',[])) for r in reports.values()); failed=sum(1 for r in reports.values() for c in r.get('checks',[]) if not c.get('ok') and c.get('level')!='warning')
    cards=[]
    for key,r in reports.items():
        status,label=_v49_status(r); cards.append(f'<article class="card {status}"><span class="badge {status}">{_pc_h(label)}</span><h2>{_pc_h(PRESENTATION_CONTROL_TARGETS[key]["label"])}</h2><div class="score">{r.get("score",0)}</div><p>{len(r.get("checks",[]))} בדיקות</p></article>')
    body=f'''<section class="panel"><h2>{"✅ Trip Ready" if all_ready else "⚠️ לא מוכן למסירה"}</h2><div class="big">{total_checks-failed}/{total_checks}</div><p>בדיקות שעברו · {failed} כשלים חוסמים</p><p><a class="btn" href="/admin/platform/audit">🔄 הרץ בדיקת מוכנות מלאה</a> <a class="btn" href="/admin/platform/audit.json" target="_blank">דוח JSON</a></p></section><section class="grid">{''.join(cards)}</section><section class="panel"><h2>חותמת מסירה</h2><p>{"כל היעדים עברו את תנאי הסף של המנוע." if all_ready else "אין להכריז על GOLD לפני תיקון כל הכשלים החוסמים."}</p><small>מנוע v{TRIP_PLATFORM_QA_VERSION} · נוצר {datetime.now().isoformat(timespec="seconds")}</small></section>'''
    return _v49_shell('בדיקת מוכנות מלאה','audit','usa_phone',body,'Audit מאוחד לכל יעדי המצגות הרשומים בפלטפורמה.')


@app.route('/admin/platform')
@app.route('/admin/platform/dashboard')
def admin_platform_dashboard_v49(): return _v49_dashboard()

@app.route('/admin/platform/studio')
def admin_platform_studio_v49(): return _v49_studio()

@app.route('/admin/platform/qa')
def admin_platform_qa_v49(): return _v49_qa()

@app.route('/admin/platform/deployment')
def admin_platform_deployment_v49(): return _v49_deployment()

@app.route('/admin/platform/analytics')
def admin_platform_analytics_v49(): return _v49_analytics()

@app.route('/admin/platform/audit')
def admin_platform_audit_v49(): return _v49_audit()

@app.route('/admin/platform/report.json')
def admin_platform_report_json_v49():
    target=_v49_target_key()
    return Response(json.dumps(build_presentation_control_report(target),ensure_ascii=False,indent=2),content_type='application/json; charset=utf-8')

@app.route('/admin/platform/audit.json')
def admin_platform_audit_json_v49():
    reports=_v49_reports()
    payload={"version":TRIP_PLATFORM_QA_VERSION,"generated_at":datetime.now().isoformat(timespec='seconds'),"trip_ready":all(r.get('trip_ready') for r in reports.values()),"targets":reports}
    return Response(json.dumps(payload,ensure_ascii=False,indent=2),content_type='application/json; charset=utf-8')

# ================= v51 SINGLE-SOURCE OFFLINE REGISTRY REPAIR =================
V51_OFFLINE_REGISTRY_FUNCTION = r"""function allOfflineAssetUrls() {
    // מקור אמת יחיד: קובץ המצגת ורשימת המדיה המוטמעת שהאדמין מייצר.
    // אין לצרף סריקת DOM: תמונה ישנה שהוחלפה בסרטון עלולה להיספר
    // לצד הסרטון וליצור פער שקרי בין קובץ הטיול למצגת.
    const inline = eitanReadInlineManifest();
    const declared = (Array.isArray(inline.items) ? inline.items : [])
      .map(item => item && (item.url || item.file))
      .filter(src => src && !/^data:/i.test(src))
      .map(absoluteAssetUrl);
    return [...new Set([canonicalPresentationUrl(), ...declared])];
  }"""


def _v51_js_function_span(source, function_name):
    """Return (start, end, body_start, body_end) for a named classic JS function.

    The scanner balances braces while ignoring strings and comments. This avoids the
    fragile non-greedy regex that previously failed when a function contained nested
    blocks or arrow callbacks.
    """
    match = re.search(r"function\s+" + re.escape(function_name) + r"\s*\([^)]*\)\s*\{", source, re.I)
    if not match:
        return None
    open_brace = source.find("{", match.start(), match.end())
    if open_brace < 0:
        return None
    depth = 0
    i = open_brace
    state = "code"
    quote = ""
    escape = False
    while i < len(source):
        ch = source[i]
        nxt = source[i + 1] if i + 1 < len(source) else ""
        if state == "line_comment":
            if ch in "\r\n":
                state = "code"
        elif state == "block_comment":
            if ch == "*" and nxt == "/":
                state = "code"
                i += 1
        elif state == "string":
            if escape:
                escape = False
            elif ch == "\\":
                escape = True
            elif ch == quote:
                state = "code"
        else:
            if ch == "/" and nxt == "/":
                state = "line_comment"
                i += 1
            elif ch == "/" and nxt == "*":
                state = "block_comment"
                i += 1
            elif ch in ("'", '"', "`"):
                state = "string"
                quote = ch
                escape = False
            elif ch == "{":
                depth += 1
            elif ch == "}":
                depth -= 1
                if depth == 0:
                    return match.start(), i + 1, open_brace + 1, i
        i += 1
    return None


def _v51_extract_js_function_body(source, function_name):
    span = _v51_js_function_span(source, function_name)
    if not span:
        return None
    return source[span[2]:span[3]]


def _v51_force_single_source_offline_registry(presentation_path, target_key="unknown"):
    """Repair an already-patched presentation in place.

    Older TV files already contained the media runtime marker, so the normal patcher
    skipped them before replacing allOfflineAssetUrls(). This targeted repair is
    idempotent and preserves every slide, image, video and timing value.
    """
    presentation_path = str(presentation_path)
    if not os.path.isfile(presentation_path):
        return {"ok": False, "changed": False, "error": "presentation_missing", "path": presentation_path}
    html = read_text_file_utf8(presentation_path)
    span = _v51_js_function_span(html, "allOfflineAssetUrls")
    if not span:
        return {"ok": False, "changed": False, "error": "offline_function_missing", "path": presentation_path}
    current = html[span[0]:span[1]]
    changed = current.strip() != V51_OFFLINE_REGISTRY_FUNCTION.strip()
    updated = html[:span[0]] + V51_OFFLINE_REGISTRY_FUNCTION + html[span[1]:]
    body = _v51_extract_js_function_body(updated, "allOfflineAssetUrls") or ""
    valid = (
        "eitanReadInlineManifest" in body
        and "canonicalPresentationUrl" in body
        and "querySelectorAll" not in body
        and "eitanManifestUrls" not in body
    )
    if not valid:
        return {"ok": False, "changed": False, "error": "repair_verification_failed", "path": presentation_path}
    if changed:
        write_text_file_utf8_atomic_with_backup(presentation_path, updated, f"v51_offline_registry_{target_key}")
    return {
        "ok": True,
        "changed": changed,
        "path": presentation_path,
        "single_source": True,
        "uses_inline_registry": True,
        "uses_dom_scan": False,
    }


# ================= v51 SAFE PRESENTATION MIGRATION =================
def _v50_sync_presentation_target(target_key):
    """Synchronize the active presentation in-place while preserving current media.

    The operation is safe and repeatable:
    1. Probe the two active MP3 files.
    2. Write their real durations into PART_1_SECONDS / PART_2_SECONDS.
    3. Run the existing media-engine patch against the files currently on disk.
    4. Rebuild the inline Registry from the actual image/video siblings.
    Every write uses the existing atomic backup helpers.
    """
    cfg, root, presentation_path, _media_dir = _pc_target_paths(target_key)
    if not os.path.isfile(presentation_path):
        return {"ok": False, "target": target_key, "error": "presentation_missing", "path": presentation_path}
    music = _pc_probe_music_for_timeline(cfg, root)
    if len(music) != 2 or not all(x.get("probe_ok") and (x.get("duration") or 0) > 0 for x in music):
        return {"ok": False, "target": target_key, "error": "audio_probe_failed", "music": music}
    html = read_text_file_utf8(presentation_path)
    values = [float(music[0]["duration"]), float(music[1]["duration"])]
    updated, n1 = re.subn(r"\bconst\s+PART_1_SECONDS\s*=\s*[0-9]+(?:\.[0-9]+)?\s*;", f"const PART_1_SECONDS = {values[0]:.6f};", html, count=1)
    updated, n2 = re.subn(r"\bconst\s+PART_2_SECONDS\s*=\s*[0-9]+(?:\.[0-9]+)?\s*;", f"const PART_2_SECONDS = {values[1]:.6f};", updated, count=1)
    if not (n1 and n2):
        return {"ok": False, "target": target_key, "error": "duration_constants_missing"}
    if updated != html:
        write_text_file_utf8_atomic_with_backup(presentation_path, updated, f"v51_audio_truth_{target_key}")
    variant = "phone" if target_key == "usa_phone" else "tv"
    engine = ensure_usa_presentation_media_engine(variant)
    offline_registry_repair = _v51_force_single_source_offline_registry(presentation_path, target_key)
    final_html = read_text_file_utf8(presentation_path)
    final_info = inspect_presentation_html(final_html)
    match_ok, match_detail = _pc_audio_mismatch_detail(final_info, music)
    inline, inline_error = _pc_extract_inline_manifest(final_html)
    registry_body = _v51_extract_js_function_body(final_html, "allOfflineAssetUrls") or ""
    registry_ok = bool("eitanReadInlineManifest" in registry_body and "querySelectorAll" not in registry_body and "eitanManifestUrls" not in registry_body)
    ok = bool(match_ok and inline and len(inline.get("items") or []) >= 2 and offline_registry_repair.get("ok") and registry_ok)
    return {
        "ok": ok,
        "target": target_key,
        "presentation": presentation_path,
        "actual_song_seconds": values,
        "audio_match": match_ok,
        "audio_detail": match_detail,
        "inline_count": len((inline or {}).get("items") or []),
        "inline_error": inline_error,
        "engine": engine,
        "offline_registry_repair": offline_registry_repair,
        "offline_registry_single_source": registry_ok,
    }


def run_v51_safe_migration():
    results = {}
    for key in ("usa_phone", "usa_tv"):
        try:
            results[key] = _v50_sync_presentation_target(key)
        except Exception as exc:
            results[key] = {"ok": False, "target": key, "error": repr(exc)}
    try:
        out = os.path.join(BASE_DIR, "v51_presentation_migration_report.json")
        _json_safe_write(out, {"version": "51.0", "generated_at": datetime.now().isoformat(timespec="seconds"), "targets": results})
    except Exception:
        pass
    return results


@app.route('/admin/platform/repair-presentations', methods=['GET','POST'])
def admin_platform_repair_presentations_v51():
    if request.method == 'GET':
        return _upd_page(
            "סנכרון מצגות — אישור פעולה",
            '<div class="card warn"><h1>סנכרון ותיקון מצגות</h1><p>הפעולה בודקת את קובצי המצגות הפעילים ועלולה לעדכן זמני שירים, Registry ומיפויי מדיה. שום שינוי לא מבוצע בכניסה למסך זה.</p><form method="post" onsubmit="return confirm(\'להפעיל כעת את סנכרון המצגות על הקבצים הפעילים?\')"><button type="submit">הפעל סנכרון ובדיקה</button></form></div>'
        )
    results = run_v51_safe_migration()
    ok = all(x.get('ok') for x in results.values())
    details = json.dumps(results, ensure_ascii=False, indent=2)
    return admin_status_page(
        "סנכרון המצגות הושלם" if ok else "סנכרון המצגות דורש בדיקה",
        "זמני השירים, מנוע המדיה ו־Registry עודכנו מתוך הקבצים הפעילים בשרת." if ok else "לפחות יעד אחד לא הושלם. ראה פירוט.",
        ok=ok,
        details=details,
    ), (200 if ok else 500)

# v49 self-test additions are consumed by the existing self-test page through route availability.



# ================= DYNAMIC MUSIC SEGMENTS v53 =================
USA_MUSIC_SEGMENTS_CONFIG = os.path.join(BASE_DIR, "usa_music_segments_v53.json")
USA_MUSIC_SEGMENTS_MIN_IMAGE_SECONDS = 5.0


def _v52_default_music_segments():
    return {
        "schema": 2,
        "version": "53.0",
        "groups": [
            {"group": 1, "canonical": USA_MUSIC_FILES["usa_music"], "segments": [
                {"id": "song-1", "label": "שיר 1", "startSlide": 1, "endSlide": 23,
                 "source": os.path.join("media","usa-presentation","music","segments","part1-song1.mp3")}
            ]},
            {"group": 2, "canonical": USA_MUSIC_FILES["usa_music_part2"], "segments": [
                {"id": "song-2", "label": "שיר 2", "startSlide": 24, "endSlide": 44,
                 "source": os.path.join("media","usa-presentation","music","segments","part2-song1.mp3")}
            ]},
        ]
    }


def _v52_json_write(path, data):
    ensure_parent_dir(path)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)


def _v52_ensure_initial_music_segments():
    if os.path.isfile(USA_MUSIC_SEGMENTS_CONFIG):
        try:
            return json.load(open(USA_MUSIC_SEGMENTS_CONFIG, encoding="utf-8"))
        except Exception:
            pass
    # Seamless migration from the first dynamic-music release. Never discard a
    # user's existing song layout merely because the admin package was upgraded.
    legacy_config = os.path.join(BASE_DIR, "usa_music_segments_v52.json")
    if os.path.isfile(legacy_config):
        try:
            data = json.load(open(legacy_config, encoding="utf-8"))
            data["version"] = "53.0"
            _v52_validate_segments(data)
            _v52_json_write(USA_MUSIC_SEGMENTS_CONFIG, data)
            return data
        except Exception:
            pass
    data = _v52_default_music_segments()
    for root_key in ("USA", "USA_TV"):
        root = FOLDERS[root_key]
        for group in data["groups"]:
            canonical = os.path.join(root, group["canonical"])
            source = os.path.join(root, group["segments"][0]["source"])
            ensure_parent_dir(source)
            if os.path.isfile(canonical) and not os.path.isfile(source):
                shutil.copy2(canonical, source)
    # Measure the current canonical songs immediately so the first dashboard render is accurate.
    phone_root = FOLDERS["USA"]
    for group in data["groups"]:
        canonical = os.path.join(phone_root, group["canonical"])
        probe = probe_audio_duration_seconds(canonical) if os.path.isfile(canonical) else {"ok":False}
        duration = float(probe.get("duration_seconds") or 0) if probe.get("ok") else 0.0
        group["duration"] = duration
        if group.get("segments"):
            group["segments"][0]["duration"] = duration
            group["segments"][0]["offset"] = 0.0
    _v52_json_write(USA_MUSIC_SEGMENTS_CONFIG, data)
    return data


def _v52_validate_segments(data):
    groups = data.get("groups") or []
    if len(groups) != 2:
        raise ValueError("במצגת ארה״ב קיימות שתי קבוצות אודיו: שקפים 1–23 ושקפים 24–44.")
    expected = {1:(1,23), 2:(24,44)}
    all_rows=[]
    for group in groups:
        gid=int(group.get("group") or 0)
        if gid not in expected: raise ValueError("קבוצת אודיו לא חוקית.")
        rows=sorted(group.get("segments") or [], key=lambda x:int(x.get("startSlide") or 0))
        if not rows: raise ValueError(f"קבוצה {gid} חייבת להכיל לפחות שיר אחד.")
        cursor=expected[gid][0]
        for row in rows:
            start=int(row.get("startSlide") or 0); end=int(row.get("endSlide") or 0)
            if start != cursor: raise ValueError(f"קבוצה {gid}: החלוקה חייבת להיות רציפה. ציפיתי להתחלה בשקף {cursor}.")
            if end < start or end > expected[gid][1]: raise ValueError(f"קבוצה {gid}: טווח שקפים לא תקין.")
            if not str(row.get("label") or "").strip(): raise ValueError("לכל שיר נדרש שם.")
            if not str(row.get("source") or "").strip(): raise ValueError("לכל שיר נדרש קובץ מקור.")
            cursor=end+1; all_rows.append(row)
        if cursor != expected[gid][1]+1: raise ValueError(f"קבוצה {gid}: לא כל השקפים משויכים לשירים.")
    return True


def _v52_concat_group(root, group):
    ffmpeg=find_ffmpeg_executable()
    if not ffmpeg: raise RuntimeError("FFmpeg לא נמצא; לא ניתן לחבר מספר שירים בבטחה.")
    sources=[]
    for row in group.get("segments") or []:
        src=os.path.join(root,row["source"])
        if not os.path.isfile(src): raise FileNotFoundError(src)
        probe=probe_audio_duration_seconds(src)
        if not probe.get("ok"): raise RuntimeError("לא ניתן למדוד את השיר: "+os.path.basename(src))
        row["duration"]=float(probe["duration_seconds"])
        sources.append(src)
    canonical=os.path.join(root,group["canonical"])
    ensure_parent_dir(canonical)
    if len(sources)==1:
        tmp=canonical+".v52.tmp"
        shutil.copy2(sources[0],tmp); os.replace(tmp,canonical)
    else:
        list_path=canonical+".concat.txt"
        with open(list_path,"w",encoding="utf-8") as f:
            for src in sources:
                f.write("file '"+src.replace("'","'\\''")+"'\n")
        tmp=canonical+".v52.tmp.mp3"
        run=run_hidden_subprocess([ffmpeg,"-y","-hide_banner","-loglevel","error","-f","concat","-safe","0","-i",list_path,"-vn","-ar","44100","-ac","2","-c:a","libmp3lame","-b:a","192k",tmp],timeout=300)
        try: os.remove(list_path)
        except Exception: pass
        if run.returncode!=0 or not os.path.isfile(tmp):
            raise RuntimeError("חיבור השירים נכשל: "+str((run.stderr or run.stdout or "")[-1000:]))
        os.replace(tmp,canonical)
    actual=probe_audio_duration_seconds(canonical)
    if not actual.get("ok"): raise RuntimeError("לא ניתן למדוד את קובץ השירים המאוחד.")
    total=sum(float(x.get("duration") or 0) for x in group["segments"])
    residue=float(actual["duration_seconds"])-total
    if group["segments"]: group["segments"][-1]["duration"] += residue
    offset=0.0
    for row in group["segments"]:
        row["offset"]=offset; offset += float(row["duration"])
    group["duration"]=float(actual["duration_seconds"])
    return canonical


def _v52_patch_presentation_segments(path, config):
    html=read_text_file_utf8(path)
    payload={"schema":2,"version":"53.0","groups":[]}
    for g in config["groups"]:
        payload["groups"].append({"group":g["group"],"canonical":g["canonical"],"duration":g.get("duration"),"segments":[{k:r.get(k) for k in ("id","label","startSlide","endSlide","duration","offset")} for r in g["segments"]]})
    node='<script id="usa2027-music-segments" type="application/json">'+json.dumps(payload,ensure_ascii=False,separators=(",",":"))+'</script>'
    if 'id="usa2027-music-segments"' in html:
        html=re.sub(r'<script id="usa2027-music-segments"[^>]*>.*?</script>',node,html,count=1,flags=re.S)
    else:
        anchor=html.find('<script>',html.find('usa2027-inline-media-manifest'))
        if anchor<0: raise ValueError("לא נמצא עוגן להטמעת מקטעי המוזיקה.")
        html=html[:anchor]+node+html[anchor:]
    durations=[sum(float(r.get("duration") or 0) for r in g["segments"]) for g in config["groups"]]
    html,n1=re.subn(r'const\s+PART_1_SECONDS\s*=\s*[0-9.]+\s*;',f'const PART_1_SECONDS = {durations[0]:.6f};',html,count=1)
    html,n2=re.subn(r'const\s+PART_2_SECONDS\s*=\s*[0-9.]+\s*;',f'const PART_2_SECONDS = {durations[1]:.6f};',html,count=1)
    if not (n1 and n2): raise ValueError("קבועי משך המוזיקה חסרים במצגת.")
    write_text_file_utf8_atomic_with_backup(path,html,"v53_dynamic_music_segments")


def _v53_preflight_music_segments(config):
    """Measure every source and reject a layout before any canonical MP3 or HTML is changed."""
    _v52_validate_segments(config)
    phone_root = FOLDERS["USA"]
    for group in config.get("groups") or []:
        offset = 0.0
        for row in group.get("segments") or []:
            src = os.path.join(phone_root, row["source"])
            if not os.path.isfile(src):
                raise FileNotFoundError(src)
            probe = probe_audio_duration_seconds(src)
            if not probe.get("ok"):
                raise RuntimeError("לא ניתן למדוד את השיר: " + os.path.basename(src))
            row["duration"] = float(probe["duration_seconds"])
            row["offset"] = offset
            offset += row["duration"]
        group["duration"] = offset

    failures = []
    for variant in ("phone", "tv"):
        for row in _v52_segment_metrics(config, variant):
            if row["imageCount"] and row["perImageSeconds"] < USA_MUSIC_SEGMENTS_MIN_IMAGE_SECONDS:
                failures.append(
                    f"{('פלאפון' if variant == 'phone' else 'טלוויזיה')} · {row['label']}: "
                    f"{row['perImageSeconds']:.2f} שניות בלבד לכל תמונה"
                )
            if row["videoSeconds"] > float(row.get("duration") or 0) + 0.05:
                failures.append(
                    f"{('פלאפון' if variant == 'phone' else 'טלוויזיה')} · {row['label']}: "
                    "משך הסרטונים ארוך ממשך השיר"
                )
    if failures:
        raise ValueError("לא ניתן לשמור את החלוקה:\n" + "\n".join(failures))
    return True


def _v53_backup_paths(config):
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backups = []
    for root_key in ("USA", "USA_TV"):
        root = FOLDERS[root_key]
        for group in config.get("groups") or []:
            path = os.path.join(root, group["canonical"])
            if os.path.isfile(path):
                bak = path + f".before_v53_{stamp}.bak"
                shutil.copy2(path, bak)
                backups.append((path, bak))
    for path in (
        os.path.join(FOLDERS["USA"], USA_PRESENTATION_FILES["usa_presentation"]),
        os.path.join(FOLDERS["USA_TV"], USA_PRESENTATION_FILES["usa_presentation_tv"]),
    ):
        if os.path.isfile(path):
            bak = path + f".before_v53_{stamp}.bak"
            shutil.copy2(path, bak)
            backups.append((path, bak))
    return backups


def _v53_restore_backups(backups):
    for path, bak in backups:
        try:
            if os.path.isfile(bak):
                shutil.copy2(bak, path)
        except Exception:
            pass


def _v52_apply_music_segments(config):
    _v53_preflight_music_segments(config)
    backups = _v53_backup_paths(config)
    try:
        # Build both phone and TV independently so every target remains self-contained/offline.
        for root_key in ("USA","USA_TV"):
            root=FOLDERS[root_key]
            for group in config["groups"]:
                _v52_concat_group(root,group)
        for target,path in (("phone",os.path.join(FOLDERS["USA"],USA_PRESENTATION_FILES["usa_presentation"])),("tv",os.path.join(FOLDERS["USA_TV"],USA_PRESENTATION_FILES["usa_presentation_tv"]))):
            if os.path.isfile(path):
                _v52_patch_presentation_segments(path,config)
                try: write_usa_media_metadata(target)
                except Exception: pass
        _v52_json_write(USA_MUSIC_SEGMENTS_CONFIG,config)
        return config
    except Exception:
        _v53_restore_backups(backups)
        raise


def _v52_segment_metrics(config, target="phone"):
    root=FOLDERS["USA" if target=="phone" else "USA_TV"]
    media_dir=os.path.join(root,"media","usa-presentation","images")
    media_map=media_files_by_base(Path(media_dir)); rows=[]
    presentation=os.path.join(root,USA_PRESENTATION_FILES["usa_presentation" if target=="phone" else "usa_presentation_tv"])
    if not os.path.isfile(presentation): return rows
    info=inspect_presentation_html(read_text_file_utf8(presentation))
    for group in config.get("groups") or []:
        for seg in group.get("segments") or []:
            videos=0; video_seconds=0.0; images=0
            for slide in range(int(seg["startSlide"]),int(seg["endSlide"])+1):
                base=info.slide_bases[slide-1]; path=media_map.get(str(base).lower())
                if path and Path(path).suffix.lower() in VIDEO_EXTENSIONS:
                    videos+=1; video_seconds+=float(probe_video_duration_seconds(str(path)))
                else: images+=1
            duration=float(seg.get("duration") or 0); remaining=duration-video_seconds
            per=remaining/images if images else 0.0
            rows.append({**seg,"group":group["group"],"videoCount":videos,"videoSeconds":video_seconds,"imageCount":images,"perImageSeconds":per,"status":"green" if (not images or per>=8) else ("yellow" if per>=5 else "red")})
    return rows


@app.route('/admin/usa-music-segments', methods=['GET'])
def admin_usa_music_segments_v52():
    cfg=_v52_ensure_initial_music_segments(); phone=_v52_segment_metrics(cfg,"phone"); tv=_v52_segment_metrics(cfg,"tv")
    flat=[r for g in cfg["groups"] for r in g["segments"]]
    rows=[]
    for i,r in enumerate(flat):
        rows.append(f'''<tr class="song-row"><td><input type="hidden" data-field="group" name="group_{i}" value="{1 if int(r['startSlide'])<=23 else 2}"><input data-field="label" name="label_{i}" value="{html_lib.escape(str(r.get('label','')))}" required></td><td><input data-field="start" type="number" name="start_{i}" min="1" max="44" value="{int(r['startSlide'])}" required></td><td><input data-field="end" type="number" name="end_{i}" min="1" max="44" value="{int(r['endSlide'])}" required></td><td><input data-field="source" type="hidden" name="source_{i}" value="{html_lib.escape(str(r.get('source','')))}"><input data-field="file" type="file" name="file_{i}" accept="{MEDIA_AUDIO_ACCEPT}"></td><td><div class="row-actions"><button type="button" onclick="moveRow(this,-1)" title="העבר למעלה">⬆</button><button type="button" onclick="moveRow(this,1)" title="העבר למטה">⬇</button><button type="button" class="danger" onclick="removeRow(this)" title="מחק שיר">🗑</button></div></td></tr>''')
    metric=lambda data: ''.join(f'<tr><td>{html_lib.escape(str(x["label"]))}</td><td>{x["startSlide"]}–{x["endSlide"]}</td><td>{x["videoCount"]}</td><td>{x["imageCount"]}</td><td class="{x["status"]}">{x["perImageSeconds"]:.2f} שנ׳</td></tr>' for x in data)
    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{{font-family:Arial;background:#07111f;color:#fff;margin:0;padding:18px}}main{{max-width:1200px;margin:auto}}section{{background:#101d31;border:1px solid #304560;border-radius:22px;padding:18px;margin:14px 0;overflow-x:auto}}table{{width:100%;border-collapse:collapse;min-width:780px}}th,td{{padding:9px;border-bottom:1px solid #304560}}input,button{{width:100%;padding:10px;border-radius:10px;border:1px solid #64748b}}button{{background:#2563eb;color:#fff;font-weight:900;cursor:pointer}}button.danger{{background:#991b1b}}.row-actions{{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}}.green{{color:#86efac}}.yellow{{color:#fde68a}}.red{{color:#fca5a5}}a{{color:#bfdbfe}}.note{{color:#cbd5e1;line-height:1.6}}@media(max-width:700px){{body{{padding:10px}}table{{font-size:11px}}th,td{{padding:5px}}}}</style></head><body><main><a href="/admin">⬅ חזרה לאדמין</a><h1>🎵 מקטעי מוזיקה דינמיים</h1><p class="note">ברירת המחדל נשמרת: שקפים 1–23 ושקפים 24–44. ניתן להוסיף, למחוק ולסדר שירים. כל השקפים חייבים להישאר רציפים וללא חפיפה. לפני כתיבה המערכת מודדת את השירים ב־ffprobe וחוסמת חלוקה שמשאירה פחות מ־5 שניות לתמונה.</p><form id="songsForm" action="/admin/usa-music-segments/save" method="post" enctype="multipart/form-data"><input type="hidden" id="row_count" name="row_count" value="{len(flat)}"><section><table id="songs"><thead><tr><th>שם</th><th>שקף ראשון</th><th>שקף אחרון</th><th>קובץ חדש (אופציונלי)</th><th>פעולות</th></tr></thead><tbody>{''.join(rows)}</tbody></table><button type="button" onclick="addRow()">➕ הוסף שיר</button><br><br><button type="submit">💾 אמת, חבר שירים ועדכן את כל המצגות</button></section></form><section><h2>פלאפון / מחשב</h2><table><tr><th>שיר</th><th>שקפים</th><th>סרטונים</th><th>תמונות</th><th>זמן לתמונה</th></tr>{metric(phone)}</table></section><section><h2>טלוויזיה</h2><table><tr><th>שיר</th><th>שקפים</th><th>סרטונים</th><th>תמונות</th><th>זמן לתמונה</th></tr>{metric(tv)}</table></section></main><script>
function rows(){{return [...document.querySelectorAll('#songs tbody .song-row')];}}
function normalizeRows(){{rows().forEach((tr,i)=>{{tr.querySelectorAll('[data-field]').forEach(el=>{{el.name=el.dataset.field+'_'+i;}});}});document.getElementById('row_count').value=rows().length;}}
function addRow(){{let n=rows().length;let tr=document.createElement('tr');tr.className='song-row';tr.innerHTML=`<td><input type="hidden" data-field="group" value="2"><input data-field="label" value="שיר ${{n+1}}" required></td><td><input data-field="start" type="number" min="1" max="44" required></td><td><input data-field="end" type="number" min="1" max="44" required></td><td><input data-field="source" type="hidden" value=""><input data-field="file" type="file" accept="{MEDIA_AUDIO_ACCEPT}" required></td><td><div class="row-actions"><button type="button" onclick="moveRow(this,-1)">⬆</button><button type="button" onclick="moveRow(this,1)">⬇</button><button type="button" class="danger" onclick="removeRow(this)">🗑</button></div></td>`;document.querySelector('#songs tbody').appendChild(tr);normalizeRows();}}
function removeRow(btn){{if(rows().length<=2){{alert('נדרשים לפחות שני שירים — אחד בכל חלק.');return;}}btn.closest('tr').remove();normalizeRows();}}
function moveRow(btn,dir){{const tr=btn.closest('tr'),body=tr.parentElement,all=rows(),i=all.indexOf(tr),j=i+dir;if(j<0||j>=all.length)return;if(dir<0)body.insertBefore(tr,all[j]);else body.insertBefore(all[j],tr);normalizeRows();}}
document.getElementById('songsForm').addEventListener('submit',normalizeRows);normalizeRows();
</script></body></html>'''


@app.route('/admin/usa-music-segments/save', methods=['POST'])
def admin_usa_music_segments_save_v52():
    _v52_ensure_initial_music_segments(); count=int(request.form.get('row_count') or 0); groups={1:[],2:[]}
    upload_dir=os.path.join(FOLDERS['USA'],'media','usa-presentation','music','segments'); os.makedirs(upload_dir,exist_ok=True)
    for i in range(count):
        label=(request.form.get(f'label_{i}') or '').strip(); start=int(request.form.get(f'start_{i}') or 0); end=int(request.form.get(f'end_{i}') or 0)
        gid=1 if start<=23 else 2; source=(request.form.get(f'source_{i}') or '').strip(); up=request.files.get(f'file_{i}')
        if up and up.filename:
            safe=secure_filename(up.filename) or f'song-{i+1}.bin'; tmp=os.path.join(BASE_DIR,'tmp',f'v53_{uuid.uuid4().hex}_{safe}'); ensure_parent_dir(tmp); up.save(tmp)
            source=os.path.join('media','usa-presentation','music','segments',f'part{gid}-{uuid.uuid4().hex[:10]}.mp3')
            target=os.path.join(FOLDERS['USA'],source); convert_or_copy_audio_to_mp3(tmp,target,up.filename)
            try: os.remove(tmp)
            except Exception: pass
            # Copy source to TV segment library too.
            tv_target=os.path.join(FOLDERS['USA_TV'],source); ensure_parent_dir(tv_target); shutil.copy2(target,tv_target)
        groups[gid].append({'id':f'song-{i+1}','label':label,'startSlide':start,'endSlide':end,'source':source})
    config={'schema':2,'version':'53.0','groups':[{'group':1,'canonical':USA_MUSIC_FILES['usa_music'],'segments':groups[1]},{'group':2,'canonical':USA_MUSIC_FILES['usa_music_part2'],'segments':groups[2]}]}
    try:
        _v52_apply_music_segments(config)
    except Exception as exc:
        return admin_status_page('שמירת מקטעי המוזיקה נכשלה',str(exc),ok=False),400
    return redirect('/admin/usa-music-segments')


# Enrich QA/Audit with per-song timing and a hard gate below five seconds per image.
_v52_original_build_presentation_control_report = _pc_music_build_presentation_control_report
def build_presentation_control_report(target_key='usa_phone'):
    report=_v52_original_build_presentation_control_report(target_key)
    try:
        cfg=_v52_ensure_initial_music_segments(); variant='phone' if target_key=='usa_phone' else 'tv'
        rows=_v52_segment_metrics(cfg,variant); report['song_segments']=rows
        for row in rows:
            ok=row['imageCount']==0 or row['perImageSeconds']>=USA_MUSIC_SEGMENTS_MIN_IMAGE_SECONDS
            report.setdefault('checks',[]).append({'name':f"{row['label']}: לפחות 5 שניות לתמונה",'ok':ok,'detail':f"שקפים {row['startSlide']}–{row['endSlide']} · {row['perImageSeconds']:.2f} שנ׳",'level':'ok' if ok else 'error','category':'timeline'})
        report['trip_ready']=bool(report.get('trip_ready')) and all(x['imageCount']==0 or x['perImageSeconds']>=USA_MUSIC_SEGMENTS_MIN_IMAGE_SECONDS for x in rows)
        report['ok']=bool(report.get('ok')) and report['trip_ready']
    except Exception as exc:
        report.setdefault('warnings',[]).append('Dynamic music segments: '+repr(exc)); report['trip_ready']=False; report['ok']=False
    return report


# v57 — Mandatory USA release matrix and strict dual-presentation sync gate.
USA_RELEASE_MATRIX_V57 = [
    ("trip_phone", "קובץ טיול — פלאפון/מחשב", "/USA/"),
    ("trip_tv", "קובץ טיול — טלוויזיה", "/USA_TV/usa2027-tv.html"),
    ("presentation_phone", "מצגת — פלאפון/מחשב עם סכומים", "/USA/usa2027-presentation.html"),
    ("presentation_tv", "מצגת — טלוויזיה", "/USA_TV/usa2027-presentation-tv.html"),
    ("netlify_trip", "Netlify — קובץ טיול", "netlify-package:USA/index.html"),
    ("netlify_presentation", "Netlify — מצגת ללא סכומים", "netlify-package:USA/usa2027-presentation.html"),
]

USA_BUDGET_SECTION_RE_V57 = re.compile(
    r'<section\b[^>]*data-key=[\'\"]budget[\'\"][^>]*>[\s\S]*?</section>',
    re.I,
)


def _v57_normalize_presentation_for_sync(html_text):
    """Normalize only approved deployment differences before comparing presentations."""
    text = str(html_text or "").replace("\\", "/")
    text = USA_BUDGET_SECTION_RE_V57.sub("__BUDGET_SLIDE_VARIANT__", text, count=1)
    # Deployment-only URL rewrites are allowed and must not count as engine drift.
    text = re.sub(r'https?://[^\'\"\s<>]+/USA/', '/USA/', text, flags=re.I)
    text = re.sub(r'https?://[^\'\"\s<>]+/Moneyapp/?(?:index\.html)?(?:\?[^\'\"\s<>]*)?', '__BUDGET_APP_URL__', text, flags=re.I)
    text = re.sub(r'https?://beautiful-kitsune-dafd66\.netlify\.app(?:/index\.html)?/?', '__BUDGET_APP_URL__', text, flags=re.I)
    text = re.sub(r'(?<![\w./-])(?:\./)?usa2027-presentation\.html(?:\?[^\'\"\s<>]*)?', '/USA/usa2027-presentation.html', text, flags=re.I)
    # Ignore insignificant whitespace only; code/content changes remain detectable.
    text = re.sub(r'>\s+<', '><', text)
    text = re.sub(r'[ \t]+', ' ', text)
    return text.strip()


def _v57_validate_dual_presentation_sync(private_html, netlify_html):
    private_budget = USA_BUDGET_SECTION_RE_V57.search(private_html or "")
    netlify_budget = USA_BUDGET_SECTION_RE_V57.search(netlify_html or "")
    errors = []
    if not private_budget:
        errors.append("במצגת השרת הפרטי לא נמצא שקף תקציב")
    if not netlify_budget:
        errors.append("במצגת Netlify לא נמצא שקף תקציב")
    if errors:
        return {"ok": False, "errors": errors}
    left = _v57_normalize_presentation_for_sync(private_html)
    right = _v57_normalize_presentation_for_sync(netlify_html)
    ok = left == right
    return {
        "ok": ok,
        "errors": [] if ok else ["מצגת Netlify אינה מסונכרנת עם מצגת השרת הפרטי מחוץ לשקף התקציב ולהחלפות נתיבי Deploy המאושרות"],
        "private_sha256": hashlib.sha256(left.encode("utf-8")).hexdigest(),
        "netlify_sha256": hashlib.sha256(right.encode("utf-8")).hexdigest(),
    }


@app.route('/admin/platform/release-matrix')
def usa_release_matrix_v57():
    rows=''.join(
        f'<tr><td>{i+1}</td><td><b>{label}</b><br><small>{key}</small></td><td><code>{path}</code></td><td>חובה לבדוק בכל שינוי מנוע/עיצוב משותף</td></tr>'
        for i,(key,label,path) in enumerate(USA_RELEASE_MATRIX_V57)
    )
    html = '<!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{font-family:Arial;background:#07111f;color:#f8fafc;margin:0;padding:24px}main{max-width:1050px;margin:auto}section{background:#101d31;border:1px solid #304560;border-radius:24px;padding:20px}table{width:100%;border-collapse:collapse}th,td{padding:12px;border-bottom:1px solid #304560;text-align:right}code{direction:ltr;unicode-bidi:isolate;color:#bfdbfe}a{color:#bfdbfe}.rule{background:#15263e;border:1px solid #3b82f6;border-radius:16px;padding:14px;margin:14px 0;line-height:1.7}</style></head><body><main><section><h1>🔄 מטריצת סנכרון ארה״ב</h1><div class="rule"><b>שער מסירה מחייב:</b> בכל בניית Netlify המערכת משווה אוטומטית את המצגת ללא הסכומים למצגת השרת הפרטי. ההבדלים המותרים היחידים הם שקף התקציב והחלפות נתיבי Deploy מאושרות. כל שינוי אחר חוסם את יצירת ה־ZIP.</div><p>אין למסור שינוי משותף לפני שנבדקו כל ששת היעדים. גרסת Netlify שומרת במכוון על שקף תקציב ללא סכומים.</p><p><a href="/admin">חזרה לאדמין</a></p><table><thead><tr><th>#</th><th>יעד</th><th>נתיב</th><th>כלל</th></tr></thead><tbody>' + rows + '</tbody></table></section></main></body></html>'
    return html



# v58 — Netlify is always generated from the current private-server source of truth.
NETLIFY_PROFILE_PATH_V58 = os.path.join(BASE_DIR, "netlify_profiles.json")
NETLIFY_V58_EXCLUDED_DIRS = {"tmp", "backups", "control_server", "Moneyapp", "MoneyHome", "TripForm", "USA_TV", "__pycache__"}


def v58_read_json(path, default):
    try:
        with open(path, "r", encoding="utf-8") as fh:
            data = json.load(fh)
        return data
    except Exception:
        return default


def v58_write_json_atomic(path, data):
    ensure_parent_dir(path)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8", newline="") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)
    os.replace(tmp, path)


def v58_discover_netlify_trips():
    profiles = v58_read_json(NETLIFY_PROFILE_PATH_V58, {})
    if not isinstance(profiles, dict): profiles = {}
    found = []
    if not os.path.isdir(BASE_DIR): return found
    for name in sorted(os.listdir(BASE_DIR), key=str.lower):
        root = os.path.join(BASE_DIR, name)
        if not os.path.isdir(root) or name in NETLIFY_V58_EXCLUDED_DIRS or name.startswith('.'):
            continue
        entry = os.path.join(root, "index.html")
        if not os.path.isfile(entry):
            continue
        key = name.upper()
        prof = profiles.get(key) if isinstance(profiles.get(key), dict) else {}
        found.append({
            "key": key,
            "folder": name,
            "root": root,
            "label": prof.get("label") or ({"USA":"ארצות הברית 2027","JAPAN":"יפן 2026"}.get(key) or name),
            "mode": "specialized" if key in {"USA","JAPAN"} else "generic",
        })
    return found


def v58_normalize_generic_trip_key(trip):
    raw = (trip or "").strip().upper()
    for item in v58_discover_netlify_trips():
        if raw in {item["key"], item["folder"].upper()}:
            return item["key"]
    return ""


def v58_trip_item(key):
    key = (key or "").upper()
    return next((x for x in v58_discover_netlify_trips() if x["key"] == key), None)


def v58_sha256_file(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 v58_write_build_manifest(build_root, trip_key, source_root, validation, mode):
    files=[]
    for dirpath, _, names in os.walk(build_root):
        for name in sorted(names, key=str.lower):
            full=os.path.join(dirpath,name)
            rel=os.path.relpath(full,build_root).replace('\\','/')
            if rel == 'build-manifest.json': continue
            files.append({"path":rel,"size":os.path.getsize(full),"sha256":v58_sha256_file(full)})
    manifest={
        "schema":"eitan-trips-netlify-build/v1",
        "generated_at":datetime.now().isoformat(timespec="seconds"),
        "trip_key":trip_key,
        "source_root_id":os.path.basename(os.path.normpath(source_root)),
        "builder":"admin-v58-live-source",
        "mode":mode,
        "file_count":len(files),
        "validation":{"ok":bool(validation.get('ok')),"errors":validation.get('errors') or [],"warnings":validation.get('warnings') or []},
        "files":files,
    }
    v58_write_json_atomic(os.path.join(build_root,'build-manifest.json'),manifest)
    return manifest


def v58_patch_generic_html(build_root):
    patched=[]
    for dirpath,_,names in os.walk(build_root):
        for name in names:
            if file_ext(name) not in {'.html','.htm'}: continue
            path=os.path.join(dirpath,name)
            text=Path(path).read_text(encoding='utf-8',errors='replace')
            original=text
            text=re.sub(r'https?://work-room\.tail978ec2\.ts\.net(?::\d+)?', '', text, flags=re.I)
            text=re.sub(r'https?://[^\'"\s<>]+/(?:USA|JAPAN)/', './', text, flags=re.I)
            text=text.replace('\\','/')
            if text != original:
                Path(path).write_text(text,encoding='utf-8',newline='')
                patched.append(os.path.relpath(path,build_root).replace('\\','/'))
    return patched


def v58_validate_generic_build(build_root):
    errors=[]; warnings=[]; checked=0
    if not os.path.isfile(os.path.join(build_root,'index.html')):
        errors.append('חסר index.html בשורש חבילת Netlify')
    attr_re=re.compile(r"\b(?:href|src|poster|data-href)\s*=\s*[\"']([^\"']+)[\"']",re.I)
    for dirpath,_,names in os.walk(build_root):
        for name in names:
            if file_ext(name) not in {'.html','.htm'}: continue
            path=os.path.join(dirpath,name); relhtml=os.path.relpath(path,build_root).replace('\\','/')
            text=Path(path).read_text(encoding='utf-8',errors='replace')
            if 'C:/TripServer' in text or 'C:\\TripServer' in text or 'tail978ec2' in text:
                errors.append('נשאר נתיב פרטי בתוך '+relhtml)
            for raw in attr_re.findall(text):
                rel=netlify_local_ref_to_path(build_root,relhtml,raw)
                if not rel: continue
                checked+=1
                if not netlify_path_exists(build_root,rel):
                    errors.append(f'קישור פנימי חסר: {relhtml} -> {raw}')
    return {"ok":not errors,"errors":sorted(set(errors)),"warnings":warnings,"checked_refs":checked}


def build_netlify_deploy_zip_v58(trip):
    known = normalize_netlify_trip_key(trip)
    if known in {'USA','JAPAN'}:
        zip_path, details = build_netlify_deploy_zip(known)
        build_root=details['build_root']
        manifest=v58_write_build_manifest(build_root,known,details['source_root'],details['validation'],'specialized-live-source')
        files=zip_directory_contents(build_root,zip_path)
        details.update({"files":len(files),"manifest":manifest,"live_source":True})
        return zip_path,details
    item=v58_trip_item(trip)
    if not item: raise ValueError('trip_not_supported_or_missing_index')
    stamp=time.strftime('%Y%m%d_%H%M%S')
    build_root=os.path.join(NETLIFY_BUILD_DIR,item['key'].lower()+'_'+stamp)
    if os.path.exists(build_root): shutil.rmtree(build_root)
    os.makedirs(build_root,exist_ok=True); os.makedirs(NETLIFY_EXPORT_DIR,exist_ok=True)
    copied=netlify_copy_static_tree(item['root'],build_root,prefix='')
    patched=v58_patch_generic_html(build_root)
    validation=v58_validate_generic_build(build_root)
    if not validation['ok']:
        raise RuntimeError('netlify_validation_failed:\n'+'\n'.join(validation['errors']))
    manifest=v58_write_build_manifest(build_root,item['key'],item['root'],validation,'generic-live-source')
    safe_name=re.sub(r'[^A-Za-z0-9_-]+','_',item['key'])
    zip_path=os.path.join(NETLIFY_EXPORT_DIR,f'{safe_name}_NETLIFY_LIVE_{stamp}.zip')
    files=zip_directory_contents(build_root,zip_path)
    return zip_path,{"trip":item['key'],"source_root":item['root'],"build_root":build_root,"zip_path":zip_path,"files":len(files),"copied":len(copied),"patched_html":patched,"validation":validation,"manifest":manifest,"live_source":True,"size_bytes":os.path.getsize(zip_path)}


@app.route('/admin/netlify/builder')
def admin_netlify_builder_v58():
    trips=v58_discover_netlify_trips()
    cards=''.join(f"""<article><h2>{html_lib.escape(x['label'])}</h2><p><code>{html_lib.escape(x['root'])}</code></p><p>מצב: {'פרופיל מותאם' if x['mode']=='specialized' else 'Build גנרי אוטומטי'}</p><a href="/admin/netlify/download/{html_lib.escape(x['key'])}">צור עכשיו ZIP מסונכרן</a></article>""" for x in trips)
    if not cards: cards='<p>לא נמצאו תיקיות טיול עם index.html תחת C:\\TripServer.</p>'
    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>Netlify Live Builder</title><style>body{{font-family:Arial;background:#07111f;color:#fff;margin:0;padding:22px}}main{{max-width:1050px;margin:auto}}.note,article{{background:#101d31;border:1px solid #304560;border-radius:20px;padding:18px;margin:14px 0}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}}a{{display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 16px;border-radius:999px;font-weight:800}}code{{direction:ltr;unicode-bidi:isolate;color:#bfdbfe;word-break:break-all}}</style></head><body><main><h1>🌐 Netlify — בנייה מהמקור הפעיל</h1><div class="note"><b>בחר טיול:</b> כל ZIP נוצר מחדש מהקבצים, התמונות, הסרטונים והשירים שנמצאים בשרת באותו רגע. Netlify אינו נשמר כמקור נפרד.</div><div class="grid">{cards}</div><p><a href="/admin">חזרה לאדמין</a></p></main></body></html>"""


# v82 — Verified live-source gate for USA Netlify.
# The builder must never infer freshness from filenames or from an old media ZIP.
# The active C:\TripServer\USA presentation and its inline media manifest are the source of truth.

def v82_read_inline_usa_media_manifest(presentation_path):
    text = Path(presentation_path).read_text(encoding="utf-8", errors="strict")
    match = re.search(
        r'<script\b[^>]*\bid=["\']usa2027-inline-media-manifest["\'][^>]*>([\s\S]*?)</script>',
        text,
        re.I,
    )
    if not match:
        raise RuntimeError("usa_inline_media_manifest_missing")
    try:
        payload = json.loads(match.group(1))
    except Exception as exc:
        raise RuntimeError("usa_inline_media_manifest_invalid_json:" + repr(exc))
    if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
        raise RuntimeError("usa_inline_media_manifest_invalid_shape")
    return payload


def v82_usa_live_source_gate():
    source_root = FOLDERS["USA"]
    trip_path = os.path.join(source_root, "index.html")
    presentation_path = os.path.join(source_root, USA_PRESENTATION_FILES["usa_presentation"])
    for required in (trip_path, presentation_path):
        if not os.path.isfile(required) or os.path.getsize(required) <= 0:
            raise FileNotFoundError("usa_live_source_missing:" + required)

    # Rebuild metadata from the actual files installed on the server. This validates
    # that every slide has one active media source and probes every active video.
    metadata = write_usa_media_metadata("phone")
    if not metadata.get("ok"):
        raise RuntimeError("usa_media_metadata_refresh_failed:" + repr(metadata))

    payload = v82_read_inline_usa_media_manifest(presentation_path)
    items = payload.get("items") or []
    slides = [x for x in items if isinstance(x, dict) and x.get("slide")]
    audio = [x for x in items if isinstance(x, dict) and x.get("type") == "audio"]
    if len(slides) != 44:
        raise RuntimeError(f"usa_media_slide_count_mismatch:{len(slides)}")

    # Audio inventory is dynamic. Never assume two tracks: validate every supported
    # file currently installed in the canonical music folder, and separately validate
    # every audio item declared by the active presentation manifest.
    music_root = os.path.join(source_root, "media", "usa-presentation", "music")
    if not os.path.isdir(music_root):
        raise FileNotFoundError("usa_music_folder_missing:" + music_root)
    folder_audio = []
    for dirpath, _, names in os.walk(music_root):
        for name in sorted(names, key=str.lower):
            full = os.path.join(dirpath, name)
            if file_ext(name) not in USA_AUDIO_EXTENSIONS:
                continue
            if not os.path.isfile(full) or os.path.getsize(full) <= 0:
                raise RuntimeError("usa_music_file_empty:" + full)
            rel = os.path.relpath(full, source_root).replace("\\", "/")
            folder_audio.append({"url": rel, "size": os.path.getsize(full), "sha256": file_sha256(full)})
    if not folder_audio:
        raise RuntimeError("usa_music_inventory_empty")

    declared_audio_urls = set()
    for item in audio:
        raw_url = str(item.get("url") or "").replace("\\", "/").lstrip("/")
        safe = safe_zip_relpath(raw_url)
        if not safe:
            raise RuntimeError("usa_audio_unsafe_path:" + raw_url)
        full = os.path.join(source_root, *safe.split("/"))
        if not os.path.isfile(full) or os.path.getsize(full) <= 0:
            raise FileNotFoundError("usa_declared_audio_missing:" + raw_url)
        if file_ext(full) not in USA_AUDIO_EXTENSIONS:
            raise RuntimeError("usa_declared_audio_extension_invalid:" + raw_url)
        declared_audio_urls.add(safe)

    folder_audio_urls = {x["url"] for x in folder_audio}
    missing_from_folder = sorted(declared_audio_urls - folder_audio_urls)
    if missing_from_folder:
        raise RuntimeError("usa_declared_audio_not_in_music_inventory:" + repr(missing_from_folder))

    seen_slides, seen_bases, rows = set(), set(), []
    image_count = video_count = 0
    for item in slides + audio:
        raw_url = str(item.get("url") or "").replace("\\", "/").lstrip("/")
        safe = safe_zip_relpath(raw_url)
        if not safe:
            raise RuntimeError("usa_media_unsafe_path:" + raw_url)
        full = os.path.join(source_root, *safe.split("/"))
        if not os.path.isfile(full) or os.path.getsize(full) <= 0:
            raise FileNotFoundError("usa_declared_media_missing:" + raw_url)
        ext = file_ext(full)
        row = {"url": safe, "size": os.path.getsize(full), "sha256": file_sha256(full), "type": item.get("type")}
        if item.get("slide"):
            slide = int(item.get("slide"))
            base = str(item.get("base") or "").lower()
            if slide in seen_slides or base in seen_bases:
                raise RuntimeError("usa_media_duplicate_slide_or_base:" + repr(item))
            seen_slides.add(slide); seen_bases.add(base)
            if item.get("type") == "video":
                if ext not in USA_VIDEO_EXTENSIONS:
                    raise RuntimeError("usa_manifest_video_extension_mismatch:" + raw_url)
                duration = probe_video_duration_seconds(full)
                if duration <= 0:
                    raise RuntimeError("usa_video_duration_invalid:" + raw_url)
                row["duration_seconds"] = round(float(duration), 6)
                video_count += 1
            elif item.get("type") == "image":
                if ext not in USA_IMAGE_EXTENSIONS:
                    raise RuntimeError("usa_manifest_image_extension_mismatch:" + raw_url)
                image_count += 1
            else:
                raise RuntimeError("usa_manifest_unknown_media_type:" + repr(item.get("type")))
        rows.append(row)

    if seen_slides != set(range(1, 45)):
        raise RuntimeError("usa_media_slide_numbers_incomplete")
    canonical = json.dumps(rows, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    return {
        "ok": True,
        "trip_sha256": file_sha256(trip_path),
        "presentation_sha256": file_sha256(presentation_path),
        "inline_manifest_token": payload.get("token"),
        "slide_media_count": len(slides),
        "image_count": image_count,
        "video_count": video_count,
        "audio_count": len(folder_audio),
        "declared_audio_count": len(audio),
        "audio_files": folder_audio,
        "unreferenced_audio_files": sorted(folder_audio_urls - declared_audio_urls),
        "media_contract_sha256": hashlib.sha256(canonical.encode("utf-8")).hexdigest(),
        "files": rows,
        "metadata": metadata,
    }


def v82_zip_inventory(zip_path):
    rows = {}
    with zipfile.ZipFile(zip_path, "r") as zf:
        bad = zf.testzip()
        if bad:
            raise RuntimeError("netlify_zip_crc_failed:" + bad)
        for info in zf.infolist():
            if info.is_dir():
                continue
            safe = safe_zip_relpath(info.filename)
            if not safe or safe != info.filename.replace("\\", "/"):
                raise RuntimeError("netlify_zip_unsafe_member:" + info.filename)
            data = zf.read(info.filename)
            rows[safe] = {"size": len(data), "sha256": hashlib.sha256(data).hexdigest()}
    return rows


def v82_build_inventory(build_root):
    rows = {}
    for dirpath, _, names in os.walk(build_root):
        for name in names:
            full = os.path.join(dirpath, name)
            rel = os.path.relpath(full, build_root).replace("\\", "/")
            safe = safe_zip_relpath(rel)
            if not safe:
                raise RuntimeError("netlify_build_unsafe_member:" + rel)
            rows[safe] = {"size": os.path.getsize(full), "sha256": file_sha256(full)}
    return rows


def v82_verify_final_zip(build_root, zip_path, trip_key, live_gate=None):
    build = v82_build_inventory(build_root)
    archive = v82_zip_inventory(zip_path)
    missing = sorted(set(build) - set(archive))
    extra = sorted(set(archive) - set(build))
    mismatched = sorted(k for k in set(build) & set(archive) if build[k] != archive[k])
    errors = []
    if missing: errors.append("zip_missing:" + ",".join(missing))
    if extra: errors.append("zip_extra:" + ",".join(extra))
    if mismatched: errors.append("zip_hash_mismatch:" + ",".join(mismatched))

    if trip_key == "USA":
        forbidden = [k for k in archive if k.lower().startswith("usa_tv/") or "presentation-tv" in k.lower() or k.lower().endswith("usa2027-tv.html")]
        if forbidden:
            errors.append("usa_phone_zip_contains_tv:" + ",".join(sorted(forbidden)))
        if live_gate:
            for item in live_gate.get("files") or []:
                rel = "USA/" + str(item.get("url") or "").lstrip("/")
                archived = archive.get(rel)
                if not archived:
                    errors.append("declared_live_media_missing_from_zip:" + rel)
                elif archived.get("sha256") != item.get("sha256"):
                    errors.append("declared_live_media_hash_mismatch:" + rel)
    if errors:
        raise RuntimeError("netlify_final_zip_verification_failed:\n" + "\n".join(errors))
    return {"ok": True, "file_count": len(archive), "missing": [], "extra": [], "mismatched": [], "crc_ok": True}


_v82_original_build_netlify_deploy_zip_v58 = build_netlify_deploy_zip_v58

def build_netlify_deploy_zip_v58(trip):
    known = normalize_netlify_trip_key(trip)
    live_gate = v82_usa_live_source_gate() if known == "USA" else None
    zip_path, details = _v82_original_build_netlify_deploy_zip_v58(trip)
    verification = v82_verify_final_zip(details["build_root"], zip_path, known or str(trip).upper(), live_gate)
    details["final_zip_verification"] = verification
    if live_gate:
        details["usa_live_source_gate"] = {
            "ok": True,
            "trip_sha256": live_gate["trip_sha256"],
            "presentation_sha256": live_gate["presentation_sha256"],
            "inline_manifest_token": live_gate["inline_manifest_token"],
            "slide_media_count": live_gate["slide_media_count"],
            "image_count": live_gate["image_count"],
            "video_count": live_gate["video_count"],
            "audio_count": live_gate["audio_count"],
            "media_contract_sha256": live_gate["media_contract_sha256"],
        }
    return zip_path, details


# === v72 Unified Update Package Installer =====================================
# Future releases can be delivered as one ZIP with a signed-by-hash manifest.
# The manifest, not the filename, is the source of truth for routing.
UPDATE_PACKAGE_SCHEMA = "eitan-tripserver-update/v1"
UPDATE_PACKAGE_MANIFEST = "tripserver-update.json"
UPDATE_PACKAGE_PENDING_DIR = os.path.join(BASE_DIR, "tmp", "pending_update_packages")
UPDATE_PACKAGE_BACKUP_DIR = os.path.join(BASE_DIR, "backups", "update_packages")
UPDATE_PACKAGE_LOG_PATH = os.path.join(BASE_DIR, "update_package_log.json")
UPDATE_PACKAGE_MAX_BYTES = 350 * 1024 * 1024
UPDATE_PACKAGE_MAX_FILES = 500
UPDATE_PACKAGE_MAX_UNCOMPRESSED_BYTES = 900 * 1024 * 1024
UPDATE_PACKAGE_TOKEN_TTL_SECONDS = 60 * 60

# Exact destination registry. A package cannot write to arbitrary paths.
# A target may fan out to more than one destination (for example shared music).
UPDATE_PACKAGE_TARGETS = {
    "USA_TRIP": {"label": "קובץ טיול ארה״ב", "paths": [os.path.join(FOLDERS["USA"], "index.html")], "kind": "trip_html"},
    "USA_PRESENTATION_PHONE": {"label": "מצגת ארה״ב — אייפון/מחשב", "paths": [os.path.join(FOLDERS["USA"], "usa2027-presentation.html")], "kind": "presentation_html"},
    "USA_TRIP_TV": {"label": "קובץ טיול ארה״ב — טלוויזיה", "paths": [os.path.join(FOLDERS["USA_TV"], "usa2027-tv.html")], "kind": "trip_html"},
    "USA_PRESENTATION_TV": {"label": "מצגת ארה״ב — טלוויזיה", "paths": [os.path.join(FOLDERS["USA_TV"], "usa2027-presentation-tv.html")], "kind": "presentation_html"},
    "JAPAN_TRIP": {"label": "קובץ טיול יפן", "paths": [os.path.join(FOLDERS["JAPAN"], "index.html")], "kind": "trip_html"},
    "JAPAN_PRESENTATION": {"label": "מצגת יפן", "paths": [os.path.join(FOLDERS["JAPAN"], "japan-experience.html")], "kind": "presentation_html"},
    "JAPAN_FLIGHT_INTRO": {"label": "פתיח טיסה יפן", "paths": [os.path.join(FOLDERS["JAPAN"], "japan-flight-intro.html")], "kind": "presentation_html"},
    "MONEYAPP": {"label": "אפליקציית תקציב חופשה", "paths": [os.path.join(FOLDERS["Moneyapp"], "index.html")], "kind": "html"},
    "MONEYHOME": {"label": "אפליקציית תקציב משפחתי", "paths": [os.path.join(FOLDERS["MoneyHome"], "index.html")], "kind": "html"},
    "TRIPFORM": {"label": "טופס פתיחת טיול", "paths": [os.path.join(FOLDERS["TripForm"], "index.html")], "kind": "html"},
    "USA_MUSIC_PART1": {"label": "שיר ארה״ב — חלק 1", "paths": [os.path.join(FOLDERS["USA"], USA_MUSIC_FILES["usa_music"]), os.path.join(FOLDERS["USA_TV"], USA_MUSIC_FILES["usa_music"])], "kind": "audio"},
    "USA_MUSIC_PART2": {"label": "שיר ארה״ב — חלק 2", "paths": [os.path.join(FOLDERS["USA"], USA_MUSIC_FILES["usa_music_part2"]), os.path.join(FOLDERS["USA_TV"], USA_MUSIC_FILES["usa_music_part2"])], "kind": "audio"},
    "ADMIN_SERVER": {"label": "מנוע האדמין", "paths": [os.path.join(BASE_DIR, "admin_server.py")], "kind": "admin_python", "apply_last": True},
}

UPDATE_PACKAGE_POST_ACTIONS = {
    "REBUILD_USA_MEDIA_METADATA": "בנייה מחדש ואימות נפרד של manifests עבור iPhone ועבור TV",
    "SYNC_USA_MEDIA_TO_TV": "העתקה ידנית מפורשת של מדיית iPhone אל TV (פעולת תאימות בלבד)",
}



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


def _upd_safe_member(name):
    raw = str(name or "")
    if "\x00" in raw:
        raise ValueError("unsafe_zip_member:" + raw)
    normalized = raw.replace("\\", "/")
    # Reject absolute POSIX paths and Windows drive/UNC paths before normalization.
    if normalized.startswith("/") or normalized.startswith("//") or re.match(r"^[A-Za-z]:($|/)", normalized):
        raise ValueError("unsafe_zip_member:" + raw)
    clean = normalized.strip("/")
    first = clean.split("/", 1)[0]
    if (not clean or clean.startswith("../") or "/../" in clean or clean == ".."
            or re.fullmatch(r"[A-Za-z]:", first)):
        raise ValueError("unsafe_zip_member:" + raw)
    return clean


def _upd_validate_content(path, kind):
    ext = Path(path).suffix.lower()
    if kind in {"html", "trip_html", "presentation_html"}:
        if ext not in {".html", ".htm"}:
            raise ValueError("expected_html:" + path)
        raw = Path(path).read_bytes()
        if len(raw) < 500:
            raise ValueError("html_too_small:" + path)
        text = raw.decode("utf-8", errors="replace")
        low = text.lower()
        if "<html" not in low or "<script" not in low:
            raise ValueError("html_missing_structure:" + path)
        if kind == "presentation_html":
            if not ("class=\"slide" in low or "class='slide" in low or "startgate" in low):
                raise ValueError("presentation_marker_missing:" + path)
        if kind == "trip_html":
            if "טיול" not in text and "trip" not in low:
                raise ValueError("trip_marker_missing:" + path)
        return {"bytes": len(raw), "syntax": "html-structure-ok"}
    if kind == "audio":
        if ext not in AUDIO_UPLOAD_EXTENSIONS:
            raise ValueError("expected_audio:" + path)
        if os.path.getsize(path) < 1024:
            raise ValueError("audio_too_small:" + path)
        return {"bytes": os.path.getsize(path), "syntax": "audio-extension-ok"}
    if kind == "admin_python":
        if ext != ".py":
            raise ValueError("expected_python:" + path)
        py_compile.compile(path, doraise=True)
        code = Path(path).read_text(encoding="utf-8", errors="replace")
        required = ["Flask", "@app.route", "BASE_DIR", "ADMIN_PORT", "app.run"]
        missing = [x for x in required if x not in code]
        if missing:
            raise ValueError("admin_markers_missing:" + ",".join(missing))
        return {"bytes": os.path.getsize(path), "syntax": "python-compile-ok"}
    raise ValueError("unknown_kind:" + str(kind))


def _upd_parse_and_stage(zip_path, token):
    stage = os.path.join(UPDATE_PACKAGE_PENDING_DIR, token)
    if os.path.exists(stage):
        shutil.rmtree(stage)
    os.makedirs(stage, exist_ok=True)
    total = 0
    with zipfile.ZipFile(zip_path, "r") as zf:
        infos = [i for i in zf.infolist() if not i.is_dir()]
        if len(infos) > UPDATE_PACKAGE_MAX_FILES:
            raise ValueError("too_many_files")
        for info in infos:
            _upd_safe_member(info.filename)
            total += int(info.file_size or 0)
            if total > UPDATE_PACKAGE_MAX_UNCOMPRESSED_BYTES:
                raise ValueError("package_uncompressed_too_large")
        normalized_names = [_upd_safe_member(i.filename) for i in infos]
        if len(normalized_names) != len(set(normalized_names)):
            raise ValueError("duplicate_zip_member")
        names = dict(zip(normalized_names, infos))
        if UPDATE_PACKAGE_MANIFEST not in names:
            raise ValueError("manifest_missing")
        manifest = json.loads(zf.read(names[UPDATE_PACKAGE_MANIFEST]).decode("utf-8-sig"))
        if manifest.get("schema") != UPDATE_PACKAGE_SCHEMA:
            raise ValueError("unsupported_schema")
        files = manifest.get("files")
        if not isinstance(files, list) or not files:
            raise ValueError("manifest_files_empty")
        post_actions = manifest.get("post_actions") or []
        if not isinstance(post_actions, list) or len(post_actions) != len(set(map(str, post_actions))):
            raise ValueError("bad_post_actions")
        unknown_actions = [str(x) for x in post_actions if str(x) not in UPDATE_PACKAGE_POST_ACTIONS]
        if unknown_actions:
            raise ValueError("unknown_post_action:" + ",".join(unknown_actions))
        if len(files) != len({str(x.get("target_id")) for x in files}):
            raise ValueError("duplicate_target_id")
        # System invariant: a package that touches USA trip/presentation/music
        # rebuilds each target's media metadata independently. Never mirror phone media
        # into TV implicitly: the targets may intentionally use device-specific files.
        media_sensitive_targets = {
            "USA_TRIP", "USA_TRIP_TV", "USA_PRESENTATION_PHONE",
            "USA_PRESENTATION_TV", "USA_MUSIC_PART1", "USA_MUSIC_PART2"
        }
        package_target_ids = {str(x.get("target_id") or "") for x in files if isinstance(x, dict)}
        if package_target_ids & media_sensitive_targets and "REBUILD_USA_MEDIA_METADATA" not in post_actions:
            post_actions = list(post_actions) + ["REBUILD_USA_MEDIA_METADATA"]
        plan = []
        for index, item in enumerate(files):
            if not isinstance(item, dict):
                raise ValueError("bad_file_entry")
            target_id = str(item.get("target_id") or "")
            source = _upd_safe_member(item.get("source"))
            expected = str(item.get("sha256") or "").lower()
            if target_id not in UPDATE_PACKAGE_TARGETS:
                raise ValueError("unknown_target_id:" + target_id)
            if source not in names:
                raise ValueError("source_missing:" + source)
            if not re.fullmatch(r"[0-9a-f]{64}", expected):
                raise ValueError("bad_sha256:" + target_id)
            extract_path = os.path.join(stage, "payload", f"{index:03d}_{Path(source).name}")
            os.makedirs(os.path.dirname(extract_path), exist_ok=True)
            with zf.open(names[source], "r") as srcf, open(extract_path, "wb") as dstf:
                shutil.copyfileobj(srcf, dstf)
            actual = _upd_sha256(extract_path)
            if actual != expected:
                raise ValueError("sha256_mismatch:" + target_id)
            target = UPDATE_PACKAGE_TARGETS[target_id]
            validation = _upd_validate_content(extract_path, target["kind"])
            if target_id == "MONEYAPP":
                budget_validation = inspect_budget_app_file(extract_path)
                if not budget_validation.get("valid"):
                    raise ValueError("budget_app_contract_failed:" + str(budget_validation.get("error") or "unknown"))
                validation = dict(validation)
                validation["artifact_contract"] = {
                    "schema": budget_validation.get("contract_schema"),
                    "mode": budget_validation.get("contract_mode"),
                    "version": budget_validation.get("version"),
                    "artifact_id": (budget_validation.get("identity") or {}).get("artifact_id") or "Moneyapp",
                }
            plan.append({
                "target_id": target_id,
                "label": target["label"],
                "source": source,
                "stage_path": extract_path,
                "sha256": actual,
                "destinations": list(target["paths"]),
                "kind": target["kind"],
                "apply_last": bool(target.get("apply_last")),
                "validation": validation,
            })
    meta = {
        "token": token,
        "created_at_epoch": time.time(),
        "package_id": str(manifest.get("package_id") or "unnamed"),
        "version": str(manifest.get("version") or ""),
        "description": str(manifest.get("description") or ""),
        "manifest": manifest,
        "post_actions": [str(x) for x in post_actions],
        "plan": plan,
    }
    Path(os.path.join(stage, "inspection.json")).write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
    return meta


def _upd_load_pending(token):
    if not re.fullmatch(r"[A-Za-z0-9_-]{12,80}", str(token or "")):
        raise ValueError("bad_token")
    path = os.path.join(UPDATE_PACKAGE_PENDING_DIR, token, "inspection.json")
    if not os.path.isfile(path):
        raise ValueError("pending_package_not_found")
    meta = json.loads(Path(path).read_text(encoding="utf-8"))
    if time.time() - float(meta.get("created_at_epoch", 0)) > UPDATE_PACKAGE_TOKEN_TTL_SECONDS:
        raise ValueError("pending_package_expired")
    for item in meta.get("plan", []):
        if not os.path.isfile(item["stage_path"]):
            raise ValueError("staged_file_missing:" + item["target_id"])
        if _upd_sha256(item["stage_path"]) != item["sha256"]:
            raise ValueError("staged_file_changed:" + item["target_id"])
    return meta


def _upd_write_log(payload):
    payload = dict(payload)
    payload["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
    tmp = UPDATE_PACKAGE_LOG_PATH + ".tmp"
    Path(tmp).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    os.replace(tmp, UPDATE_PACKAGE_LOG_PATH)


def _upd_apply_content_transaction(meta):
    stamp = time.strftime("%Y%m%d_%H%M%S")
    safe_pkg = re.sub(r"[^A-Za-z0-9_.-]+", "_", meta.get("package_id", "package"))[:80]
    backup_root = os.path.join(UPDATE_PACKAGE_BACKUP_DIR, stamp + "_" + safe_pkg)
    os.makedirs(backup_root, exist_ok=True)
    applied = []
    backups = []
    admin_item = None
    try:
        ordered = sorted(meta["plan"], key=lambda x: bool(x.get("apply_last")))
        for item in ordered:
            if item.get("apply_last"):
                admin_item = item
                continue
            for destination in item["destinations"]:
                os.makedirs(os.path.dirname(destination), exist_ok=True)
                rel = os.path.relpath(destination, BASE_DIR)
                backup = os.path.join(backup_root, rel)
                existed = os.path.exists(destination)
                if existed:
                    os.makedirs(os.path.dirname(backup), exist_ok=True)
                    shutil.copy2(destination, backup)
                backups.append({"destination": destination, "backup": backup if existed else None, "existed": existed})
                tmp = destination + ".update_v72_tmp"
                shutil.copy2(item["stage_path"], tmp)
                if _upd_sha256(tmp) != item["sha256"]:
                    raise RuntimeError("copy_hash_mismatch:" + item["target_id"])
                os.replace(tmp, destination)
                applied.append({"target_id": item["target_id"], "destination": destination, "sha256": item["sha256"]})
        for row in applied:
            if not os.path.isfile(row["destination"]) or _upd_sha256(row["destination"]) != row["sha256"]:
                raise RuntimeError("post_install_hash_mismatch:" + row["target_id"])
    except Exception:
        for row in reversed(backups):
            try:
                if row["existed"]:
                    os.makedirs(os.path.dirname(row["destination"]), exist_ok=True)
                    shutil.copy2(row["backup"], row["destination"])
                elif os.path.exists(row["destination"]):
                    os.remove(row["destination"])
            except Exception:
                pass
        raise
    return backup_root, applied, admin_item, backups


def _upd_rollback_records(backups):
    errors = []
    for row in reversed(backups or []):
        try:
            if row.get("existed"):
                os.makedirs(os.path.dirname(row["destination"]), exist_ok=True)
                shutil.copy2(row["backup"], row["destination"])
            elif os.path.exists(row["destination"]):
                os.remove(row["destination"])
        except Exception as exc:
            errors.append({"destination": row.get("destination"), "error": repr(exc)})
    return errors


def _upd_queue_admin_last(admin_item, package_id):
    if not admin_item:
        return {"queued": False}
    os.makedirs(CONTROL_DIR, exist_ok=True)
    shutil.copy2(admin_item["stage_path"], CONTROL_PENDING_ADMIN_PATH)
    if _upd_sha256(CONTROL_PENDING_ADMIN_PATH) != admin_item["sha256"]:
        raise RuntimeError("pending_admin_hash_mismatch")
    armed, info = arm_admin_update_failsafe_restart()
    if not armed:
        raise RuntimeError("failsafe_not_armed:" + str(info))
    request_payload = {
        "action": "apply_update",
        "reason": "unified_update_package:" + str(package_id),
        "not_before": time.time() + 1.5,
    }
    tmp = CONTROL_REQUEST_PATH + ".tmp"
    Path(tmp).write_text(json.dumps(request_payload, ensure_ascii=False, indent=2), encoding="utf-8")
    os.replace(tmp, CONTROL_REQUEST_PATH)
    return {"queued": True, "sha256": admin_item["sha256"], "failsafe": info}


def _upd_run_post_actions(actions):
    results = []
    for action in actions or []:
        if action == "REBUILD_USA_MEDIA_METADATA":
            rebuilt = {}
            for target_variant in ("phone", "tv"):
                rebuilt[target_variant] = write_usa_media_metadata(target_variant)
                if not rebuilt[target_variant].get("ok"):
                    raise RuntimeError("usa_media_metadata_rebuild_failed:" + target_variant + ":" + repr(rebuilt[target_variant]))
            results.append({"action": action, "result": rebuilt})
        elif action == "SYNC_USA_MEDIA_TO_TV":
            # Retained only for an explicitly requested compatibility package/manual workflow.
            results.append({"action": action, "result": sync_usa_phone_media_to_tv(prune=True, rebuild=True)})
        else:
            raise ValueError("unknown_post_action:" + str(action))
    return results


def _upd_escape(value):
    return html_lib.escape(str(value or ""))


def _upd_page(title, body):
    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>{_upd_escape(title)}</title><style>
body{{font-family:Arial;background:#07111f;color:#eef;margin:0;padding:18px}}main{{max-width:1050px;margin:auto}}.card{{background:#111d31;border:1px solid #304560;border-radius:22px;padding:18px;margin:14px 0}}h1,h2{{margin-top:0}}input,button{{font:inherit}}input[type=file]{{display:block;width:100%;padding:14px;background:#091426;color:#fff;border:1px solid #47607d;border-radius:14px;margin:12px 0}}button,.btn{{display:inline-block;border:0;border-radius:999px;padding:13px 18px;background:#2563eb;color:#fff;text-decoration:none;font-weight:900;cursor:pointer}}.danger{{background:#dc2626}}.good{{background:#052e2b;border-color:#0f766e}}.warn{{background:#3a2607;border-color:#d97706}}table{{width:100%;border-collapse:collapse}}th,td{{padding:10px;border-bottom:1px solid #304560;text-align:right;vertical-align:top}}code{{direction:ltr;unicode-bidi:isolate;color:#bfdbfe;word-break:break-all}}small{{color:#cbd5e1}}ul{{line-height:1.8}}</style></head><body><main><p><a class="btn" href="/admin">חזרה לאדמין</a></p>{body}</main></body></html>'''


@app.route("/admin/usa-media-alignment")
def admin_usa_media_alignment_v77():
    report = validate_usa_media_source_alignment()
    body = '<div class="card %s"><h1>מדיית ארה״ב — השוואה והעתקה ידנית</h1><pre style="white-space:pre-wrap;direction:ltr">%s</pre><form action="/admin/usa-media-alignment/sync" method="post" onsubmit="return confirm(\'לבצע העתקה ידנית של מדיית iPhone אל TV? פעולה זו תדרוס מדיה ייעודית ל-TV.\')"><button type="submit">העתק iPhone אל TV — פעולה ידנית</button></form></div>' % ('good' if report.get('ok') else 'warn', _upd_escape(json.dumps(report,ensure_ascii=False,indent=2)))
    return _upd_page("השוואת מדיית USA", body)


@app.route("/admin/usa-media-alignment/sync", methods=["POST"])
def admin_usa_media_alignment_sync_v77():
    try:
        result = sync_usa_phone_media_to_tv(prune=True, rebuild=True)
        return _upd_page("הסנכרון הושלם", '<div class="card good"><h1>המדיה סונכרנה ואומתה</h1><pre style="white-space:pre-wrap;direction:ltr">'+_upd_escape(json.dumps(result,ensure_ascii=False,indent=2))+'</pre><p><a class="btn" href="/admin/presentation-qa">פתח QA מצגות</a></p></div>')
    except Exception as exc:
        return _upd_page("הסנכרון נכשל", '<div class="card warn"><h1>לא בוצע סנכרון מלא</h1><code>'+_upd_escape(repr(exc))+'</code></div>'), 500


@app.route("/admin/update-package")
def admin_update_package_page_v72():
    last = ""
    if os.path.isfile(UPDATE_PACKAGE_LOG_PATH):
        try:
            data = json.loads(Path(UPDATE_PACKAGE_LOG_PATH).read_text(encoding="utf-8"))
            last = f'<div class="card"><h2>עדכון אחרון</h2><pre style="white-space:pre-wrap;direction:ltr">{_upd_escape(json.dumps(data,ensure_ascii=False,indent=2))}</pre></div>'
        except Exception:
            pass
    body = f'''<div class="card good"><h1>📦 עדכון גורף — ZIP אחד לכל המערכת</h1><p>הזיהוי נעשה לפי <b>tripserver-update.json</b> שבתוך החבילה. שמות הקבצים אינם קובעים את היעד, ולכן קובץ טיול לא יכול להישלח בטעות ליעד של מצגת.</p><ul><li>שלב 1: העלאה ובדיקה בלבד — אין שינוי בקבצים.</li><li>שלב 2: מוצגת תוכנית התקנה מלאה עם כל יעד ו־SHA256.</li><li>שלב 3: אישור ידני, גיבוי ויישום טרנזקציוני.</li><li>אם קובץ האדמין נמצא בחבילה, הוא נבדק ומוחל אחרון דרך Control Server ו־Fail Safe.</li></ul></div>
<div class="card"><h2>העלה חבילת עדכון</h2><form action="/admin/update-package/inspect" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip" required><button type="submit">בדוק חבילה והצג תוכנית התקנה</button></form><p><small>העלאה בשלב זה אינה מחליפה אף קובץ.</small></p></div>{last}'''
    return _upd_page("עדכון גורף", body)


@app.route("/admin/update-package/inspect", methods=["POST"])
def admin_update_package_inspect_v72():
    file = request.files.get("file")
    if not file or not file.filename:
        return _upd_page("שגיאה", '<div class="card warn"><h1>לא נבחר ZIP</h1></div>'), 400
    os.makedirs(UPDATE_PACKAGE_PENDING_DIR, exist_ok=True)
    token = uuid.uuid4().hex
    upload = os.path.join(UPDATE_PACKAGE_PENDING_DIR, token + ".zip")
    file.save(upload)
    try:
        if os.path.getsize(upload) > UPDATE_PACKAGE_MAX_BYTES:
            raise ValueError("package_too_large")
        meta = _upd_parse_and_stage(upload, token)
    except Exception as exc:
        shutil.rmtree(os.path.join(UPDATE_PACKAGE_PENDING_DIR, token), ignore_errors=True)
        try: os.remove(upload)
        except Exception: pass
        return _upd_page("החבילה נדחתה", f'<div class="card warn"><h1>החבילה לא עברה בדיקה</h1><p><code>{_upd_escape(repr(exc))}</code></p><p>לא הוחלף אף קובץ.</p></div>'), 400
    try: os.remove(upload)
    except Exception: pass
    rows = []
    for item in meta["plan"]:
        dest = "<br>".join("<code>" + _upd_escape(x) + "</code>" for x in item["destinations"])
        rows.append(f'<tr><td>{_upd_escape(item["target_id"])}</td><td>{_upd_escape(item["label"])}</td><td>{dest}</td><td><code>{_upd_escape(item["sha256"][:16])}</code></td><td>{_upd_escape(item["validation"].get("syntax"))}</td></tr>')
    actions_html = ""
    if meta.get("post_actions"):
        actions_html = '<div class="card"><h2>פעולות סנכרון לאחר ההתקנה</h2><ul>' + ''.join('<li>'+_upd_escape(UPDATE_PACKAGE_POST_ACTIONS.get(x,x))+'</li>' for x in meta["post_actions"]) + '</ul></div>'
    body = f'''<div class="card good"><h1>החבילה עברה בדיקה</h1><p><b>חבילה:</b> {_upd_escape(meta['package_id'])} · <b>גרסה:</b> {_upd_escape(meta['version'])}</p><p>{_upd_escape(meta['description'])}</p></div><div class="card"><h2>תוכנית התקנה</h2><table><thead><tr><th>מזהה יעד</th><th>רכיב</th><th>נתיב/ים</th><th>SHA16</th><th>בדיקת תוכן</th></tr></thead><tbody>{''.join(rows)}</tbody></table></div>{actions_html}<div class="card warn"><h2>אישור סופי</h2><p>בלחיצה הבאה ייווצר גיבוי מלא וכל הקבצים יוחלפו יחד. במקרה של כשל בקובצי התוכן תתבצע חזרה אוטומטית.</p><form action="/admin/update-package/apply" method="post" onsubmit="return confirm('להתקין את כל רכיבי החבילה כעת?')"><input type="hidden" name="token" value="{_upd_escape(token)}"><button class="danger" type="submit">התקן את כל החבילה</button></form></div>'''
    return _upd_page("אישור חבילת עדכון", body)


@app.route("/admin/update-package/apply", methods=["POST"])
def admin_update_package_apply_v72():
    token = (request.form.get("token", "") or "").strip()
    if not re.fullmatch(r"[0-9a-fA-F]{32}", token):
        return _upd_page("בקשה לא תקינה", '<div class="card warn"><h1>אסימון חבילת העדכון חסר או לא תקין</h1><p>חזור למסך העדכון, העלה את ה־ZIP מחדש ואשר את תוכנית ההתקנה.</p></div>'), 400
    try:
        meta = _upd_load_pending(token)
        backup_root, applied, admin_item, backups = _upd_apply_content_transaction(meta)
        try:
            post_action_results = _upd_run_post_actions(meta.get("post_actions") or [])
            admin_result = _upd_queue_admin_last(admin_item, meta.get("package_id"))
        except Exception:
            rollback_errors = _upd_rollback_records(backups)
            if rollback_errors:
                raise RuntimeError("admin_queue_failed_and_rollback_incomplete:" + json.dumps(rollback_errors, ensure_ascii=False))
            raise
        result = {
            "ok": True,
            "package_id": meta.get("package_id"),
            "version": meta.get("version"),
            "backup_root": backup_root,
            "applied": applied,
            "post_actions": post_action_results,
            "admin_update": admin_result,
        }
        _upd_write_log(result)
    except Exception as exc:
        result = {"ok": False, "token": token, "error": repr(exc)}
        _upd_write_log(result)
        return _upd_page("העדכון נכשל", f'<div class="card warn"><h1>החבילה לא הושלמה</h1><p><code>{_upd_escape(repr(exc))}</code></p><p>קובצי תוכן שהוחלפו לפני הכשל שוחזרו מהגיבוי.</p></div>'), 500
    finally:
        shutil.rmtree(os.path.join(UPDATE_PACKAGE_PENDING_DIR, token), ignore_errors=True)
    admin_note = ""
    if admin_result.get("queued"):
        admin_note = '<p><b>האדמין החדש הועבר ל־Control Server ויוחל אחרון. הדף עשוי להתנתק לזמן קצר.</b></p>'
    body = f'''<div class="card good"><h1>העדכון הותקן</h1><p>חבילה: <b>{_upd_escape(meta.get('package_id'))}</b> · גרסה: <b>{_upd_escape(meta.get('version'))}</b></p><p>מספר יעדי קבצים שהוחלפו: <b>{len(applied)}</b></p><p>גיבוי: <code>{_upd_escape(backup_root)}</code></p>{admin_note}<p><a class="btn" href="/admin/self-test">הרץ בדיקה עצמית מלאה</a> <a class="btn" href="/admin/status">בדוק קבצים פעילים</a></p></div>'''
    return _upd_page("העדכון הושלם", body)
# === end v72 Unified Update Package Installer =================================




# === v91 Unified Audit Center ==================================================
# One audited implementation replaces the accumulated v85/v89/v90 audit layers.
AUDIT_V91_SCHEMA = "eitan-tripserver-audit/v91"
AUDIT_V91_EXPORT_DIR = os.path.join(BASE_DIR, "tmp", "audit_exports")
AUDIT_V91_REPORT_DIR = os.path.join(BASE_DIR, "logs", "audit_reports")
AUDIT_V91_QUICK_REPORT = os.path.join(AUDIT_V91_REPORT_DIR, "USA_QUICK_AUDIT_LATEST.json")
AUDIT_V91_ROOTS = {"USA": os.path.join(BASE_DIR,"USA"), "USA_TV": os.path.join(BASE_DIR,"USA_TV"), "Moneyapp": os.path.join(BASE_DIR,"Moneyapp")}
AUDIT_V91_CANONICAL = {
    "usa_trip": ("USA", "index.html"),
    "usa_presentation_phone": ("USA", "usa2027-presentation.html"),
    "usa_trip_tv": ("USA_TV", "usa2027-tv.html"),
    "usa_presentation_tv": ("USA_TV", "usa2027-presentation-tv.html"),
    "moneyapp": ("Moneyapp", "index.html"),
}
AUDIT_V91_MEDIA_EXT = {'.jpg','.jpeg','.png','.webp','.gif','.svg','.avif','.mp4','.m4v','.mov','.webm','.mp3','.m4a','.aac','.wav','.ogg'}
AUDIT_V91_CODE_EXT = {'.html','.htm','.css','.js','.mjs','.json','.webmanifest','.manifest','.py','.txt','.md','.xml'}
AUDIT_V91_IMAGE_EXT = {'.jpg','.jpeg','.png','.webp','.gif','.svg','.avif'}
AUDIT_V91_VIDEO_EXT = {'.mp4','.m4v','.mov','.webm'}
AUDIT_V91_AUDIO_EXT = {'.mp3','.m4a','.aac','.wav','.ogg'}
AUDIT_V91_SKIP_DIRS = {'__pycache__','.git','.svn','.hg','tmp','temp','backups','backup','.admin_backups','.admin-backups','node_modules'}
AUDIT_V91_BACKUP_PATTERNS = ('.bak','.backup','.old','.tmp','.orig')


def _v91_sha256_bytes(data):
    import hashlib
    return hashlib.sha256(data).hexdigest()


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


def _v91_is_backup_name(name):
    low=str(name or '').lower()
    return low.endswith(AUDIT_V91_BACKUP_PATTERNS) or '.before_' in low or low.startswith('backup_') or low.endswith('~')


def _v91_kind(ext):
    ext=ext.lower()
    if ext in AUDIT_V91_IMAGE_EXT: return 'image'
    if ext in AUDIT_V91_VIDEO_EXT: return 'video'
    if ext in AUDIT_V91_AUDIO_EXT: return 'audio'
    if ext in AUDIT_V91_CODE_EXT: return 'code'
    return 'other'


def _v91_probe_media(path, kind):
    result={'probe':'not-required'}
    if kind not in {'video','audio'}: return result
    result={'probe':'ffprobe-unavailable'}
    import shutil as _shutil, subprocess as _subprocess
    exe=_shutil.which('ffprobe')
    if not exe: return result
    try:
        cp=_subprocess.run([exe,'-v','error','-show_entries','format=duration,bit_rate:stream=codec_name,codec_type,width,height,r_frame_rate','-of','json',path],capture_output=True,text=True,timeout=35,creationflags=hidden_subprocess_flags())
        if cp.returncode==0:
            payload=json.loads(cp.stdout or '{}'); result={'probe':'ok','ffprobe':payload}
        else: result={'probe':'failed','error':(cp.stderr or '')[-800:]}
    except Exception as exc: result={'probe':'failed','error':repr(exc)}
    return result


def _v91_collect_inventory(include_media_payload=False):
    files=[]; payloads=[]; counts={'code':0,'image':0,'video':0,'audio':0,'other':0,'backup_excluded':0}; roots={}
    canonical_paths={root+'/'+rel:key for key,(root,rel) in AUDIT_V91_CANONICAL.items()}
    for root_name,root in AUDIT_V91_ROOTS.items():
        if not os.path.isdir(root): continue
        root_count=0; root_bytes=0
        for dirpath,dirs,names in os.walk(root):
            dirs[:]=[d for d in dirs if d.lower() not in AUDIT_V91_SKIP_DIRS and not d.startswith('.')]
            for name in names:
                full=os.path.join(dirpath,name)
                rel=os.path.relpath(full,root).replace('\\','/')
                if _v91_is_backup_name(name): counts['backup_excluded']+=1; continue
                ext=os.path.splitext(name)[1].lower(); kind=_v91_kind(ext)
                try: st=os.stat(full)
                except OSError: continue
                arc=root_name+'/'+rel
                rec={'path':arc,'root':root_name,'relative_path':rel,'live_source':full,'extension':ext,'kind':kind,'size_bytes':st.st_size,'mtime_ns':st.st_mtime_ns,'sha256':_v91_sha256_file(full),'canonical_component':canonical_paths.get(arc),'included_payload':kind!='media'}
                if kind in {'video','audio'}: rec.update(_v91_probe_media(full,kind))
                files.append(rec); counts[kind]=counts.get(kind,0)+1; root_count+=1; root_bytes+=st.st_size
                if kind not in {'image','video','audio'} or include_media_payload: payloads.append((full,arc)); rec['included_payload']=True
                else: rec['included_payload']=False
        roots[root_name]={'path':root,'files':root_count,'bytes':root_bytes}
    admin_path=os.path.join(BASE_DIR,'admin_server.py')
    if not os.path.isfile(admin_path): raise RuntimeError('active_admin_missing:'+admin_path)
    st=os.stat(admin_path); admin={'path':'admin_server.py','root':'ADMIN','relative_path':'admin_server.py','live_source':admin_path,'extension':'.py','kind':'code','size_bytes':st.st_size,'mtime_ns':st.st_mtime_ns,'sha256':_v91_sha256_file(admin_path),'canonical_component':'admin','included_payload':True}
    files.append(admin); payloads.append((admin_path,'admin_server.py')); counts['code']+=1
    # explicit source-of-truth; filename timestamps alone are never used as proof.
    components={'admin':{k:admin[k] for k in ('path','sha256','size_bytes','mtime_ns')}}
    by_path={r['path']:r for r in files}
    for key,(root,rel) in AUDIT_V91_CANONICAL.items():
        arc=root+'/'+rel; rec=by_path.get(arc)
        components[key]=({'path':arc,'exists':False} if not rec else {k:rec[k] for k in ('path','sha256','size_bytes','mtime_ns')})
    sot={'schema':AUDIT_V91_SCHEMA,'created_at':datetime.now().isoformat(timespec='seconds'),'admin_version':ADMIN_PANEL_VERSION,'roots':roots,'components':components,'netlify_policy':{'scope':'USA phone/computer only','excluded':['USA_TV','usa2027-tv.html','usa2027-presentation-tv.html'],'allowed_difference':'Only deployment paths/layout profile may differ; the budget slide is text-only and logically identical everywhere.'}}
    return files,payloads,counts,sot


def _v91_url_category(raw):
    from urllib.parse import urlsplit
    value=(raw or '').strip()
    low=value.lower()
    if not value: return 'empty'
    if low.startswith(('data:','blob:')): return 'embedded'
    if low.startswith(('http://','https://','//')): return 'external'
    if low.startswith(('mailto:','tel:','sms:','javascript:')): return 'protocol'
    if value.startswith('#'): return 'anchor'
    parsed=urlsplit(value)
    if parsed.scheme: return 'external-scheme'
    return 'local'


def _v91_resolve_reference(source_arc, raw):
    from urllib.parse import urlsplit,unquote
    clean=unquote(urlsplit(raw).path or '').strip().replace('\\','/')
    if not clean: return ''
    if clean.startswith('/'):
        return clean.lstrip('/')
    base=source_arc.rsplit('/',1)[0] if '/' in source_arc else ''
    return os.path.normpath(base+'/'+clean).replace('\\','/').lstrip('./')


def _v91_looks_asset_string(value):
    from urllib.parse import urlsplit
    path=(urlsplit(str(value or '')).path or '').lower()
    ext=os.path.splitext(path)[1]
    return ext in (AUDIT_V91_MEDIA_EXT|AUDIT_V91_CODE_EXT)


def _v91_extract_html_references(text, source_arc):
    from html.parser import HTMLParser
    refs=[]; ignored=[]; manifest_video_bases=set()
    # Read the inline media manifest first, so old JPG fallbacks replaced by videos are not false failures.
    for m in re.finditer(r'<script[^>]+id=["\']usa2027-inline-media-manifest["\'][^>]*>(.*?)</script>',text,re.I|re.S):
        try:
            data=json.loads(m.group(1));
            for item in data.get('items') or []:
                if item.get('type')=='video': manifest_video_bases.add(str(item.get('base') or '').lower())
        except Exception: pass
    class Parser(HTMLParser):
        def __init__(self): super().__init__(convert_charrefs=True); self.tag=''; self.line=0; self.json_depth=0; self.json_parts=[]
        def handle_starttag(self,tag,attrs):
            self.tag=tag.lower(); self.line=self.getpos()[0]; amap={str(k).lower():v for k,v in attrs}
            pairs=[]
            if self.tag in {'img','script','iframe','embed','source','audio','video','input','track'}:
                for a in ('src','poster'): 
                    if amap.get(a): pairs.append((a,amap[a]))
                for a in ('srcset',):
                    if amap.get(a):
                        for item in str(amap[a]).split(','): pairs.append((a,item.strip().split()[0]))
            elif self.tag=='link' and amap.get('href'): pairs.append(('href',amap['href']))
            elif self.tag=='object' and amap.get('data'): pairs.append(('data',amap['data']))
            elif self.tag=='a' and amap.get('href') and _v91_looks_asset_string(amap['href']): pairs.append(('href',amap['href']))
            if amap.get('style'):
                for mm in re.finditer(r'url\(\s*["\']?([^"\')]+)',amap['style'],re.I): pairs.append(('style-url',mm.group(1)))
            for attr,val in pairs:
                base=os.path.splitext(os.path.basename(str(val).split('?')[0]))[0].lower()
                fallback=(self.tag=='img' and base in manifest_video_bases)
                refs.append({'from':source_arc,'line':self.line,'source_type':self.tag+'@'+attr,'reference':str(val),'inactive_video_fallback':fallback})
            typ=str(amap.get('type') or '').lower(); ident=str(amap.get('id') or '')
            if self.tag=='script' and ('json' in typ or ident in {'usa2027-inline-media-manifest','usa2027-music-segments'}): self.json_depth=1; self.json_parts=[]
        def handle_endtag(self,tag):
            if self.json_depth and tag.lower()=='script':
                raw=''.join(self.json_parts)
                try:
                    data=json.loads(raw)
                    def walk(v):
                        if isinstance(v,dict):
                            for vv in v.values(): walk(vv)
                        elif isinstance(v,list):
                            for vv in v: walk(vv)
                        elif isinstance(v,str) and _v91_looks_asset_string(v): refs.append({'from':source_arc,'line':self.line,'source_type':'inline-json','reference':v,'inactive_video_fallback':False})
                    walk(data)
                except Exception: pass
                self.json_depth=0; self.json_parts=[]
        def handle_data(self,data):
            if self.json_depth: self.json_parts.append(data)
    p=Parser(); p.feed(text)
    for mm in re.finditer(r'<style[^>]*>(.*?)</style>',text,re.I|re.S):
        start_line=text.count('\n',0,mm.start())+1
        for u in re.finditer(r'url\(\s*["\']?([^"\')]+)',mm.group(1),re.I): refs.append({'from':source_arc,'line':start_line+mm.group(1).count('\n',0,u.start()),'source_type':'css-url','reference':u.group(1),'inactive_video_fallback':False})
    # Explicit JS resource operations and asset-looking string literals only.
    for mm in re.finditer(r'\b(?:fetch|importScripts|Worker|SharedWorker)\s*\(\s*["\']([^"\']+)',text,re.I):
        refs.append({'from':source_arc,'line':text.count('\n',0,mm.start())+1,'source_type':'js-resource','reference':mm.group(1),'inactive_video_fallback':False})
    return refs,ignored


def _v91_reference_audit(files):
    by_arc={r['path']:r for r in files}; prefixes=set()
    for path in by_arc:
        parts=path.split('/')
        for i in range(1,len(parts)): prefixes.add('/'.join(parts[:i])+'/')
    refs=[]; ignored=[]; missing=[]; suspicious=[]
    canonical_scan_paths = {root+'/'+rel for root,rel in AUDIT_V91_CANONICAL.values()}
    for rec in files:
        if rec.get('extension') not in AUDIT_V91_CODE_EXT: continue
        path_key=str(rec.get('path') or '')
        # Missing-reference results are about the active runtime/deploy chain, not historical
        # receipts, backups or duplicate convenience copies left beside canonical files.
        if path_key == 'admin_server.py': continue
        if path_key not in canonical_scan_paths and '/media/' not in path_key and not path_key.lower().endswith(('/manifest.json','.webmanifest')):
            continue
        try: text=Path(rec['live_source']).read_text(encoding='utf-8',errors='replace')
        except Exception as exc: suspicious.append({'file':rec['path'],'type':'read-error','detail':repr(exc)}); continue
        extracted=[]
        if rec['extension'] in {'.html','.htm'}: extracted,_=_v91_extract_html_references(text,rec['path'])
        elif rec['extension']=='.css':
            extracted=[{'from':rec['path'],'line':text.count('\n',0,m.start())+1,'source_type':'css-url','reference':m.group(1),'inactive_video_fallback':False} for m in re.finditer(r'url\(\s*["\']?([^"\')]+)',text,re.I)]
        elif rec['extension'] in {'.json','.webmanifest','.manifest'}:
            try:
                data=json.loads(text)
                def walk(v):
                    if isinstance(v,dict):
                        for vv in v.values(): walk(vv)
                    elif isinstance(v,list):
                        for vv in v: walk(vv)
                    elif isinstance(v,str) and _v91_looks_asset_string(v): extracted.append({'from':rec['path'],'line':None,'source_type':'json-asset','reference':v,'inactive_video_fallback':False})
                walk(data)
            except Exception: pass
        elif rec['extension'] in {'.js','.mjs','.py'}:
            for m in re.finditer(r'["\']([^"\']+\.(?:js|mjs|css|json|webmanifest|manifest|html?|jpg|jpeg|png|webp|gif|svg|avif|mp4|m4v|mov|webm|mp3|m4a|aac|wav|ogg)(?:[?#][^"\']*)?)["\']',text,re.I): extracted.append({'from':rec['path'],'line':text.count('\n',0,m.start())+1,'source_type':'code-asset-string','reference':m.group(1),'inactive_video_fallback':False})
        for item in extracted:
            category=_v91_url_category(item['reference']); item['category']=category
            if category!='local': ignored.append(item); continue
            resolved=_v91_resolve_reference(rec['path'],item['reference']); item['resolved']=resolved; refs.append(item)
            exists=resolved in by_arc or resolved.rstrip('/')+'/' in prefixes
            if not exists:
                if item.get('inactive_video_fallback'):
                    ignored.append({**item,'category':'inactive-video-fallback'}); continue
                missing.append({**item,'reason':'target-not-found'})
        low=text.lower()
        for token in ('file://','localhost','127.0.0.1','c:\\tripserver','c:/tripserver'):
            if token in low: suspicious.append({'file':rec['path'],'type':'private-or-local-token','token':token})
    unique={}
    for item in missing:
        key=(item['from'],item.get('resolved',''),item.get('reference',''))
        bucket=unique.setdefault(key,{**item,'occurrences':0,'lines':[]}); bucket['occurrences']+=1
        if item.get('line') not in bucket['lines']: bucket['lines'].append(item.get('line'))
    unique_list=sorted(unique.values(),key=lambda x:(x['from'],x.get('line') or 0,x.get('reference','')))
    return {'schema':AUDIT_V91_SCHEMA,'created_at':datetime.now().isoformat(timespec='seconds'),'summary':{'local_reference_occurrences':len(refs),'missing_occurrences':len(missing),'missing_unique':len(unique_list),'ignored_occurrences':len(ignored),'suspicious_items':len(suspicious)},'references':refs,'missing_local_references':missing,'missing_unique_references':unique_list,'ignored_references':ignored,'suspicious_tokens':suspicious}


def _v91_find_private_deploy_references(text):
    '''Return real private deployment references without flagging host-detection code.

    A trip file may legitimately compare ``location.hostname`` with literal values such
    as ``localhost`` and ``127.0.0.1`` in order to choose an offline/private-server
    mode. Those literals are not outbound links and must not block a Netlify export.
    Actual local URLs, private IP URLs, Tailscale hosts and local filesystem paths
    remain hard failures.
    '''
    from urllib.parse import urlsplit

    raw_text = str(text or '')
    low = raw_text.lower()
    hits = []
    seen = set()

    def add(token, start, value=''):
        line = raw_text.count('\n', 0, max(0, int(start))) + 1
        key = (str(token), line, str(value)[:240])
        if key in seen:
            return
        seen.add(key)
        hits.append({'token': str(token), 'line': line, 'value': str(value)[:240]})

    # Filesystem references are never valid in a public static deployment.
    for token, pattern in (
        ('file://', r'file\s*://'),
        ('c:\\tripserver', r'c:(?:\\+|/+)tripserver(?:\\+|/+|\b)'),
    ):
        for match in re.finditer(pattern, low, re.I):
            add(token, match.start(), match.group(0))

    candidates = []
    # Absolute and protocol-relative URLs anywhere in the document.
    for match in re.finditer(r'''(?i)(?:https?:)?//[^\s"'<>`]+''', raw_text):
        candidates.append((match.start(), match.group(0), True))

    # URL-bearing HTML attributes can also contain a host without an explicit scheme.
    attr_re = re.compile(
        r'''(?is)\b(?:href|src|poster|data-href|action|formaction|data)\s*=\s*(?P<q>["'])(?P<value>.*?)(?P=q)'''
    )
    for match in attr_re.finditer(raw_text):
        candidates.append((match.start('value'), match.group('value'), True))

    # Quoted host/path constants catch values such as localhost:8000/api while
    # deliberately allowing plain comparison literals: h === 'localhost'.
    quoted_host_re = re.compile(
        r'''(?is)(?P<q>["'])(?P<value>(?:localhost|127\.0\.0\.1|\[::1\]|[a-z0-9.-]*tail978ec2(?:\.ts\.net)?)(?::\d{1,5})?(?:[/\\][^"']*)?)(?P=q)'''
    )
    for match in quoted_host_re.finditer(raw_text):
        value = match.group('value')
        low_value = value.lower()
        has_route_or_port = bool(re.search(r'(?::\d{1,5})(?:[/\\]|$)|[/\\]', low_value))
        is_tailnet = 'tail978ec2' in low_value or low_value.endswith('.ts.net')
        if has_route_or_port or is_tailnet:
            candidates.append((match.start('value'), value, False))

    def classify(candidate, url_context):
        value = html_lib.unescape(str(candidate or '')).strip()
        if not value:
            return None
        value = value.rstrip('.,;)}]')
        low_value = value.lower()
        if low_value.startswith('file://'):
            return 'file://'
        if re.match(r'(?i)^c:(?:\\+|/+)tripserver(?:\\+|/+|$)', value):
            return 'c:\\tripserver'

        parsed_value = value
        if low_value.startswith('//'):
            parsed_value = 'http:' + value
        elif not re.match(r'(?i)^https?://', value):
            # A host without a scheme is only a deployment reference when it appears
            # in a URL attribute, contains a port/path, or names the private tailnet.
            hostish = re.match(
                r'(?i)^(localhost|127\.0\.0\.1|\[::1\]|[a-z0-9.-]*tail978ec2(?:\.ts\.net)?)(?::\d{1,5})?(?:[/\\].*)?$',
                value,
            )
            if not hostish:
                return None
            if not (url_context or re.search(r'(?::\d{1,5})(?:[/\\]|$)|[/\\]', value) or 'tail978ec2' in low_value):
                return None
            parsed_value = 'http://' + value.replace('\\', '/')

        try:
            host = (urlsplit(parsed_value).hostname or '').strip().lower()
        except Exception:
            return None
        if not host:
            return None
        if host in {'localhost', '::1'}:
            return 'localhost'
        if 'tail978ec2' in host or host.endswith('.ts.net'):
            return 'tail978ec2'
        if host.endswith('.local'):
            return 'private-host'
        try:
            ip = ipaddress.ip_address(host)
            tailscale_cgnat = ip.version == 4 and ip in ipaddress.ip_network('100.64.0.0/10')
            if ip.is_loopback or ip.is_private or ip.is_link_local or tailscale_cgnat:
                return '127.0.0.1' if str(ip) == '127.0.0.1' else 'private-ip'
        except ValueError:
            pass
        return None

    for start, candidate, url_context in candidates:
        token = classify(candidate, url_context)
        if token:
            add(token, start, candidate)
    return hits


def _v91_validate_netlify_zip(zip_path, details):
    import tempfile, shutil as _shutil
    errors=[]; warnings=[]; inventory={}
    with zipfile.ZipFile(zip_path,'r') as zf:
        bad=zf.testzip()
        if bad: errors.append('crc_failed:'+bad)
        names=[x.filename.replace('\\','/') for x in zf.infolist() if not x.is_dir()]
        for name in names:
            data=zf.read(name); inventory[name]={'size_bytes':len(data),'sha256':_v91_sha256_bytes(data)}
            low=name.lower()
            if low.startswith('usa_tv/') or 'presentation-tv' in low or low.endswith('usa2027-tv.html'): errors.append('tv_file_forbidden:'+name)
            if low.endswith(('.html','.htm','.css','.js','.json','.webmanifest','.manifest')):
                text=data.decode('utf-8','replace')
                for hit in _v91_find_private_deploy_references(text):
                    errors.append('private_path_or_host:'+name+':'+hit['token']+':line='+str(hit['line']))
        if not any(n=='USA/index.html' or n=='index.html' for n in names): errors.append('index_missing')
        if not any(n.endswith('usa2027-presentation.html') for n in names): errors.append('phone_presentation_missing')
    # Extract cleanly and compare extraction inventory to archive bytes.
    temp=tempfile.mkdtemp(prefix='tripserver_netlify_verify_')
    try:
        with zipfile.ZipFile(zip_path,'r') as zf: zf.extractall(temp)
        extracted={}
        for dp,_,ns in os.walk(temp):
            for n in ns:
                p=os.path.join(dp,n); rel=os.path.relpath(p,temp).replace('\\','/'); extracted[rel]={'size_bytes':os.path.getsize(p),'sha256':_v91_sha256_file(p)}
        if extracted!=inventory: errors.append('extract_inventory_mismatch')
        scan_records=[]
        for rel,meta in extracted.items():
            full=os.path.join(temp,*rel.split('/')); ext=os.path.splitext(rel)[1].lower()
            scan_records.append({'path':rel,'root':rel.split('/',1)[0] if '/' in rel else 'ROOT','relative_path':rel.split('/',1)[1] if '/' in rel else rel,'live_source':full,'extension':ext,'kind':_v91_kind(ext),'size_bytes':meta['size_bytes'],'sha256':meta['sha256']})
        ref_report=_v91_reference_audit(scan_records)
        if ref_report['summary']['missing_unique']:
            errors.extend('missing_internal_reference:'+x.get('from','')+'->'+x.get('reference','') for x in ref_report['missing_unique_references'])
        # The one intentional content exception: Netlify budget slide must omit amounts.
        presentation_candidates=[x for x in extracted if x.endswith('usa2027-presentation.html')]
        if presentation_candidates:
            ptext=Path(os.path.join(temp,*presentation_candidates[0].split('/'))).read_text(encoding='utf-8',errors='replace')
            mm=re.search(r'<section[^>]+data-key=["\']budget["\'][^>]*>(.*?)</section>',ptext,re.I|re.S)
            if not mm: errors.append('netlify_budget_slide_missing')
            elif re.search(r'(?:\$\s*\d|₪\s*\d|\d[\d,]*\s*(?:USD|ILS))',mm.group(1),re.I): errors.append('netlify_budget_slide_contains_amounts')
        else: errors.append('netlify_phone_presentation_missing_for_budget_check')
    finally: _shutil.rmtree(temp,ignore_errors=True)
    return {'ok':not errors,'errors':sorted(set(errors)),'warnings':warnings,'file_count':len(inventory),'inventory':inventory,'builder_details':details}


def _v91_prune_netlify_unreferenced_audio(build_root):
    """Keep only runtime-declared audio in a USA Netlify deployment.

    The private server may contain editable segment/source tracks. They are inventoried
    and validated by the live-source audit, but they are not runtime dependencies and
    therefore must not inflate or leak into the public Netlify ZIP.
    """
    presentation_path = os.path.join(build_root, 'USA', 'usa2027-presentation.html')
    if not os.path.isfile(presentation_path):
        return []
    payload = v82_read_inline_usa_media_manifest(presentation_path)
    declared = {
        ('USA/' + str(item.get('url') or '').replace('\\','/').lstrip('/'))
        for item in (payload.get('items') or [])
        if isinstance(item,dict) and item.get('type') == 'audio' and item.get('url')
    }
    music_root = os.path.join(build_root, 'USA', 'media', 'usa-presentation', 'music')
    removed = []
    if not os.path.isdir(music_root):
        return removed
    for dirpath, _, names in os.walk(music_root, topdown=False):
        for name in names:
            if file_ext(name) not in USA_AUDIO_EXTENSIONS:
                continue
            full = os.path.join(dirpath,name)
            rel = os.path.relpath(full,build_root).replace('\\','/')
            if rel not in declared:
                os.remove(full); removed.append(rel)
        if dirpath != music_root:
            try:
                if not os.listdir(dirpath): os.rmdir(dirpath)
            except OSError:
                pass
    return sorted(removed)


def build_netlify_deploy_zip_v91(trip):
    result=build_netlify_deploy_zip_v58(trip)
    zip_path,details=result if isinstance(result,tuple) else (result,{})
    trip_key=normalize_netlify_trip_key(trip) or str(trip or '').upper()
    if trip_key == 'USA':
        build_root=details.get('build_root')
        removed=_v91_prune_netlify_unreferenced_audio(build_root)
        if removed:
            clean_validation=netlify_validate_build(build_root,'USA')
            if not clean_validation.get('ok'):
                raise RuntimeError('v91_netlify_validation_after_prune_failed:\n'+'\n'.join(clean_validation.get('errors') or []))
            manifest=v58_write_build_manifest(build_root,'USA',details.get('source_root') or FOLDERS['USA'],clean_validation,'specialized-live-source-v91')
            files=zip_directory_contents(build_root,zip_path)
            live_gate=v82_usa_live_source_gate()
            details.update({
                'files':len(files),
                'size_bytes':os.path.getsize(zip_path),
                'manifest':manifest,
                'validation':clean_validation,
                'pruned_unreferenced_audio':removed,
                'final_zip_verification':v82_verify_final_zip(build_root,zip_path,'USA',live_gate),
            })
    validation=_v91_validate_netlify_zip(zip_path,details)
    if not validation['ok']: raise RuntimeError('v91_netlify_post_validation_failed:\n'+'\n'.join(validation['errors']))
    details['v91_post_validation']={k:v for k,v in validation.items() if k!='inventory'}
    return zip_path,details


def _v91_readiness(files,counts,sot,refs,netlify=None):
    checks=[]
    def add(name,ok,detail,severity='error'): checks.append({'name':name,'ok':bool(ok),'severity':severity,'detail':detail})
    for key,item in sot.get('components',{}).items(): add('source_'+key,item.get('exists',True),item)
    add('media_inventory_present',sum(counts.get(k,0) for k in ('image','video','audio'))>0,{k:counts.get(k,0) for k in ('image','video','audio')})
    add('no_missing_local_references',refs['summary']['missing_unique']==0,refs['summary'],'warning')
    add('private_or_local_tokens_reported',True,{'items':refs['summary']['suspicious_items']},'warning')
    if netlify is not None: add('netlify_builder_and_post_validation',True,netlify)
    failed=[c for c in checks if not c['ok'] and c['severity']=='error']; warnings=[c for c in checks if not c['ok'] and c['severity']=='warning']
    return {'schema':AUDIT_V91_SCHEMA,'created_at':datetime.now().isoformat(timespec='seconds'),'ready':not failed,'checks':checks,'failed':failed,'warnings':warnings,'summary':{'total':len(checks),'passed':sum(c['ok'] for c in checks),'failed':len(failed),'warnings':len(warnings)}}


def _v91_write_json_atomic(path,payload):
    os.makedirs(os.path.dirname(path),exist_ok=True); tmp=path+'.tmp'; Path(tmp).write_text(json.dumps(payload,ensure_ascii=False,indent=2,default=str),encoding='utf-8'); os.replace(tmp,path)


def run_quick_usa_audit_v91():
    files,_,counts,sot=_v91_collect_inventory(False); refs=_v91_reference_audit(files); report=_v91_readiness(files,counts,sot,refs,None)
    report.update({'source_of_truth':sot,'file_counts':counts,'reference_audit':refs,'self_test':run_self_test_suite(include_media_conversion=False)})
    _v91_write_json_atomic(AUDIT_V91_QUICK_REPORT,report); return report


def _v91_build_audit_bundle(include_media=False):
    os.makedirs(AUDIT_V91_EXPORT_DIR,exist_ok=True); stamp=datetime.now().strftime('%Y%m%d_%H%M%S'); mode='FULL' if include_media else 'CHATGPT'; zip_path=os.path.join(AUDIT_V91_EXPORT_DIR,f'USA_{mode}_AUDIT_{stamp}.zip')
    netlify_path,netlify_details=build_netlify_deploy_zip_v91('USA')
    # Inventory is collected after Netlify build, because that build may legitimately refresh media-manifest.json.
    files,payloads,counts,sot=_v91_collect_inventory(include_media); refs=_v91_reference_audit(files); readiness=_v91_readiness(files,counts,sot,refs,netlify_details); self_test=run_self_test_suite(include_media_conversion=False)
    media=[r for r in files if r['kind'] in {'image','video','audio'}]
    reports={'SOURCE_OF_TRUTH.json':sot,'FILE_INVENTORY.json':{'schema':AUDIT_V91_SCHEMA,'counts':counts,'files':files},'MEDIA_METADATA.json':{'schema':AUDIT_V91_SCHEMA,'counts':{k:counts.get(k,0) for k in ('image','video','audio')},'media':media},'REFERENCE_AUDIT.json':refs,'MISSING_REFERENCES.json':{'summary':refs['summary'],'missing_unique_references':refs['missing_unique_references'],'missing_local_references':refs['missing_local_references']},'AUDIT_READINESS.json':readiness,'SELF_TEST.json':self_test,'NETLIFY_BUILD.json':{'zip_name':os.path.basename(netlify_path),'size_bytes':os.path.getsize(netlify_path),'details':netlify_details,'zip_sha256':_v91_sha256_file(netlify_path)},'ADMIN_ACTIVE_VERSION.json':{'version':ADMIN_PANEL_VERSION,'sha256':_v91_sha256_file(os.path.join(BASE_DIR,'admin_server.py')),'schema':AUDIT_V91_SCHEMA}}
    with zipfile.ZipFile(zip_path,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=6 if include_media else 9) as zf:
        for src,arc in payloads: zf.write(src,arcname=arc)
        for name,payload in reports.items(): zf.writestr(name,json.dumps(payload,ensure_ascii=False,indent=2,default=str))
    with zipfile.ZipFile(zip_path,'r') as zf:
        bad=zf.testzip(); names=set(zf.namelist())
        if bad: raise RuntimeError('audit_zip_crc_failed:'+bad)
        if not set(reports).issubset(names): raise RuntimeError('audit_reports_missing')
        if 'admin_server.py' not in names: raise RuntimeError('audit_active_admin_missing')
    return zip_path,readiness


def _v91_human_detail(check):
    d=check.get('detail')
    if check.get('name')=='no_missing_local_references' and isinstance(d,dict): return f"הפניות חסרות ייחודיות: {d.get('missing_unique',0)} · מופעים: {d.get('missing_occurrences',0)}"
    if isinstance(d,dict): return ' · '.join(f'{k}: {v}' for k,v in d.items() if k in {'path','files','bytes','image','video','audio','missing_unique','suspicious_items'}) or json.dumps(d,ensure_ascii=False,default=str)
    return str(d)


def _v91_result_page(title,report):
    s=report.get('summary') or {}; rows=''.join('<tr><td>'+_upd_escape(c.get('name',''))+'</td><td>'+('✅' if c.get('ok') else '⚠️')+'</td><td>'+_upd_escape(_v91_human_detail(c))+'</td></tr>' for c in report.get('checks',[]))
    body='<div class="card '+('good' if report.get('ready') else 'warn')+'"><h1>'+_upd_escape(title)+'</h1><p>עברו '+str(s.get('passed',0))+' מתוך '+str(s.get('total',0))+' · כשלים '+str(s.get('failed',0))+' · אזהרות '+str(s.get('warnings',0))+'</p><table style="width:100%;border-collapse:collapse"><tr><th>בדיקה</th><th>מצב</th><th>פירוט</th></tr>'+rows+'</table><p><a class="btn" href="/admin/live-usa-audit/quick/report">⬇ הורד דוח מלא</a></p><p><a class="btn" href="/admin/live-usa-audit">חזרה ל־Audit Center</a></p></div>'
    return _upd_page(title,body)


@app.route('/admin/live-usa-audit')
def admin_live_usa_audit_v91():
    body='<div class="card good"><h1>Audit Center — ארצות הברית</h1><p>כל המצבים קוראים את הקבצים הפעילים בשרת. חבילת ChatGPT כוללת קוד ומטא־נתונים מלאים ללא המדיה הכבדה.</p><div style="display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))"><div class="card"><h2>⚡ Quick Audit</h2><p><a class="btn" href="/admin/live-usa-audit/quick">הרץ בדיקה והפק דוח</a></p></div><div class="card"><h2>🤖 ChatGPT Audit</h2><p><a class="btn" href="/admin/live-usa-audit/chatgpt">צור חבילה קטנה</a></p></div><div class="card"><h2>📦 Full Archive</h2><p><a class="btn" href="/admin/live-usa-audit/full">צור צילום מלא</a></p></div></div><p><a class="btn" href="/admin">⬅ חזרה לאדמין</a></p></div>'
    return _upd_page('Audit Center — ארצות הברית',body)


@app.route('/admin/live-usa-audit/quick')
def admin_live_usa_quick_v91():
    try: return _v91_result_page('Quick Audit — USA',run_quick_usa_audit_v91())
    except Exception as exc: return _upd_page('Quick Audit נכשל','<div class="card warn"><h1>הבדיקה נכשלה</h1><code>'+_upd_escape(repr(exc))+'</code><p><a class="btn" href="/admin/live-usa-audit">חזרה</a></p></div>'),200


@app.route('/admin/live-usa-audit/quick/report')
def admin_live_usa_quick_report_v91():
    try:
        if not os.path.isfile(AUDIT_V91_QUICK_REPORT): run_quick_usa_audit_v91()
        response=send_file(AUDIT_V91_QUICK_REPORT,as_attachment=True,download_name='USA_QUICK_AUDIT_REPORT.json',mimetype='application/json',conditional=True,max_age=0); response.headers['Cache-Control']='no-store, max-age=0'; return response
    except Exception as exc: return _upd_page('הורדת הדוח נכשלה','<div class="card warn"><code>'+_upd_escape(repr(exc))+'</code></div>'),200


@app.route('/admin/live-usa-audit/chatgpt')
def admin_live_usa_chatgpt_v91():
    try:
        path,_=_v91_build_audit_bundle(False); response=send_file(path,as_attachment=True,download_name=os.path.basename(path),mimetype='application/zip',conditional=True,max_age=0); response.headers['Cache-Control']='no-store, max-age=0'; return response
    except Exception as exc:
        import traceback
        try: os.makedirs(os.path.join(BASE_DIR,'logs'),exist_ok=True); Path(os.path.join(BASE_DIR,'logs','live_usa_audit_error.txt')).write_text(traceback.format_exc(),encoding='utf-8')
        except Exception: pass
        return _upd_page('ChatGPT Audit נכשל','<div class="card warn"><h1>החבילה לא נוצרה</h1><code style="white-space:pre-wrap">'+_upd_escape(repr(exc))+'</code><p><a class="btn" href="/admin/live-usa-audit">חזרה</a></p></div>'),200


@app.route('/admin/live-usa-audit/full')
def admin_live_usa_full_v91():
    try:
        path,_=_v91_build_audit_bundle(True); response=send_file(path,as_attachment=True,download_name=os.path.basename(path),mimetype='application/zip',conditional=True,max_age=0); response.headers['Cache-Control']='no-store, max-age=0'; return response
    except Exception as exc: return _upd_page('Full Audit נכשל','<div class="card warn"><code>'+_upd_escape(repr(exc))+'</code><p><a class="btn" href="/admin/live-usa-audit">חזרה</a></p></div>'),200

# Route used by the ordinary Netlify download now receives the same v91 post-validation.
def admin_netlify_download_v91(trip):
    trip_key=normalize_netlify_trip_key(trip) or v58_normalize_generic_trip_key(trip)
    if not trip_key: return admin_status_page('טיול לא מוכר','לא נמצאה תיקיית טיול פעילה.',ok=False),404
    try:
        zip_path,details=build_netlify_deploy_zip_v91(trip_key)
        response=send_file(zip_path,as_attachment=True,download_name=os.path.basename(zip_path),mimetype='application/zip',conditional=False,max_age=0); response.headers['Cache-Control']='no-store, no-cache, must-revalidate, max-age=0'; response.headers['X-TripServer-Netlify-Files']=str(details.get('files','')); return response
    except Exception as exc: return admin_status_page('יצירת ZIP ל־Netlify נכשלה','החבילה לא נמסרה מפני שאחת מבדיקות הבנייה או האימות נכשלה.',ok=False,details=repr(exc)),500

app.view_functions['admin_netlify_download']=admin_netlify_download_v91
# === end v91 Unified Audit Center =============================================

# === v92 Verified Runtime Identity and Post-Update Verification ================
# This layer deliberately wraps the stable v91 application instead of duplicating
# its trip, presentation, media and Netlify engines. It adds a verifiable runtime
# identity, a self-update handshake, duplicate-admin discovery and audit evidence.

RUNTIME_STATUS_PATH = os.path.join(BASE_DIR, "admin_runtime_status.json")
ADMIN_UPDATE_SUBMISSION_PATH = os.path.join(BASE_DIR, "admin_update_submission.json")
ADMIN_EXPECTED_INSTALL_PATH = os.path.abspath(os.path.join(BASE_DIR, "admin_server.py"))
ADMIN_RUNTIME_LOADED_AT = time.strftime("%Y-%m-%d %H:%M:%S")
try:
    ADMIN_LOADED_SHA256 = file_sha256(ADMIN_SERVER_PATH)
except Exception:
    ADMIN_LOADED_SHA256 = ""
try:
    ADMIN_LOADED_MTIME = os.path.getmtime(ADMIN_SERVER_PATH)
    ADMIN_LOADED_SIZE = os.path.getsize(ADMIN_SERVER_PATH)
except Exception:
    ADMIN_LOADED_MTIME = 0.0
    ADMIN_LOADED_SIZE = 0


def _v92_norm_path(path):
    try:
        return os.path.normcase(os.path.realpath(os.path.abspath(path or "")))
    except Exception:
        return str(path or "").strip().lower()


def _v92_read_json(path, default=None):
    fallback = {} if default is None else default
    try:
        with open(path, "r", encoding="utf-8") as fh:
            data = json.load(fh)
        return data if isinstance(data, dict) else fallback
    except Exception:
        return fallback


def _v92_write_json_atomic(path, payload):
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(payload, fh, ensure_ascii=False, indent=2, default=str)
        fh.flush()
        try:
            os.fsync(fh.fileno())
        except Exception:
            pass
    os.replace(tmp, path)
    return path


def _v92_source_identity(path):
    result = {"path": os.path.abspath(path), "exists": os.path.isfile(path), "version": "", "build_id": "", "sha256": ""}
    if not result["exists"]:
        return result
    try:
        result["sha256"] = file_sha256(path)
        result["size_bytes"] = os.path.getsize(path)
        result["modified_at"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(path)))
        text = Path(path).read_text(encoding="utf-8", errors="replace")
        version_match = re.search(r'^\s*ADMIN_PANEL_VERSION\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE)
        build_match = re.search(r'^\s*ADMIN_BUILD_ID\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE)
        if version_match:
            result["version"] = version_match.group(1).strip()
        if build_match:
            result["build_id"] = build_match.group(1).strip()
    except Exception as exc:
        result["error"] = repr(exc)
    return result


def _v92_pids_on_port(port):
    pids = []
    if os.name != "nt":
        return pids
    try:
        cp = subprocess.run(["netstat", "-ano", "-p", "tcp"], capture_output=True, text=True, timeout=10, creationflags=hidden_subprocess_flags())
        marker = ":" + str(int(port))
        for line in (cp.stdout or "").splitlines():
            if marker not in line or "listen" not in line.lower():
                continue
            parts = line.split()
            if not parts:
                continue
            try:
                pid = int(parts[-1])
            except Exception:
                continue
            if pid > 0 and pid not in pids:
                pids.append(pid)
    except Exception:
        pass
    return pids


def scan_admin_server_instances_v92():
    """Find exact admin_server.py instances without walking heavy media trees."""
    found = []
    seen = set()
    skip_dirs = {"media", "node_modules", ".git", "__pycache__", "exports", "audit_exports"}

    def add(path, role="candidate"):
        if not path:
            return
        absolute = os.path.abspath(path)
        norm = _v92_norm_path(absolute)
        if norm in seen:
            return
        seen.add(norm)
        item = {
            "path": absolute,
            "role": role,
            "exists": os.path.isfile(absolute),
            "active_loaded_file": norm == _v92_norm_path(ADMIN_SERVER_PATH),
            "canonical_install_file": norm == _v92_norm_path(ADMIN_EXPECTED_INSTALL_PATH),
        }
        if item["exists"]:
            try:
                item.update({
                    "sha256": file_sha256(absolute),
                    "size_bytes": os.path.getsize(absolute),
                    "modified_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(absolute))),
                })
            except Exception as exc:
                item["error"] = repr(exc)
        found.append(item)

    add(ADMIN_SERVER_PATH, "loaded")
    add(ADMIN_EXPECTED_INSTALL_PATH, "canonical")
    if os.path.isfile(CONTROL_PENDING_ADMIN_PATH):
        add(CONTROL_PENDING_ADMIN_PATH, "pending_update")

    if os.path.isdir(BASE_DIR):
        visited = 0
        for root, dirs, files in os.walk(BASE_DIR):
            visited += 1
            if visited > 6000:
                break
            dirs[:] = [d for d in dirs if d.lower() not in skip_dirs]
            for name in files:
                if name.lower() == "admin_server.py":
                    add(os.path.join(root, name), "discovered")
    exact_instances = [x for x in found if os.path.basename(x["path"]).lower() == "admin_server.py" and x.get("exists")]
    extra_instances = [x for x in exact_instances if not x.get("active_loaded_file")]
    return {
        "checked_at": time.strftime("%Y-%m-%d %H:%M:%S"),
        "instances": found,
        "exact_admin_server_count": len(exact_instances),
        "extra_exact_instances": len(extra_instances),
        "has_extra_exact_instances": bool(extra_instances),
    }


def get_runtime_identity_v92(include_duplicates=False):
    try:
        disk_sha = file_sha256(ADMIN_SERVER_PATH)
    except Exception:
        disk_sha = ""
    try:
        current_mtime = os.path.getmtime(ADMIN_SERVER_PATH)
        current_size = os.path.getsize(ADMIN_SERVER_PATH)
    except Exception:
        current_mtime = 0.0
        current_size = 0
    port_pids = _v92_pids_on_port(ADMIN_PORT)
    identity = {
        "service": "TripServer Admin",
        "version": ADMIN_PANEL_VERSION,
        "build_id": ADMIN_BUILD_ID,
        "pid": os.getpid(),
        "python_executable": sys.executable,
        "argv": list(sys.argv),
        "working_directory": os.getcwd(),
        "host": ADMIN_HOST,
        "port": ADMIN_PORT,
        "loaded_at": ADMIN_RUNTIME_LOADED_AT,
        "started_at": ADMIN_STARTED_AT,
        "loaded_file": os.path.abspath(ADMIN_SERVER_PATH),
        "loaded_realpath": os.path.realpath(ADMIN_SERVER_PATH),
        "expected_install_file": ADMIN_EXPECTED_INSTALL_PATH,
        "running_from_expected_file": _v92_norm_path(ADMIN_SERVER_PATH) == _v92_norm_path(ADMIN_EXPECTED_INSTALL_PATH),
        "loaded_sha256": ADMIN_LOADED_SHA256,
        "disk_sha256": disk_sha,
        "loaded_matches_disk": bool(ADMIN_LOADED_SHA256 and disk_sha and ADMIN_LOADED_SHA256 == disk_sha),
        "loaded_size_bytes": ADMIN_LOADED_SIZE,
        "disk_size_bytes": current_size,
        "loaded_mtime_epoch": ADMIN_LOADED_MTIME,
        "disk_mtime_epoch": current_mtime,
        "disk_modified_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(current_mtime)) if current_mtime else "",
        "port_owner_pids": port_pids,
        "pid_owns_admin_port": (os.getpid() in port_pids) if port_pids else None,
        "checked_at": time.strftime("%Y-%m-%d %H:%M:%S"),
    }
    identity["runtime_verified"] = bool(
        identity["running_from_expected_file"]
        and identity["loaded_matches_disk"]
        and len(identity["loaded_sha256"]) == 64
    )
    if include_duplicates:
        identity["admin_file_scan"] = scan_admin_server_instances_v92()
    return identity


def _v92_write_runtime_snapshot(reason="runtime"):
    payload = get_runtime_identity_v92(include_duplicates=True)
    payload["reason"] = reason
    try:
        _v92_write_json_atomic(RUNTIME_STATUS_PATH, payload)
    except Exception:
        pass
    return payload



def _v92_finalize_verified_startup():
    """Close the update handshake after a verified boot.

    This also covers the one-time transition from pre-v92 control code: the old
    control process may be replaced while it is still waiting for /ping.json.
    A verified v92 process may therefore safely cancel an armed admin Fail Safe
    and record its own loaded/disk identity without relying on the old process.
    """
    runtime = get_runtime_identity_v92(include_duplicates=False)
    if not runtime.get("runtime_verified"):
        return {"ok": False, "reason": "runtime_not_verified", "runtime": runtime}
    failsafe = read_admin_failsafe_status()
    control = _v92_read_json(CONTROL_STATUS_PATH, {})
    submission = _v92_read_json(ADMIN_UPDATE_SUBMISSION_PATH, {})
    expected = str(
        submission.get("expected_sha")
        or control.get("expected_sha")
        or control.get("new_sha")
        or runtime.get("loaded_sha256")
        or ""
    ).lower()
    if expected and expected != str(runtime.get("loaded_sha256") or "").lower():
        return {"ok": False, "reason": "expected_sha_mismatch", "expected_sha": expected, "runtime": runtime}
    cancel_ok = None
    cancel_output = ""
    if str(failsafe.get("state") or "") == "armed" and os.name == "nt":
        try:
            cp = subprocess.run(["shutdown", "/a"], capture_output=True, text=True, timeout=10, creationflags=hidden_subprocess_flags())
            cancel_output = (cp.stdout or cp.stderr or "").strip()
            cancel_ok = cp.returncode == 0 or is_no_pending_shutdown_message(cancel_output)
            if cancel_ok:
                write_admin_failsafe_status(
                    "auto_cancelled" if cp.returncode == 0 else "none",
                    "Fail Safe בוטל לאחר שה־Runtime החדש הוכיח התאמה מלאה בין הקוד שנטען לקובץ בדיסק.",
                    loaded_sha256=runtime.get("loaded_sha256"),
                    build_id=runtime.get("build_id"),
                    windows_output=cancel_output,
                    source="v92_startup_handshake",
                )
            else:
                write_admin_failsafe_status(
                    "cancel_failed",
                    "ה־Runtime אומת, אך ביטול Fail Safe נכשל.",
                    windows_output=cancel_output,
                    return_code=cp.returncode,
                    source="v92_startup_handshake",
                )
        except Exception as exc:
            cancel_ok = False
            cancel_output = repr(exc)
            write_admin_failsafe_status("cancel_failed", "שגיאה בביטול Fail Safe לאחר אימות Runtime: " + repr(exc), source="v92_startup_handshake")
    status = dict(control)
    status.update({
        "control_version": status.get("control_version") or "TripServerControl/92",
        "last_action": status.get("last_action") if status.get("last_action") == "apply_update" else "startup_runtime_verification",
        "state": "verified",
        "ok": True,
        "verification_ok": True,
        "message": "Runtime identity verified by v92 startup handshake.",
        "expected_sha": expected or runtime.get("loaded_sha256"),
        "expected_version": runtime.get("version"),
        "expected_build_id": runtime.get("build_id"),
        "disk_sha": runtime.get("disk_sha256"),
        "runtime": {"runtime": runtime},
        "failsafe_cancel_ok": cancel_ok,
        "failsafe_cancel_msg": cancel_output,
        "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
    })
    try:
        _v92_write_json_atomic(CONTROL_STATUS_PATH, status)
    except Exception:
        pass
    return status


def build_admin_update_status_v92(expected_sha=""):
    control = _v92_read_json(CONTROL_STATUS_PATH, {})
    submission = _v92_read_json(ADMIN_UPDATE_SUBMISSION_PATH, {})
    runtime = get_runtime_identity_v92(include_duplicates=False)
    expected = (expected_sha or control.get("expected_sha") or control.get("new_sha") or submission.get("expected_sha") or "").strip().lower()
    runtime_loaded = str(runtime.get("loaded_sha256") or "").lower()
    runtime_disk = str(runtime.get("disk_sha256") or "").lower()
    canonical_disk = ""
    try:
        if os.path.isfile(ADMIN_EXPECTED_INSTALL_PATH):
            canonical_disk = file_sha256(ADMIN_EXPECTED_INSTALL_PATH).lower()
    except Exception:
        canonical_disk = ""
    hash_match = bool(expected and runtime_loaded == expected and runtime_disk == expected and canonical_disk == expected)
    same_update = bool(expected and str(control.get("expected_sha") or control.get("new_sha") or "").lower() == expected)
    control_verified = bool(same_update and control.get("verification_ok") is True and control.get("state") == "verified")
    verified = bool(hash_match and runtime.get("running_from_expected_file") and runtime.get("loaded_matches_disk"))
    state = str(control.get("state") or submission.get("state") or "idle")
    failed_states = {"validation_failed", "backup_failed", "stop_failed", "replace_failed", "disk_verify_failed", "installed_unverified", "request_failed"}
    failed = bool(same_update and (state in failed_states or control.get("verification_ok") is False))
    if verified:
        public_state = "verified"
        message = "העדכון אומת: הקובץ שנטען לזיכרון, הקובץ בדיסק וה־SHA שהועלה זהים."
    elif failed:
        public_state = "failed"
        message = "העדכון לא עבר אימות מלא. אין להסתמך על הודעת העלאה בלבד; יש לעיין בפרטי הבקרה."
    elif expected:
        public_state = "pending"
        message = "העדכון נשלח וממתין להחלפה, אתחול ואימות Runtime."
    else:
        public_state = state
        message = "לא נבחר SHA של עדכון לבדיקה."
    return {
        "ok": verified,
        "verified": verified,
        "failed": failed,
        "state": public_state,
        "message": message,
        "expected_sha": expected,
        "runtime_loaded_sha": runtime_loaded,
        "runtime_disk_sha": runtime_disk,
        "canonical_disk_sha": canonical_disk,
        "hash_match": hash_match,
        "control_verified": control_verified,
        "control_status": control,
        "submission": submission,
        "runtime": runtime,
        "failsafe_status": read_admin_failsafe_status(),
        "checked_at": time.strftime("%Y-%m-%d %H:%M:%S"),
    }


# A fresh external control server. Unlike v34, it never records update success
# merely because the replacement was attempted. Success requires loaded SHA,
# disk SHA, build identity and canonical runtime path to match the upload.
def build_control_server_code():
    template = r'''# -*- coding: utf-8 -*-
import os, sys, time, json, shutil, subprocess, socket, threading, traceback, hashlib, re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
from urllib.request import urlopen
BASE_DIR = __BASE_DIR_REPR__
ADMIN_PORT = __ADMIN_PORT__
CONTROL_PORT = __CONTROL_PORT__
ADMIN_SCRIPT = os.path.join(BASE_DIR, "admin_server.py")
CONTROL_DIR = os.path.join(BASE_DIR, "control_server")
PENDING_ADMIN = os.path.join(CONTROL_DIR, "pending_admin_server.py")
REQUEST_JSON = os.path.join(CONTROL_DIR, "request.json")
STATUS_JSON = os.path.join(CONTROL_DIR, "status.json")
FAILSAFE_STATUS_JSON = os.path.join(BASE_DIR, "admin_failsafe_status.json")
LOG_PATH = os.path.join(CONTROL_DIR, "tripserver_control_server.log")
BACKUP_DIR = os.path.join(BASE_DIR, "backups", "admin_server")
PYTHON_EXE = sys.executable
if os.path.basename(PYTHON_EXE).lower() == "pythonw.exe":
    candidate = os.path.join(os.path.dirname(PYTHON_EXE), "python.exe")
    if os.path.exists(candidate): PYTHON_EXE = candidate
os.makedirs(CONTROL_DIR, exist_ok=True)
os.makedirs(BACKUP_DIR, exist_ok=True)
ACTION_LOCK = threading.RLock()
LAST_START_ATTEMPT = 0.0
START_COOLDOWN_SECONDS = 12.0

def now(): return time.strftime("%Y-%m-%d %H:%M:%S")

def hidden_flags(extra=0):
    flags = int(extra or 0)
    if os.name == "nt": flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
    return flags

def log(msg):
    try:
        with open(LOG_PATH, "a", encoding="utf-8") as fh:
            fh.write(now() + " | control-v92 | " + str(msg) + "\n")
    except Exception: pass

def atomic_json(path, data):
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2, default=str)
        fh.flush()
        try: os.fsync(fh.fileno())
        except Exception: pass
    os.replace(tmp, path)

def read_json(path):
    try:
        with open(path, "r", encoding="utf-8") as fh:
            data = json.load(fh)
        return data if isinstance(data, dict) else {}
    except Exception: return {}

def write_status(**kw):
    previous = read_json(STATUS_JSON)
    data = {
        "control_version": "TripServerControl/92",
        "updated_at": now(),
        "control_port": CONTROL_PORT,
        "admin_port": ADMIN_PORT,
        "python": PYTHON_EXE,
    }
    for key in ("update_id", "expected_sha", "expected_version", "expected_build_id", "backup"):
        if key in previous: data[key] = previous[key]
    data.update(kw)
    try: atomic_json(STATUS_JSON, data)
    except Exception as exc: log("status write failed " + repr(exc))
    return data

def 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 source_identity(path):
    result = {"path": os.path.abspath(path), "exists": os.path.isfile(path), "sha256": "", "version": "", "build_id": ""}
    if not result["exists"]: return result
    try:
        result["sha256"] = sha256(path)
        text = open(path, "r", encoding="utf-8", errors="replace").read()
        m = re.search(r'^\s*ADMIN_PANEL_VERSION\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE)
        b = re.search(r'^\s*ADMIN_BUILD_ID\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE)
        if m: result["version"] = m.group(1).strip()
        if b: result["build_id"] = b.group(1).strip()
    except Exception as exc: result["error"] = repr(exc)
    return result

def compile_ok(path):
    try:
        src = open(path, "r", encoding="utf-8", errors="replace").read()
        compile(src, path, "exec")
        return True, "OK"
    except Exception as exc: return False, repr(exc)

def port_open(port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(0.7)
    try: return sock.connect_ex(("127.0.0.1", int(port))) == 0
    finally:
        try: sock.close()
        except Exception: pass

def pids_on_port(port):
    pids = []
    if os.name != "nt": return pids
    try:
        cp = subprocess.run(["netstat", "-ano", "-p", "tcp"], capture_output=True, text=True, timeout=10, creationflags=hidden_flags())
        marker = ":" + str(int(port))
        for line in (cp.stdout or "").splitlines():
            if marker not in line or "listen" not in line.lower(): continue
            parts = line.split()
            if not parts: continue
            try: pid = int(parts[-1])
            except Exception: continue
            if pid > 0 and pid not in pids: pids.append(pid)
    except Exception as exc: log("pids_on_port failed " + repr(exc))
    return pids

def kill_admin():
    pids = pids_on_port(ADMIN_PORT)
    log("kill_admin pids=" + repr(pids))
    for pid in pids:
        if pid == os.getpid(): continue
        try:
            cp = subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, text=True, timeout=15, creationflags=hidden_flags())
            log("taskkill pid=%s rc=%s out=%s" % (pid, cp.returncode, (cp.stdout or cp.stderr or "").strip()[:600]))
        except Exception as exc: log("taskkill failed pid=%s %r" % (pid, exc))
    deadline = time.time() + 12
    while time.time() < deadline:
        if not port_open(ADMIN_PORT): return True
        time.sleep(0.3)
    return not port_open(ADMIN_PORT)

def start_admin():
    global LAST_START_ATTEMPT
    LAST_START_ATTEMPT = time.time()
    flags = 0
    if os.name == "nt":
        flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(subprocess, "DETACHED_PROCESS", 0)
    out = open(os.path.join(BASE_DIR, "admin_stdout.log"), "a", encoding="utf-8", errors="replace")
    try:
        proc = subprocess.Popen([PYTHON_EXE, ADMIN_SCRIPT], cwd=BASE_DIR, stdout=out, stderr=out, stdin=subprocess.DEVNULL, creationflags=hidden_flags(flags))
    finally:
        out.close()
    log("started admin pid=" + str(proc.pid))
    return proc.pid

def fetch_admin_runtime(timeout=2.5):
    try:
        with urlopen("http://127.0.0.1:%s/ping.json" % ADMIN_PORT, timeout=timeout) as resp:
            if resp.status != 200: return {}
            data = json.loads(resp.read().decode("utf-8", errors="replace"))
            return data if isinstance(data, dict) else {}
    except Exception: return {}

def wait_for_runtime(expected_sha, expected_version="", expected_build_id="", timeout=35):
    deadline = time.time() + timeout
    last = {}
    while time.time() < deadline:
        payload = fetch_admin_runtime()
        if payload:
            last = payload
            runtime = payload.get("runtime") if isinstance(payload.get("runtime"), dict) else payload
            loaded_sha = str(runtime.get("loaded_sha256") or "").lower()
            disk_sha = str(runtime.get("disk_sha256") or "").lower()
            version = str(runtime.get("version") or payload.get("version") or "")
            build_id = str(runtime.get("build_id") or payload.get("build_id") or "")
            path_ok = bool(runtime.get("running_from_expected_file"))
            loaded_disk_ok = bool(runtime.get("loaded_matches_disk"))
            ok = loaded_sha == expected_sha.lower() and disk_sha == expected_sha.lower() and path_ok and loaded_disk_ok
            if expected_version: ok = ok and version == expected_version
            if expected_build_id: ok = ok and build_id == expected_build_id
            if ok: return True, payload
        time.sleep(0.7)
    return False, last

def write_failsafe_status(state, msg, **extra):
    try:
        data = {"state": state, "message": msg, "updated_at": now(), "source": "control_server_v92"}
        data.update(extra)
        atomic_json(FAILSAFE_STATUS_JSON, data)
    except Exception as exc: log("failsafe status write failed " + repr(exc))

def no_pending_shutdown_msg(txt):
    low = (txt or "").lower()
    return "1116" in low or "no shutdown was in progress" in low

def cancel_pending_shutdown(reason="verified_update"):
    if os.name != "nt": return False, "not windows"
    try:
        cp = subprocess.run(["shutdown", "/a"], capture_output=True, text=True, timeout=10, creationflags=hidden_flags())
        msg = (cp.stdout or cp.stderr or "").strip()
        if cp.returncode == 0 or no_pending_shutdown_msg(msg):
            write_failsafe_status("auto_cancelled" if cp.returncode == 0 else "none", "Fail Safe בוטל רק לאחר אימות Runtime מלא.", windows_output=msg, reason=reason)
            return True, msg
        write_failsafe_status("cancel_failed", "אימות Runtime עבר, אך ביטול Fail Safe נכשל.", windows_output=msg, return_code=cp.returncode)
        return False, msg
    except Exception as exc:
        write_failsafe_status("cancel_failed", "שגיאה בביטול Fail Safe: " + repr(exc))
        return False, repr(exc)

def restore_backup(backup, reason):
    if not backup or not os.path.isfile(backup): return False, "backup missing"
    try:
        shutil.copy2(backup, ADMIN_SCRIPT)
        actual = sha256(ADMIN_SCRIPT)
        log("rollback restored reason=%s sha=%s backup=%s" % (reason, actual, backup))
        return True, actual
    except Exception as exc:
        log("rollback failed " + repr(exc))
        return False, repr(exc)

def apply_update(reason="manual", request_data=None):
    req = request_data if isinstance(request_data, dict) else {}
    update_id = str(req.get("update_id") or "")
    expected_sha = str(req.get("expected_sha") or "").lower()
    expected_version = str(req.get("expected_version") or "")
    expected_build_id = str(req.get("expected_build_id") or "")
    write_status(last_action="apply_update", state="validating", ok=False, verification_ok=False, reason=reason, update_id=update_id, expected_sha=expected_sha, expected_version=expected_version, expected_build_id=expected_build_id)
    if not os.path.isfile(PENDING_ADMIN):
        msg = "pending admin not found"
        write_status(last_action="apply_update", state="validation_failed", ok=False, verification_ok=False, error=msg)
        return False, msg
    ok, info = compile_ok(PENDING_ADMIN)
    if not ok:
        write_status(last_action="apply_update", state="validation_failed", ok=False, verification_ok=False, error=info)
        return False, info
    candidate = source_identity(PENDING_ADMIN)
    new_sha = candidate.get("sha256", "").lower()
    if expected_sha and new_sha != expected_sha:
        msg = "pending SHA mismatch expected=%s actual=%s" % (expected_sha, new_sha)
        write_status(last_action="apply_update", state="validation_failed", ok=False, verification_ok=False, error=msg, candidate=candidate)
        return False, msg
    expected_sha = expected_sha or new_sha
    expected_version = expected_version or str(candidate.get("version") or "")
    expected_build_id = expected_build_id or str(candidate.get("build_id") or "")
    stamp = time.strftime("%Y%m%d_%H%M%S")
    backup = os.path.join(BACKUP_DIR, "admin_server_backup_" + stamp + ".py")
    try:
        if os.path.isfile(ADMIN_SCRIPT): shutil.copy2(ADMIN_SCRIPT, backup)
    except Exception as exc:
        msg = "backup failed: " + repr(exc)
        write_status(last_action="apply_update", state="backup_failed", ok=False, verification_ok=False, error=msg, backup=backup)
        return False, msg
    if not kill_admin():
        msg = "admin port did not close"
        write_status(last_action="apply_update", state="stop_failed", ok=False, verification_ok=False, error=msg, backup=backup)
        return False, msg
    try:
        os.replace(PENDING_ADMIN, ADMIN_SCRIPT)
    except Exception as exc:
        msg = "replace failed: " + repr(exc)
        write_status(last_action="apply_update", state="replace_failed", ok=False, verification_ok=False, error=msg, backup=backup)
        return False, msg
    disk_sha = ""
    try: disk_sha = sha256(ADMIN_SCRIPT).lower()
    except Exception as exc: log("disk sha failed " + repr(exc))
    if disk_sha != expected_sha:
        rollback_ok, rollback_info = restore_backup(backup, "disk_sha_mismatch")
        if rollback_ok:
            try: start_admin()
            except Exception as exc: rollback_info += " | restart=" + repr(exc)
        msg = "disk SHA mismatch after replace expected=%s actual=%s" % (expected_sha, disk_sha)
        write_status(last_action="apply_update", state="disk_verify_failed", ok=False, verification_ok=False, error=msg, expected_sha=expected_sha, disk_sha=disk_sha, backup=backup, rollback_performed=rollback_ok, rollback_result=rollback_info)
        return False, msg
    write_status(last_action="apply_update", state="replaced", ok=False, verification_ok=False, expected_sha=expected_sha, expected_version=expected_version, expected_build_id=expected_build_id, disk_sha=disk_sha, backup=backup, candidate=candidate)
    try:
        pid = start_admin()
    except Exception as exc:
        msg = "start failed: " + repr(exc)
        write_status(last_action="apply_update", state="installed_unverified", ok=False, verification_ok=False, error=msg, expected_sha=expected_sha, disk_sha=disk_sha, backup=backup)
        return False, msg
    verified, runtime_payload = wait_for_runtime(expected_sha, expected_version, expected_build_id, timeout=35)
    cancel_ok = False
    cancel_msg = ""
    if verified:
        cancel_ok, cancel_msg = cancel_pending_shutdown("runtime_identity_verified")
    state = "verified" if verified else "installed_unverified"
    message = "verified update" if verified else "new file is on disk, but loaded runtime identity did not match"
    write_status(last_action="apply_update", state=state, ok=verified, verification_ok=verified, message=message, update_id=update_id, expected_sha=expected_sha, expected_version=expected_version, expected_build_id=expected_build_id, disk_sha=disk_sha, backup=backup, started_pid=pid, runtime=runtime_payload, failsafe_cancel_ok=cancel_ok, failsafe_cancel_msg=cancel_msg)
    return verified, message

def restart_admin(reason="manual"):
    expected_sha = sha256(ADMIN_SCRIPT) if os.path.isfile(ADMIN_SCRIPT) else ""
    if not kill_admin():
        write_status(last_action="restart_admin", state="stop_failed", ok=False, verification_ok=False, error="admin port did not close")
        return False, "admin port did not close"
    pid = start_admin()
    verified, runtime_payload = wait_for_runtime(expected_sha, timeout=30)
    write_status(last_action="restart_admin", state="verified" if verified else "installed_unverified", ok=verified, verification_ok=verified, expected_sha=expected_sha, started_pid=pid, runtime=runtime_payload)
    return verified, "restart verified" if verified else "restart was not verified"

def run_locked(func, *args, **kwargs):
    with ACTION_LOCK:
        return func(*args, **kwargs)

def process_request_file():
    if not os.path.isfile(REQUEST_JSON): return
    try: req = read_json(REQUEST_JSON)
    except Exception as exc: log("bad request " + repr(exc)); return
    try: not_before = float(req.get("not_before", 0) or 0)
    except Exception: not_before = 0
    if time.time() < not_before: return
    try: os.remove(REQUEST_JSON)
    except Exception: pass
    action = req.get("action")
    if action == "apply_update": run_locked(apply_update, req.get("reason", "request"), req)
    elif action == "restart_admin": run_locked(restart_admin, req.get("reason", "request"))
    else: write_status(last_action="unknown_request", state="request_failed", ok=False, verification_ok=False, error="unknown action " + repr(action))

def poll_loop():
    while True:
        try:
            process_request_file()
            if not port_open(ADMIN_PORT) and time.time() - LAST_START_ATTEMPT >= START_COOLDOWN_SECONDS:
                with ACTION_LOCK:
                    if not port_open(ADMIN_PORT) and time.time() - LAST_START_ATTEMPT >= START_COOLDOWN_SECONDS:
                        log("admin port closed outside update; starting canonical admin")
                        start_admin()
        except Exception as exc: log("poll error " + repr(exc) + "\n" + traceback.format_exc())
        time.sleep(2)

class Handler(BaseHTTPRequestHandler):
    server_version = "TripServerControl/92"
    def send_body(self, code, body, ctype="application/json; charset=utf-8"):
        if isinstance(body, str): body = body.encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)
    def do_GET(self):
        path = urlparse(self.path).path
        if path == "/ping.json":
            payload = fetch_admin_runtime()
            self.send_body(200, json.dumps({"ok": True, "control_version": "TripServerControl/92", "admin_port_open": port_open(ADMIN_PORT), "admin_runtime": payload}, ensure_ascii=False))
            return
        if path == "/status.json":
            data = read_json(STATUS_JSON)
            data.update({"control_version": "TripServerControl/92", "admin_port_open": port_open(ADMIN_PORT), "pids": pids_on_port(ADMIN_PORT), "active_disk_identity": source_identity(ADMIN_SCRIPT), "admin_runtime": fetch_admin_runtime()})
            self.send_body(200, json.dumps(data, ensure_ascii=False, indent=2, default=str))
            return
        if path == "/log":
            try: txt = open(LOG_PATH, "r", encoding="utf-8", errors="replace").read()[-30000:]
            except Exception as exc: txt = repr(exc)
            self.send_body(200, "<pre style='white-space:pre-wrap;direction:ltr'>" + txt.replace("<", "&lt;") + "</pre>", "text/html; charset=utf-8")
            return
        html = """<html><head><meta charset='utf-8'><title>TripServer Control v92</title></head><body style='font-family:Arial;background:#0b1220;color:#eef;direction:rtl;padding:30px'><h1>TripServer Control v92</h1><p>עדכון נחשב מוצלח רק לאחר אימות Runtime מלא.</p><p><a style='color:#93c5fd' href='/status.json'>Status JSON</a> · <a style='color:#93c5fd' href='/log'>Log</a></p></body></html>"""
        self.send_body(200, html, "text/html; charset=utf-8")
    def do_POST(self):
        path = urlparse(self.path).path
        if path == "/restart": ok, msg = run_locked(restart_admin, "http")
        elif path == "/apply-update": ok, msg = run_locked(apply_update, "http", {})
        else:
            self.send_body(404, "not found", "text/plain; charset=utf-8")
            return
        self.send_body(200 if ok else 500, json.dumps({"ok": ok, "message": msg}, ensure_ascii=False))
    def log_message(self, fmt, *args): log("http " + (fmt % args))

if __name__ == "__main__":
    log("starting control server v92 python=" + sys.executable)
    write_status(last_action="control_start", state="ready", ok=True, verification_ok=None)
    threading.Thread(target=poll_loop, daemon=True).start()
    server = ThreadingHTTPServer(("0.0.0.0", CONTROL_PORT), Handler)
    log("listening on port " + str(CONTROL_PORT))
    server.serve_forever()
'''
    return (template
            .replace("__BASE_DIR_REPR__", repr(BASE_DIR))
            .replace("__ADMIN_PORT__", str(int(ADMIN_PORT)))
            .replace("__CONTROL_PORT__", str(int(CONTROL_PORT))))


def _v92_kill_control_server_processes():
    if os.name != "nt":
        return []
    killed = []
    for pid in _v92_pids_on_port(CONTROL_PORT):
        if pid == os.getpid():
            continue
        try:
            cp = subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, text=True, timeout=15, creationflags=hidden_subprocess_flags())
            log_control_bootstrap("control v92 taskkill pid=%s rc=%s out=%s" % (pid, cp.returncode, (cp.stdout or cp.stderr or "").strip()))
            if cp.returncode == 0:
                killed.append(pid)
        except Exception as exc:
            log_control_bootstrap("control v92 kill failed pid=%s %r" % (pid, exc))
    deadline = time.time() + 8
    while time.time() < deadline and control_server_is_alive():
        time.sleep(0.25)
    return killed


def bootstrap_control_server(reason="startup"):
    """Install and, when changed, actually reload the independent control server."""
    try:
        os.makedirs(CONTROL_DIR, exist_ok=True)
        code = build_control_server_code()
        compile(code, CONTROL_SCRIPT_PATH, "exec")
        previous = ""
        if os.path.isfile(CONTROL_SCRIPT_PATH):
            previous = Path(CONTROL_SCRIPT_PATH).read_text(encoding="utf-8", errors="replace")
        changed = previous != code
        if changed:
            tmp = CONTROL_SCRIPT_PATH + ".tmp"
            Path(tmp).write_text(code, encoding="utf-8")
            py_compile.compile(tmp, doraise=True)
            os.replace(tmp, CONTROL_SCRIPT_PATH)
            log_control_bootstrap("control v92 script replaced reason=%s path=%s" % (reason, CONTROL_SCRIPT_PATH))
        pyw = quiet_pythonw_path()
        task_details = []
        if os.name == "nt":
            cp = subprocess.run(["schtasks", "/Create", "/TN", CONTROL_TASK_NAME, "/SC", "ONLOGON", "/TR", f'"{pyw}" "{CONTROL_SCRIPT_PATH}"', "/F"], capture_output=True, text=True, timeout=25, creationflags=hidden_subprocess_flags())
            task_details.append("create_rc=%s" % cp.returncode)
            alive_before_run = control_server_is_alive()
            if changed and alive_before_run:
                killed = _v92_kill_control_server_processes()
                task_details.append("killed=%s" % killed)
                alive_before_run = False
            if changed or not alive_before_run:
                run = subprocess.run(["schtasks", "/Run", "/TN", CONTROL_TASK_NAME], capture_output=True, text=True, timeout=25, creationflags=hidden_subprocess_flags())
                task_details.append("run_rc=%s" % run.returncode)
            else:
                task_details.append("run_skipped=already_alive")
        if changed or not control_server_is_alive():
            deadline = time.time() + 6
            while time.time() < deadline and not control_server_is_alive():
                time.sleep(0.3)
        if not control_server_is_alive():
            flags = 0
            if os.name == "nt":
                flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0)
            subprocess.Popen([pyw, CONTROL_SCRIPT_PATH], cwd=BASE_DIR, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=flags, start_new_session=(os.name != "nt"))
            deadline = time.time() + 8
            while time.time() < deadline and not control_server_is_alive():
                time.sleep(0.3)
        alive = control_server_is_alive()
        info = "changed=%s alive=%s %s" % (changed, alive, " ".join(task_details))
        log_control_bootstrap("control v92 bootstrap " + info)
        return alive, info
    except Exception as exc:
        log_control_bootstrap("control v92 bootstrap failed " + repr(exc))
        return False, repr(exc)


def write_control_request(action, reason="admin", pending_path=None, expected_sha="", expected_version="", expected_build_id="", update_id=""):
    os.makedirs(CONTROL_DIR, exist_ok=True)
    req = {
        "action": action,
        "reason": reason,
        "requested_at": time.strftime("%Y-%m-%d %H:%M:%S"),
        "not_before": time.time() + 2.5,
        "admin_pid": os.getpid(),
        "pending_admin_path": pending_path or CONTROL_PENDING_ADMIN_PATH,
        "expected_sha": expected_sha,
        "expected_version": expected_version,
        "expected_build_id": expected_build_id,
        "update_id": update_id,
    }
    _v92_write_json_atomic(CONTROL_REQUEST_PATH, req)
    return req


def _v92_update_tracking_page(expected, request_payload, source_note, bootstrap_info, failsafe_info):
    expected_sha = html_lib.escape(expected.get("sha256", ""), quote=True)
    expected_version = html_lib.escape(expected.get("version", "") or "לא זוהתה", quote=True)
    expected_build = html_lib.escape(expected.get("build_id", "") or "לא זוהה", quote=True)
    details = html_lib.escape(json.dumps({
        "request": request_payload,
        "source_validation": source_note,
        "control_bootstrap": bootstrap_info,
        "failsafe": failsafe_info,
    }, ensure_ascii=False, indent=2), quote=False)
    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:#fff;font-family:Arial;padding:18px}}.wrap{{max-width:900px;margin:auto}}.card{{background:#111c31;border:1px solid #334155;border-radius:24px;padding:20px;margin:14px 0}}h1{{margin-top:0}}.badge{{display:inline-block;border-radius:999px;padding:8px 13px;font-weight:900;background:#78350f;color:#fde68a}}.ok{{background:#14532d;color:#bbf7d0}}.bad{{background:#7f1d1d;color:#fecaca}}code,pre{{direction:ltr;text-align:left;overflow-wrap:anywhere}}pre{{white-space:pre-wrap;background:#020617;border:1px solid #334155;border-radius:16px;padding:14px;max-height:320px;overflow:auto}}a{{display:inline-flex;background:#2563eb;color:#fff;text-decoration:none;border-radius:12px;padding:11px 14px;margin:5px;font-weight:900}}.line{{padding:9px 0;border-bottom:1px solid #334155}} </style></head><body><div class="wrap"><div class="card"><h1>עדכון האדמין נשלח — ממתינים לאימות</h1><p>הודעה זו אינה נחשבת הצלחת התקנה. הצלחה תוצג רק לאחר שהשרת החדש יעלה ויוכיח שה־SHA שנטען לזיכרון זהה לקובץ שהועלה.</p><div id="badge" class="badge">ממתין להחלפה ולאתחול…</div><div class="line"><b>גרסה צפויה:</b> {expected_version}</div><div class="line"><b>Build צפוי:</b> {expected_build}</div><div class="line"><b>SHA צפוי:</b><code>{expected_sha}</code></div><p id="message">הדף בודק מחדש כל שתי שניות.</p><pre id="technical">{details}</pre><div><a href="/admin/runtime">מצב Runtime</a><a href="/admin">חזרה לאדמין</a></div></div></div><script>
const expectedSha={json.dumps(expected.get("sha256", ""))};
async function poll(){{
 try{{
  const response=await fetch('/admin/update-status.json?expected_sha='+encodeURIComponent(expectedSha)+'&_='+Date.now(),{{cache:'no-store'}});
  const data=await response.json();
  document.getElementById('technical').textContent=JSON.stringify(data,null,2);
  document.getElementById('message').textContent=data.message||'ממתין…';
  const badge=document.getElementById('badge');
  if(data.verified){{badge.className='badge ok';badge.textContent='✅ העדכון הותקן ואומת';return;}}
  if(data.failed){{badge.className='badge bad';badge.textContent='❌ העדכון לא אומת';return;}}
  badge.className='badge';badge.textContent='⏳ ממתין לאימות Runtime…';
 }}catch(error){{document.getElementById('message').textContent='השרת מתאתחל כעת; ממשיכים לנסות…';}}
 setTimeout(poll,2000);
}}
poll();
</script></body></html>'''


def upload_admin_server_v92():
    file = request.files.get("file")
    if not file or file.filename == "":
        return admin_status_page("לא נבחר קובץ", "בחר קובץ admin_server.py או ZIP עדכון אדמין להעלאה.", ok=False), 400
    original_name = file.filename or ""
    lower_name = original_name.lower()
    is_py = lower_name.endswith(".py")
    is_zip = lower_name.endswith(".zip")
    if not (is_py or is_zip):
        return admin_status_page("קובץ לא תקין", "ניתן להעלות קובץ Python או ZIP עדכון אדמין מבוקר.", ok=False), 400
    limit = ADMIN_MAX_PACKAGE_UPLOAD_BYTES if is_zip else ADMIN_MAX_UPLOAD_BYTES
    if request.content_length and request.content_length > limit + 250000:
        return admin_status_page("קובץ גדול מדי", f"המגבלה היא בערך {limit:,} bytes.", ok=False), 413
    os.makedirs(CONTROL_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"admin_server_upload_{stamp}_{os.getpid()}{'.zip' if is_zip else '.py'}")
    extracted_admin_path = None
    work_dir = ""
    try:
        file.save(tmp_path)
        if is_zip:
            work_dir = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"admin_zip_extract_{stamp}_{os.getpid()}")
            valid, source_note, extracted_admin_path = extract_admin_server_from_zip_upload(tmp_path, work_dir)
            if not valid:
                return admin_status_page("העלאת ZIP אדמין נדחתה", "הקובץ הפעיל לא הוחלף.", ok=False, details=source_note), 400
            candidate = extracted_admin_path
        else:
            valid, source_note = validate_admin_server_upload(tmp_path)
            if not valid:
                return admin_status_page("העלאת admin_server.py נדחתה", "הקובץ הפעיל לא הוחלף.", ok=False, details=source_note), 400
            candidate = tmp_path
        expected = _v92_source_identity(candidate)
        if not expected.get("sha256") or len(expected["sha256"]) != 64:
            return admin_status_page("בדיקת SHA נכשלה", "לא ניתן היה לחשב זהות חד־משמעית לקובץ שהועלה.", ok=False, details=json.dumps(expected, ensure_ascii=False, indent=2)), 400
        pending_tmp = CONTROL_PENDING_ADMIN_PATH + ".tmp"
        shutil.copy2(candidate, pending_tmp)
        pending_sha = file_sha256(pending_tmp)
        if pending_sha != expected["sha256"]:
            try: os.remove(pending_tmp)
            except Exception: pass
            return admin_status_page("העתקת העדכון נכשלה", "ה־SHA השתנה בזמן העתקת הקובץ הממתין.", ok=False, details=f"expected={expected['sha256']}\nactual={pending_sha}"), 500
        os.replace(pending_tmp, CONTROL_PENDING_ADMIN_PATH)
        update_id = uuid.uuid4().hex
        submission = {
            "state": "queued",
            "update_id": update_id,
            "submitted_at": time.strftime("%Y-%m-%d %H:%M:%S"),
            "source_filename": original_name,
            "expected_sha": expected["sha256"],
            "expected_version": expected.get("version", ""),
            "expected_build_id": expected.get("build_id", ""),
            "pending_path": CONTROL_PENDING_ADMIN_PATH,
            "current_runtime": get_runtime_identity_v92(False),
        }
        _v92_write_json_atomic(ADMIN_UPDATE_SUBMISSION_PATH, submission)
        failsafe_ok, failsafe_info = arm_admin_update_failsafe_restart()
        if not failsafe_ok:
            submission.update({"state": "request_failed", "error": "failsafe_not_armed", "failsafe": failsafe_info})
            _v92_write_json_atomic(ADMIN_UPDATE_SUBMISSION_PATH, submission)
            return admin_status_page("Fail Safe לא הופעל", "העדכון לא נשלח ליישום.", ok=False, details=failsafe_info), 500
        bootstrap_ok, bootstrap_info = bootstrap_control_server("admin-upload-v92")
        if not bootstrap_ok:
            submission.update({"state": "request_failed", "error": "control_server_not_alive", "control": bootstrap_info})
            _v92_write_json_atomic(ADMIN_UPDATE_SUBMISSION_PATH, submission)
            return admin_status_page("שרת הבקרה לא זמין", "הקובץ נשמר כממתין אך לא נשלחה פקודת החלפה.", ok=False, details=bootstrap_info), 500
        req = write_control_request(
            "apply_update",
            reason="admin_upload_v92_verified_runtime",
            pending_path=CONTROL_PENDING_ADMIN_PATH,
            expected_sha=expected["sha256"],
            expected_version=expected.get("version", ""),
            expected_build_id=expected.get("build_id", ""),
            update_id=update_id,
        )
        submission.update({"state": "requested", "request": req, "control_bootstrap": bootstrap_info, "failsafe": failsafe_info})
        _v92_write_json_atomic(ADMIN_UPDATE_SUBMISSION_PATH, submission)
        return _v92_update_tracking_page(expected, req, source_note, bootstrap_info, failsafe_info)
    except Exception as exc:
        try:
            _v92_write_json_atomic(ADMIN_UPDATE_SUBMISSION_PATH, {"state": "request_failed", "error": repr(exc), "updated_at": time.strftime("%Y-%m-%d %H:%M:%S")})
        except Exception:
            pass
        return admin_status_page("שגיאה בעדכון אדמין", "העדכון לא אומת ולא דווח כהצלחה.", ok=False, details=repr(exc)), 500
    finally:
        for path in (tmp_path,):
            try:
                if path and os.path.isfile(path): os.remove(path)
            except Exception:
                pass
        try:
            if work_dir and os.path.isdir(work_dir): shutil.rmtree(work_dir, ignore_errors=True)
        except Exception:
            pass


def restore_admin_server_backup_v92():
    backup_path = latest_admin_backup()
    if not backup_path:
        return admin_status_page("אין גיבוי לשחזור", "לא נמצא קובץ גיבוי בתיקיית הגיבויים.", ok=False), 404
    try:
        valid, details = validate_admin_server_upload(backup_path)
        if not valid:
            return admin_status_page("הגיבוי האחרון לא תקין", "הגיבוי לא שוחזר.", ok=False, details=details), 400
        safety_backup = backup_current_admin_server("admin_server_before_restore")
        expected = _v92_source_identity(backup_path)
        shutil.copy2(backup_path, ADMIN_SERVER_PATH)
        actual_sha = file_sha256(ADMIN_SERVER_PATH)
        verified_copy = actual_sha == expected.get("sha256")
        status = {
            "last_action": "manual_restore",
            "state": "restore_staged" if verified_copy else "disk_verify_failed",
            "ok": False,
            "verification_ok": False,
            "rollback_performed": True,
            "restored_from": backup_path,
            "safety_backup": safety_backup,
            "expected_sha": expected.get("sha256"),
            "disk_sha": actual_sha,
            "copy_verified": verified_copy,
            "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
            "message": "הגיבוי הועתק ואומת בדיסק; הוא עדיין לא נחשב Runtime פעיל עד לאתחול ואימות." if verified_copy else "העתקת הגיבוי לא עברה אימות SHA.",
        }
        _v92_write_json_atomic(CONTROL_STATUS_PATH, status)
        return admin_status_page("שחזור גיבוי הוכן" if verified_copy else "שחזור גיבוי נכשל", status["message"], ok=verified_copy, details=json.dumps(status, ensure_ascii=False, indent=2))
    except Exception as exc:
        return admin_status_page("שגיאה בשחזור גיבוי", "השחזור לא הושלם.", ok=False, details=repr(exc)), 500


@app.route("/admin/runtime")
def admin_runtime_v92():
    runtime = get_runtime_identity_v92(include_duplicates=True)
    update = build_admin_update_status_v92()
    scan = runtime.get("admin_file_scan", {})
    verified = runtime.get("runtime_verified")
    badge = "✅ Runtime מאומת" if verified else "❌ Runtime אינו מאומת"
    badge_cls = "ok" if verified else "bad"
    rows = []
    for item in scan.get("instances", []):
        state = "ACTIVE" if item.get("active_loaded_file") else ("CANONICAL" if item.get("canonical_install_file") else item.get("role", ""))
        rows.append(f"<tr><td>{html_lib.escape(state)}</td><td><code>{html_lib.escape(item.get('path',''))}</code></td><td><code>{html_lib.escape((item.get('sha256') or '')[:20])}</code></td><td>{html_lib.escape(str(item.get('modified_at','')))}</td></tr>")
    control_state = update.get("control_status", {}).get("state", "לא קיים עדיין")
    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>Runtime Identity</title><style>
body{{margin:0;background:#07111f;color:#fff;font-family:Arial;padding:18px}}.wrap{{max-width:1100px;margin:auto}}.hero,.card{{background:#111c31;border:1px solid #334155;border-radius:24px;padding:20px;margin:14px 0}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px}}.mini{{background:#0b1220;border:1px solid #334155;border-radius:18px;padding:14px}}.label{{color:#94a3b8;font-size:13px}}.value{{font-weight:900;overflow-wrap:anywhere;margin-top:6px}}.badge{{display:inline-block;padding:8px 13px;border-radius:999px;font-weight:900}}.ok{{background:#14532d;color:#bbf7d0}}.bad{{background:#7f1d1d;color:#fecaca}}code{{direction:ltr;overflow-wrap:anywhere}}table{{width:100%;border-collapse:collapse}}th,td{{padding:10px;border-bottom:1px solid #334155;text-align:right;vertical-align:top}}a{{display:inline-flex;background:#2563eb;color:white;text-decoration:none;padding:11px 14px;border-radius:12px;margin:4px;font-weight:900}}@media(max-width:700px){{table,tbody,tr,td,th{{display:block}}thead{{display:none}}tr{{border:1px solid #334155;border-radius:14px;margin:10px 0}}}}</style></head><body><div class="wrap"><div class="hero"><h1>Runtime Identity — אדמין</h1><span class="badge {badge_cls}">{badge}</span><p>המסך מפריד בין הקוד שנטען לזיכרון בזמן עליית התהליך לבין הקובץ שנמצא כרגע בדיסק.</p><a href="/admin">חזרה לאדמין</a><a href="/admin/runtime.json" target="_blank">JSON</a><a href="/admin/self-test">בדיקה עצמית</a></div><div class="grid">
<div class="mini"><div class="label">Running Version</div><div class="value">{html_lib.escape(runtime['version'])}</div></div>
<div class="mini"><div class="label">Running Build ID</div><div class="value">{html_lib.escape(runtime['build_id'])}</div></div>
<div class="mini"><div class="label">Process ID</div><div class="value">{runtime['pid']}</div></div>
<div class="mini"><div class="label">Loaded At</div><div class="value">{html_lib.escape(runtime['loaded_at'])}</div></div>
<div class="mini"><div class="label">Running File</div><div class="value"><code>{html_lib.escape(runtime['loaded_file'])}</code></div></div>
<div class="mini"><div class="label">Expected File</div><div class="value"><code>{html_lib.escape(runtime['expected_install_file'])}</code></div></div>
<div class="mini"><div class="label">Loaded SHA-256</div><div class="value"><code>{html_lib.escape(runtime['loaded_sha256'])}</code></div></div>
<div class="mini"><div class="label">Disk SHA-256</div><div class="value"><code>{html_lib.escape(runtime['disk_sha256'])}</code></div></div>
<div class="mini"><div class="label">Loaded = Disk</div><div class="value">{'כן' if runtime['loaded_matches_disk'] else 'לא'}</div></div>
<div class="mini"><div class="label">Port Owner PIDs</div><div class="value">{html_lib.escape(str(runtime['port_owner_pids']))}</div></div>
<div class="mini"><div class="label">Post Update State</div><div class="value">{html_lib.escape(str(control_state))}</div></div>
<div class="mini"><div class="label">Extra admin_server.py</div><div class="value">{scan.get('extra_exact_instances',0)}</div></div>
</div><div class="card"><h2>מופעי admin_server.py</h2><table><thead><tr><th>תפקיד</th><th>נתיב</th><th>SHA</th><th>עודכן</th></tr></thead><tbody>{''.join(rows)}</tbody></table></div></div></body></html>'''


@app.route("/admin/runtime.json")
def admin_runtime_json_v92():
    response = make_response(json.dumps(get_runtime_identity_v92(include_duplicates=True), ensure_ascii=False, indent=2, default=str))
    response.headers["Content-Type"] = "application/json; charset=utf-8"
    response.headers["Cache-Control"] = "no-store"
    return response


@app.route("/admin/update-status.json")
def admin_update_status_json_v92():
    payload = build_admin_update_status_v92(request.args.get("expected_sha", ""))
    response = make_response(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
    response.headers["Content-Type"] = "application/json; charset=utf-8"
    response.headers["Cache-Control"] = "no-store"
    return response


def ping_json_v92():
    runtime = get_runtime_identity_v92(include_duplicates=False)
    return {
        "ok": bool(runtime.get("runtime_verified")),
        "service": "TripServer Admin",
        "version": ADMIN_PANEL_VERSION,
        "build_id": ADMIN_BUILD_ID,
        "started_at": ADMIN_STARTED_AT,
        "port": ADMIN_PORT,
        "runtime": runtime,
    }


_v91_health_payload = health_payload
def health_payload():
    payload = _v91_health_payload()
    payload["runtime"] = get_runtime_identity_v92(include_duplicates=False)
    payload["update_status"] = build_admin_update_status_v92()
    payload["build_id"] = ADMIN_BUILD_ID
    payload["ok"] = bool(payload.get("ok") and payload["runtime"].get("runtime_verified"))
    return payload


_v91_run_self_test_suite = run_self_test_suite
def run_self_test_suite(include_media_conversion=False):
    payload = _v91_run_self_test_suite(include_media_conversion=include_media_conversion)
    runtime = get_runtime_identity_v92(include_duplicates=True)
    scan = runtime.get("admin_file_scan", {})
    update = build_admin_update_status_v92()
    results = list(payload.get("results") or [])
    add_self_test_result(results, "זהות Runtime", "הקובץ שנטען לזיכרון תואם לקובץ בדיסק", bool(runtime.get("loaded_matches_disk")), f"loaded={runtime.get('loaded_sha256')} | disk={runtime.get('disk_sha256')}")
    add_self_test_result(results, "זהות Runtime", "האדמין רץ מהנתיב הקנוני C:\\TripServer\\admin_server.py", bool(runtime.get("running_from_expected_file")), f"loaded={runtime.get('loaded_file')} | expected={runtime.get('expected_install_file')}")
    add_self_test_result(results, "זהות Runtime", "Build ID ו־SHA זמינים", bool(runtime.get("build_id") and len(runtime.get("loaded_sha256") or "") == 64), f"build={runtime.get('build_id')} | sha={runtime.get('loaded_sha256')}")
    owner_ok = runtime.get("pid_owns_admin_port") in (True, None)
    add_self_test_result(results, "זהות Runtime", "התהליך הפעיל מזוהה כבעל פורט האדמין", owner_ok, f"pid={runtime.get('pid')} | port_pids={runtime.get('port_owner_pids')}", severity="warning")
    add_self_test_result(results, "זהות Runtime", "לא נמצאו עותקי admin_server.py נוספים", not scan.get("has_extra_exact_instances"), json.dumps(scan, ensure_ascii=False, default=str)[:1800], severity="warning")
    try:
        control_code = build_control_server_code()
        compile(control_code, "<tripserver_control_v92>", "exec")
        markers_ok = all(token in control_code for token in ("loaded_sha256", "verification_ok", "installed_unverified", "expected_build_id", "TripServerControl/92"))
        add_self_test_result(results, "עדכון אדמין", "Control Server v92 כולל אימות Runtime לאחר החלפה", markers_ok, "generated control source compiled")
    except Exception as exc:
        add_self_test_result(results, "עדכון אדמין", "Control Server v92 כולל אימות Runtime לאחר החלפה", False, repr(exc))
    last_action = str(update.get("control_status", {}).get("last_action") or "")
    if last_action == "apply_update":
        update_ok = update.get("control_status", {}).get("state") in {"verified", "installed_unverified", "validation_failed", "backup_failed", "stop_failed", "replace_failed", "disk_verify_failed"}
        add_self_test_result(results, "עדכון אדמין", "תוצאת העדכון האחרון כוללת מצב אימות מפורש", update_ok, json.dumps(update.get("control_status", {}), ensure_ascii=False, default=str)[:1800])
    else:
        add_self_test_result(results, "עדכון אדמין", "מנגנון סטטוס עדכון מוכן", True, "טרם נרשם apply_update ב־Control Server")
    passed = sum(1 for item in results if item.get("status") == "pass")
    warned = sum(1 for item in results if item.get("status") == "warn")
    failed = sum(1 for item in results if item.get("status") == "fail")
    payload.update({
        "ok": failed == 0,
        "passed": passed,
        "warnings": warned,
        "failed": failed,
        "total": len(results),
        "version": ADMIN_PANEL_VERSION,
        "build_id": ADMIN_BUILD_ID,
        "runtime": runtime,
        "update_status": update,
        "results": results,
    })
    return payload


_v91_render_admin_status = render_admin_status
def render_admin_status():
    base = _v91_render_admin_status()
    runtime = get_runtime_identity_v92(include_duplicates=True)
    scan = runtime.get("admin_file_scan", {})
    cls = "#14532d" if runtime.get("runtime_verified") else "#7f1d1d"
    runtime_html = f'''<div style="background:{cls};border:1px solid #475569;border-radius:18px;padding:16px;margin:14px 0"><h2 style="margin-top:0">Runtime Identity</h2><p><b>גרסה:</b> {html_lib.escape(runtime['version'])}<br><b>Build:</b> {html_lib.escape(runtime['build_id'])}<br><b>קובץ פעיל:</b> <code>{html_lib.escape(runtime['loaded_file'])}</code><br><b>Loaded SHA:</b> <code>{html_lib.escape(runtime['loaded_sha256'])}</code><br><b>Disk SHA:</b> <code>{html_lib.escape(runtime['disk_sha256'])}</code><br><b>Loaded = Disk:</b> {'כן' if runtime['loaded_matches_disk'] else 'לא'} · <b>עותקים נוספים:</b> {scan.get('extra_exact_instances',0)}</p><a href="/admin/runtime" style="display:inline-block;background:#2563eb;color:white;text-decoration:none;border-radius:12px;padding:10px 14px;font-weight:900">פתח אימות Runtime מלא</a></div>'''
    return base.replace("<p>כאן רואים בפועל", runtime_html + "<p>כאן רואים בפועל", 1)


_v91_render_admin_health = render_admin_health
def render_admin_health():
    base = _v91_render_admin_health()
    runtime = get_runtime_identity_v92(False)
    runtime_note = f'''<div class="note"><b>Runtime Identity</b><br>Build: {html_lib.escape(runtime['build_id'])}<br>Loaded SHA: <span class="ltr">{html_lib.escape(runtime['loaded_sha256'])}</span><br>Disk SHA: <span class="ltr">{html_lib.escape(runtime['disk_sha256'])}</span><br>Loaded = Disk: {'כן' if runtime['loaded_matches_disk'] else 'לא'}<br><a href="/admin/runtime">פתח מסך Runtime המלא</a></div>'''
    return base.replace('<div class="note"><b>ארכיטקטורת התאוששות פעילה</b>', runtime_note + '<div class="note"><b>ארכיטקטורת התאוששות פעילה</b>', 1)


_v91_admin_view = app.view_functions.get("admin")
def admin_v92():
    base = _v91_admin_view()
    runtime = get_runtime_identity_v92(False)
    runtime_link = '<a class="btn-link" href="/admin/runtime">🧬 Runtime</a>'
    base = base.replace('<a class="btn-link" href="/admin/status">📊 סטטוס קבצים</a>', '<a class="btn-link" href="/admin/status">📊 סטטוס קבצים</a>\n' + runtime_link, 1)
    runtime_card = f'''<div class="status-card"><b>🧬 Runtime {html_lib.escape(runtime['build_id'])}</b><span>Loaded SHA: {html_lib.escape(runtime['loaded_sha256'][:16])}… · Loaded=Disk: {'כן' if runtime['loaded_matches_disk'] else 'לא'}</span></div>'''
    base = base.replace('<div class="status-strip">', '<div class="status-strip">\n' + runtime_card, 1)
    base = base.replace('נתמך קובץ Python ישיר, ומגרסה זו גם ZIP עדכון מבוקר.', 'נתמך קובץ Python ישיר או ZIP מבוקר. הצלחת עדכון נקבעת רק אחרי אימות Loaded SHA מול Disk SHA.', 1)
    return base


# Extend the live USA audit evidence with runtime/update identity files.
_v91_run_quick_usa_audit_original = run_quick_usa_audit_v91
def run_quick_usa_audit_v91():
    report = _v91_run_quick_usa_audit_original()
    report["runtime_status"] = get_runtime_identity_v92(include_duplicates=True)
    report["admin_update_status"] = build_admin_update_status_v92()
    _v91_write_json_atomic(AUDIT_V91_QUICK_REPORT, report)
    return report


_v91_build_audit_bundle_original = _v91_build_audit_bundle
def _v91_build_audit_bundle(include_media=False):
    zip_path, readiness = _v91_build_audit_bundle_original(include_media)
    additions = {
        "RUNTIME_STATUS.json": get_runtime_identity_v92(include_duplicates=True),
        "ADMIN_UPDATE_STATUS.json": build_admin_update_status_v92(),
        "ADMIN_FILE_SCAN.json": scan_admin_server_instances_v92(),
    }
    with zipfile.ZipFile(zip_path, "a", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
        for name, payload in additions.items():
            zf.writestr(name, json.dumps(payload, ensure_ascii=False, indent=2, default=str))
    with zipfile.ZipFile(zip_path, "r") as zf:
        bad = zf.testzip()
        if bad:
            raise RuntimeError("v92 audit append CRC failed: " + bad)
        missing = [name for name in additions if name not in zf.namelist()]
        if missing:
            raise RuntimeError("v92 audit evidence missing: " + ", ".join(missing))
    return zip_path, readiness


# Replace only the existing endpoints; all trip/media/Netlify endpoints remain v91.
app.view_functions["admin"] = admin_v92
app.view_functions["upload_admin_server"] = upload_admin_server_v92
app.view_functions["restore_admin_server_backup"] = restore_admin_server_backup_v92
app.view_functions["ping_json"] = ping_json_v92

# === end v92 Verified Runtime Identity ========================================


# === v93 USA GOLD release governance ==========================================
USA_GOLD_RELEASE_ID = "EITAN-USA-GOLD-v93-20260717-01"
USA_GOLD_REFERENCE_PATH = os.path.join(BASE_DIR, "USA_GOLD_REFERENCE_v93.json")
USA_GOLD_AUDIT_PATH = os.path.join(BASE_DIR, "USA_GOLD_AUDIT_v93.json")
USA_GOLD_BUDGET_MODE = "TEXT_ONLY"
USA_GOLD_OFFLINE_SCHEMA = 3
USA_GOLD_REQUIRED_TARGETS = {
    "USA_TRIP": os.path.join(FOLDERS["USA"], "index.html"),
    "USA_PRESENTATION_PHONE": os.path.join(FOLDERS["USA"], "usa2027-presentation.html"),
    "USA_TRIP_TV": os.path.join(FOLDERS["USA_TV"], "usa2027-tv.html"),
    "USA_PRESENTATION_TV": os.path.join(FOLDERS["USA_TV"], "usa2027-presentation-tv.html"),
}


def _v93_read(path):
    return Path(path).read_text(encoding="utf-8", errors="replace")


def _v93_contract(text):
    m = re.search(r'<script\b[^>]*\bid=["\']usa2027-release-contract["\'][^>]*>([\s\S]*?)</script>', text, re.I)
    if not m:
        return {}
    try:
        return json.loads(m.group(1))
    except Exception:
        return {}


def _v93_budget_slide(text):
    m = re.search(r'<section\b[^>]*\bdata-key=["\']budget["\'][^>]*>[\s\S]*?</section>', text, re.I)
    return m.group(0) if m else ""


def _v93_slide_keys(text):
    return re.findall(r'<section\b[^>]*\bclass=["\'][^"\']*\bslide\b[^"\']*["\'][^>]*\bdata-key=["\']([^"\']+)["\']', text, re.I)


def _v93_inline_manifest(text):
    m = re.search(r'<script\b[^>]*\bid=["\']usa2027-inline-media-manifest["\'][^>]*>([\s\S]*?)</script>', text, re.I)
    if not m:
        return {}
    try:
        return json.loads(m.group(1))
    except Exception:
        return {}


def _v93_normalized_budget_text(text):
    slide = _v93_budget_slide(text)
    slide = re.sub(r'\s+', ' ', slide).strip()
    return slide


def _v93_gold_fingerprint(payloads):
    phone_trip = payloads.get("USA_TRIP", "")
    tv_trip = payloads.get("USA_TRIP_TV", "")
    phone_pres = payloads.get("USA_PRESENTATION_PHONE", "")
    tv_pres = payloads.get("USA_PRESENTATION_TV", "")
    phone_manifest = _v93_inline_manifest(phone_pres)
    tv_manifest = _v93_inline_manifest(tv_pres)
    obj = {
        "release_id": USA_GOLD_RELEASE_ID,
        "trip_days_phone": sorted(set(map(int, re.findall(r'data-day=["\'](\d+)["\']', phone_trip)))),
        "trip_days_tv": sorted(set(map(int, re.findall(r'data-day=["\'](\d+)["\']', tv_trip)))),
        "slide_keys_phone": _v93_slide_keys(phone_pres),
        "slide_keys_tv": _v93_slide_keys(tv_pres),
        "manifest_phone": [(str(i.get("base") or ""), str(i.get("type") or ""), str(i.get("url") or "")) for i in phone_manifest.get("items") or []],
        "manifest_tv": [(str(i.get("base") or ""), str(i.get("type") or ""), str(i.get("url") or "")) for i in tv_manifest.get("items") or []],
        "budget_sha256": hashlib.sha256(_v93_normalized_budget_text(phone_pres).encode("utf-8")).hexdigest(),
        "offline_schema": USA_GOLD_OFFLINE_SCHEMA,
        "budget_mode": USA_GOLD_BUDGET_MODE,
    }
    canonical = json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    return {"sha256": hashlib.sha256(canonical.encode("utf-8")).hexdigest(), "data": obj}


def run_usa_gold_audit_v93(write_report=True):
    checks = []
    payloads = {}
    def add(name, ok, detail="", severity="error"):
        checks.append({"name":name, "ok":bool(ok), "status":"pass" if ok else ("warn" if severity == "warning" else "fail"), "detail":str(detail or ""), "severity":severity})
    for target, path in USA_GOLD_REQUIRED_TARGETS.items():
        exists = os.path.isfile(path)
        add(target + " קיים", exists, path)
        if exists:
            payloads[target] = _v93_read(path)
    if len(payloads) == len(USA_GOLD_REQUIRED_TARGETS):
        contracts = {k:_v93_contract(v) for k,v in payloads.items()}
        add("Build ID אחיד בכל רכיבי USA", all(c.get("releaseId") == USA_GOLD_RELEASE_ID for c in contracts.values()), json.dumps(contracts, ensure_ascii=False)[:1800])
        add("Offline Registry schema 3 בכל רכיבי USA", all(int(c.get("offlineRegistrySchema") or 0) == USA_GOLD_OFFLINE_SCHEMA for c in contracts.values()), json.dumps(contracts, ensure_ascii=False)[:1800])
        add("מדיניות תקציב TEXT_ONLY בכל רכיבי USA", all(c.get("budgetSlideMode") == USA_GOLD_BUDGET_MODE for c in contracts.values()), json.dumps(contracts, ensure_ascii=False)[:1800])

        phone_trip = payloads["USA_TRIP"]
        tv_trip = payloads["USA_TRIP_TV"]
        phone_pres = payloads["USA_PRESENTATION_PHONE"]
        tv_pres = payloads["USA_PRESENTATION_TV"]
        for label, trip in (("iPhone", phone_trip), ("TV", tv_trip)):
            days = sorted(set(map(int, re.findall(r'data-day=["\'](\d+)["\']', trip))))
            add(f"36 ימי טיול רציפים — {label}", days == list(range(1,37)), days)
            add(f"כותרת פירוט עלות בכל יום — {label}", len(re.findall(r'פירוט עלות יום', trip)) >= 36, len(re.findall(r'פירוט עלות יום', trip)))
            add(f"אין שגיאת ההגהה קובצי המצגת — {label}", "קובצי המצגת" not in trip)
            add(f"חיפוש חכם קיים — {label}", "usa2027-smart-search" in trip)
            add(f"מצב הצג/הסתר קיים — {label}", "global-info-hidden" in trip and "toggle-global-info" in trip)
            add(f"מנוע תקציב דינמי קיים — {label}", "USA2027Money" in trip and (("data-budget-engine" in trip) or trip.count('class="cost-table') >= 36))
            add(f"Offline registry יציב ולא תלוי בסדר — {label}", "usa2027-offline-registry-v93" in trip and "signature" in trip and "currentUrls.every(function(u,i)" not in trip and "savedUrls[i]" not in trip)

        pkeys = _v93_slide_keys(phone_pres)
        tkeys = _v93_slide_keys(tv_pres)
        add("44 שקפים במצגת iPhone", len(pkeys) == 44, len(pkeys))
        add("44 שקפים במצגת TV", len(tkeys) == 44, len(tkeys))
        add("סדר ותוכן שקפים לוגי זהה iPhone/TV", pkeys == tkeys, {"phone":pkeys,"tv":tkeys})
        for label, pres in (("iPhone", phone_pres), ("TV", tv_pres)):
            budget = _v93_budget_slide(pres)
            amount_pattern = r'(?:[$₪¥€£]\s*\d|\d[\d,]*(?:\.\d+)?\s*(?:USD|ILS|ש["״]?ח|דולר|ין))'
            add(f"שקף תקציב ללא סכומים — {label}", bool(budget) and not re.search(amount_pattern, budget, re.I), budget[:1200])
            add(f"כפתור ראשי מתחיל מההתחלה — {label}", 'id="startWithMusic"' in pres and '▶ התחל מההתחלה' in pres)
            add(f"כפתור המשך משני ומותנה — {label}", 'id="resumeFromLast"' in pres and 'hidden' in pres and 'startPresentationWithMusic(true)' in pres)
            add(f"אין שחזור אוטומטי לפני בחירת המשתמש — {label}", "if (pendingRestoreState) applyPresentationRestore" not in pres and "presentationSessionStarted" in pres)
            add(f"Offline Registry schema 3 — {label}", "OFFLINE_REGISTRY_SCHEMA = 3" in pres and "usa2027-offline-registry-v93" in pres)
            add(f"השוואת אופליין מבוססת חתימה וסט מפתחות — {label}", "offlineEntryMatches" in pres and "signature" in pres and "sharedUrls.every((url, i)" not in pres)
            manifest = _v93_inline_manifest(pres)
            items = manifest.get("items") or []
            add(f"Manifest כולל 44 מדיות שקף + 2 אודיו — {label}", len(items) == 46 and sum(1 for i in items if i.get("type") == "audio") == 2, len(items))

        add("שקף התקציב זהה iPhone/TV", _v93_normalized_budget_text(phone_pres) == _v93_normalized_budget_text(tv_pres))
        add("Manifest לוגי זהה iPhone/TV", [(i.get("base"),i.get("type"),i.get("url")) for i in (_v93_inline_manifest(phone_pres).get("items") or [])] == [(i.get("base"),i.get("type"),i.get("url")) for i in (_v93_inline_manifest(tv_pres).get("items") or [])])
        add("קישורי מצגת נכונים בקובצי הטיול", "/USA/usa2027-presentation.html" in phone_trip and "/USA_TV/usa2027-presentation-tv.html" in tv_trip)

        fingerprint = _v93_gold_fingerprint(payloads)
        reference = None
        if os.path.isfile(USA_GOLD_REFERENCE_PATH):
            try: reference = json.loads(Path(USA_GOLD_REFERENCE_PATH).read_text(encoding="utf-8"))
            except Exception: reference = None
        if reference:
            add("Regression מול USA GOLD Reference", reference.get("fingerprint_sha256") == fingerprint["sha256"], {"reference":reference.get("fingerprint_sha256"),"current":fingerprint["sha256"]})
        else:
            add("USA GOLD Reference טרם נוצר", True, "ייווצר אוטומטית רק לאחר שכל בדיקות ה-GOLD יעברו", severity="warning")
    else:
        fingerprint = {"sha256":"", "data":{}}
    failed = [c for c in checks if c["status"] == "fail"]
    warnings = [c for c in checks if c["status"] == "warn"]
    report = {
        "schema":"eitan-usa-gold-audit/v93",
        "release_id":USA_GOLD_RELEASE_ID,
        "checked_at":datetime.now().isoformat(timespec="seconds"),
        "ok":not failed,
        "passed":sum(1 for c in checks if c["status"] == "pass"),
        "warnings":len(warnings),
        "failed":len(failed),
        "checks":checks,
        "fingerprint":fingerprint,
        "policy":{"budget_mode":USA_GOLD_BUDGET_MODE,"offline_registry_schema":USA_GOLD_OFFLINE_SCHEMA,"default_start":"START_FROM_BEGINNING","resume":"SECONDARY_WHEN_AVAILABLE"},
    }
    if write_report:
        try:
            tmp = USA_GOLD_AUDIT_PATH + ".tmp"
            Path(tmp).write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
            os.replace(tmp, USA_GOLD_AUDIT_PATH)
        except Exception:
            pass
    return report


def _v93_bootstrap_gold_reference():
    try:
        report = run_usa_gold_audit_v93(write_report=True)
        if not report.get("ok"):
            return {"created":False,"reason":"audit_failed"}
        fp = report.get("fingerprint") or {}
        if not fp.get("sha256"):
            return {"created":False,"reason":"fingerprint_missing"}
        existing = None
        if os.path.isfile(USA_GOLD_REFERENCE_PATH):
            try: existing = json.loads(Path(USA_GOLD_REFERENCE_PATH).read_text(encoding="utf-8"))
            except Exception: existing = None
        if existing and existing.get("release_id") == USA_GOLD_RELEASE_ID:
            return {"created":False,"reason":"already_exists","path":USA_GOLD_REFERENCE_PATH}
        payload = {"schema":"eitan-usa-gold-reference/v93","release_id":USA_GOLD_RELEASE_ID,"approved_at":datetime.now().isoformat(timespec="seconds"),"fingerprint_sha256":fp["sha256"],"fingerprint":fp.get("data") or {}}
        tmp = USA_GOLD_REFERENCE_PATH + ".tmp"
        Path(tmp).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
        os.replace(tmp, USA_GOLD_REFERENCE_PATH)
        return {"created":True,"path":USA_GOLD_REFERENCE_PATH,"fingerprint":fp["sha256"]}
    except Exception as exc:
        return {"created":False,"reason":repr(exc)}


# Enforce USA GOLD contracts at package inspection time, before any file is replaced.
_v92_upd_validate_content = _upd_validate_content
def _upd_validate_content(path, kind):
    result = _v92_upd_validate_content(path, kind)
    if kind not in {"trip_html", "presentation_html"}:
        return result
    text = _v93_read(path)
    if "USA2027" not in text and "ארצות הברית 2027" not in text and "ארה״ב 2027" not in text:
        return result
    contract = _v93_contract(text)
    if contract.get("releaseId") != USA_GOLD_RELEASE_ID:
        raise ValueError("usa_gold_release_id_missing_or_wrong")
    if int(contract.get("offlineRegistrySchema") or 0) != USA_GOLD_OFFLINE_SCHEMA:
        raise ValueError("usa_gold_offline_schema_wrong")
    if contract.get("budgetSlideMode") != USA_GOLD_BUDGET_MODE:
        raise ValueError("usa_gold_budget_mode_wrong")
    if kind == "presentation_html":
        budget = _v93_budget_slide(text)
        if not budget:
            raise ValueError("usa_gold_budget_slide_missing")
        if re.search(r'(?:[$₪¥€£]\s*\d|\d[\d,]*(?:\.\d+)?\s*(?:USD|ILS|ש["״]?ח|דולר|ין))', budget, re.I):
            raise ValueError("usa_gold_budget_slide_contains_amounts")
        required = ["▶ התחל מההתחלה", 'id="resumeFromLast"', "usa2027-offline-registry-v93", "OFFLINE_REGISTRY_SCHEMA = 3"]
        missing = [x for x in required if x not in text]
        if missing:
            raise ValueError("usa_gold_presentation_contract_missing:" + ",".join(missing))
    if kind == "trip_html":
        if "קובצי המצגת" in text:
            raise ValueError("usa_gold_typo_kovtzei")
        if "usa2027-offline-registry-v93" not in text or "OFFLINE_REGISTRY_SCHEMA=3" not in text:
            raise ValueError("usa_gold_trip_offline_contract_missing")
    result["usa_gold"] = {"release_id":USA_GOLD_RELEASE_ID,"budget_mode":USA_GOLD_BUDGET_MODE,"offline_schema":USA_GOLD_OFFLINE_SCHEMA}
    return result


# Netlify is generated from active phone sources and must preserve the same text-only budget slide.
_v92_build_netlify_deploy_zip_v91 = build_netlify_deploy_zip_v91
def build_netlify_deploy_zip_v91(trip):
    zip_path, details = _v92_build_netlify_deploy_zip_v91(trip)
    if (normalize_netlify_trip_key(trip) or str(trip).upper()) == "USA":
        with zipfile.ZipFile(zip_path, "r") as zf:
            candidates = [n for n in zf.namelist() if n.endswith("usa2027-presentation.html")]
            if not candidates:
                raise RuntimeError("v93_netlify_presentation_missing")
            text = zf.read(candidates[0]).decode("utf-8", errors="replace")
            budget = _v93_budget_slide(text)
            if not budget or re.search(r'(?:[$₪¥€£]\s*\d|\d[\d,]*(?:\.\d+)?\s*(?:USD|ILS))', budget, re.I):
                raise RuntimeError("v93_netlify_budget_not_text_only")
            if _v93_normalized_budget_text(text) != re.sub(r'\s+', ' ', USA_NETLIFY_BUDGET_SLIDE_HTML).strip():
                raise RuntimeError("v93_netlify_budget_not_canonical")
            if "tail978ec2" in text.lower() or "c:\\tripserver" in text.lower():
                raise RuntimeError("v93_netlify_private_reference")
        details = dict(details or {})
        details["usa_gold_release_id"] = USA_GOLD_RELEASE_ID
        details["budget_mode"] = USA_GOLD_BUDGET_MODE
    return zip_path, details


@app.route("/admin/usa-gold-audit.json")
def admin_usa_gold_audit_json_v93():
    report = run_usa_gold_audit_v93(write_report=True)
    response = make_response(json.dumps(report, ensure_ascii=False, indent=2, default=str))
    response.headers["Content-Type"] = "application/json; charset=utf-8"
    response.headers["Cache-Control"] = "no-store"
    return response


@app.route("/admin/usa-gold-audit")
def admin_usa_gold_audit_html_v93():
    report = run_usa_gold_audit_v93(write_report=True)
    rows = []
    for item in report.get("checks") or []:
        icon = "✅" if item.get("status") == "pass" else ("⚠️" if item.get("status") == "warn" else "❌")
        rows.append(f'<tr><td>{icon}</td><td>{html_lib.escape(str(item.get("name") or ""))}</td><td><code>{html_lib.escape(str(item.get("detail") or ""))}</code></td></tr>')
    badge = "PASS" if report.get("ok") else "FAIL"
    cls = "good" if report.get("ok") else "warn"
    body = f'<div class="card {cls}"><h1>🇺🇸 USA GOLD v93 — {badge}</h1><p>Release: <code>{USA_GOLD_RELEASE_ID}</code></p><p>עברו: {report.get("passed")} · אזהרות: {report.get("warnings")} · נכשלו: {report.get("failed")}</p><p><a class="btn" href="/admin/usa-gold-audit.json" target="_blank">JSON מלא</a></p></div><div class="card"><table><thead><tr><th>מצב</th><th>בדיקה</th><th>פרטים</th></tr></thead><tbody>{"".join(rows)}</tbody></table></div>'
    return _upd_page("USA GOLD Audit", body)


_v92_run_self_test_suite_v93_base = run_self_test_suite
def run_self_test_suite(include_media_conversion=False):
    payload = _v92_run_self_test_suite_v93_base(include_media_conversion=include_media_conversion)
    audit = run_usa_gold_audit_v93(write_report=True)
    results = list(payload.get("results") or [])
    for item in audit.get("checks") or []:
        add_self_test_result(results, "USA GOLD v93", item.get("name"), bool(item.get("ok")), item.get("detail"), severity="warning" if item.get("severity") == "warning" else "error")
    try:
        sample_identity = {
            "schema": EITAN_ARTIFACT_CONTRACT_SCHEMA,
            "artifact_id": BUDGET_APP_EXPECTED_ARTIFACT_ID,
            "artifact_type": BUDGET_APP_EXPECTED_ARTIFACT_TYPE,
            "version": "2099.01.01-FUTURE-v999",
            "features": sorted(BUDGET_APP_REQUIRED_FEATURES),
        }
        contract_errors = validate_eitan_artifact_identity(
            sample_identity,
            expected_id=BUDGET_APP_EXPECTED_ARTIFACT_ID,
            expected_type=BUDGET_APP_EXPECTED_ARTIFACT_TYPE,
            required_features=BUDGET_APP_REQUIRED_FEATURES,
        )
        add_self_test_result(results, "תאימות קבצים", "חוזה זהות קבוע מקבל גרסת Moneyapp עתידית", not contract_errors, ", ".join(contract_errors) or EITAN_ARTIFACT_CONTRACT_SCHEMA)
    except Exception as exc:
        add_self_test_result(results, "תאימות קבצים", "בדיקת חוזה זהות עתידי", False, repr(exc))
    try:
        harmless_host_detection = "function isPrivateHost(){var h=String(location.hostname||'').toLowerCase();return h==='localhost'||h==='127.0.0.1'||h==='::1';}"
        dangerous_private_refs = (
            '<a href="http://localhost:8000/USA/">x</a>',
            "fetch('http://127.0.0.1:8000/api')",
            '<script src="//work-room.tail978ec2.ts.net/private.js"></script>',
            '<a href="http://192.168.1.20/app">x</a>',
            r'const localPath="C:\\TripServer\\USA\\index.html";',
        )
        harmless_hits = _v91_find_private_deploy_references(harmless_host_detection)
        dangerous_hits = [_v91_find_private_deploy_references(sample) for sample in dangerous_private_refs]
        gate_ok = not harmless_hits and all(dangerous_hits)
        add_self_test_result(
            results,
            "Netlify",
            "וולידציית נתיבים פרטיים מבדילה בין זיהוי hostname לבין קישור מקומי אמיתי",
            gate_ok,
            json.dumps({'harmless_hits':harmless_hits,'dangerous_hit_counts':[len(x) for x in dangerous_hits]}, ensure_ascii=False),
        )
    except Exception as exc:
        add_self_test_result(results, "Netlify", "בדיקת רגרסיה לוולידציית hostname", False, repr(exc))
    passed = sum(1 for item in results if item.get("status") == "pass")
    warned = sum(1 for item in results if item.get("status") == "warn")
    failed = sum(1 for item in results if item.get("status") == "fail")
    payload.update({"ok":failed == 0,"passed":passed,"warnings":warned,"failed":failed,"total":len(results),"version":ADMIN_PANEL_VERSION,"build_id":ADMIN_BUILD_ID,"usa_gold_audit":audit,"results":results})
    return payload


_v92_admin_view_v93_base = app.view_functions.get("admin")
def admin_v93():
    base = _v92_admin_view_v93_base()
    link = '<a class="btn-link" href="/admin/usa-gold-audit">🇺🇸 USA GOLD</a>'
    base = base.replace('<a class="btn-link" href="/admin/runtime">🧬 Runtime</a>', '<a class="btn-link" href="/admin/runtime">🧬 Runtime</a>\n' + link, 1)
    card = '<div class="status-card"><b>🇺🇸 USA GOLD v93</b><span>Offline Registry יציב · התחלה מההתחלה כברירת מחדל · שקף תקציב ללא סכומים בכל הגרסאות</span></div>'
    return base.replace('<div class="status-strip">', '<div class="status-strip">\n' + card, 1)


app.view_functions["admin"] = admin_v93
# === end v93 USA GOLD release governance =====================================

if __name__ == "__main__":
    _v92_write_runtime_snapshot("admin-startup")
    bootstrap_control_server("admin-startup")
    _v92_finalize_verified_startup()
    _v93_bootstrap_gold_reference()
    maybe_start_camera_auto_install("admin-startup")
    try:
        run_v51_safe_migration()
        _v52_ensure_initial_music_segments()
    except Exception:
        pass
    app.run(host=ADMIN_HOST, port=ADMIN_PORT, threaded=True, use_reloader=False)
