"""
app.py — QR Carrier PDF generator: single-item and CSV-driven multi-item workflows.

Serves the upload UI at /qr/add/ (Apache ProxyPass).

Environment variables:
  QR_WATCH_DIR   Root directory that contains job subfolders
                 (default: /var/www/html/qr)
  QR_BASE_URL    Public URL that maps to QR_WATCH_DIR
                 (default: http://fileshare.icastinc.com/qr)
  PORT           Port for the built-in dev server (default: 5001)
"""

import csv
import io
import json
import os
import re
import shutil
import sys
import threading
import uuid
import zipfile
from pathlib import Path
from urllib.parse import quote

from flask import Flask, abort, jsonify, render_template_string, request, send_file
from werkzeug.utils import secure_filename

import msal
import requests as _http_requests
from functools import wraps
from flask import redirect, session, url_for

# Allow importing qr_merge from the parent /var/www/html/qr/ directory
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

# Prefer merge_item (DVI-176 spec); fall back to build_carrier_pdf until it lands.
try:
    from qr_merge import merge_item as _qr_merge_item  # noqa: E402

    def _merge_item(carrier_path: Path, shop_path: Path, output_folder: Path,
                    submittal_path: Path | None = None) -> Path:
        generated = Path(_qr_merge_item(
            carrier_sheet_path=str(carrier_path),
            shop_drawing_path=str(shop_path),
            output_folder=str(output_folder),
            submittal_path=str(submittal_path) if submittal_path else None,
        ))
        # Overwrite the carrier sheet with the merged output; remove generated file.
        if generated.resolve() != carrier_path.resolve():
            carrier_path.write_bytes(generated.read_bytes())
            generated.unlink()
        return carrier_path

except ImportError:
    # merge_item not yet in qr_merge.py — use build_carrier_pdf directly.
    from qr_merge import build_carrier_pdf as _build_carrier_pdf  # noqa: E402

    def _merge_item(carrier_path: Path, shop_path: Path, output_folder: Path,
                    submittal_path: Path | None = None) -> Path:
        _base_url = os.environ.get("QR_BASE_URL", "http://fileshare.icastinc.com/qr")
        folder_url = f"{_base_url.rstrip('/')}/{quote(output_folder.name)}"
        shop_url = f"{folder_url}/{quote(shop_path.name)}"
        submittal_url = f"{folder_url}/{quote(submittal_path.name)}" if submittal_path else ""
        carrier_bytes = carrier_path.read_bytes()  # read before overwriting
        result_bytes = _build_carrier_pdf(carrier_bytes, submittal_url, shop_url)
        carrier_path.write_bytes(result_bytes)  # overwrite in place
        return carrier_path

# Also keep build_carrier_pdf available for the legacy single-item route.
try:
    from qr_merge import build_carrier_pdf  # noqa: E402
except ImportError:
    build_carrier_pdf = None  # type: ignore[assignment]

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 500 * 1024 * 1024  # 500 MB (zip files with PDFs)

# ---------------------------------------------------------------------------
# Azure AD / Microsoft 365 SSO configuration
# ---------------------------------------------------------------------------
app.config["SECRET_KEY"] = os.environ["FLASK_SECRET_KEY"]

_AZ_CLIENT_ID  = os.environ["AZURE_CLIENT_ID"]
_AZ_SECRET     = os.environ["AZURE_CLIENT_SECRET"]
_AZ_TENANT     = os.environ["AZURE_TENANT_ID"]
_AZ_GROUP      = os.environ["AZURE_TOGEN_GROUP_ID"]
_AZ_AUTHORITY  = f"https://login.microsoftonline.com/{_AZ_TENANT}"
_AZ_SCOPES     = ["User.Read", "GroupMember.Read.All"]
_AZ_REDIRECT   = "https://togen.icastinc.com/auth/callback"

WATCH_DIR  = Path(os.environ.get("QR_WATCH_DIR",    "/var/www/html/qr"))
SHARED_DIR = Path(os.environ.get("QR_SHARED_DIR",  "/var/www/html/shared"))
BASE_URL   = os.environ.get("QR_BASE_URL", "http://fileshare.icastinc.com/qr")

_SAFE_JOB_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9 _\-\.]{0,79}$")


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _safe_job_folder(job_id: str) -> Path:
    """Return resolved Path for job_id; abort(400) if unsafe."""
    if not _SAFE_JOB_RE.match(job_id) or ".." in job_id:
        abort(400)
    folder = (WATCH_DIR / job_id).resolve()
    if not str(folder).startswith(str(WATCH_DIR.resolve())):
        abort(400)
    return folder


def _find_generated(job_folder: Path) -> Path | None:
    if not job_folder.is_dir():
        return None
    for f in job_folder.iterdir():
        if f.is_file() and "generated" in f.name.lower() and f.suffix.lower() == ".pdf":
            return f
    return None


def _state_path(folder: Path) -> Path:
    return folder / "state.json"


def _load_state(folder: Path) -> dict:
    p = _state_path(folder)
    return json.loads(p.read_text()) if p.exists() else {}


def _save_state(folder: Path, state: dict) -> None:
    _state_path(folder).write_text(json.dumps(state, indent=2))


def _match_files_to_items(
    folder: Path,
    items: list[str],
    item_refs: list[str],
    item_cols_a: list[str],
) -> list[dict]:
    """Scan *folder* recursively for PDFs and match them to items by identifier presence + suffix.

    Identifiers: col A (item_cols_a[i]), col H (item_refs[i]), col C (items[i]).
    All non-empty identifiers must appear in the filename stem (case-insensitive).
    Suffix determines file type:
      stem ends with 'shop'      → shop drawing
      stem ends with 'submittal' → submittal document
      stem ends with 'carrierqr' → QR carrier sheet

    Submittal fallback: if no submittal matches all identifiers (common when the submittal is
    a shared document not named per item), retry with only col A as the required identifier.

    Paths stored are relative to *folder* so they work regardless of subdirectory depth.
    Returns a list[dict] parallel to *items*; each dict maps 'shop'/'submittal'/'carrier'
    to the relative path string from *folder* (key absent if no match found).
    """
    # rglob so files inside subdirectories (e.g. zip-extracted sub-folder) are found
    pdf_files = [f for f in folder.rglob("*.pdf")]

    auto_files: list[dict] = []
    for i, item_name in enumerate(items):
        col_a = item_cols_a[i] if i < len(item_cols_a) else ""
        col_h = item_refs[i] if i < len(item_refs) else ""
        col_c = item_name

        # All non-empty identifiers must appear in the stem (case-insensitive)
        identifiers = [s.strip().lower() for s in [col_a, col_h, col_c] if s.strip()]
        col_a_only  = [col_a.strip().lower()] if col_a.strip() else []

        matched: dict = {}
        for pdf in pdf_files:
            stem_lower = pdf.stem.lower()
            rel = str(pdf.relative_to(folder))
            if not all(ident in stem_lower for ident in identifiers):
                continue
            if stem_lower.endswith("shop"):
                matched.setdefault("shop", rel)
            elif stem_lower.endswith("submittal"):
                matched.setdefault("submittal", rel)
            elif stem_lower.endswith("carrierqr"):
                matched.setdefault("carrier", rel)

        # Submittal fallback: shared submittal files often omit item-specific identifiers.
        # Only apply when a shop drawing was also matched — no shop means no submittal.
        if "submittal" not in matched and "shop" in matched and col_a_only:
            for pdf in pdf_files:
                stem_lower = pdf.stem.lower()
                if stem_lower.endswith("submittal") and all(ident in stem_lower for ident in col_a_only):
                    matched.setdefault("submittal", str(pdf.relative_to(folder)))
                    break

        auto_files.append(matched)

    return auto_files


# ---------------------------------------------------------------------------
# Legacy single-item background merge (unchanged from original)
# ---------------------------------------------------------------------------

def _run_merge_single(job_folder: Path) -> None:
    """Classify uploaded PDFs by filename and run the QR merge (legacy route)."""
    if build_carrier_pdf is None:
        return

    carrier = submittal = shop = None
    for f in sorted(job_folder.iterdir()):
        if not f.is_file() or f.suffix.lower() != ".pdf":
            continue
        name = f.name.lower()
        if "generated" in name:
            continue
        if "carrier" in name and carrier is None:
            carrier = f
        elif "submittal" in name and submittal is None:
            submittal = f
        elif "shop" in name and shop is None:
            shop = f

    if not (carrier and submittal and shop):
        return

    folder_name = job_folder.name
    folder_url = f"{BASE_URL.rstrip('/')}/{quote(folder_name)}"
    submittal_url = f"{folder_url}/{quote(submittal.name)}"
    shop_url = f"{folder_url}/{quote(shop.name)}"

    blank_bytes = carrier.read_bytes()
    result_bytes = build_carrier_pdf(blank_bytes, submittal_url, shop_url)

    output_path = job_folder / f"{folder_name} CarrierQR generated.pdf"
    output_path.write_bytes(result_bytes)


# ---------------------------------------------------------------------------
# HTML (multi-item CSV workflow at top; legacy single-item below)
# ---------------------------------------------------------------------------

_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Togen — Pull it Forward</title>
<meta name="description" content="Togen — bridging material quantity estimation to production.">
<meta property="og:title" content="Togen — Pull it Forward">
<meta property="og:description" content="Togen — bridging material quantity estimation to production.">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 130 150'><rect x='10' y='40' width='14' height='70' rx='2' fill='%234A5568'/><rect x='106' y='40' width='14' height='70' rx='2' fill='%234A5568'/><path d='M 10 45 Q 65 5 120 45' stroke='%2300B4D8' stroke-width='10' fill='none' stroke-linecap='round'/><rect x='8' y='73' width='114' height='10' rx='2' fill='%2300B4D8'/><polygon points='20,90 35,90 42,107 35,124 20,124 27,107' fill='%2300B4D8' opacity='0.5'/><polygon points='50,90 65,90 72,107 65,124 50,124 57,107' fill='%2300B4D8' opacity='0.7'/><polygon points='80,90 95,90 102,107 95,124 80,124 87,107' fill='%2300B4D8' opacity='0.9'/></svg>">
<script>
  // Apply saved theme before first render to avoid flash
  (function(){
    var t = localStorage.getItem('theme');
    if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  })();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = {
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        togen: {
          teal: '#00B4D8',
          'teal-dark': '#0098B7',
          slate: '#4A5568',
          dark: '#1A202C',
        }
      }
    }
  }
}</script>
</head>
<body class="bg-gray-50 dark:bg-gray-950 min-h-screen font-sans">

<!-- ===== TOP NAVBAR ===== -->
<nav class="fixed top-0 left-0 right-0 z-50 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 h-14 flex items-center justify-between px-4">

  <!-- Togen Bridge Mark logo -->
  <a href="/" class="flex items-center select-none" aria-label="Togen home">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="20 25 360 135" height="38" role="img" aria-label="Togen">
      <defs>
        <linearGradient id="navGrad" x1="0%" y1="0%" x2="100%" y2="100%">
          <stop offset="0%" style="stop-color:#00B4D8;stop-opacity:1"/>
          <stop offset="100%" style="stop-color:#0077A8;stop-opacity:1"/>
        </linearGradient>
      </defs>
      <rect x="30" y="80" width="14" height="70" rx="2" fill="#4A5568"/>
      <rect x="94" y="80" width="14" height="70" rx="2" fill="#4A5568"/>
      <path d="M 30 85 Q 67 30 108 85" stroke="url(#navGrad)" stroke-width="8" fill="none" stroke-linecap="round"/>
      <rect x="28" y="114" width="90" height="8" rx="2" fill="url(#navGrad)"/>
      <polygon points="45,130 55,130 60,140 55,150 45,150 50,140" fill="#00B4D8" opacity="0.5"/>
      <polygon points="63,130 73,130 78,140 73,150 63,150 68,140" fill="#00B4D8" opacity="0.7"/>
      <polygon points="81,130 91,130 96,140 91,150 81,150 86,140" fill="#00B4D8" opacity="0.9"/>
      <text x="132" y="112" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="700" font-size="62" class="fill-gray-900 dark:fill-white" letter-spacing="-1">togen</text>
      <circle cx="154" cy="70" r="5" fill="#00B4D8"/>
    </svg>
  </a>

  <div class="flex items-center gap-2">
    <!-- Light/Dark mode toggle -->
    <button id="theme-toggle" onclick="toggleTheme()" title="Toggle light/dark mode"
            class="p-2 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
      <!-- Sun icon — shown in dark mode -->
      <svg id="icon-sun" class="hidden h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
        <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z"/>
      </svg>
      <!-- Moon icon — shown in light mode -->
      <svg id="icon-moon" class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
        <path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
      </svg>
    </button>

    <!-- User profile icon + dropdown -->
    <div class="relative">
      <button id="profile-btn" onclick="toggleProfileMenu()" title="User menu"
              class="p-1.5 rounded-full bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors">
        <svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
          <path fill-rule="evenodd" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" clip-rule="evenodd"/>
        </svg>
      </button>
      <div id="profile-menu"
           class="hidden absolute right-0 mt-2 w-44 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
        <div class="px-4 py-3 border-b border-gray-100 dark:border-gray-700">
          <p class="text-xs text-gray-500 dark:text-gray-400">Signed in as</p>
          <p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">User</p>
        </div>
        <button onclick="handleLogout()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
          Logout
        </button>
      </div>
    </div>
  </div>
</nav>

<div class="max-w-4xl mx-auto pt-24 pb-10 px-4">

  <div class="mb-4">
    <div class="flex items-baseline gap-3 flex-wrap">
      <h1 class="text-3xl font-light text-togen-dark dark:text-white" style="font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;letter-spacing:0.05em;">togen</h1>
      <span class="text-sm font-light text-togen-teal tracking-widest uppercase" style="font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;">Pull it Forward</span>
    </div>
  </div>

  <div class="mb-5">
    <p class="text-xs font-semibold uppercase tracking-widest text-togen-slate dark:text-gray-500" style="font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;">Take-off Bridge Workflow</p>
  </div>

  <!-- ===== FOLDER SELECTION (expanded by default) ===== -->
  <section class="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
    <div class="flex items-center justify-between px-6 py-4 cursor-pointer select-none"
         onclick="toggleSection('shared')">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Shared Folder Selection</h2>
      <svg id="chevron-shared" class="h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform duration-200 rotate-180"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
      </svg>
    </div>
    <div id="body-shared" class="px-6 pb-6">
      <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Select a published folder to load its CSV and files.</p>
      <input id="shared-search" type="text" placeholder="Filter folders…"
             oninput="filterSharedFolders(this.value)"
             class="w-full mb-2 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-xl
                    bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                    focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
      <div class="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
        <div id="shared-folders-list" class="max-h-64 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
          <p class="text-sm text-gray-400 dark:text-gray-500 px-4 py-3">Loading folders…</p>
        </div>
      </div>
      <span id="folder-select-lbl" class="mt-3 block text-sm text-gray-500 dark:text-gray-400"></span>
    </div>
  </section>

  <!-- ===== ZIP UPLOAD (collapsed by default) ===== -->
  <section class="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
    <div class="flex items-center justify-between px-6 py-4 cursor-pointer select-none"
         onclick="toggleSection('zip')">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Zip File Upload</h2>
      <svg id="chevron-zip" class="h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform duration-200"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
      </svg>
    </div>
    <div id="body-zip" class="hidden px-6 pb-6">
      <input type="file" id="zip-input" accept=".zip" class="hidden" onchange="onZipChosen(this)">
      <div id="zip-drop"
           onclick="document.getElementById('zip-input').click()"
           class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer
                  hover:border-togen-teal dark:hover:border-togen-teal hover:bg-togen-teal/5 dark:hover:bg-togen-teal/10 transition-colors">
        <svg class="mx-auto h-10 w-10 text-gray-400 dark:text-gray-500 mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
          <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
        </svg>
        <p class="text-sm font-medium text-gray-700 dark:text-gray-300">Drop a ZIP file here, or click to browse</p>
        <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ZIP must contain a CSV file and the PDF files for each item</p>
      </div>
      <span id="zip-lbl" class="mt-3 block text-sm text-gray-500 dark:text-gray-400"></span>
    </div>
  </section>

  <!-- ===== CSV UPLOAD (collapsed by default) ===== -->
  <section class="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
    <div class="flex items-center justify-between px-6 py-4 cursor-pointer select-none"
         onclick="toggleSection('csv')">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Multi-Item — CSV Upload</h2>
      <svg id="chevron-csv" class="h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform duration-200"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
      </svg>
    </div>
    <div id="body-csv" class="hidden px-6 pb-6">
      <div class="mb-4">
        <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Choose CSV file</label>
        <input type="file" id="csv-input" accept=".csv" class="hidden" onchange="onCsvChosen(this)">
        <button id="csv-btn" onclick="document.getElementById('csv-input').click()"
                class="px-4 py-2 border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg text-sm hover:border-gray-600 dark:hover:border-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors">
          Choose CSV…
        </button>
        <span id="csv-lbl" class="ml-3 text-sm text-gray-500 dark:text-gray-400"></span>
      </div>
    </div>
  </section>

  <!-- ===== ITEMS (shared — shown after zip or csv upload) ===== -->
  <div id="items-wrap" class="hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-4">
    <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Upload files per item</h3>
    <div id="items-container" class="space-y-3"></div>
    <div class="mt-5 flex flex-wrap items-center gap-3">
      <button id="submit-btn" onclick="submitAll()"
              class="px-6 py-3 bg-togen-teal text-white font-semibold rounded-xl text-sm
                     disabled:opacity-40 disabled:cursor-not-allowed hover:enabled:bg-togen-teal-dark transition-colors">
        Submit All
      </button>
      <a id="dl-csv" href="#" class="hidden px-5 py-3 bg-purple-600 text-white font-semibold
                                      rounded-xl text-sm hover:bg-purple-700 transition-colors">
        Download Updated CSV
      </a>
      <button id="dl-zip-btn" onclick="downloadJobZip()"
              class="hidden px-5 py-3 bg-green-600 text-white font-semibold rounded-xl text-sm hover:bg-green-700 transition-colors">
        Download ZIP
      </button>
      <button id="publish-btn" onclick="publishJob()"
              class="hidden px-5 py-3 bg-orange-500 text-white font-semibold rounded-xl text-sm hover:bg-orange-600 transition-colors">
        Publish
      </button>
    </div>
    <p id="publish-msg" class="hidden mt-2 text-sm font-medium"></p>
  </div>

  <!-- ===== SINGLE-ITEM (collapsed by default) ===== -->
  <section class="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
    <div class="flex items-center justify-between px-6 py-4 cursor-pointer select-none"
         onclick="toggleSection('single')">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Single-Item Upload</h2>
      <svg id="chevron-single" class="h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform duration-200"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
      </svg>
    </div>
    <div id="body-single" class="hidden px-6 pb-6">
    <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Single-Item Upload</h2>
    <p class="text-sm text-gray-500 dark:text-gray-400 mb-5">Generate one QR carrier sheet from three PDFs.</p>

    <!-- Preview -->
    <div id="preview-placeholder"
         class="flex flex-col items-center justify-center h-40 bg-gray-50 dark:bg-gray-800 rounded-xl
                border-2 border-dashed border-gray-200 dark:border-gray-700 mb-5">
      <p class="text-gray-400 dark:text-gray-500 text-sm">Preview appears here after generation</p>
    </div>
    <div id="preview-container" class="hidden mb-5">
      <embed id="preview-embed" type="application/pdf"
             class="w-full rounded-xl border border-gray-200 dark:border-gray-700" style="height:480px;" />
      <div class="mt-4 flex flex-wrap gap-3">
        <a id="download-btn" href="#"
           class="inline-flex items-center gap-2 px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark
                  text-white font-medium rounded-xl text-sm transition-colors">
          Download PDF
        </a>
        <button onclick="resetSingleForm()"
                class="px-5 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700
                       text-gray-700 dark:text-gray-300 font-medium rounded-xl text-sm transition-colors">
          Upload Another
        </button>
      </div>
    </div>

    <form id="single-form" class="space-y-4">
      <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <div>
          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
            Shop Drawing <span class="text-red-500 dark:text-red-400">*</span>
          </label>
          <input type="file" name="shop" accept=".pdf" required
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-xl py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        file:mr-3 file:py-1 file:px-3 file:rounded-lg file:border-0
                        file:text-sm file:font-medium file:bg-togen-teal/10 file:text-togen-teal
                        dark:file:bg-togen-teal/20 dark:file:text-togen-teal
                        hover:file:bg-togen-teal/20 dark:hover:file:bg-togen-teal/30
                        cursor-pointer focus:outline-none focus:ring-2 focus:ring-togen-teal">
        </div>
        <div>
          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
            Submittal Drawing <span class="text-red-500 dark:text-red-400">*</span>
          </label>
          <input type="file" name="submittal" accept=".pdf" required
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-xl py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        file:mr-3 file:py-1 file:px-3 file:rounded-lg file:border-0
                        file:text-sm file:font-medium file:bg-togen-teal/10 file:text-togen-teal
                        dark:file:bg-togen-teal/20 dark:file:text-togen-teal
                        hover:file:bg-togen-teal/20 dark:hover:file:bg-togen-teal/30
                        cursor-pointer focus:outline-none focus:ring-2 focus:ring-togen-teal">
        </div>
        <div>
          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
            QR Carrier Sheet <span class="text-red-500 dark:text-red-400">*</span>
          </label>
          <input type="file" name="carrier" accept=".pdf" required
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-xl py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        file:mr-3 file:py-1 file:px-3 file:rounded-lg file:border-0
                        file:text-sm file:font-medium file:bg-togen-teal/10 file:text-togen-teal
                        dark:file:bg-togen-teal/20 dark:file:text-togen-teal
                        hover:file:bg-togen-teal/20 dark:hover:file:bg-togen-teal/30
                        cursor-pointer focus:outline-none focus:ring-2 focus:ring-togen-teal">
        </div>
        <div>
          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
            Job Name <span class="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
          </label>
          <input type="text" name="job_name" placeholder="e.g. 26-1132 MBW"
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-xl py-2.5 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        focus:outline-none focus:ring-2 focus:ring-togen-teal
                        placeholder-gray-400 dark:placeholder-gray-500">
        </div>
      </div>
      <button type="submit" id="single-submit-btn"
              class="px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold
                     rounded-xl text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
        Generate Carrier PDF
      </button>
    </form>

    <div id="single-status" class="hidden mt-4 flex items-center gap-3 p-4 rounded-xl border">
      <svg id="s-spinner" class="hidden animate-spin h-5 w-5 text-togen-teal flex-shrink-0"
           xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
        <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
      </svg>
      <svg id="s-check" class="hidden h-5 w-5 text-green-500 flex-shrink-0"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
      </svg>
      <svg id="s-x" class="hidden h-5 w-5 text-red-500 flex-shrink-0"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
      </svg>
      <span id="s-msg" class="text-sm font-medium"></span>
    </div>
    </div><!-- /body-single -->
  </section>

</div><!-- /container -->

<!-- Tailwind safelist: ensures dynamic JS-set class names are compiled by CDN -->
<div class="hidden bg-togen-teal/10 dark:bg-togen-teal/20 border-togen-teal/30 dark:border-togen-teal/40 text-togen-dark dark:text-togen-teal outline-togen-teal hover:bg-togen-teal/5 dark:hover:bg-togen-teal/10"></div>

<script>
// ===== THEME =====
function toggleTheme() {
  var isDark = document.documentElement.classList.toggle('dark');
  localStorage.setItem('theme', isDark ? 'dark' : 'light');
  document.getElementById('icon-sun').classList.toggle('hidden', !isDark);
  document.getElementById('icon-moon').classList.toggle('hidden', isDark);
}

function initThemeIcons() {
  var isDark = document.documentElement.classList.contains('dark');
  document.getElementById('icon-sun').classList.toggle('hidden', !isDark);
  document.getElementById('icon-moon').classList.toggle('hidden', isDark);
}

// ===== PROFILE MENU =====
function toggleProfileMenu() {
  document.getElementById('profile-menu').classList.toggle('hidden');
}

function handleLogout() {
  // SSO logout wired up once SSO is implemented
  alert('Logout will be available once SSO is implemented.');
  document.getElementById('profile-menu').classList.add('hidden');
}

document.addEventListener('click', function(e) {
  var btn  = document.getElementById('profile-btn');
  var menu = document.getElementById('profile-menu');
  if (!btn.contains(e.target) && !menu.contains(e.target)) {
    menu.classList.add('hidden');
  }
});

initThemeIcons();

// ===== COLLAPSIBLE SECTIONS =====
function toggleSection(id) {
  var body    = document.getElementById('body-'+id);
  var chevron = document.getElementById('chevron-'+id);
  var collapsed = body.classList.toggle('hidden');
  chevron.style.transform = collapsed ? '' : 'rotate(180deg)';
}

// ===== ZIP UPLOAD =====
function onZipChosen(input) {
  var file = input.files[0]; if (file) uploadZipFile(file);
}

function uploadZipFile(file) {
  var lbl = document.getElementById('zip-lbl');
  lbl.textContent = 'Uploading and extracting…';
  lbl.className = 'mt-3 block text-sm text-gray-500 dark:text-gray-400';
  var fd = new FormData(); fd.append('zip', file);
  fetch('upload-zip', {method:'POST', body:fd})
    .then(function(r){ return r.json(); }).then(function(data) {
      if (data.error) {
        lbl.textContent = 'Error: '+data.error;
        lbl.className = 'mt-3 block text-sm text-red-600 dark:text-red-400';
        return;
      }
      csvFolder = data.folder; csvItems = data.items; csvItemRefs = data.item_refs || [];
      csvAutoFiles = data.auto_files || [];
      lbl.textContent = '✓ '+file.name+' — '+csvItems.length+' items extracted';
      lbl.className = 'mt-3 block text-sm text-green-700 dark:text-green-400 font-medium';
      renderItems();
      document.getElementById('items-wrap').classList.remove('hidden');
      document.getElementById('items-wrap').scrollIntoView({behavior:'smooth', block:'start'});
    }).catch(function() {
      lbl.textContent = 'Upload failed';
      lbl.className = 'mt-3 block text-sm text-red-600 dark:text-red-400';
    });
}

// ===== MULTI-ITEM STATE =====
var csvFolder    = null;
var csvItems     = [];
var csvItemRefs  = [];  // column H — Part Reference Number
var csvAutoFiles = [];  // auto-matched filenames from zip [{shop, submittal, carrier}, ...]
// uploaded[i] = {shop: bool, submittal: bool, carrier: bool}
var uploaded     = {};

function esc(s) {
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

function onCsvChosen(input) {
  var file = input.files[0]; if (file) uploadCsvFile(file);
}

function uploadCsvFile(file) {
  var lbl = document.getElementById('csv-lbl');
  lbl.textContent = 'Uploading…'; lbl.className = 'ml-3 text-sm text-gray-500 dark:text-gray-400';
  var fd = new FormData(); fd.append('csv', file);
  fetch('upload-csv', {method:'POST', body:fd})
    .then(function(r){ return r.json(); }).then(function(data) {
      if (data.error) { lbl.textContent = 'Error: '+data.error; lbl.className = 'ml-3 text-sm text-red-600 dark:text-red-400'; return; }
      csvFolder = data.folder; csvItems = data.items; csvItemRefs = data.item_refs || [];
      csvAutoFiles = [];  // no auto-matching for plain CSV upload
      lbl.textContent = '✓ '+file.name+' — '+csvItems.length+' items';
      lbl.className = 'ml-3 text-sm text-green-700 dark:text-green-400 font-medium';
      renderItems();
      document.getElementById('items-wrap').classList.remove('hidden');
    }).catch(function(){ lbl.textContent = 'Upload failed'; lbl.className = 'ml-3 text-sm text-red-600 dark:text-red-400'; });
}

function setupDragDrop(btn, onFile, accept) {
  btn.addEventListener('dragover', function(e) {
    e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
    btn.classList.add('outline', 'outline-2', 'outline-togen-teal', 'outline-offset-2');
  });
  btn.addEventListener('dragleave', function(e) {
    if (!btn.contains(e.relatedTarget))
      btn.classList.remove('outline', 'outline-2', 'outline-togen-teal', 'outline-offset-2');
  });
  btn.addEventListener('drop', function(e) {
    e.preventDefault();
    btn.classList.remove('outline', 'outline-2', 'outline-togen-teal', 'outline-offset-2');
    var file = e.dataTransfer.files[0];
    if (!file) return;
    if (accept && !file.name.toLowerCase().endsWith(accept)) return;
    onFile(file);
  });
}

function renderItems() {
  var c = document.getElementById('items-container');
  c.innerHTML = ''; uploaded = {}; previewUrls = {};
  // Show folder-level action buttons — disabled until Submit All completes
  var zipBtn = document.getElementById('dl-zip-btn');
  zipBtn.classList.remove('hidden');
  zipBtn.disabled = true;
  zipBtn.className = 'px-5 py-3 bg-gray-300 dark:bg-gray-700 text-gray-400 dark:text-gray-500 font-semibold rounded-xl text-sm cursor-not-allowed';
  var pubBtn = document.getElementById('publish-btn');
  pubBtn.classList.remove('hidden');
  pubBtn.disabled = true;
  pubBtn.textContent = 'Publish';
  pubBtn.className = 'px-5 py-3 bg-gray-300 dark:bg-gray-700 text-gray-400 dark:text-gray-500 font-semibold rounded-xl text-sm cursor-not-allowed';
  document.getElementById('publish-msg').classList.add('hidden');
  csvItems.forEach(function(name, i) {
    uploaded[i] = {shop: false, submittal: false, carrier: false};
    // Preview panel — sits above the item card, hidden until toggled
    var pw = document.createElement('div');
    pw.id = 'preview-wrap-'+i;
    pw.className = 'hidden mb-2 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden bg-white dark:bg-gray-900';
    pw.innerHTML = '<embed id="preview-embed-'+i+'" type="application/pdf" class="w-full" style="height:500px;">';
    c.appendChild(pw);
    var d = document.createElement('div');
    d.className = 'border border-gray-200 dark:border-gray-700 rounded-xl p-4 bg-gray-50 dark:bg-gray-800';
    d.id = 'card-'+i;
    var ref = csvItemRefs[i] || '';
    d.innerHTML =
      '<div class="flex items-center justify-between mb-2">' +
        '<p class="font-medium text-gray-800 dark:text-gray-200">' + esc(name) + (ref ? ' <span class="font-normal text-gray-500 dark:text-gray-400 text-xs ml-2">Ref: '+esc(ref)+'</span>' : '') + '</p>' +
        '<div id="result-'+i+'" class="flex gap-2"></div>' +
      '</div>' +
      '<div class="flex flex-wrap gap-3 items-center">' +
        '<input type="file" id="fi-shop-'+i+'" accept=".pdf" class="hidden" onchange="doUpload('+i+',\'shop\',this)">' +
        '<button id="btn-shop-'+i+'" onclick="document.getElementById(\'fi-shop-'+i+'\').click()"' +
                ' class="px-3 py-1.5 text-xs font-medium border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">' +
          'Shop Drawing' +
        '</button>' +
        '<span id="lbl-shop-'+i+'" class="text-xs text-gray-500 dark:text-gray-400"></span>' +

        '<input type="file" id="fi-sub-'+i+'" accept=".pdf" class="hidden" onchange="doUpload('+i+',\'submittal\',this)">' +
        '<button id="btn-sub-'+i+'" onclick="document.getElementById(\'fi-sub-'+i+'\').click()"' +
                ' class="px-3 py-1.5 text-xs font-medium border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">' +
          'Submittal' +
        '</button>' +
        '<span id="lbl-sub-'+i+'" class="text-xs text-gray-500 dark:text-gray-400"></span>' +

        '<input type="file" id="fi-car-'+i+'" accept=".pdf" class="hidden" onchange="doUpload('+i+',\'carrier\',this)">' +
        '<button id="btn-car-'+i+'" onclick="document.getElementById(\'fi-car-'+i+'\').click()"' +
                ' class="px-3 py-1.5 text-xs font-medium border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">' +
          'QR Carrier Sheet' +
        '</button>' +
        '<span id="lbl-car-'+i+'" class="text-xs text-gray-500 dark:text-gray-400"></span>' +
      '</div>';
    c.appendChild(d);
    // Wire drag-and-drop on item upload buttons
    setupDragDrop(document.getElementById('btn-shop-'+i), function(f){ doUploadFile(i,'shop',f); }, '.pdf');
    setupDragDrop(document.getElementById('btn-sub-'+i),  function(f){ doUploadFile(i,'submittal',f); }, '.pdf');
    setupDragDrop(document.getElementById('btn-car-'+i),  function(f){ doUploadFile(i,'carrier',f); }, '.pdf');
    // Apply auto-matched files (zip workflow only — csvAutoFiles is empty for plain CSV upload)
    var auto = csvAutoFiles[i] || {};
    ['shop', 'submittal', 'carrier'].forEach(function(ftype) {
      if (!auto[ftype]) return;
      var k   = _ftKey[ftype];
      var lbl = document.getElementById('lbl-'+k+'-'+i);
      var btn = document.getElementById('btn-'+k+'-'+i);
      // Show just the filename even if the path includes a subdirectory
      lbl.textContent = '✓ '+auto[ftype].split('/').pop();
      lbl.className = 'text-xs text-green-700 dark:text-green-400 font-medium';
      btn.className = _btnOk;
      uploaded[i][ftype] = true;
    });
    // If shop auto-matched but carrier not → carrier button goes red (required)
    if (uploaded[i].shop && !uploaded[i].carrier) {
      document.getElementById('btn-car-'+i).className = _btnErr;
    }
  });
  checkSubmitReady();
}

var _ftKey = {shop:'shop', submittal:'sub', carrier:'car'};
var _btnBase  = 'px-3 py-1.5 text-xs font-medium border-2 rounded-lg transition-colors ';
var _btnNorm  = _btnBase + 'border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700';
var _btnOk    = _btnBase + 'border-green-500 text-green-700 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30';
var _btnErr   = _btnBase + 'border-red-400 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30';

function doUpload(idx, ftype, input) {
  var file = input.files[0]; if (file) doUploadFile(idx, ftype, file);
}

function doUploadFile(idx, ftype, file) {
  var k   = _ftKey[ftype];
  var lbl = document.getElementById('lbl-'+k+'-'+idx);
  var btn = document.getElementById('btn-'+k+'-'+idx);
  lbl.textContent = 'Uploading…'; lbl.className = 'text-xs text-gray-500 dark:text-gray-400';
  var fd = new FormData();
  fd.append('file', file); fd.append('folder', csvFolder);
  fd.append('item_index', idx); fd.append('file_type', ftype);
  fetch('upload-file', {method:'POST', body:fd})
    .then(function(r){ return r.json(); }).then(function(data) {
      if (data.error) { lbl.textContent = 'Error: '+data.error; lbl.className = 'text-xs text-red-600 dark:text-red-400'; return; }
      lbl.textContent = '✓ '+file.name; lbl.className = 'text-xs text-green-700 dark:text-green-400 font-medium';
      btn.className = _btnOk;
      uploaded[idx][ftype] = true;
      // When a shop drawing is uploaded, the carrier sheet becomes required for this item
      if (ftype === 'shop' && !uploaded[idx].carrier) {
        document.getElementById('btn-car-'+idx).className = _btnErr;
      }
      checkSubmitReady();
    }).catch(function(){ lbl.textContent = 'Failed'; lbl.className = 'text-xs text-red-600 dark:text-red-400'; });
}

function checkSubmitReady() {
  // Invalid only if a shop drawing was uploaded without a carrier sheet
  var valid = csvItems.every(function(_, i){ return !uploaded[i].shop || uploaded[i].carrier; });
  document.getElementById('submit-btn').disabled = !valid;
}

function submitAll() {
  var btn = document.getElementById('submit-btn');
  btn.disabled = true; btn.textContent = 'Processing…';
  csvItems.forEach(function(_, i) {
    document.getElementById('result-'+i).innerHTML =
      '<span class="text-xs text-orange-600 dark:text-orange-400 italic">Processing…</span>';
  });
  fetch('submit', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({folder: csvFolder, item_count: csvItems.length})
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) { btn.textContent = 'Error: '+data.error; btn.disabled = false; return; }
    var anyProcessed = false;
    data.results.forEach(function(r, i) {
      var el = document.getElementById('result-'+i);
      if (r.skipped) {
        el.innerHTML = '';  // line had no files — nothing to show
      } else if (r.error) {
        el.innerHTML = '<span class="text-xs text-red-600 dark:text-red-400">Error: '+esc(r.error)+'</span>';
      } else {
        previewUrls[i] = r.preview_url;
        el.innerHTML =
          '<button onclick="toggleItemPreview('+i+')" class="px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white text-xs font-medium rounded-lg transition-colors">Preview QR Carrier Sheet</button>' +
          '<a href="'+r.download_url+'" download class="px-3 py-1.5 bg-togen-teal hover:bg-togen-teal-dark text-white text-xs font-medium rounded-lg transition-colors">Download QR Carrier Sheet</a>';
        anyProcessed = true;
      }
    });
    btn.textContent = 'Done';
    // Enable Download ZIP and Publish now that submit has run
    var zipBtn = document.getElementById('dl-zip-btn');
    zipBtn.disabled = false;
    zipBtn.className = 'px-5 py-3 bg-green-600 text-white font-semibold rounded-xl text-sm hover:bg-green-700 transition-colors';
    var pubBtn2 = document.getElementById('publish-btn');
    pubBtn2.disabled = false;
    pubBtn2.className = 'px-5 py-3 bg-orange-500 text-white font-semibold rounded-xl text-sm hover:bg-orange-600 transition-colors';
    if (anyProcessed) {
      var a = document.getElementById('dl-csv');
      a.href = 'download-csv?folder='+encodeURIComponent(csvFolder);
      a.classList.remove('hidden');
    }
  }).catch(function(){ btn.textContent = 'Submit failed'; btn.disabled = false; });
}

// ===== SHARED FOLDER SELECTION =====
var _allSharedFolders = [];
var _selectedFolderBtn = null;
var _folderItemNorm = 'w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-togen-teal/10 dark:hover:bg-togen-teal/10 transition-colors';
var _folderItemSel  = 'w-full text-left px-4 py-2.5 text-sm font-medium text-togen-teal dark:text-togen-teal bg-togen-teal/10 dark:bg-togen-teal/10 transition-colors';

function loadSharedFolders() {
  var container = document.getElementById('shared-folders-list');
  fetch('list-shared-folders')
    .then(function(r){ return r.json(); })
    .then(function(data) {
      _allSharedFolders = data.folders || [];
      renderSharedFolders(_allSharedFolders);
    })
    .catch(function() {
      container.innerHTML = '<p class="text-sm text-red-500 dark:text-red-400 px-4 py-3">Failed to load folders.</p>';
    });
}

function renderSharedFolders(folders) {
  var container = document.getElementById('shared-folders-list');
  if (folders.length === 0) {
    container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 px-4 py-3">No folders found.</p>';
    return;
  }
  container.innerHTML = '';
  folders.forEach(function(name) {
    var btn = document.createElement('button');
    btn.className = _folderItemNorm;
    btn.textContent = name;
    btn.onclick = function() { selectSharedFolder(name, btn); };
    container.appendChild(btn);
  });
}

function filterSharedFolders(q) {
  var lower = q.trim().toLowerCase();
  var filtered = lower ? _allSharedFolders.filter(function(n){ return n.toLowerCase().includes(lower); }) : _allSharedFolders;
  renderSharedFolders(filtered);
}

function selectSharedFolder(name, btn) {
  if (_selectedFolderBtn) _selectedFolderBtn.className = _folderItemNorm;
  _selectedFolderBtn = btn;
  btn.className = _folderItemSel;

  var lbl = document.getElementById('folder-select-lbl');
  lbl.textContent = 'Loading…'; lbl.className = 'mt-3 block text-sm text-gray-500 dark:text-gray-400';

  fetch('select-shared-folder', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({folder: name})
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) {
      lbl.textContent = 'Error: '+data.error;
      lbl.className = 'mt-3 block text-sm text-red-600 dark:text-red-400';
      return;
    }
    csvFolder = data.folder; csvItems = data.items; csvItemRefs = data.item_refs || [];
    csvAutoFiles = data.auto_files || [];
    lbl.textContent = '✓ '+name+' — '+csvItems.length+' items';
    lbl.className = 'mt-3 block text-sm text-green-700 dark:text-green-400 font-medium';
    renderItems();
    document.getElementById('items-wrap').classList.remove('hidden');
    document.getElementById('items-wrap').scrollIntoView({behavior:'smooth', block:'start'});
  }).catch(function() {
    lbl.textContent = 'Failed to load folder.';
    lbl.className = 'mt-3 block text-sm text-red-600 dark:text-red-400';
  });
}

// ===== ITEM PREVIEW =====
var previewUrls = {};

function toggleItemPreview(idx) {
  var wrap  = document.getElementById('preview-wrap-'+idx);
  var embed = document.getElementById('preview-embed-'+idx);
  if (wrap.classList.contains('hidden')) {
    embed.src = previewUrls[idx];
    wrap.classList.remove('hidden');
    wrap.scrollIntoView({behavior:'smooth', block:'nearest'});
  } else {
    wrap.classList.add('hidden');
    embed.removeAttribute('src');
  }
}

// ===== DOWNLOAD ZIP =====
function downloadJobZip() {
  if (!csvFolder) return;
  window.location = 'download-job-zip?folder='+encodeURIComponent(csvFolder);
}

// ===== PUBLISH =====
function publishJob(overwrite) {
  if (!csvFolder) return;
  var btn = document.getElementById('publish-btn');
  var msg = document.getElementById('publish-msg');
  btn.disabled = true; btn.textContent = 'Publishing…';
  msg.classList.add('hidden');
  fetch('publish', {
    method:'POST',
    headers:{'Content-Type':'application/json'},
    body: JSON.stringify({folder: csvFolder, overwrite: !!overwrite})
  }).then(function(r){ return r.json().then(function(data){ return {status: r.status, data: data}; }); }).then(function(res) {
    var data = res.data;
    if (res.status === 409 && data.exists) {
      btn.disabled = false; btn.textContent = 'Publish';
      if (confirm('A folder named "' + csvFolder + '" already exists in the shared directory.\n\nDo you want to overwrite it?')) {
        publishJob(true);
      }
    } else if (data.error) {
      msg.textContent = 'Publish failed: '+data.error;
      msg.className = 'mt-2 text-sm font-medium text-red-600 dark:text-red-400';
      msg.classList.remove('hidden');
      btn.disabled = false; btn.textContent = 'Publish';
    } else {
      btn.textContent = '✓ Published';
      btn.className = 'px-5 py-3 bg-gray-400 text-white font-semibold rounded-xl text-sm cursor-default';
      msg.textContent = overwrite ? 'Folder overwritten and published.' : 'Folder published to shared directory.';
      msg.className = 'mt-2 text-sm font-medium text-green-700 dark:text-green-400';
      msg.classList.remove('hidden');
    }
  }).catch(function() {
    msg.textContent = 'Publish request failed.';
    msg.className = 'mt-2 text-sm font-medium text-red-600 dark:text-red-400';
    msg.classList.remove('hidden');
    btn.disabled = false; btn.textContent = 'Publish';
  });
}

// ===== SINGLE-ITEM LEGACY =====
var singlePollTimer = null;
var currentJob = null;

function setSingleStatus(msg, type) {
  var bar = document.getElementById('single-status');
  bar.className = 'mt-4 flex items-center gap-3 p-4 rounded-xl border ' + (
    type === 'success' ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' :
    type === 'error'   ? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800' :
                         'bg-togen-teal/10 dark:bg-togen-teal/20 border-togen-teal/30 dark:border-togen-teal/40'
  );
  bar.classList.remove('hidden');
  document.getElementById('s-spinner').classList.toggle('hidden', type !== 'info');
  document.getElementById('s-check').classList.toggle('hidden', type !== 'success');
  document.getElementById('s-x').classList.toggle('hidden', type !== 'error');
  var msgEl = document.getElementById('s-msg');
  msgEl.className = 'text-sm font-medium ' + (
    type === 'success' ? 'text-green-700 dark:text-green-400' :
    type === 'error'   ? 'text-red-700 dark:text-red-400' :
                         'text-togen-dark dark:text-togen-teal'
  );
  msgEl.textContent = msg;
}

function resetSingleForm() {
  if (singlePollTimer) { clearInterval(singlePollTimer); singlePollTimer = null; }
  currentJob = null;
  document.getElementById('single-form').reset();
  document.getElementById('single-submit-btn').disabled = false;
  document.getElementById('preview-placeholder').classList.remove('hidden');
  document.getElementById('preview-container').classList.add('hidden');
  document.getElementById('preview-embed').removeAttribute('src');
  document.getElementById('single-status').classList.add('hidden');
}

function showSinglePreview(jobId) {
  document.getElementById('preview-placeholder').classList.add('hidden');
  document.getElementById('preview-embed').src = 'preview/'+encodeURIComponent(jobId);
  document.getElementById('download-btn').href = 'download/'+encodeURIComponent(jobId);
  document.getElementById('preview-container').classList.remove('hidden');
}

async function pollSingleStatus() {
  if (!currentJob) return;
  try {
    var r = await fetch('status/'+encodeURIComponent(currentJob));
    if (!r.ok) return;
    var d = await r.json();
    if (d.status === 'done') {
      clearInterval(singlePollTimer); singlePollTimer = null;
      setSingleStatus('Done! Your carrier PDF is ready.', 'success');
      showSinglePreview(currentJob);
      document.getElementById('single-submit-btn').disabled = false;
    }
  } catch (_) {}
}

document.getElementById('single-form').addEventListener('submit', async function(e) {
  e.preventDefault();
  if (singlePollTimer) { clearInterval(singlePollTimer); singlePollTimer = null; }
  document.getElementById('single-submit-btn').disabled = true;
  setSingleStatus('Uploading…', 'info');
  var fd = new FormData(document.getElementById('single-form'));
  try {
    var r = await fetch('upload', {method:'POST', body:fd});
    var d = await r.json();
    if (!r.ok) { setSingleStatus(d.error || 'Upload failed.', 'error'); document.getElementById('single-submit-btn').disabled = false; return; }
    currentJob = d.job_id;
    setSingleStatus('Processing — checking every 2 seconds…', 'info');
    await pollSingleStatus();
    if (singlePollTimer === null && currentJob) singlePollTimer = setInterval(pollSingleStatus, 2000);
  } catch (err) {
    setSingleStatus('Network error: '+err.message, 'error');
    document.getElementById('single-submit-btn').disabled = false;
  }
});

// Wire drag-and-drop on static upload elements
setupDragDrop(document.getElementById('zip-drop'), uploadZipFile, '.zip');
setupDragDrop(document.getElementById('csv-btn'),  uploadCsvFile, '.csv');

// Load shared folder list on page init
loadSharedFolders();
</script>
</body>
</html>"""



# ---------------------------------------------------------------------------
# Authentication helpers — Azure AD SSO (DVI-194)
# ---------------------------------------------------------------------------

def login_required(f):
    """Redirect unauthenticated requests to /login."""
    @wraps(f)
    def _wrap(*args, **kwargs):
        if not session.get("user"):
            session["next"] = request.url
            return redirect(url_for("login"))
        return f(*args, **kwargs)
    return _wrap


@app.route("/login")
def login():
    """Initiate Microsoft OAuth 2.0 Authorization Code flow."""
    cl = msal.ConfidentialClientApplication(
        _AZ_CLIENT_ID, authority=_AZ_AUTHORITY, client_credential=_AZ_SECRET)
    auth_url = cl.get_authorization_request_url(
        _AZ_SCOPES,
        redirect_uri=_AZ_REDIRECT,
        state=session.get("next", "/"),
    )
    return redirect(auth_url)


@app.route("/auth/callback")
def auth_callback():
    """Handle the redirect from Microsoft after login."""
    code = request.args.get("code")
    if not code:
        return "Authentication failed: no code returned.", 401

    cl = msal.ConfidentialClientApplication(
        _AZ_CLIENT_ID, authority=_AZ_AUTHORITY, client_credential=_AZ_SECRET)
    result = cl.acquire_token_by_authorization_code(
        code, scopes=_AZ_SCOPES, redirect_uri=_AZ_REDIRECT)

    if "error" in result:
        return f"Login error: {result.get('error_description', result['error'])}", 401

    # Verify the user is a member of the Togen Azure AD group.
    # GET /me/memberOf/{groupId} returns 200 if member, 404 if not.
    token = result["access_token"]
    graph_resp = _http_requests.get(
        f"https://graph.microsoft.com/v1.0/me/memberOf/{_AZ_GROUP}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=10,
    )
    if graph_resp.status_code != 200:
        return (
            "Access denied: your account is not a member of the Togen group. "
            "Contact your IT administrator to request access.",
            403,
        )

    claims = result["id_token_claims"]
    session["user"] = {
        "name":  claims.get("name"),
        "email": claims.get("preferred_username"),
    }
    next_url = request.args.get("state", "/")
    return redirect(next_url)


@app.route("/logout")
def logout():
    """Clear local session and sign out from Microsoft."""
    session.clear()
    post_logout = "https://togen.icastinc.com"
    return redirect(
        f"{_AZ_AUTHORITY}/oauth2/v2.0/logout"
        f"?post_logout_redirect_uri={post_logout}"
    )

# ---------------------------------------------------------------------------
# Routes — multi-item CSV workflow
# ---------------------------------------------------------------------------

@app.route("/")
@login_required
def index():
    return render_template_string(_HTML)


@app.route("/upload-csv", methods=["POST"])
@login_required
def upload_csv():
    if "csv" not in request.files:
        return jsonify({"error": "No CSV file provided"}), 400
    f = request.files["csv"]
    if not f.filename or not f.filename.lower().endswith(".csv"):
        return jsonify({"error": "File must be a .csv"}), 400

    safe_name = secure_filename(f.filename)
    stem = Path(safe_name).stem
    folder = WATCH_DIR / stem
    folder.mkdir(parents=True, exist_ok=True)

    csv_path = folder / safe_name
    f.save(str(csv_path))

    # Skip header row; use column C (index 2) as item name, column H (index 7) as part reference
    items: list[str] = []
    item_refs: list[str] = []
    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        next(reader, None)  # header
        for row in reader:
            if len(row) >= 3 and row[2].strip():
                items.append(row[2].strip())
                item_refs.append(row[7].strip() if len(row) >= 8 else "")

    if not items:
        return jsonify({"error": "No items found — expected data in column C from row 2 onward"}), 400

    state: dict = {
        "csv_filename": safe_name,
        "stem": stem,
        "items": items,
        "item_refs": item_refs,
        "files": {str(i): {} for i in range(len(items))},
        "results": {},
    }
    _save_state(folder, state)
    return jsonify({"folder": stem, "items": items, "item_refs": item_refs})


@app.route("/upload-zip", methods=["POST"])
@login_required
def upload_zip():
    if "zip" not in request.files:
        return jsonify({"error": "No zip file provided"}), 400
    f = request.files["zip"]
    if not f.filename or not f.filename.lower().endswith(".zip"):
        return jsonify({"error": "File must be a .zip"}), 400

    safe_name = secure_filename(f.filename)
    stem = Path(safe_name).stem
    folder = WATCH_DIR / stem
    folder.mkdir(parents=True, exist_ok=True)

    # Extract zip, rejecting any entries that would escape the target folder
    with zipfile.ZipFile(f.stream) as zf:
        for member in zf.namelist():
            dest = (folder / member).resolve()
            if not str(dest).startswith(str(folder.resolve())):
                return jsonify({"error": f"Unsafe path in zip entry: {member}"}), 400
        zf.extractall(str(folder))

    # Find the CSV file (search root first, then subdirectories)
    csv_files = sorted(folder.glob("*.csv")) or sorted(folder.glob("**/*.csv"))
    if not csv_files:
        return jsonify({"error": "No CSV file found in zip"}), 400
    csv_path = csv_files[0]

    # Parse same as upload_csv: skip header, col A = identifier, col C = item name, col H = ref
    items: list[str] = []
    item_refs: list[str] = []
    item_cols_a: list[str] = []
    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        next(reader, None)
        for row in reader:
            if len(row) >= 3 and row[2].strip():
                items.append(row[2].strip())
                item_refs.append(row[7].strip() if len(row) >= 8 else "")
                item_cols_a.append(row[0].strip() if row else "")

    if not items:
        return jsonify({"error": "CSV in zip has no items (expected data in column C from row 2 onward)"}), 400

    # Auto-match extracted PDF files to CSV rows
    auto_files = _match_files_to_items(folder, items, item_refs, item_cols_a)

    # Pre-populate state["files"] with matched filenames
    files_state: dict = {}
    for i, matched in enumerate(auto_files):
        files_state[str(i)] = dict(matched)  # copy; user uploads will override

    state: dict = {
        "csv_filename": str(csv_path.relative_to(folder)),
        "stem": stem,
        "items": items,
        "item_refs": item_refs,
        "item_cols_a": item_cols_a,
        "files": files_state,
        "results": {},
    }
    _save_state(folder, state)
    return jsonify({"folder": stem, "items": items, "item_refs": item_refs, "auto_files": auto_files})


@app.route("/upload-file", methods=["POST"])
@login_required
def upload_file():
    if "file" not in request.files:
        return jsonify({"error": "No file provided"}), 400
    f = request.files["file"]
    if not f.filename or not f.filename.lower().endswith(".pdf"):
        return jsonify({"error": "Only PDF files are accepted"}), 400

    folder_name = request.form.get("folder", "")
    item_index  = request.form.get("item_index", "")
    file_type   = request.form.get("file_type", "")

    if file_type not in ("shop", "submittal", "carrier"):
        return jsonify({"error": "Invalid file_type"}), 400
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder"}), 400

    folder = WATCH_DIR / folder_name
    if not folder.exists():
        return jsonify({"error": "Session not found — please re-upload CSV"}), 400

    save_name = secure_filename(f.filename)
    f.save(str(folder / save_name))

    state = _load_state(folder)
    state.setdefault("files", {}).setdefault(item_index, {})[file_type] = save_name
    _save_state(folder, state)
    return jsonify({"ok": True})


@app.route("/submit", methods=["POST"])
@login_required
def submit():
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder", "")
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder"}), 400

    folder = WATCH_DIR / folder_name
    state  = _load_state(folder)
    items  = state.get("items", [])
    if not items:
        return jsonify({"error": "No items in session — please re-upload CSV"}), 400

    # Load CSV rows to append URL columns
    csv_path = folder / state["csv_filename"]
    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        rows = list(csv.reader(fh))

    # Map item index → CSV row index (skip header; only count populated col-C rows)
    item_row_map: dict[int, int] = {}
    item_count = 0
    for row_idx, row in enumerate(rows):
        if row_idx == 0:
            continue
        if len(row) >= 3 and row[2].strip():
            item_row_map[item_count] = row_idx
            item_count += 1

    results: list[dict] = []
    for i, item_name in enumerate(items):
        files = state.get("files", {}).get(str(i), {})
        shop_name      = files.get("shop")
        carrier_name   = files.get("carrier")
        submittal_name = files.get("submittal")

        # No shop drawing → skip this line (no merge, no CSV URLs written)
        if not shop_name:
            results.append({"skipped": True})
            continue

        # Shop provided without carrier → validation error
        if not carrier_name:
            results.append({"error": f'Shop Drawing uploaded for "{item_name}" but QR Carrier Sheet is required'})
            continue

        shop_path     = folder / shop_name
        carrier_path  = folder / carrier_name
        submittal_path = (folder / submittal_name) if submittal_name else None

        try:
            output_path = _merge_item(carrier_path, shop_path, folder, submittal_path)

            # shop_name / carrier_name / submittal_name are relative paths from folder
            # (either just "file.pdf" for manual uploads or "subdir/file.pdf" for zip auto-matches).
            # Use safe='/' so subdirectory separators survive URL encoding.
            folder_url    = f"{BASE_URL.rstrip('/')}/{quote(folder_name, safe='')}"
            shop_url      = f"{folder_url}/{quote(shop_name, safe='/')}"
            carrier_url   = f"{folder_url}/{quote(carrier_name, safe='/')}"
            submittal_url = f"{folder_url}/{quote(submittal_name, safe='/')}" if submittal_name else ""

            # Write URLs into columns J (9), K (10), L (11)
            if i in item_row_map:
                row = rows[item_row_map[i]]
                while len(row) < 12:
                    row.append("")
                row[9]  = shop_url
                row[10] = submittal_url
                row[11] = carrier_url

            state.setdefault("results", {})[str(i)] = {"output": carrier_name}
            results.append({
                "ok": True,
                "download_url": f"download-file?folder={quote(folder_name, safe='')}&file={quote(carrier_name, safe='/')}",
                "preview_url":  f"preview-file?folder={quote(folder_name, safe='')}&file={quote(carrier_name, safe='/')}",
            })
        except Exception as exc:
            results.append({"error": str(exc)})

    # Overwrite the original CSV with the updated rows (URLs now in columns J/K/L)
    with open(csv_path, "w", newline="", encoding="utf-8") as fh:
        csv.writer(fh).writerows(rows)

    _save_state(folder, state)
    return jsonify({"results": results})


@app.route("/download-file")
@login_required
def download_file():
    folder_name = request.args.get("folder", "")
    filename    = request.args.get("file", "")
    if not folder_name or not filename:
        return "Missing parameters", 400
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return "Invalid folder", 400
    # Use path resolution for safety instead of secure_filename, which mangles spaces.
    folder    = (WATCH_DIR / folder_name).resolve()
    file_path = (folder / filename).resolve()
    if not str(file_path).startswith(str(folder) + os.sep) and file_path != folder:
        return "Invalid path", 400
    if not file_path.exists():
        return "File not found", 404
    return send_file(str(file_path), as_attachment=True)


@app.route("/download-csv")
@login_required
def download_csv():
    folder_name = request.args.get("folder", "")
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return "Invalid folder", 400
    folder = WATCH_DIR / folder_name
    state  = _load_state(folder)
    csv_path = folder / state.get("csv_filename", "")
    if not csv_path.exists():
        return "CSV not found", 404
    return send_file(str(csv_path), as_attachment=True)


@app.route("/list-shared-folders")
@login_required
def list_shared_folders():
    """Return sorted list of subdirectory names in SHARED_DIR."""
    if not SHARED_DIR.is_dir():
        return jsonify({"folders": []})
    folders = sorted(
        (f.name for f in SHARED_DIR.iterdir() if f.is_dir() and not f.name.startswith(".")),
        reverse=True,
    )
    return jsonify({"folders": folders})


@app.route("/select-shared-folder", methods=["POST"])
@login_required
def select_shared_folder():
    """Load a shared folder: copy to WATCH_DIR if needed, parse CSV, auto-match files."""
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder", "")
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder name"}), 400

    src = (SHARED_DIR / folder_name).resolve()
    if not str(src).startswith(str(SHARED_DIR.resolve())) or not src.is_dir():
        return jsonify({"error": "Folder not found in shared directory"}), 404

    # Use or create a working copy in WATCH_DIR
    dst = WATCH_DIR / folder_name
    if not dst.exists():
        shutil.copytree(str(src), str(dst))

    # Find CSV (root first, then subdirectories)
    csv_files = sorted(dst.glob("*.csv")) or sorted(dst.glob("**/*.csv"))
    if not csv_files:
        return jsonify({"error": "No CSV file found in folder"}), 400
    csv_path = csv_files[0]

    # Parse same as upload-zip: col A=identifier, col C=item name, col H=ref
    items: list[str] = []
    item_refs: list[str] = []
    item_cols_a: list[str] = []
    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        next(reader, None)
        for row in reader:
            if len(row) >= 3 and row[2].strip():
                items.append(row[2].strip())
                item_refs.append(row[7].strip() if len(row) >= 8 else "")
                item_cols_a.append(row[0].strip() if row else "")

    if not items:
        return jsonify({"error": "CSV has no items (expected data in column C from row 2 onward)"}), 400

    auto_files = _match_files_to_items(dst, items, item_refs, item_cols_a)
    files_state = {str(i): dict(m) for i, m in enumerate(auto_files)}

    state: dict = {
        "csv_filename": str(csv_path.relative_to(dst)),
        "stem": folder_name,
        "items": items,
        "item_refs": item_refs,
        "item_cols_a": item_cols_a,
        "files": files_state,
        "results": {},
    }
    _save_state(dst, state)
    return jsonify({"folder": folder_name, "items": items, "item_refs": item_refs, "auto_files": auto_files})


@app.route("/preview-file")
@login_required
def preview_file():
    """Serve a job file inline (no download prompt) for PDF preview."""
    folder_name = request.args.get("folder", "")
    filename    = request.args.get("file", "")
    if not folder_name or not filename:
        return "Missing parameters", 400
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return "Invalid folder", 400
    folder    = (WATCH_DIR / folder_name).resolve()
    file_path = (folder / filename).resolve()
    if not str(file_path).startswith(str(folder) + os.sep) and file_path != folder:
        return "Invalid path", 400
    if not file_path.exists():
        return "File not found", 404
    return send_file(str(file_path), mimetype="application/pdf")  # inline, no as_attachment


@app.route("/download-job-zip")
@login_required
def download_job_zip():
    """Zip the entire job folder (excluding state.json) and stream it as a download."""
    folder_name = request.args.get("folder", "")
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return "Invalid folder", 400
    folder = (WATCH_DIR / folder_name).resolve()
    if not folder.is_dir():
        return "Folder not found", 404
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        for f in sorted(folder.rglob("*")):
            if f.is_file() and f.name != "state.json":
                zf.write(f, f.relative_to(folder))
    buf.seek(0)
    return send_file(
        buf,
        as_attachment=True,
        download_name=f"{folder_name}.zip",
        mimetype="application/zip",
    )


@app.route("/publish", methods=["POST"])
@login_required
def publish():
    """Copy job folder to SHARED_DIR. Errors if destination already exists."""
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder", "")
    overwrite = bool(data.get("overwrite", False))
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder"}), 400
    src = (WATCH_DIR / folder_name).resolve()
    if not src.is_dir():
        return jsonify({"error": "Source folder not found"}), 400
    SHARED_DIR.mkdir(parents=True, exist_ok=True)
    dst = (SHARED_DIR / folder_name).resolve()
    if not str(dst).startswith(str(SHARED_DIR.resolve())):
        return jsonify({"error": "Invalid destination path"}), 400
    if dst.exists():
        if not overwrite:
            return jsonify({"error": f'A folder named "{folder_name}" already exists in the shared directory', "exists": True}), 409
        shutil.rmtree(str(dst))
    shutil.copytree(str(src), str(dst))
    return jsonify({"ok": True})


# ---------------------------------------------------------------------------
# Routes — single-item legacy workflow (unchanged)
# ---------------------------------------------------------------------------

@app.route("/upload", methods=["POST"])
@login_required
def upload():
    shop      = request.files.get("shop")
    submittal = request.files.get("submittal")
    carrier   = request.files.get("carrier")
    csv_file  = request.files.get("csv")
    job_name  = request.form.get("job_name", "").strip()

    if not (shop and shop.filename and submittal and submittal.filename
            and carrier and carrier.filename):
        return jsonify(error="shop, submittal, and carrier PDFs are required"), 400

    for label, f in [("shop", shop), ("submittal", submittal), ("carrier", carrier)]:
        if not f.filename.lower().endswith(".pdf"):
            return jsonify(error=f"'{label}' must be a PDF file"), 400

    if csv_file and csv_file.filename and not csv_file.filename.lower().endswith(".csv"):
        return jsonify(error="csv must be a .csv file"), 400

    job_id = re.sub(r"[^\w\s\-\.]", "_", job_name)[:80].strip() if job_name else str(uuid.uuid4())[:8]
    job_folder = _safe_job_folder(job_id)

    if _find_generated(job_folder):
        return jsonify(job_id=job_id)

    job_folder.mkdir(parents=True, exist_ok=True)
    shop.save(job_folder / secure_filename(shop.filename))
    submittal.save(job_folder / secure_filename(submittal.filename))
    carrier.save(job_folder / secure_filename(carrier.filename))
    if csv_file and csv_file.filename:
        csv_file.save(job_folder / secure_filename(csv_file.filename))

    t = threading.Thread(target=_run_merge_single, args=(job_folder,), daemon=True)
    t.start()
    return jsonify(job_id=job_id)


@app.route("/status/<path:job_id>")
@login_required
def status(job_id):
    job_folder = _safe_job_folder(job_id)
    if not job_folder.is_dir():
        abort(404)
    generated = _find_generated(job_folder)
    if generated:
        return jsonify(status="done", filename=generated.name)
    return jsonify(status="pending")


@app.route("/preview/<path:job_id>")
@login_required
def preview(job_id):
    job_folder = _safe_job_folder(job_id)
    generated = _find_generated(job_folder)
    if not generated:
        abort(404)
    return send_file(generated, mimetype="application/pdf")


@app.route("/download/<path:job_id>")
@login_required
def download_pdf(job_id):
    job_folder = _safe_job_folder(job_id)
    generated = _find_generated(job_folder)
    if not generated:
        abort(404)
    return send_file(generated, as_attachment=True, download_name=generated.name)


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    port = int(os.environ.get("PORT", 5001))
    app.run(host="0.0.0.0", port=port, debug=False)
