import asyncio
import re
from playwright.async_api import Page
from app.automation.tasks.login import run as login_run
from app.automation.helpers import human_delay
from app.automation.tasks.popup import close_hudl_popups
from app.automation.selectors import (
    LIBRARY_NAV_LINK,
    LIBRARY_TILE,
    MANAGE_LIBRARY_CONTENT,
    MANAGE_LIBRARY_SECTION,
    PRIMARY_NAVBAR,
    STORAGE_BAR_TEAM_USAGE,
    STORAGE_LEARN_MORE_TEXT,
    STORAGE_PROGRESS_BAR_CONTAINER,
    STORAGE_USAGE_CONTAINER,
    STORAGE_USAGE_EXPAND_BUTTON,
    STORAGE_USAGE_MESSAGE,
)


def _is_library_page(url: str) -> bool:
    lower = url.lower()
    return "/library" in lower or ("/watch/team/" in lower and "/analyze" in lower)


def _team_id_from_any_url(url: str | None) -> str | None:
    if not url:
        return None
    match = re.search(
        r"/(?:watch/team|library|manage/library|manage/exchanges|teams)/(\d+)",
        url,
    )
    return match.group(1) if match else None


async def _resolve_library_team_id(page: Page, team_id: str | None = None) -> str | None:
    explicit = (team_id or "").strip()
    if explicit.isdigit():
        return explicit

    from_url = _team_id_from_any_url(page.url or "")
    if from_url:
        return from_url

    try:
        selected = page.locator(
            '[data-qa-id^="webnav-teamswitcher-team-"].hui-primaryteamswitcher__item--selected, '
            '[data-qa-id="webnav-teamswitcher-menu"] > a.hui-primaryteamswitcher__item--selected'
        ).first
        if await selected.count() > 0:
            qa = await selected.get_attribute("data-qa-id") or ""
            m = re.search(r"webnav-teamswitcher-team-(\d+)", qa)
            if m:
                return m.group(1)
    except Exception:
        pass

    try:
        from app.automation.session_store import session_store

        return _team_id_from_any_url(session_store.get_exchanges_url())
    except Exception:
        return None


async def _resolve_library_url(page: Page, team_id: str | None = None) -> str:
    """Prefer app analyze library (where tiles live), then classic /library/{id}."""
    tid = await _resolve_library_team_id(page, team_id)
    if tid:
        return f"https://app.hudl.com/watch/team/{tid}/analyze"

    if "hudl.com/home" not in (page.url or ""):
        await page.goto("https://www.hudl.com/home", wait_until="domcontentloaded")
        await human_delay(1.5, 2.5)
        await close_hudl_popups(page)

    await page.locator(PRIMARY_NAVBAR).first.wait_for(state="visible", timeout=15000)

    nav_link = page.locator(LIBRARY_NAV_LINK).first
    try:
        await nav_link.wait_for(state="attached", timeout=10000)
        href = await nav_link.get_attribute("href")
        if href:
            if href.startswith("http"):
                return href
            if href.startswith("/"):
                if "/library/" in href and "watch/team" not in href:
                    return f"https://www.hudl.com{href}"
                return f"https://app.hudl.com{href}"
            return f"https://app.hudl.com/{href}"
    except Exception:
        pass

    tid = await _resolve_library_team_id(page, None)
    if tid:
        return f"https://app.hudl.com/watch/team/{tid}/analyze"

    raise ValueError(
        "Could not resolve Hudl library URL dynamically. "
        "Select a team first (Playwright team switch)."
    )


async def _navigate_to_library(page: Page, team_id: str | None = None) -> str:
    """Open the Hudl library page for the active team. Returns the URL used."""
    tile_locator = page.locator(LIBRARY_TILE)
    try:
        if _is_library_page(page.url) and await tile_locator.count() > 0:
            return page.url
        # Already on classic library with content
        if "/library/" in (page.url or "") and "manage/library" not in (page.url or ""):
            return page.url
    except Exception:
        pass

    library_url = await _resolve_library_url(page, team_id=team_id)
    print(f"Navigating to library at {library_url}")
    await page.goto(library_url, wait_until="domcontentloaded")
    await human_delay(2.0, 3.5)
    await close_hudl_popups(page)
    return library_url


def _normalize_thumbnail_url(raw: str | None) -> str:
    """Keep real film thumbs; drop profile/avatar/logo placeholders."""
    url = str(raw or "").strip()
    if not url or url.startswith("data:image/svg"):
        return ""
    if url.startswith("//"):
        url = "https:" + url
    bad = re.compile(
        r"avatar|logo|icon|sprite|profile|headshot|user[-_]?photo|/users?/|"
        r"gravatar|placeholder|default.?image|person|coach.?photo|"
        r"team.?switcher|nav.?photo|account.?image",
        re.I,
    )
    if bad.search(url):
        return ""
    # Tiny/circular profile assets are usually small fixed sizes in the path
    if re.search(r"[_-](32|40|48|64|72|96)[x._-]", url, re.I):
        return ""
    return url


async def _scrape_library_videos_js(page: Page) -> list[dict]:
    """Scrape modern analyze library cards + classic /library/{id} rows."""
    rows = await page.evaluate(
        """() => {
          const abs = (href) => {
            if (!href) return '';
            if (href.startsWith('http')) return href;
            if (href.startsWith('//')) return 'https:' + href;
            if (href.startsWith('/')) {
              if (location.hostname.includes('app.hudl')) return 'https://app.hudl.com' + href;
              return location.origin + href;
            }
            return href;
          };

          const BAD_THUMB = /avatar|logo|icon|sprite|profile|headshot|user[-_]?photo|\\/users?\\/|gravatar|placeholder|default.?image|person|coach|team.?switcher|nav.?photo|account.?image/i;
          const TYPE_RE = /\\b(GAME|SCOUT|PRACTICE|SCRIMMAGE|HIGHLIGHT|OPPONENT|OTHER)\\b/i;
          const DATE_RE = /\\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\\s+\\d{1,2},?\\s+\\d{4}\\b/i;

          const isBadThumb = (src) => {
            if (!src || typeof src !== 'string') return true;
            if (src.startsWith('data:image/svg')) return true;
            if (BAD_THUMB.test(src)) return true;
            if (/[_-](32|40|48|64|72|96)[x._-]/.test(src)) return true;
            return false;
          };

          const fromSrcset = (el) => {
            const ss = el.getAttribute('srcset') || el.getAttribute('data-srcset') || '';
            if (!ss) return '';
            let best = '', bestW = 0;
            ss.split(',').forEach((part) => {
              const bits = part.trim().split(/\\s+/);
              const u = bits[0] || '';
              const w = parseInt((bits[1] || '').replace('w', ''), 10) || 0;
              if (u && !isBadThumb(u) && w >= bestW) { best = u; bestW = w; }
            });
            return best;
          };

          /** Prefer real film stills (bg-image / large img), never profile photos. */
          const pickThumb = (root) => {
            if (!root) return '';
            const scored = [];

            root.querySelectorAll('img').forEach((img) => {
              const src = abs(
                fromSrcset(img) ||
                img.currentSrc ||
                img.getAttribute('src') ||
                img.getAttribute('data-src') ||
                img.getAttribute('data-lazy-src') ||
                ''
              );
              if (isBadThumb(src)) return;
              const r = img.getBoundingClientRect();
              const area = Math.max(0, r.width) * Math.max(0, r.height);
              // Skip tiny circular avatars even if URL looks ok
              if (r.width > 0 && r.height > 0 && (r.width < 72 || r.height < 48)) return;
              if (Math.abs(r.width - r.height) < 8 && r.width < 120) return; // circle avatar
              scored.push({ src, area, w: r.width, h: r.height });
            });

            const nodes = [root, ...root.querySelectorAll('*')];
            for (const n of nodes) {
              let bg = '';
              try { bg = getComputedStyle(n).backgroundImage || ''; } catch (_) {}
              const m = bg && bg.match(/url\\(["']?(.*?)["']?\\)/i);
              if (!m || !m[1] || m[1] === 'none') continue;
              const src = abs(m[1]);
              if (isBadThumb(src)) continue;
              const r = n.getBoundingClientRect();
              if (r.width < 90 || r.height < 55) continue;
              scored.push({ src, area: r.width * r.height, w: r.width, h: r.height });
            }

            // Also check inline style background-image (before paint / computed empty)
            root.querySelectorAll('[style*="background"]').forEach((n) => {
              const st = n.getAttribute('style') || '';
              const m = st.match(/background(?:-image)?\\s*:\\s*url\\(["']?(.*?)["']?\\)/i);
              if (!m || !m[1]) return;
              const src = abs(m[1]);
              if (isBadThumb(src)) return;
              const r = n.getBoundingClientRect();
              scored.push({ src, area: Math.max(r.width * r.height, 1), w: r.width, h: r.height });
            });

            scored.sort((a, b) => b.area - a.area);
            // Prefer landscape film thumbs over square
            const landscape = scored.find((s) => s.w >= s.h * 1.15);
            return (landscape || scored[0] || {}).src || '';
          };

          const out = [];
          const seen = new Set();
          const push = (item) => {
            let url = abs(item.watch_url || '');
            const title = (item.title || 'Video').trim().replace(/\\s+/g, ' ').slice(0, 200);
            if (!title || title.length < 2) return;
            const thumb = abs(item.thumbnail_url || '');
            const key = `${title.toLowerCase()}|${item.date || ''}`;
            if (seen.has(key)) return;
            seen.add(key);
            if (!url) {
              const m = location.pathname.match(/\\/watch\\/team\\/(\\d+)/);
              url = m
                ? `https://app.hudl.com/watch/team/${m[1]}/analyze`
                : location.href;
            }
            out.push({
              title,
              watch_url: url,
              thumbnail_url: isBadThumb(thumb) ? '' : thumb,
              type: (item.type || 'GAME').toUpperCase(),
              duration: item.duration || '00:00',
              date: item.date || '',
            });
          };

          const parseCardText = (text) => {
            const lines = text.split('\\n').map((s) => s.trim()).filter(Boolean);
            const typeLine = lines.find((l) => TYPE_RE.test(l)) || 'GAME';
            const dateLine = lines.find((l) => DATE_RE.test(l)) || '';
            const titleLine =
              lines.find(
                (l) =>
                  l !== typeLine &&
                  l !== dateLine &&
                  !/^\\d+\\s*Items?$/i.test(l) &&
                  !/newest uploads|oldest uploads|search|filter/i.test(l) &&
                  l.length > 2
              ) || 'Video';
            return { typeLine, dateLine, titleLine };
          };

          // --- 1) Legacy tile attribute ---
          document.querySelectorAll('[data-qa-id="tile-library-item"]').forEach((tile) => {
            const a = tile.querySelector('a[href]');
            push({
              title: tile.querySelector('a p span, a p, [class*="title"]')?.innerText || a?.innerText || 'Video',
              watch_url: a?.getAttribute('href') || '',
              thumbnail_url: pickThumb(tile),
              type: tile.querySelector('[class*="label"], .label')?.innerText || 'GAME',
              duration: (tile.querySelector('[class*="duration"]')?.innerText || '00:00').trim(),
              date: (tile.querySelector('div > p, [class*="date"]')?.innerText || '').trim(),
            });
          });
          if (out.length) return out;

          // --- 2) Any data-qa-id that looks like a library/media/cutup card ---
          const qaCards = document.querySelectorAll(
            '[data-qa-id*="tile" i], [data-qa-id*="library-item" i], [data-qa-id*="cutup" i], ' +
            '[data-qa-id*="media-card" i], [data-qa-id*="video-card" i], [data-qa-id*="grid-item" i]'
          );
          qaCards.forEach((tile) => {
            const text = (tile.innerText || '').trim();
            if (!text || text.length < 3) return;
            if (!TYPE_RE.test(text) && !pickThumb(tile)) return;
            const { typeLine, dateLine, titleLine } = parseCardText(text);
            const a = tile.closest('a[href]') || tile.querySelector('a[href]');
            push({
              title: titleLine,
              watch_url: a?.getAttribute('href') || '',
              thumbnail_url: pickThumb(tile),
              type: (typeLine.match(TYPE_RE) || ['GAME'])[0],
              date: dateLine,
            });
          });
          if (out.length) return out;

          // --- 3) Visual cards: film thumb (img or CSS bg) + Game/Scout label ---
          const main =
            document.querySelector('[class*="Library"], [class*="library"], main, [role="main"]') ||
            document.body;

          // Walk candidate card roots that contain a type label
          const candidates = new Set();
          main.querySelectorAll('article, li, [class*="Card"], [class*="card"], [class*="Tile"], [class*="tile"], [role="listitem"]').forEach((el) => {
            const t = (el.innerText || '').trim();
            if (TYPE_RE.test(t) && t.length > 8 && t.length < 600) candidates.add(el);
          });
          // Also from large landscape media nodes
          main.querySelectorAll('img, [style*="background"]').forEach((node) => {
            const r = node.getBoundingClientRect();
            if (r.width < 120 || r.height < 70) return;
            let card = node.parentElement;
            for (let i = 0; i < 8 && card; i++) {
              const t = (card.innerText || '').trim();
              if (TYPE_RE.test(t) && t.length < 600) {
                candidates.add(card);
                break;
              }
              card = card.parentElement;
            }
          });

          candidates.forEach((card) => {
            const text = (card.innerText || '').trim();
            if (!TYPE_RE.test(text)) return;
            const lines = text.split('\\n').map((s) => s.trim()).filter(Boolean);
            if (lines.length < 2) return;
            const { typeLine, dateLine, titleLine } = parseCardText(text);
            const a = card.closest('a[href]') || card.querySelector('a[href]');
            const thumb = pickThumb(card);
            // Skip rows that only have a profile photo (no film thumb)
            push({
              title: titleLine,
              watch_url: a?.getAttribute('href') || '',
              thumbnail_url: thumb,
              type: (typeLine.match(TYPE_RE) || ['GAME'])[0],
              date: dateLine,
            });
          });
          if (out.length) return out;

          // --- 4) Classic library links ---
          document.querySelectorAll(
            'a[href*="/videos/"], a[href*="/video/"], a[href*="/watch/"]'
          ).forEach((a) => {
            const href = a.getAttribute('href') || '';
            if (/webnav|teamswitcher|login|support|manage\\/library/i.test(href)) return;
            const title = (a.innerText || a.getAttribute('title') || '').trim().split('\\n')[0];
            if (!title || /^(library|home|upload)$/i.test(title)) return;
            push({ title, watch_url: href, thumbnail_url: pickThumb(a) });
          });

          return out;
        }"""
    )
    if not rows:
        # Help diagnose empty scrapes in server logs
        try:
            meta = await page.evaluate(
                """() => ({
                  url: location.href,
                  hasItems: /\\d+\\s*Items?/i.test(document.body?.innerText || ''),
                  imgCount: document.querySelectorAll('img').length,
                  qaSample: [...new Set([...document.querySelectorAll('[data-qa-id]')]
                    .map(e => e.getAttribute('data-qa-id')).filter(Boolean))].slice(0, 40),
                  snippet: (document.body?.innerText || '').slice(0, 300),
                })"""
            )
            print(f"Library scrape empty — meta={meta}")
        except Exception as exc:
            print(f"Library scrape empty — meta failed: {exc}")
    return rows or []


async def _wait_for_library_content(page: Page, timeout_ms: int = 20000) -> None:
    """Wait for analyze SPA tiles / 'N Items' header to paint."""
    try:
        await page.wait_for_function(
            """() => {
              const t = document.body?.innerText || '';
              if (/\\d+\\s*Items?/i.test(t)) return true;
              if (document.querySelectorAll('[data-qa-id="tile-library-item"]').length > 0) return true;
              if (document.querySelectorAll('img').length > 12) return true;
              return false;
            }""",
            timeout=timeout_ms,
        )
    except Exception:
        pass
    await human_delay(0.8, 1.4)
    # Lazy grid: scroll main panel a bit
    try:
        await page.mouse.wheel(0, 1200)
        await human_delay(0.5, 0.9)
        await page.mouse.wheel(0, -600)
        await human_delay(0.3, 0.6)
    except Exception:
        pass


async def get_library_videos(page: Page, team_id: str | None = None) -> dict:
    """
    Navigate to Hudl Library (analyze tiles first) and scrape video cards.
    Also captures JSON API payloads while the SPA loads (more reliable than DOM alone).
    """
    from app.automation.tasks.login import ensure_hudl_session

    await ensure_hudl_session(page)
    await close_hudl_popups(page)

    tid = await _resolve_library_team_id(page, team_id)
    candidate_urls: list[str] = []
    if tid:
        candidate_urls.extend(
            [
                f"https://app.hudl.com/watch/team/{tid}/analyze",
                f"https://www.hudl.com/library/{tid}",
            ]
        )
    if _is_library_page(page.url or ""):
        candidate_urls.insert(0, page.url)

    seen_urls: set[str] = set()
    urls: list[str] = []
    for u in candidate_urls:
        if u and u not in seen_urls:
            seen_urls.add(u)
            urls.append(u)

    if not urls:
        try:
            urls.append(await _resolve_library_url(page, team_id=tid))
        except Exception as exc:
            return {
                "status": "error",
                "videos": [],
                "count": 0,
                "message": str(exc),
            }

    def _videos_from_json_blob(data, acc: list[dict]) -> None:
        """Recursively pull video-like objects from Hudl API JSON."""
        if data is None:
            return
        if isinstance(data, list):
            for item in data:
                _videos_from_json_blob(item, acc)
            return
        if not isinstance(data, dict):
            return

        title = (
            data.get("title")
            or data.get("name")
            or data.get("displayName")
            or data.get("videoTitle")
        )
        watch = (
            data.get("watchUrl")
            or data.get("watch_url")
            or data.get("url")
            or data.get("permalink")
            or data.get("href")
        )
        content_type = (
            data.get("contentType")
            or data.get("type")
            or data.get("videoType")
            or data.get("label")
            or ""
        )
        thumb = (
            data.get("thumbnailUrl")
            or data.get("thumbnail_url")
            or data.get("thumbnail")
            or data.get("posterUrl")
            or data.get("poster")
            or data.get("imageUrl")
            or data.get("previewUrl")
            or ""
        )
        if isinstance(thumb, dict):
            thumb = (
                thumb.get("url")
                or thumb.get("large")
                or thumb.get("medium")
                or thumb.get("small")
                or thumb.get("src")
                or ""
            )
        if isinstance(thumb, list) and thumb:
            first = thumb[0]
            thumb = first.get("url") if isinstance(first, dict) else first
        thumb = _normalize_thumbnail_url(str(thumb or ""))
        date_val = (
            data.get("date")
            or data.get("uploadedAt")
            or data.get("createdAt")
            or data.get("captureDate")
            or ""
        )
        if isinstance(date_val, (int, float)):
            date_val = str(date_val)

        looks_video = bool(title) and (
            bool(watch)
            or bool(thumb)
            or bool(data.get("id"))
            or bool(data.get("cutupId"))
            or bool(data.get("videoId"))
        )
        type_s = str(content_type)
        if looks_video and (
            not type_s
            or re.search(
                r"game|scout|practice|scrimmage|highlight|video|cutup|film",
                type_s,
                re.I,
            )
            or data.get("cutupId")
            or data.get("videoId")
        ):
            acc.append(
                {
                    "title": str(title).strip()[:200],
                    "watch_url": str(watch or "").strip(),
                    "thumbnail_url": str(thumb or "").strip(),
                    "type": (
                        type_s.upper()
                        if type_s and len(type_s) < 24
                        else "GAME"
                    ),
                    "duration": str(data.get("duration") or "00:00"),
                    "date": str(date_val)[:40],
                }
            )

        for key, val in data.items():
            if key in (
                "items",
                "results",
                "videos",
                "cutups",
                "nodes",
                "edges",
                "data",
                "libraryItems",
                "media",
            ) or isinstance(val, (list, dict)):
                _videos_from_json_blob(val, acc)

    videos: list[dict] = []
    used_url = ""
    for url in urls:
        api_videos: list[dict] = []

        async def _on_response(response) -> None:
            try:
                if response.status != 200:
                    return
                rurl = (response.url or "").lower()
                ctype = (response.headers.get("content-type") or "").lower()
                if "json" not in ctype and "graphql" not in rurl:
                    return
                if not any(
                    k in rurl
                    for k in (
                        "cutup",
                        "library",
                        "media",
                        "video",
                        "footage",
                        "graphql",
                        "analyze",
                        "watch",
                        "content",
                    )
                ):
                    return
                data = await response.json()
                _videos_from_json_blob(data, api_videos)
            except Exception:
                return

        try:
            page.on("response", _on_response)
            print(f"Navigating to library at {url}")
            await page.goto(url, wait_until="domcontentloaded")
            await human_delay(1.5, 2.5)
            await close_hudl_popups(page)
            await _wait_for_library_content(page, timeout_ms=25000)
            # Extra settle for XHR after first paint
            await human_delay(1.5, 2.5)
            used_url = page.url

            scraped = await _scrape_library_videos_js(page)
            if scraped:
                videos = scraped
            elif api_videos:
                # Dedupe API captures by title
                seen_t: set[str] = set()
                for v in api_videos:
                    key = (v.get("title") or "").lower()
                    if not key or key in seen_t:
                        continue
                    seen_t.add(key)
                    videos.append(v)
                print(f"Library videos from API intercept: {len(videos)}")

            if videos:
                break

            if "analyze" in url:
                await page.reload(wait_until="domcontentloaded")
                await human_delay(2.0, 3.0)
                await close_hudl_popups(page)
                await _wait_for_library_content(page)
                scraped = await _scrape_library_videos_js(page)
                if scraped:
                    videos = scraped
                    used_url = page.url
                    break
                if api_videos and not videos:
                    seen_t = set()
                    for v in api_videos:
                        key = (v.get("title") or "").lower()
                        if not key or key in seen_t:
                            continue
                        seen_t.add(key)
                        videos.append(v)
                    if videos:
                        break
        except Exception as exc:
            print(f"Library scrape failed for {url}: {exc}")
            continue
        finally:
            try:
                page.remove_listener("response", _on_response)
            except Exception:
                pass

    result_videos = []
    for i, v in enumerate(videos):
        watch = v.get("watch_url") or ""
        if watch and not str(watch).startswith("http"):
            watch = f"https://app.hudl.com{watch}" if str(watch).startswith("/") else watch
        if not watch and tid:
            watch = f"https://app.hudl.com/watch/team/{tid}/analyze"
        thumb = _normalize_thumbnail_url(v.get("thumbnail_url"))
        result_videos.append(
            {
                "id": i + 1,
                "title": v.get("title") or f"Video {i + 1}",
                "watch_url": watch,
                "thumbnail_url": thumb,
                "type": (v.get("type") or "GAME").upper(),
                "duration": v.get("duration") or "00:00",
                "date": v.get("date") or "",
            }
        )

    # When DOM thumbs are missing, pull stills from the live page as data URLs
    # (auth-gated CDN URLs often fail in the browser without Hudl cookies).
    missing = [v for v in result_videos if not v.get("thumbnail_url")]
    if missing and page is not None:
        try:
            data_thumbs = await page.evaluate(
                """() => {
                  const TYPE_RE = /\\b(GAME|SCOUT|PRACTICE|SCRIMMAGE|HIGHLIGHT|OPPONENT|OTHER)\\b/i;
                  const BAD = /avatar|logo|icon|sprite|profile|headshot|user|gravatar|placeholder/i;
                  const out = {};
                  const cards = [];
                  document.querySelectorAll('article, li, [class*="Card"], [class*="card"], [class*="Tile"], [class*="tile"]').forEach((el) => {
                    const t = (el.innerText || '').trim();
                    if (!TYPE_RE.test(t) || t.length < 8 || t.length > 600) return;
                    const lines = t.split('\\n').map(s => s.trim()).filter(Boolean);
                    const typeLine = lines.find(l => TYPE_RE.test(l)) || '';
                    const title = lines.find(l => l !== typeLine && l.length > 2 && !/^\\d+\\s*Items?/i.test(l)) || '';
                    if (!title) return;
                    cards.push({ el, title });
                  });
                  const canvasFromImg = (img) => {
                    try {
                      const w = Math.min(img.naturalWidth || img.width, 480);
                      const h = Math.min(img.naturalHeight || img.height, 270);
                      if (w < 80 || h < 45) return '';
                      const c = document.createElement('canvas');
                      c.width = w; c.height = h;
                      c.getContext('2d').drawImage(img, 0, 0, w, h);
                      return c.toDataURL('image/jpeg', 0.72);
                    } catch (_) { return ''; }
                  };
                  for (const { el, title } of cards) {
                    if (out[title]) continue;
                    let dataUrl = '';
                    el.querySelectorAll('img').forEach((img) => {
                      if (dataUrl) return;
                      const src = img.currentSrc || img.src || '';
                      if (BAD.test(src)) return;
                      const r = img.getBoundingClientRect();
                      if (r.width < 90 || r.height < 55) return;
                      if (Math.abs(r.width - r.height) < 8 && r.width < 120) return;
                      dataUrl = canvasFromImg(img);
                    });
                    if (dataUrl) out[title] = dataUrl;
                  }
                  return out;
                }"""
            )
            if isinstance(data_thumbs, dict) and data_thumbs:
                for v in result_videos:
                    if v.get("thumbnail_url"):
                        continue
                    title = v.get("title") or ""
                    if title in data_thumbs:
                        v["thumbnail_url"] = data_thumbs[title]
                    else:
                        # soft match
                        for k, val in data_thumbs.items():
                            if title and (title in k or k in title):
                                v["thumbnail_url"] = val
                                break
                print(
                    f"Library thumbs: filled {sum(1 for v in result_videos if str(v.get('thumbnail_url') or '').startswith('data:'))} data-URLs"
                )
        except Exception as exc:
            print(f"Library data-URL thumb capture failed: {exc}")

    thumbs_ok = sum(1 for v in result_videos if v.get("thumbnail_url"))
    print(f"Library videos={len(result_videos)} with_thumbs={thumbs_ok}")

    return {
        "status": "ok",
        "videos": result_videos,
        "count": len(result_videos),
        "url": used_url or (page.url if page else ""),
        "team_id": tid,
        "message": (
            f"Found {len(result_videos)} library videos."
            if result_videos
            else "No library items found."
        ),
    }


STORAGE_USAGE_PATTERN = re.compile(
    r"(\d+)\s+of\s+([\d.]+)\s+hrs\s+\((\d+)%\)\s+used",
    re.IGNORECASE,
)
STORAGE_BAR_WIDTH_PATTERN = re.compile(r"width:\s*([\d.]+)%", re.IGNORECASE)
STORAGE_WARNING_THRESHOLD_PERCENT = 90
STORAGE_FULL_MESSAGE = (
    "Your Hudl library is full. Please free up some space to upload."
)


def _build_storage_usage_result(
    percent: int,
    used_hours: str,
    total_hours: str,
) -> dict:
    show_popup = percent >= STORAGE_WARNING_THRESHOLD_PERCENT
    result = {
        "available": True,
        "used_hours": used_hours,
        "total_hours": total_hours,
        "usage_percent": percent,
        "show_popup": show_popup,
    }
    if show_popup:
        result["message"] = STORAGE_FULL_MESSAGE
    return result


async def _ensure_storage_section_visible(page: Page) -> None:
    container = page.locator(STORAGE_USAGE_CONTAINER).first
    try:
        await container.wait_for(state="attached", timeout=10000)
    except Exception:
        return

    bar = page.locator(STORAGE_BAR_TEAM_USAGE).first
    if await bar.count() > 0:
        return

    expand_btn = page.locator(STORAGE_USAGE_EXPAND_BUTTON).first
    if await expand_btn.count() > 0:
        try:
            await expand_btn.click()
            await human_delay(0.5, 1.0)
        except Exception:
            pass


async def _read_storage_percent_from_bar(page: Page) -> float | None:
    await _ensure_storage_section_visible(page)

    bar = page.locator(STORAGE_BAR_TEAM_USAGE).first
    if await bar.count() == 0:
        progress = page.locator(STORAGE_PROGRESS_BAR_CONTAINER).first
        if await progress.count() > 0:
            bar = progress.locator('[class*="barTeamUsage"]').first
    if await bar.count() == 0:
        return None

    style = await bar.get_attribute("style") or ""
    match = STORAGE_BAR_WIDTH_PATTERN.search(style)
    if not match:
        return None
    return float(match.group(1))


async def _read_storage_usage_text(page: Page) -> str:
    await _ensure_storage_section_visible(page)

    for selector in (STORAGE_USAGE_MESSAGE, STORAGE_LEARN_MORE_TEXT):
        loc = page.locator(selector).first
        if await loc.count() == 0:
            continue
        text = (await loc.inner_text() or "").strip()
        if text and STORAGE_USAGE_PATTERN.search(text):
            return text

    container = page.locator(STORAGE_USAGE_CONTAINER).first
    if await container.count() > 0:
        text = (await container.inner_text() or "").strip()
        if text and STORAGE_USAGE_PATTERN.search(text):
            return text

    body_text = await page.locator("body").inner_text()
    match = STORAGE_USAGE_PATTERN.search(body_text)
    return match.group(0) if match else ""


async def fetch_hudl_storage_usage(page: Page, team_id: str | None = None) -> dict:
    """
    Read Manage Storage usage for the given Hudl team (or the active team).
    Uses the library page storage widget (fast). Manage Library is only a
    fallback so team switches are not blocked by a 45s probe.
    """
    tid = str(team_id or "").strip() or None

    try:
        await _navigate_to_library(page, team_id=tid)

        percent_from_bar = await _read_storage_percent_from_bar(page)
        text = await _read_storage_usage_text(page)
        match = STORAGE_USAGE_PATTERN.search(text) if text else None

        if percent_from_bar is not None:
            percent = int(round(percent_from_bar))
            used_hours = match.group(1) if match else ""
            total_hours = match.group(2) if match else ""
            result = _build_storage_usage_result(percent, used_hours, total_hours)
            if tid:
                result["team_id"] = tid
            return result

        if match:
            used_hours, total_hours, percent = match.groups()
            result = _build_storage_usage_result(int(percent), used_hours, total_hours)
            if tid:
                result["team_id"] = tid
            return result

        # Fallback: Classic Manage Library left-column usage (Football).
        if tid and tid.isdigit():
            try:
                await _ensure_manage_library_page(page, tid)
                parsed = await _parse_manage_library_page(page)
                storage = parsed.get("storage") or {}
                used = storage.get("used_hours")
                total = storage.get("total_hours")
                percent = storage.get("usage_percent")
                if used is not None and total is not None:
                    used_s = (
                        str(used).rstrip("0").rstrip(".")
                        if isinstance(used, float)
                        else str(used)
                    )
                    total_s = (
                        str(total).rstrip("0").rstrip(".")
                        if isinstance(total, float)
                        else str(total)
                    )
                    if percent is None:
                        try:
                            percent = int(round((float(used) / float(total)) * 100))
                        except (TypeError, ValueError, ZeroDivisionError):
                            percent = 0
                    result = _build_storage_usage_result(int(percent or 0), used_s, total_s)
                    result["team_id"] = tid
                    return result
            except Exception as exc:
                print(f"Manage Library storage read soft-fail for team {tid}: {exc}")

        return {"available": False, "show_popup": False, **({"team_id": tid} if tid else {})}
    except Exception as exc:
        print(f"Non-critical: failed to fetch Hudl storage usage: {exc}")
        return {"available": False, "show_popup": False, **({"team_id": tid} if tid else {})}


async def get_video_stream_url(page: Page, watch_url: str) -> dict:
    """
    Navigates to the direct watch URL, intercepts HLS requests (.m3u8),
    and returns the stream URL.
    """
    await login_run(page)
    await close_hudl_popups(page)
    
    stream_url = None
    event = asyncio.Event()
    
    async def intercept_request(request):
        nonlocal stream_url
        url = request.url
        # Filter for HLS playlists (.m3u8)
        if ".m3u8" in url and not event.is_set():
            stream_url = url
            event.set()
            
    page.on("request", intercept_request)
    
    try:
        print(f"Navigating to watch URL: {watch_url}")
        await page.goto(watch_url, wait_until="domcontentloaded")
        
        # Wait up to 15 seconds to capture the .m3u8 stream request
        try:
            await asyncio.wait_for(event.wait(), timeout=15.0)
        except asyncio.TimeoutError:
            print("Timeout waiting for HLS request interception.")
            
    finally:
        page.remove_listener("request", intercept_request)
        
    if stream_url:
        return {
            "status": "ok",
            "stream_url": stream_url
        }
    else:
        return {
            "status": "error",
            "message": "Failed to capture HLS video stream URL (.m3u8)"
        }

async def close_video_player(page: Page) -> dict:
    """
    Clicks the close/back button on Hudl's watch screen to go back to the library.
    """
    close_btn_xpath = 'xpath=(//section/div/div/div/div/div/div/button)[1]'
    try:
        btn = page.locator(close_btn_xpath).first
        if await btn.count() > 0:
            print("Found close/back button. Clicking it...")
            await btn.click()
            await page.wait_for_load_state("domcontentloaded")
            return {"status": "ok", "message": "Video player closed and returned to library."}
        else:
            print("Close/back button not found. Maybe already on library page.")
            if not _is_library_page(page.url) and "/watch" in page.url:
                print("Fallback: navigating back to library.")
                await _navigate_to_library(page)
            return {"status": "ok", "message": "Already on library page or navigated back."}
    except Exception as e:
        print(f"Error during closing video player: {e}")
        return {"status": "error", "message": str(e)}


async def locate_tile(page: Page, video_index: int):
    """Helper to locate a library video tile by index after ensuring we are on the library page."""
    # Player view (?v=) keeps stale tiles in the DOM and breaks kebab menus for
    # indexes other than the first visible card — always return to the list first.
    current_url = page.url or ""
    if "v=" in current_url or not _is_library_page(current_url):
        tid = await _resolve_library_team_id(page)
        list_url = (
            f"https://app.hudl.com/watch/team/{tid}/analyze"
            if tid
            else await _resolve_library_url(page)
        )
        list_url = list_url.split("?", 1)[0]
        print(f"locate_tile: opening clean library list {list_url}")
        await page.goto(list_url, wait_until="domcontentloaded")
        await human_delay(1.5, 2.5)
        await close_hudl_popups(page)
        await _wait_for_library_content(page, timeout_ms=20000)

    tile_locator = page.locator(LIBRARY_TILE)
    try:
        count = await tile_locator.count()
        if count > 0 and 0 <= video_index < count:
            tile = tile_locator.nth(video_index)
            await tile.scroll_into_view_if_needed()
            try:
                handle = await tile.element_handle()
                if handle:
                    await page.evaluate(
                        "(el) => el.scrollIntoView({block: 'center', inline: 'nearest'})",
                        handle,
                    )
            except Exception:
                pass
            await human_delay(0.3, 0.6)
            return tile
    except Exception:
        pass

    if not _is_library_page(page.url):
        print("Navigating to Library page...")
        await login_run(page)
        await close_hudl_popups(page)
        await _navigate_to_library(page)

    try:
        await tile_locator.first.wait_for(state="attached", timeout=12000)
    except Exception:
        print("locate_tile: No library tiles found. Attempting reload and retry...")
        try:
            await page.reload(wait_until="domcontentloaded")
            await human_delay(3, 5)
            await close_hudl_popups(page)
            if not _is_library_page(page.url):
                await _navigate_to_library(page)
            await tile_locator.first.wait_for(state="attached", timeout=15000)
        except Exception as retry_err:
            raise Exception(f"No library tiles found after reload: {retry_err}")

    count = await tile_locator.count()
    if video_index < 0 or video_index >= count:
        raise Exception(f"Video index {video_index} is out of range. Total videos: {count}")

    tile = tile_locator.nth(video_index)
    await tile.scroll_into_view_if_needed()
    try:
        handle = await tile.element_handle()
        if handle:
            await page.evaluate(
                "(el) => el.scrollIntoView({block: 'center', inline: 'nearest'})",
                handle,
            )
    except Exception:
        pass
    await human_delay(0.3, 0.6)
    return tile



async def open_library_details(page: Page, video_index: int) -> dict:
    """Clicks on tile menu and opens the details panel, returning title, type, and event."""
    tile = await locate_tile(page, video_index)
    await tile.hover()
    await human_delay(0.5, 1.0)
    
    menu_btn = tile.locator('xpath=./div/div[2]')
    await menu_btn.wait_for(state="visible", timeout=5000)
    await menu_btn.click()
    await human_delay(0.5, 1.0)
    
    open_details_btn = page.locator('xpath=//button[@aria-label="Open Details"]').locator("visible=true").first
    await open_details_btn.wait_for(state="visible", timeout=5000)
    await open_details_btn.click()
    
    # Wait for details sidebar to load
    details_title_xpath = 'xpath=//h4[@data-qa-id="details-module-title"]'
    title_loc = page.locator(details_title_xpath)
    await title_loc.wait_for(state="visible", timeout=8000)
    
    title = await title_loc.inner_text()
    
    # Check current selected type inside details-module-item-type-container
    type_container = page.locator('xpath=//div[@data-qa-id="details-module-item-type-container"]')
    selected_type = "Game" # fallback
    
    for idx, name in enumerate(["Game", "Scout", "Practice"]):
        div_locator = type_container.locator(f'xpath=./div/div/div[{idx+1}]')
        if await div_locator.count() > 0:
            aria_checked = await div_locator.get_attribute("aria-checked")
            cls = await div_locator.get_attribute("class") or ""
            if aria_checked == "true" or "checked" in cls or "active" in cls:
                selected_type = name
                break
                
    # Get current selected event
    event_select_xpath = 'xpath=//div[@data-qa-id="details-module-event-dropdown-select"]/div/div'
    event_val = ""
    event_loc = page.locator(event_select_xpath).first
    if await event_loc.count() > 0:
        event_val = await event_loc.inner_text()
        
    return {
        "status": "ok",
        "title": title,
        "type": selected_type,
        "event": event_val
    }


async def update_library_type(page: Page, video_index: int, video_type: str) -> dict:
    """Updates video type (Game, Scout, Practice) in open details panel."""
    # Maps type to div index: Game -> 1, Scout -> 2, Practice -> 3
    type_map = {"Game": 1, "Scout": 2, "Practice": 3}
    if video_type not in type_map:
        raise ValueError(f"Invalid video_type: {video_type}. Must be Game, Scout, or Practice.")
        
    div_idx = type_map[video_type]
    option_xpath = f'xpath=//div[@data-qa-id="details-module-item-type-container"]/div/div/div[{div_idx}]'
    option_loc = page.locator(option_xpath)
    await option_loc.wait_for(state="visible", timeout=5000)
    await option_loc.click()
    await human_delay(1.0, 2.0) # wait for auto-save
    
    return {
        "status": "ok",
        "message": f"Updated video type to {video_type}"
    }


async def get_library_event_options(page: Page, video_index: int) -> dict:
    """Opens the event dropdown, fetches options list, closes dropdown, and returns options."""
    dropdown_arrow = page.locator('xpath=//span[@aria-label="select open and close"]').first
    await dropdown_arrow.wait_for(state="visible", timeout=5000)
    await dropdown_arrow.click()
    
    menu_loc = page.locator('xpath=//div[@class="u-select__menu css-1nmdiq5-menu"]')
    await menu_loc.wait_for(state="visible", timeout=5000)
    
    options_loc = menu_loc.locator('xpath=.//div[contains(@class, "option") or @role="option"]')
    count = await options_loc.count()
    if count == 0:
        options_loc = menu_loc.locator('xpath=./div/div')
        count = await options_loc.count()
        
    options = []
    for i in range(count):
        txt = await options_loc.nth(i).inner_text()
        options.append(txt.strip())
        
    # Close dropdown by pressing Escape to not lose context
    await page.keyboard.press("Escape")
    await human_delay(0.5, 1.0)
    
    return {
        "status": "ok",
        "options": options
    }


async def update_library_event(page: Page, video_index: int, event_name: str) -> dict:
    """Selects an event from the dropdown inside details panel."""
    dropdown_arrow = page.locator('xpath=//span[@aria-label="select open and close"]').first
    await dropdown_arrow.wait_for(state="visible", timeout=5000)
    await dropdown_arrow.click()
    
    menu_loc = page.locator('xpath=//div[@class="u-select__menu css-1nmdiq5-menu"]')
    await menu_loc.wait_for(state="visible", timeout=5000)
    
    # Fetch options to do normalized comparison
    options_loc = menu_loc.locator('xpath=.//div[contains(@class, "option") or @role="option"]')
    count = await options_loc.count()
    if count == 0:
        options_loc = menu_loc.locator('xpath=./div/div')
        count = await options_loc.count()
        
    clicked = False
    # 1. Try exact stripped match
    for i in range(count):
        opt = options_loc.nth(i)
        txt = await opt.inner_text()
        if txt.strip() == event_name.strip():
            await opt.click()
            clicked = True
            break
            
    # 2. Try normalized whitespace match
    if not clicked:
        norm_target = " ".join(event_name.split())
        for i in range(count):
            opt = options_loc.nth(i)
            txt = await opt.inner_text()
            norm_txt = " ".join(txt.split())
            if norm_target == norm_txt or norm_target in norm_txt or norm_txt in norm_target:
                await opt.click()
                clicked = True
                break
                
    if not clicked:
        await page.keyboard.press("Escape")
        raise Exception(f"Event option '{event_name}' not found.")
        
    await human_delay(1.0, 2.0) # wait for auto-save
    return {
        "status": "ok",
        "message": f"Updated event to {event_name}"
    }



async def close_library_details(page: Page) -> dict:
    """Closes details panel."""
    close_btn = page.locator('xpath=//button[@data-qa-id="details-module-close-button"]')
    await close_btn.wait_for(state="visible", timeout=5000)
    await close_btn.click()
    await human_delay(0.5, 1.0)
    return {
        "status": "ok",
        "message": "Details panel closed"
    }


async def rename_library_video(page: Page, video_index: int, new_name: str | None = None, cancel: bool = False) -> dict:
    """Opens rename inline panel, enters new name and clicks save or cancel."""
    tile = await locate_tile(page, video_index)
    await tile.hover()
    await human_delay(0.5, 1.0)
    
    menu_btn = tile.locator('xpath=./div/div[2]')
    await menu_btn.wait_for(state="visible", timeout=5000)
    await menu_btn.click()
    await human_delay(0.5, 1.0)
    
    rename_menu_btn = page.locator('xpath=//button[@aria-label="Rename"]').locator("visible=true").first
    await rename_menu_btn.wait_for(state="visible", timeout=5000)
    await rename_menu_btn.click()
    
    input_loc = page.locator('xpath=//input[@data-qa-id="library-item-rename-input"]')
    await input_loc.wait_for(state="visible", timeout=5000)
    
    current_value = await input_loc.input_value()
    
    if cancel:
        cancel_btn = page.locator('xpath=//button[@data-qa-id="library-item-rename-cancel"]')
        await cancel_btn.click()
        return {
            "status": "ok",
            "name": current_value,
            "message": "Renaming cancelled"
        }
        
    if new_name is not None and new_name != current_value:
        await input_loc.fill(new_name)
        save_btn = page.locator('xpath=//button[@data-qa-id="library-item-rename-save"]')
        await save_btn.click()
        await human_delay(1.0, 2.0)
        return {
            "status": "ok",
            "name": new_name,
            "message": f"Renamed to {new_name}"
        }
        
    # Default behavior if no change is requested is to return current name
    cancel_btn = page.locator('xpath=//button[@data-qa-id="library-item-rename-cancel"]')
    await cancel_btn.click()
    return {
        "status": "ok",
        "name": current_value
    }


async def email_download_link(page: Page, video_index: int, option: str = "video_and_stats", cancel: bool = False) -> dict:
    """Opens Email Download Link popup, selects option, and triggers email or cancels."""
    tile = await locate_tile(page, video_index)
    await tile.hover()
    await human_delay(0.5, 1.0)
    
    menu_btn = tile.locator('xpath=./div/div[2]')
    await menu_btn.wait_for(state="visible", timeout=5000)
    await menu_btn.click()
    await human_delay(0.5, 1.0)
    
    email_btn = page.locator('xpath=//button[@aria-label="Email Download Link"]').locator("visible=true").first
    await email_btn.wait_for(state="visible", timeout=5000)
    await email_btn.click()
    await human_delay(1.0, 2.0)
    
    # Check if download options modal is shown
    if cancel:
        cancel_btn = page.locator('xpath=//button[@data-qa-id="download-modal-cancel"]')
        await cancel_btn.click()
        return {"status": "ok", "message": "Email download request cancelled."}
        
    # Select Radio options
    if option == "video_only":
        radio_loc = page.locator('xpath=//label[@data-qa-id="download-modal-radio-options-video-only-label"]')
    else:
        radio_loc = page.locator('xpath=//label[@data-qa-id="download-modal-radio-options-video-and-stats-label"]')
        
    await radio_loc.click()
    await human_delay(0.5, 1.0)
    
    download_btn = page.locator('xpath=//button[@data-qa-id="download-modal-download"]')
    await download_btn.click()
    await human_delay(1.0, 2.0)
    
    return {"status": "ok", "message": "Email download request successfully sent."}


async def download_library_video(page: Page, video_index: int) -> dict:
    """Clicks download button, intercepts file, saves it to browser_data/downloads, returns details."""
    tile = await locate_tile(page, video_index)
    return await _download_from_library_tile(page, tile)


def _canonical_hudl_v(value: str) -> str:
    """Canonical Hudl v id for API use — trailing base64 `=` padding removed."""
    return (value or "").strip().rstrip("=")


def _pad_hudl_v_for_url(value: str) -> str:
    """Restore base64 padding so Hudl analyze URLs still resolve."""
    s = _canonical_hudl_v(value)
    if not s or s.isdigit():
        return s
    pad = (4 - (len(s) % 4)) % 4
    return s + ("=" * pad)


def _extract_hudl_v(value: str) -> str:
    """Normalize a Hudl `v` id (no trailing `=`); also accepts a full watch URL."""
    from urllib.parse import parse_qs, unquote, urlparse

    raw = unquote((value or "").strip())
    if not raw:
        return ""
    # Numeric id, or base64-style id (may contain = / +)
    if raw.isdigit() or ("://" not in raw and "v=" not in raw and "?" not in raw):
        return _canonical_hudl_v(raw)
    if "://" in raw or "v=" in raw or "?" in raw:
        if raw.startswith("/"):
            parsed = urlparse(f"https://app.hudl.com{raw}")
        elif "://" in raw:
            parsed = urlparse(raw)
        else:
            parsed = urlparse(f"https://app.hudl.com/?{raw.lstrip('?&')}")
        qs = parse_qs(parsed.query, keep_blank_values=True)
        vals = qs.get("v") or []
        if vals and str(vals[0]).strip():
            return _canonical_hudl_v(unquote(str(vals[0]).strip()))
    return _canonical_hudl_v(raw)


def _watch_url_has_v(watch_url: str, v: str) -> bool:
    from urllib.parse import parse_qs, unquote, urlparse

    target = _canonical_hudl_v(unquote((v or "").strip()))
    if not target:
        return False
    url = (watch_url or "").strip()
    if not url:
        return False
    decoded_url = unquote(url)
    # Match with or without trailing `=`
    if f"v={target}" in url or f"v={target}" in decoded_url:
        return True
    if f"v={target}=" in url or f"v={target}=" in decoded_url:
        return True
    try:
        qs = parse_qs(urlparse(url).query, keep_blank_values=True)
        vals = qs.get("v") or []
        return any(_canonical_hudl_v(unquote(str(x).strip())) == target for x in vals)
    except Exception:
        return False


def _build_watch_url_for_v(v: str, team_id: str | None = None, raw_input: str | None = None) -> str:
    """Build a Hudl analyze watch URL for a given v id (pads base64 for Hudl)."""
    from urllib.parse import quote, urlencode, urlparse, parse_qs, urlunparse

    raw = (raw_input or v or "").strip()
    v_canon = _extract_hudl_v(v if not (raw.startswith("http") and "v=" in raw) else raw)
    v_for_url = _pad_hudl_v_for_url(v_canon)

    if raw.startswith("http") and "v=" in raw:
        # Rewrite query v= to padded form while keeping other params
        parsed = urlparse(raw)
        qs = parse_qs(parsed.query, keep_blank_values=True)
        qs["v"] = [v_for_url]
        flat = []
        for key, vals in qs.items():
            for val in vals:
                flat.append((key, val))
        return urlunparse(parsed._replace(query=urlencode(flat, safe="=+/")))

    tid = (team_id or "").strip() or _team_id_from_any_url(raw) or ""
    qv = quote(v_for_url, safe="=+/")
    if tid:
        return f"https://app.hudl.com/watch/team/{tid}/analyze?v={qv}"
    return f"https://app.hudl.com/watch/team/analyze?v={qv}"


async def _download_from_library_tile(page: Page, tile) -> dict:
    """
    Hudl tile Download click, then write file under media/{id}/video.mp4.
    """
    from pathlib import Path
    import uuid

    from app.services.media_service import MEDIA_ROOT

    if page.is_closed():
        raise Exception("Browser page is closed. Retry the request.")

    # Close leftover menus/filters from a previous tile action.
    try:
        await page.keyboard.press("Escape")
    except Exception:
        pass
    await human_delay(0.2, 0.4)

    await tile.scroll_into_view_if_needed()
    try:
        handle = await tile.element_handle()
        if handle:
            await page.evaluate(
                "(el) => el.scrollIntoView({block: 'center', inline: 'nearest'})",
                handle,
            )
    except Exception:
        pass
    await tile.hover()
    await human_delay(0.5, 1.0)

    async def _open_tile_menu() -> None:
        menu_btn = tile.locator('xpath=.//button[contains(@data-qa-id, "-action-list")]').first
        if await menu_btn.count() == 0:
            menu_btn = tile.locator(
                'xpath=.//button[@aria-haspopup="menu" or contains(@aria-label,"More") '
                'or contains(@aria-label,"Action") or contains(@aria-label,"options")]'
            ).first
        if await menu_btn.count() == 0:
            menu_btn = tile.locator("xpath=./div/div[2]")
        await menu_btn.wait_for(state="visible", timeout=8000)
        await menu_btn.click(force=True)
        await human_delay(0.9, 1.4)

    async def _find_download(do_click: bool = False) -> dict:
        tile_box = await tile.bounding_box() or {"x": 0, "y": 0, "width": 0, "height": 0}
        return await page.evaluate(
            """({ doClick, tileBox }) => {
              const nodes = Array.from(
                document.querySelectorAll('button, [role="menuitem"], a[role="menuitem"], div[role="menuitem"]')
              );

              const isVisible = (el) => {
                if (!el || el.offsetParent === null) return false;
                const r = el.getBoundingClientRect();
                return r.width > 0 && r.height > 0;
              };

              const parts = (el) => {
                const aria = (el.getAttribute('aria-label') || '').replace(/\\s+/g, ' ').trim();
                const text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
                return { aria, text, combo: (aria + ' ' + text).replace(/\\s+/g, ' ').trim() };
              };

              const isEmail = (p) => /email/i.test(p.aria + ' ' + p.text);
              const isDownload = (p) => {
                if (isEmail(p)) return false;
                if (/^download$/i.test(p.aria) || /^download$/i.test(p.text)) return true;
                if (/^(download\\s+)+download$/i.test(p.combo)) return true;
                return false;
              };

              const inTileMenu = (el) => {
                const root =
                  el.closest('[role="menu"], [data-radix-menu-content], [class*="Menu" i], [class*="Popover" i], [class*="Dropdown" i]')
                  || el.parentElement;
                if (!root) return false;
                const t = (root.innerText || '').toLowerCase();
                return (
                  t.includes('open details') ||
                  t.includes('rename') ||
                  t.includes('delete') ||
                  t.includes('share') ||
                  t.includes('email download')
                );
              };

              const distToTile = (el) => {
                const r = el.getBoundingClientRect();
                const cx = r.left + r.width / 2;
                const cy = r.top + r.height / 2;
                const tx = tileBox.x + tileBox.width / 2;
                const ty = tileBox.y + tileBox.height / 2;
                const dx = cx - tx;
                const dy = cy - ty;
                return Math.sqrt(dx * dx + dy * dy);
              };

              const candidates = nodes
                .filter((el) => isVisible(el) && isDownload(parts(el)))
                .map((el) => ({ el, dist: distToTile(el), menu: inTileMenu(el) }))
                .sort((a, b) => {
                  // Prefer menu items near this tile
                  if (a.menu !== b.menu) return a.menu ? -1 : 1;
                  return a.dist - b.dist;
                });

              const target = candidates[0] && candidates[0].el;
              if (!target) {
                return {
                  ok: false,
                  labels: nodes.filter(isVisible).map((el) => parts(el).combo).filter(Boolean).slice(0, 40),
                };
              }
              if (doClick) {
                target.scrollIntoView({ block: 'nearest' });
                target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
                target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
                target.click();
              }
              return { ok: true, label: parts(target).combo, dist: candidates[0].dist };
            }""",
            {"doClick": do_click, "tileBox": tile_box},
        )

    await _open_tile_menu()
    found = await _find_download(do_click=False)
    if not found.get("ok"):
        try:
            await page.keyboard.press("Escape")
        except Exception:
            pass
        await human_delay(0.4, 0.7)
        await tile.hover()
        await human_delay(0.4, 0.7)
        await _open_tile_menu()
        found = await _find_download(do_click=False)

    if not found.get("ok"):
        print(f"download_tile: menu labels visible={found.get('labels')}")
        raise Exception(
            "Hudl Download button not found after opening the tile menu. "
            "Make sure the Library list is open (not the player) and try again."
        )

    print(f"download_tile: found Download ({found.get('label')}) — saving into media/ ...")

    async with page.expect_download(timeout=600000) as download_info:
        clicked = await _find_download(do_click=True)
        if not clicked.get("ok"):
            raise Exception("Hudl Download button disappeared before click.")
        try:
            modal_dl = page.locator(
                'button[data-qa-id="download-modal-download"]'
            ).locator("visible=true").first
            await modal_dl.wait_for(state="visible", timeout=4000)
            only = page.locator(
                'label[data-qa-id="download-modal-radio-options-video-only-label"]'
            ).locator("visible=true").first
            if await only.count() > 0:
                await only.click()
                await human_delay(0.3, 0.6)
            print("download_tile: confirming Hudl download modal (video only)")
            await modal_dl.click()
        except Exception:
            pass

    download = await download_info.value
    filename = download.suggested_filename or "video.mp4"
    print(f"download_tile: download started ({filename}), writing to media/ ...")

    ext = Path(filename).suffix or ".mp4"
    if len(ext) > 10:
        ext = ".mp4"

    media_id = str(uuid.uuid4())
    dest_dir = MEDIA_ROOT / media_id
    dest_dir.mkdir(parents=True, exist_ok=True)
    dest = dest_dir / f"video{ext}"

    await download.save_as(str(dest))
    if not dest.is_file():
        raise Exception("Hudl download finished but file was not written to media/")

    size = dest.stat().st_size
    rel = f"media/{media_id}/video{ext}"
    print(f"download_tile: saved directly to {rel} ({size} bytes)")

    return {
        "status": "ok",
        "local_path": str(dest),
        "path": rel,
        "filename": filename,
        "media_id": media_id,
        "saved_to_media": True,
        "file_size": size,
    }


async def _resolve_dom_index_for_v(page: Page, v_clean: str) -> int | None:
    """Find 0-based LIBRARY_TILE index whose href contains this v (same tiles as download-to-media)."""
    tile_locator = page.locator(LIBRARY_TILE)
    try:
        await tile_locator.first.wait_for(state="attached", timeout=15000)
    except Exception:
        await _wait_for_library_content(page, timeout_ms=20000)

    for _ in range(15):
        count = await tile_locator.count()
        for i in range(count):
            tile = tile_locator.nth(i)
            href = ""
            try:
                a = tile.locator("a[href]").first
                if await a.count() > 0:
                    href = (await a.get_attribute("href")) or ""
            except Exception:
                continue
            if _watch_url_has_v(href, v_clean):
                print(f"_resolve_dom_index_for_v: v={v_clean} -> tile index {i}")
                return i
        await page.mouse.wheel(0, 1700)
        await human_delay(0.35, 0.7)
    return None


async def locate_tile_by_v(page: Page, v: str, watch_url: str | None = None):
    """
    Open analyze?v=<id> and locate THAT video's card/link.
    Does not hard-require [data-qa-id=tile-library-item] (often missing on player view).
    """
    from urllib.parse import unquote

    v_clean = _extract_hudl_v(v)
    if not v_clean:
        raise Exception("Missing Hudl v id")
    v_padded = _pad_hudl_v_for_url(v_clean)

    tid = _team_id_from_any_url(watch_url) or await _resolve_library_team_id(page)
    target_url = watch_url or _build_watch_url_for_v(v_clean, team_id=tid, raw_input=v)

    from app.automation.tasks.login import ensure_hudl_session

    await ensure_hudl_session(page)
    await close_hudl_popups(page)

    print(f"Opening library video by v at {target_url}")
    await page.goto(target_url, wait_until="domcontentloaded")
    await human_delay(1.5, 2.5)
    await close_hudl_popups(page)
    await _wait_for_analyze_ready(page, v_clean, timeout_ms=25000)

    matched = await _find_card_for_v(page, v_clean, v_padded)
    if matched is not None:
        return matched, 0, target_url

    # Scroll grid if present and keep searching
    for direction in (1600, -1600, 1600, 1600, 1600, 1600):
        try:
            await page.mouse.wheel(0, direction)
        except Exception:
            break
        await human_delay(0.35, 0.7)
        matched = await _find_card_for_v(page, v_clean, v_padded)
        if matched is not None:
            return matched, 0, target_url

    # Library list without ?v= then find the link (player-only views often hide tiles)
    if tid:
        list_url = f"https://app.hudl.com/watch/team/{tid}/analyze"
        if list_url not in (page.url or ""):
            print(f"locate_tile_by_v: falling back to library list {list_url}")
            await page.goto(list_url, wait_until="domcontentloaded")
            await human_delay(1.5, 2.5)
            await close_hudl_popups(page)
            await _wait_for_library_content(page, timeout_ms=20000)
            matched = await _find_card_for_v(page, v_clean, v_padded)
            if matched is not None:
                return matched, 0, target_url
            for _ in range(8):
                await page.mouse.wheel(0, 1800)
                await human_delay(0.35, 0.7)
                matched = await _find_card_for_v(page, v_clean, v_padded)
                if matched is not None:
                    return matched, 0, target_url
            # Re-open the specific v URL for page-level download fallback
            await page.goto(target_url, wait_until="domcontentloaded")
            await human_delay(1.2, 2.0)
            await close_hudl_popups(page)
            await _wait_for_analyze_ready(page, v_clean, timeout_ms=15000)

    current = page.url or ""
    if _watch_url_has_v(current, v_clean):
        # Caller will use page-level Download while this v is open
        print(f"locate_tile_by_v: no card for v={v_clean}; page URL has v — use page download")
        return None, -1, target_url

    raise Exception(
        f"No library card found for v={v_clean}. "
        "Confirm this video belongs to the active Hudl team and try again."
    )


async def _wait_for_analyze_ready(page: Page, v: str, timeout_ms: int = 25000) -> None:
    """Wait until analyze SPA shows tiles, player, download control, or a link for this v."""
    v_clean = _canonical_hudl_v(v)
    try:
        await page.wait_for_function(
            """(v) => {
              if (!v) return false;
              if (document.querySelector(`a[href*="${v}"]`)) return true;
              if (document.querySelector('[data-qa-id="tile-library-item"]')) return true;
              if (document.querySelector('[data-qa-id*="library-item" i], [data-qa-id*="cutup" i]')) return true;
              if (document.querySelector('button[aria-label*="Download" i]')) return true;
              if (document.querySelector('video, [class*="Player"], [class*="player"]')) return true;
              if (/\\d+\\s*Items?/i.test(document.body?.innerText || '')) return true;
              return false;
            }""",
            arg=v_clean,
            timeout=timeout_ms,
        )
    except Exception as exc:
        print(f"_wait_for_analyze_ready: soft timeout ({exc})")
    await human_delay(0.6, 1.2)


async def _find_card_for_v(page: Page, v_clean: str, v_padded: str | None = None):
    """Find a card/link for this v using flexible selectors (not only tile-library-item)."""
    needles = [x for x in (v_clean, v_padded or _pad_hudl_v_for_url(v_clean)) if x]
    card_selectors = [
        '[data-qa-id="tile-library-item"]',
        '[data-qa-id*="tile" i]',
        '[data-qa-id*="library-item" i]',
        '[data-qa-id*="cutup" i]',
        '[data-qa-id*="media-card" i]',
        '[data-qa-id*="video-card" i]',
        '[data-qa-id*="grid-item" i]',
    ]

    for needle in needles:
        link = page.locator(f'a[href*="{needle}"]').first
        if await link.count() == 0:
            continue
        try:
            await link.scroll_into_view_if_needed()
        except Exception:
            pass
        # Prefer known card ancestor, else the link itself
        for cs in (
            '[data-qa-id="tile-library-item"]',
            '[data-qa-id*="library-item" i]',
            '[data-qa-id*="cutup" i]',
            '[data-qa-id*="tile" i]',
        ):
            # CSS closest isn't in Playwright; use xpath from the matched link
            anc = page.locator(
                f'xpath=//a[contains(@href, "{needle}")]/ancestor::*[{_xpath_attr_contains(cs)}][1]'
            ).first
            if await anc.count() > 0:
                return anc
        return link

    for sel in card_selectors:
        tiles = page.locator(sel)
        try:
            count = await tiles.count()
        except Exception:
            continue
        for i in range(min(count, 80)):
            tile = tiles.nth(i)
            href = ""
            try:
                a = tile.locator("a[href]").first
                if await a.count() > 0:
                    href = (await a.get_attribute("href")) or ""
            except Exception:
                continue
            if _watch_url_has_v(href, v_clean):
                return tile
    return None


def _xpath_attr_contains(css_like: str) -> str:
    """Map a simple data-qa-id CSS selector to an XPath attribute predicate."""
    # '[data-qa-id="tile-library-item"]' or '[data-qa-id*="tile" i]'
    if 'data-qa-id="' in css_like and "*=" not in css_like:
        val = css_like.split('data-qa-id="', 1)[1].split('"', 1)[0]
        return f'@data-qa-id="{val}"'
    if "data-qa-id*=" in css_like:
        val = css_like.split('data-qa-id*="', 1)[1].split('"', 1)[0]
        return f'contains(@data-qa-id, "{val}")'
    return "true()"


async def _download_for_open_v(page: Page, v: str) -> dict:
    """
    When analyze?v= is open, download the currently opened video via page controls
    (works even when tile-library-item is not in the DOM).
    """
    from pathlib import Path
    import uuid

    v_clean = _extract_hudl_v(v)
    # Allow slight delay for SPA to finish painting actions
    await human_delay(1.0, 1.8)
    await close_hudl_popups(page)

    if not _watch_url_has_v(page.url or "", v_clean):
        # Still try if body/links reference this v
        has_link = await page.locator(f'a[href*="{v_clean}"]').count() > 0
        if not has_link:
            raise Exception(f"Page is not open on v={v_clean}")

    async def _save_download(download) -> dict:
        from app.services.media_service import MEDIA_ROOT

        filename = download.suggested_filename or "video.mp4"
        ext = Path(filename).suffix or ".mp4"
        if len(ext) > 10:
            ext = ".mp4"
        media_id = str(uuid.uuid4())
        dest_dir = MEDIA_ROOT / media_id
        dest_dir.mkdir(parents=True, exist_ok=True)
        dest = dest_dir / f"video{ext}"
        await download.save_as(str(dest))
        rel = f"media/{media_id}/video{ext}"
        print(f"_download_for_open_v: saved directly to {rel}")
        return {
            "status": "ok",
            "local_path": str(dest),
            "path": rel,
            "filename": filename,
            "media_id": media_id,
            "saved_to_media": True,
            "file_size": dest.stat().st_size if dest.is_file() else None,
        }

    # 1) Direct visible Download button
    direct = page.locator(
        'button[aria-label="Download"], button[aria-label*="Download" i], '
        'button[data-qa-id*="download" i]'
    ).locator("visible=true")
    if await direct.count() > 0:
        try:
            async with page.expect_download(timeout=300000) as download_info:
                await direct.first.click()
            return await _save_download(await download_info.value)
        except Exception as exc:
            print(f"_download_for_open_v direct download failed: {exc}")

    # 2) Open overflow / action menus then click Download
    menu_selectors = [
        'button[aria-label*="More options" i]',
        'button[aria-label*="More actions" i]',
        'button[aria-label="More"]',
        'button[aria-label*="Actions" i]',
        'button[data-qa-id*="action-list" i]',
        'button[aria-haspopup="menu"]',
        'button[aria-label*="menu" i]',
    ]
    for sel in menu_selectors:
        btns = page.locator(sel).locator("visible=true")
        n = await btns.count()
        for i in range(min(n, 6)):
            try:
                await btns.nth(i).click()
                await human_delay(0.4, 0.8)
            except Exception:
                continue
            download_btn = page.locator(
                'button[aria-label="Download"], button[aria-label*="Download" i], '
                'xpath=//button[contains(normalize-space(.), "Download")]'
            ).locator("visible=true").first
            if await download_btn.count() == 0:
                # Close menu with Escape and try next
                try:
                    await page.keyboard.press("Escape")
                except Exception:
                    pass
                continue
            try:
                async with page.expect_download(timeout=300000) as download_info:
                    await download_btn.click()
                return await _save_download(await download_info.value)
            except Exception as exc:
                print(f"_download_for_open_v menu download failed: {exc}")
                try:
                    await page.keyboard.press("Escape")
                except Exception:
                    pass

    # 3) Hover any link for this v and use nearby action button
    for needle in (v_clean, _pad_hudl_v_for_url(v_clean)):
        link = page.locator(f'a[href*="{needle}"]').first
        if await link.count() == 0:
            continue
        try:
            await link.scroll_into_view_if_needed()
            await link.hover()
            await human_delay(0.4, 0.8)
            res = await _download_from_library_tile(page, link)
            if res.get("status") == "ok":
                return res
        except Exception as exc:
            print(f"_download_for_open_v link hover download failed: {exc}")

    raise Exception(
        f"Opened v={v_clean} but could not find a Download control for that video. "
        "Make sure Hudl session is logged in and this video allows downloads."
    )


async def download_library_video_by_ref(
    page: Page,
    video_index: int | None = None,
    v: str | None = None,
) -> dict:
    """
    Same end result as /library/video/download-to-media:
    resolve the video, run the proven tile Download, save under media/{id}/.
    Accepts either video_index or Hudl `v`.
    """
    from pathlib import Path

    backend_root = Path(__file__).resolve().parents[2]

    def _with_paths(res: dict) -> dict:
        local = res.get("local_path")
        if local:
            try:
                rel = str(Path(local).resolve().relative_to(backend_root.resolve()))
            except Exception:
                rel = str(local)
            res["path"] = rel
            res["local_path"] = str(local)
        return res

    v_raw = (v or "").strip()
    v_clean = _extract_hudl_v(v_raw) if v_raw else ""

    # --- Path A: by v → resolve DOM tile index → same download_library_video as UI ---
    if video_index is None:
        if not v_clean:
            return {"status": "error", "message": "Provide either video_index or v"}

        tid = _team_id_from_any_url(v_raw) or await _resolve_library_team_id(page)
        watch_url = _build_watch_url_for_v(v_clean, team_id=tid, raw_input=v_raw)
        title = None
        try:
            from app.automation.tasks.login import ensure_hudl_session

            if page.is_closed():
                raise Exception("Browser page is closed. Retry the request.")

            await ensure_hudl_session(page)
            await close_hudl_popups(page)

            list_url = f"https://app.hudl.com/watch/team/{tid}/analyze" if tid else watch_url
            print(f"download_by_ref: opening library (same as download-to-media) {list_url}")
            await page.goto(list_url, wait_until="domcontentloaded")
            await human_delay(1.5, 2.5)
            await close_hudl_popups(page)
            await _wait_for_library_content(page, timeout_ms=20000)

            matched_index = await _resolve_dom_index_for_v(page, v_clean)

            # Fallback: scrape list (same source the UI uses) and match watch_url
            if matched_index is None:
                print("download_by_ref: DOM href miss — matching via get_library_videos")
                lib = await get_library_videos(page, team_id=tid)
                for i, vid in enumerate(lib.get("videos") or []):
                    w = str(vid.get("watch_url") or "")
                    if _watch_url_has_v(w, v_clean):
                        matched_index = i
                        watch_url = w or watch_url
                        title = vid.get("title") or title
                        break

            if matched_index is None:
                return {
                    "status": "error",
                    "message": (
                        f"No library video found with v={v_clean}. "
                        "Open Library in the app and confirm this video is listed."
                    ),
                }

            print(
                f"download_by_ref: using proven download_library_video "
                f"index={matched_index} v={v_clean}"
            )
            # Identical automation path as the first successful media save
            res = await download_library_video(page, matched_index)
        except Exception as exc:
            print(f"download_library_video_by_ref failed for v={v_clean}: {exc}")
            return {"status": "error", "message": str(exc)}

        if res.get("status") != "ok":
            return res

        res = _with_paths(res)
        res["video_index"] = matched_index
        res["v"] = v_clean
        res["watch_url"] = watch_url
        if title:
            res["title"] = title
        return res

    # --- Path B: by video_index (same as download-to-media) ---
    # Always land on a clean library list first (avoids player/?v= state breaking
    # kebab menus for indexes other than 0).
    try:
        from app.automation.tasks.login import ensure_hudl_session

        await ensure_hudl_session(page)
        await close_hudl_popups(page)
        tid = await _resolve_library_team_id(page)
        list_url = (
            f"https://app.hudl.com/watch/team/{tid}/analyze"
            if tid
            else await _resolve_library_url(page)
        )
        list_url = list_url.split("?", 1)[0]
        print(f"download_by_ref: opening library for index={video_index} {list_url}")
        await page.goto(list_url, wait_until="domcontentloaded")
        await human_delay(1.5, 2.5)
        await close_hudl_popups(page)
        await _wait_for_library_content(page, timeout_ms=20000)
    except Exception as exc:
        print(f"download_by_ref: library prepare failed (continuing): {exc}")

    # Prefer resolving this tile's watch_url/v so retries are stable.
    watch_url = None
    title = None
    try:
        tile = await locate_tile(page, video_index)
        a = tile.locator("a[href]").first
        if await a.count() > 0:
            watch_url = (await a.get_attribute("href")) or None
        title_el = tile.locator('[data-qa-id*="title" i], h3, h2, p').first
        if await title_el.count() > 0:
            title = ((await title_el.inner_text()) or "").strip() or None
        if watch_url:
            v_from_tile = _extract_hudl_v(watch_url)
            if v_from_tile:
                v_clean = v_from_tile
    except Exception as exc:
        print(f"download_by_ref: could not read tile meta for index={video_index}: {exc}")

    res = await download_library_video(page, video_index)
    if res.get("status") != "ok":
        return res

    res = _with_paths(res)
    res["video_index"] = video_index
    res["v"] = v_clean or None
    if watch_url:
        res["watch_url"] = watch_url
    elif v_clean:
        tid = _team_id_from_any_url(v_raw) or await _resolve_library_team_id(page)
        res["watch_url"] = _build_watch_url_for_v(v_clean, team_id=tid, raw_input=v_raw)
    if title:
        res["title"] = title
    return res


async def delete_library_video(page: Page, video_index: int) -> dict:
    """Deletes a library video item. In Hudl, clicking delete immediately deletes the video and shows a restore option, without any confirm modal."""
    tile = await locate_tile(page, video_index)
    await tile.hover()
    await human_delay(0.5, 1.0)
    
    # Try custom QA ID button first
    menu_btn = tile.locator('xpath=.//button[contains(@data-qa-id, "-action-list")]').first
    if await menu_btn.count() == 0:
        menu_btn = tile.locator('xpath=./div/div[2]')
        
    await menu_btn.wait_for(state="visible", timeout=5000)
    await menu_btn.click()
    await human_delay(0.8, 1.5)
    
    delete_menu_btn = page.locator('xpath=//button[@aria-label="Delete"]').locator("visible=true").first
    await delete_menu_btn.wait_for(state="visible", timeout=5000)
    await delete_menu_btn.click()
    
    # Let Hudl trigger the delete state animation
    await human_delay(2.0, 3.5)
    
    return {
        "status": "ok",
        "message": "Library video item deleted successfully"
    }


async def _resolve_manage_library_team_id(page: Page, explicit_team_id: str | None = None) -> str:
    raw = (explicit_team_id or "").strip()
    if raw.isdigit():
        return raw

    try:
        from app.core.database import SessionLocal
        from app.services.account_service import get_active_account

        with SessionLocal() as db:
            account = get_active_account(db)
            meta_id = (account.metadata_json or {}).get("team_id", "") if account else ""
            meta_id = str(meta_id).strip()
            if meta_id.isdigit():
                return meta_id
            if meta_id:
                match = re.search(r"/(?:watch/team|manage/library|library)/(\d+)", meta_id)
                if match:
                    return match.group(1)
    except Exception:
        pass

    selected_team = page.locator(
        '[data-qa-id="webnav-teamswitcher-menu"] > a.hui-primaryteamswitcher__item--selected'
    ).first
    try:
        qa_id = await selected_team.get_attribute("data-qa-id")
        if qa_id:
            match = re.search(r"webnav-teamswitcher-team-(\d+)", qa_id)
            if match:
                return match.group(1)
    except Exception:
        pass

    # Last resort: parse team id from the current page URL (already switched).
    url_match = re.search(
        r"/(?:watch/team|manage/library|manage/exchanges|library|teams)/(\d+)",
        page.url or "",
    )
    if url_match:
        return url_match.group(1)

    raise ValueError(
        "No Hudl team id found. Select a team in the frontend so Playwright "
        "can click it and return a dynamic team_id."
    )


async def _switch_hudl_team_by_id(page: Page, team_id: str) -> bool:
    """Best-effort switch of the active Hudl team on the modern home navbar.

    Returns True when the requested team appears selected. Manage Library does
    not require this — the team id is embedded in /manage/library/{team_id}.
    """
    from app.automation.tasks.login import HUDL_HOME_URL

    short_timeout = 5000

    if "hudl.com" not in page.url:
        await page.goto(HUDL_HOME_URL, wait_until="domcontentloaded")
        await human_delay(1.5, 2.5)

    if "hudl.com/home" not in page.url:
        await page.goto(HUDL_HOME_URL, wait_until="domcontentloaded")
        await human_delay(1.5, 2.5)
    await close_hudl_popups(page)

    selected = page.locator(
        '[data-qa-id="webnav-teamswitcher-menu"] > a.hui-primaryteamswitcher__item--selected'
    ).first
    try:
        await selected.wait_for(state="attached", timeout=short_timeout)
        qa_id = await selected.get_attribute("data-qa-id")
        if qa_id and qa_id.endswith(team_id):
            return True
    except Exception:
        pass

    team_link = page.locator(f'[data-qa-id="webnav-teamswitcher-team-{team_id}"]').first
    try:
        if await team_link.count() > 0:
            await team_link.click(timeout=short_timeout)
            await human_delay(2.0, 3.0)
            await close_hudl_popups(page)
            return True
    except Exception:
        pass

    switcher_btn = page.locator('[data-qa-id="webnav-teamswitcher-menu"]').first
    try:
        if await switcher_btn.count() == 0:
            print(
                f"Team switcher not on current page — skipping switch to {team_id} "
                "(will use direct team URL instead)."
            )
            return False

        await switcher_btn.click(timeout=short_timeout)
        await human_delay(0.8, 1.2)
        team_link = page.locator(f'[data-qa-id="webnav-teamswitcher-team-{team_id}"]').first
        await team_link.wait_for(state="visible", timeout=short_timeout)
        await team_link.click(timeout=short_timeout)
        await human_delay(2.0, 3.0)
        await close_hudl_popups(page)
        return True
    except Exception as exc:
        print(
            f"Could not switch Hudl team to {team_id}: {exc}. "
            "Continuing with direct Manage Library URL."
        )
        return False


def _extract_team_id_from_url(url: str) -> str | None:
    match = re.search(
        r"/(?:watch/team|manage/library|manage/exchanges|library)/(\d+)",
        url or "",
    )
    return match.group(1) if match else None


def _library_action_from_url(url: str) -> str:
    """Map Hudl landing URL → FE library mode (no school-name hardcoding)."""
    u = (url or "").lower()
    if "/manage/library/" in u:
        return "library"
    if "www.hudl.com/library/" in u or re.search(r"hudl\.com/library/\d+", u):
        if "app.hudl.com" not in u:
            return "library"
    return "dashboard"


async def list_hudl_organizations(page: Page) -> dict:
    """
    Open Hudl team switcher and return every school → team the account can access.
    Tries app.hudl.com first (modern nav) then classic home — production often
    lands on /library/{id} where the switcher is missing.
    """
    from app.automation.tasks.login import HUDL_HOME_URL

    await _ensure_hudl_session(page, probe_url="https://app.hudl.com/")
    await close_hudl_popups(page)

    candidate_urls = (
        "https://app.hudl.com/",
        "https://app.hudl.com/home",
        HUDL_HOME_URL,
        "https://www.hudl.com/",
    )

    switcher_selectors = (
        '[data-qa-id="webnav-teamswitcher-menu"]',
        '.hui-primaryteamswitcher__item--selected',
        '[data-qa-id="webnav-teamswitcher"]',
        'button.hui-primaryteamswitcher__button',
        '.hui-primaryteamswitcher',
        '[class*="primaryteamswitcher"]',
    )

    opened = False
    for url in candidate_urls:
        try:
            if url.rstrip("/") not in (page.url or ""):
                await page.goto(url, wait_until="domcontentloaded", timeout=20000)
                await human_delay(0.8, 1.2)
                await close_hudl_popups(page)
        except Exception as exc:
            print(f"list_hudl_organizations: navigation failed ({url}): {exc}")
            continue

        for attempt in range(3):
            try:
                await close_hudl_popups(page)
                switcher = None
                for sel in switcher_selectors:
                    loc = page.locator(sel).first
                    if await loc.count() > 0:
                        switcher = loc
                        break
                if not switcher:
                    raise RuntimeError("team switcher control not found")
                await switcher.wait_for(state="visible", timeout=8000)
                try:
                    await switcher.click(timeout=6000)
                except Exception:
                    await switcher.click(timeout=4000, force=True)
                await human_delay(0.5, 0.9)
                await page.locator(
                    '[data-qa-id^="webnav-teamswitcher-team-"]'
                ).first.wait_for(state="attached", timeout=8000)
                opened = True
                print(f"list_hudl_organizations: switcher opened on {page.url}")
                break
            except Exception as exc:
                print(
                    f"list_hudl_organizations: open switcher attempt "
                    f"{attempt + 1} on {page.url}: {exc}"
                )
                try:
                    await page.keyboard.press("Escape")
                except Exception:
                    pass
                await human_delay(0.4, 0.7)
        if opened:
            break

    if not opened:
        print(
            "list_hudl_organizations: team switcher did not open — scraping whatever is in DOM"
        )

    raw = await page.evaluate(
        """() => {
          const slug = (s) => (s || '')
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, '-')
            .replace(/^-+|-+$/g, '') || 'org';

          const inferIcon = (name) => {
            const n = (name || '').toLowerCase();
            if (n.includes('football')) return 'football';
            if (n.includes('basketball')) return 'basketball';
            if (n.includes('soccer')) return 'soccer';
            if (n.includes('lacrosse')) return 'lacrosse';
            if (n.includes('volleyball')) return 'volleyball';
            if (n.includes('baseball') || n.includes('softball')) return 'baseball';
            return 'users';
          };

          const orgs = [];
          const orgByName = new Map();
          const ensureOrg = (orgName) => {
            const name = (orgName || 'Organizations').trim() || 'Organizations';
            if (orgByName.has(name)) return orgByName.get(name);
            const org = { id: slug(name), name, type: 'dashboard', teams: [] };
            orgByName.set(name, org);
            orgs.push(org);
            return org;
          };

          const inferAction = (href) => {
            const h = String(href || '');
            // Classic Manage Library routes → FE /admin/manage-library (URL only).
            if (/\\/manage\\/library\\//i.test(h)) return 'library';
            if (/www\\.hudl\\.com\\/library\\//i.test(h)) return 'library';
            return 'dashboard';
          };

          const pushTeam = (orgName, teamName, teamId, photoUrl, href) => {
            const name = (teamName || '').trim().split('\\n')[0].trim();
            const id = String(teamId || '').trim();
            if (!name || !/^\\d+$/.test(id)) return;
            const org = ensureOrg(orgName);
            if (org.teams.some((t) => t.hudl_team_id === id)) return;
            const icon = inferIcon(name);
            const action = inferAction(href);
            org.teams.push({
              id: `hudl-${id}`,
              name,
              hudl_team_id: id,
              icon,
              photo_url: photoUrl || null,
              action,
            });
            if (action === 'library') org.type = 'library';
          };

          const isOrgHeaderText = (text) => {
            const t = (text || '').trim().split('\\n')[0].trim();
            if (!t || t.length > 100) return false;
            return /high school|catholic|academy|college|university|school$/i.test(t);
          };

          const menuRoot =
            document.querySelector('[data-qa-id="webnav-teamswitcher-menu"]')?.parentElement ||
            document.querySelector('.hui-primaryteamswitcher') ||
            document.querySelector('[class*="TeamSwitcher"]') ||
            document.querySelector('[class*="team-switcher"]') ||
            document.body;

          let currentOrg = 'Organizations';
          const walk = (root) => {
            const nodes = Array.from(root.querySelectorAll('*'));
            for (const el of nodes) {
              const qa = el.getAttribute('data-qa-id') || '';
              const teamMatch = qa.match(/webnav-teamswitcher-team-(\\d+)/);
              if (teamMatch) {
                const teamId = teamMatch[1];
                const teamName = (el.innerText || '').trim().split('\\n')[0].trim();
                const img = el.querySelector('img');
                const anchor = el.closest('a') || (el.tagName === 'A' ? el : null);
                const href = anchor?.getAttribute?.('href') || '';
                pushTeam(currentOrg, teamName, teamId, img?.src || null, href);
                continue;
              }

              if (el.children && el.children.length === 0) {
                const t = (el.innerText || '').trim();
                if (isOrgHeaderText(t) && !qa.includes('team-')) {
                  currentOrg = t.split('\\n')[0].trim();
                }
              }
            }
          };
          walk(menuRoot);

          if (!orgs.some((o) => o.teams.length)) {
            document.querySelectorAll('[data-qa-id^="webnav-teamswitcher-team-"]').forEach((a) => {
              const qa = a.getAttribute('data-qa-id') || '';
              const m = qa.match(/webnav-teamswitcher-team-(\\d+)/);
              const teamId = m ? m[1] : '';
              const teamName = (a.innerText || '').trim().split('\\n')[0].trim();
              let orgName = 'Organizations';
              let prev = a.previousElementSibling;
              for (let i = 0; i < 8 && prev; i++) {
                const t = (prev.innerText || '').trim().split('\\n')[0];
                if (isOrgHeaderText(t)) { orgName = t; break; }
                prev = prev.previousElementSibling;
              }
              const group = a.closest('[class*="group"], [class*="section"], [role="group"]');
              const header = group?.querySelector('[class*="header"], [class*="title"], h3, h4, strong');
              if (header && isOrgHeaderText(header.innerText || '')) {
                orgName = (header.innerText || '').trim().split('\\n')[0];
              }
              const img = a.querySelector('img');
              pushTeam(orgName, teamName, teamId, img?.src || null, a.getAttribute('href') || '');
            });
          }

          // Classic library left nav: school → team links with /library/{id}.
          if (!orgs.some((o) => o.teams.length)) {
            const left =
              document.querySelector('#leftColumnDiv, .library-left, [class*="team-list"]') ||
              document.body;
            let orgName = 'Organizations';
            left.querySelectorAll('a[href*="/library/"], a[href*="/manage/library/"]').forEach((a) => {
              const href = a.getAttribute('href') || '';
              const m = href.match(/\\/(?:manage\\/)?library\\/(\\d+)/);
              if (!m) return;
              const teamName = (a.innerText || '').trim().split('\\n')[0].trim();
              if (!teamName || teamName.length > 80) return;
              if (isOrgHeaderText(teamName)) {
                orgName = teamName;
                return;
              }
              pushTeam(orgName, teamName, m[1], a.querySelector('img')?.src || null, href);
            });
          }

          orgs.sort((a, b) => a.name.localeCompare(b.name));

          return {
            organizations: orgs.filter((o) => o.teams.length > 0),
            teams_count: orgs.reduce((n, o) => n + o.teams.length, 0),
            debug_link_count: document.querySelectorAll('[data-qa-id^="webnav-teamswitcher-team-"]').length,
          };
        }"""
    )

    try:
        await page.keyboard.press("Escape")
        await human_delay(0.3, 0.5)
    except Exception:
        pass

    organizations = (raw or {}).get("organizations") or []
    teams_count = int((raw or {}).get("teams_count") or 0)
    debug_links = int((raw or {}).get("debug_link_count") or 0)
    print(
        f"list_hudl_organizations: {len(organizations)} orgs, {teams_count} teams, "
        f"links={debug_links} opened={opened} (url={page.url})"
    )
    return {
        "status": "ok",
        "action": "list_hudl_organizations",
        "organizations": organizations,
        "organizations_count": len(organizations),
        "teams_count": teams_count,
        "url": page.url,
        "message": (
            f"Found {teams_count} teams across {len(organizations)} schools."
            if teams_count
            else "No Handshake teams found in the team switcher. Open Organizations again."
        ),
    }


async def switch_hudl_team_by_name(
    page: Page,
    team_name: str = "",
    org_name: str | None = None,
    team_id: str | None = None,
) -> dict:
    """Switch Hudl to a team and return its team id.

    Fast path: when team_id is known, go straight to /library/{id} (no navbar
    switcher). The switcher is often missing on blank lane tabs and was the
    main reason team switches felt stuck.
    """
    from app.automation.tasks.login import HUDL_HOME_URL

    name = (team_name or "").strip()
    explicit_id = (team_id or "").strip()

    if not name and not explicit_id.isdigit():
        raise ValueError("team_name or team_id is required")

    # ── Fast path: known id → direct team URL ───────────────────────────
    if explicit_id.isdigit():
        current = page.url or ""
        already_on_team = any(
            f"/{seg}/{explicit_id}" in current
            for seg in (
                "library",
                "manage/library",
                "manage/exchanges",
                "watch/team",
                "teams",
            )
        )
        if not already_on_team:
            library_url = f"https://www.hudl.com/library/{explicit_id}"
            try:
                await page.goto(
                    library_url, wait_until="domcontentloaded", timeout=20000
                )
                await human_delay(0.35, 0.6)
                await close_hudl_popups(page)
            except Exception as exc:
                print(f"Fast team switch library goto failed: {exc}")
                try:
                    manage_url = f"https://www.hudl.com/manage/library/{explicit_id}"
                    await page.goto(
                        manage_url, wait_until="domcontentloaded", timeout=20000
                    )
                    await human_delay(0.35, 0.6)
                    await close_hudl_popups(page)
                except Exception as exc2:
                    print(f"Fast team switch manage-library goto failed: {exc2}")

        resolved_id = _extract_team_id_from_url(page.url or "") or explicit_id
        print(
            f"Hudl team resolved (fast): name={name!r} org={org_name!r} "
            f"team_id={resolved_id} method=direct_url url={page.url}"
        )
        return {
            "status": "ok",
            "action": "switch_hudl_team",
            "team_name": name or f"team-{resolved_id}",
            "org_name": org_name,
            "team_id": resolved_id,
            "url": page.url,
            "library_action": _library_action_from_url(page.url or ""),
            "clicked": False,
            "method": "direct_url",
            "message": f"Switched Hudl to {name or resolved_id} (team_id={resolved_id})",
        }

    await _ensure_hudl_session(page, probe_url=HUDL_HOME_URL)
    await close_hudl_popups(page)

    # Prefer a page that shows the team switcher + library sidebar.
    if "hudl.com" not in (page.url or ""):
        await page.goto(HUDL_HOME_URL, wait_until="domcontentloaded", timeout=20000)
        await human_delay(1.0, 1.5)
        await close_hudl_popups(page)

    clicked = False
    method = None

    # Name-only path: modern navbar team switcher.
    if name:
        try:
            switcher = page.locator('[data-qa-id="webnav-teamswitcher-menu"]').first
            if await switcher.count() > 0:
                await switcher.click(timeout=6000)
                await human_delay(0.4, 0.7)
                option = page.locator(
                    '[data-qa-id^="webnav-teamswitcher-team-"]'
                ).filter(has_text=re.compile(rf"^{re.escape(name)}$", re.I)).first
                if await option.count() == 0:
                    option = page.get_by_role("link", name=re.compile(rf"^{re.escape(name)}$", re.I)).first
                if await option.count() == 0:
                    option = page.locator(
                        f'[data-qa-id^="webnav-teamswitcher-team-"]:has-text("{name}")'
                    ).first
                if await option.count() > 0:
                    qa = (await option.get_attribute("data-qa-id")) or ""
                    await option.click(timeout=8000)
                    await human_delay(1.0, 1.5)
                    await close_hudl_popups(page)
                    clicked = True
                    method = "team_switcher"
                    print(f"Hudl team switcher clicked: {name!r} ({qa})")
        except Exception as exc:
            print(f"Team switcher click by name failed: {exc}")

    # Classic / library left sidebar team list.
    if not clicked and name:
        try:
            if "/library/" not in (page.url or "") and "/manage/" not in (page.url or ""):
                await page.goto(
                    "https://www.hudl.com/home",
                    wait_until="domcontentloaded",
                    timeout=20000,
                )
                await human_delay(1.0, 1.5)
                await close_hudl_popups(page)

            candidates = [
                page.locator(f'text="{name}"').first,
                page.get_by_text(name, exact=True).first,
                page.locator(
                    f'a:has-text("{name}"), button:has-text("{name}"), li:has-text("{name}")'
                ).first,
            ]
            for loc in candidates:
                try:
                    if await loc.count() == 0:
                        continue
                    await loc.scroll_into_view_if_needed()
                    await loc.click(timeout=6000)
                    await human_delay(1.0, 1.5)
                    await close_hudl_popups(page)
                    clicked = True
                    method = "sidebar_text"
                    print(f"Hudl sidebar team clicked: {name!r}")
                    break
                except Exception:
                    continue
        except Exception as exc:
            print(f"Sidebar team click by name failed: {exc}")

    resolved_id = _extract_team_id_from_url(page.url or "")

    if not resolved_id:
        try:
            selected = page.locator(
                '[data-qa-id="webnav-teamswitcher-menu"] > a.hui-primaryteamswitcher__item--selected, '
                '[data-qa-id^="webnav-teamswitcher-team-"].hui-primaryteamswitcher__item--selected'
            ).first
            if await selected.count() > 0:
                qa = (await selected.get_attribute("data-qa-id")) or ""
                m = re.search(r"webnav-teamswitcher-team-(\d+)", qa)
                if m:
                    resolved_id = m.group(1)
        except Exception:
            pass

    if resolved_id:
        library_url = f"https://www.hudl.com/library/{resolved_id}"
        if (
            f"/library/{resolved_id}" not in (page.url or "")
            and f"/manage/library/{resolved_id}" not in (page.url or "")
        ):
            try:
                await page.goto(
                    library_url, wait_until="domcontentloaded", timeout=20000
                )
                await human_delay(0.5, 0.8)
                await close_hudl_popups(page)
                resolved_id = _extract_team_id_from_url(page.url or "") or resolved_id
            except Exception as exc:
                print(f"Post-switch library navigation failed: {exc}")

    if not resolved_id:
        raise ValueError(
            f"Clicked team {name or explicit_id!r} but could not read team id from Hudl URL "
            f"({page.url})."
        )

    print(
        f"Hudl team resolved: name={name!r} org={org_name!r} "
        f"team_id={resolved_id} method={method} url={page.url}"
    )
    return {
        "status": "ok",
        "action": "switch_hudl_team",
        "team_name": name or f"team-{resolved_id}",
        "org_name": org_name,
        "team_id": resolved_id,
        "url": page.url,
        "library_action": _library_action_from_url(page.url or ""),
        "clicked": clicked,
        "method": method,
        "message": f"Switched Hudl to {name or resolved_id} (team_id={resolved_id})",
    }


async def _navigate_to_manage_library(page: Page, team_id: str) -> str:
    """Open Hudl Classic Manage Library: https://www.hudl.com/manage/library/{team_id}"""
    from app.automation.tasks.permissions import raise_if_hudl_forbidden

    url = f"https://www.hudl.com/manage/library/{team_id}"
    # Skip reload when this lane is already on the target page.
    if f"/manage/library/{team_id}" in (page.url or ""):
        print(f"Already on Manage Library ({page.url}) — skipping reload")
        await close_hudl_popups(page)
        await raise_if_hudl_forbidden(page, team_id=team_id, area="library")
        return page.url

    print(f"Navigating to Manage Library at {url}")
    await page.goto(url, wait_until="domcontentloaded")
    # Selectors below are the real readiness gate — keep this short.
    await human_delay(0.4, 0.8)
    await close_hudl_popups(page)
    await raise_if_hudl_forbidden(page, team_id=team_id, area="library")

    for selector in (
        MANAGE_LIBRARY_CONTENT,
        MANAGE_LIBRARY_SECTION,
        ".manage-library-right-column",
        "ul.jstree-container-ul",
        '[data-qa-id="primary-navbar"]',
    ):
        try:
            await page.locator(selector).first.wait_for(state="attached", timeout=15000)
            break
        except Exception:
            continue

    return url


async def _wait_for_manage_library_tree(page: Page) -> None:
    """Wait for the Manage Library tree (jstree or classic toggle tree)."""
    selectors = (
        "#libraryView ul.jstree-container-ul",
        ".manage-library-right-column ul.jstree-container-ul",
        "ul.jstree-container-ul",
        ".tree ul.t2",
        "li.toggle",
        MANAGE_LIBRARY_CONTENT,
        MANAGE_LIBRARY_SECTION,
    )
    # Fast path: tree already mounted (typical after sticky reuse).
    for selector in selectors:
        try:
            await page.locator(selector).first.wait_for(state="attached", timeout=600)
            return
        except Exception:
            continue
    for selector in selectors:
        try:
            await page.locator(selector).first.wait_for(state="attached", timeout=12000)
            return
        except Exception:
            continue


async def _expand_manage_library_tree(page: Page, max_clicks: int = 20) -> None:
    """Expand a limited number of collapsed tree nodes (avoids hanging on huge libraries)."""
    clicks = 0
    while clicks < max_clicks:
        closed_icons = page.locator(".jstree-closed > .jstree-ocl")
        closed_count = await closed_icons.count()
        if closed_count == 0:
            break

        try:
            await closed_icons.first.click(timeout=2000)
            clicks += 1
            await asyncio.sleep(0.15)
        except Exception:
            break


async def _parse_manage_library_page(page: Page) -> dict:
    try:
        return await page.evaluate(
        """() => {
            const monthRe = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d{1,2}\\b/i;
            const hrRe = /([\\d.]+)\\s*hr\\b/i;
            const hoursRe = /([\\d.]+)\\s*hours?\\b/i;
            const usageRe = /([\\d.]+)\\s+of\\s+([\\d.]+)\\s+hours?\\s*\\((\\d+)%\\)\\s+used/i;
            const seasonRe = /^(\\d{4}-\\d{4})(?:\\s+Season)?$/i;

            const leftCol =
                document.querySelector('.manage-library-left-column') ||
                document.querySelector('#rightColumnDiv');
            const rightCol =
                document.querySelector('.manage-library-right-column') ||
                document.querySelector('.ml__section')?.closest('.manage-library-right-column') ||
                document.querySelector('.ml__section');

            const leftText = leftCol?.innerText || '';
            const usageMatch = leftText.match(usageRe);

            const storage = {
                used_hours: usageMatch ? parseFloat(usageMatch[1]) : null,
                total_hours: usageMatch ? parseFloat(usageMatch[2]) : null,
                usage_percent: usageMatch ? parseInt(usageMatch[3], 10) : null,
                breakdown: [],
            };

            if (leftCol) {
                const lines = leftText.split('\\n').map((l) => l.trim()).filter(Boolean);
                for (const line of lines) {
                    const hoursMatch = line.match(hoursRe);
                    if (!hoursMatch) continue;
                    const hours = parseFloat(hoursMatch[1]);
                    const label = line.replace(hoursMatch[0], '').trim();
                    if (!label || /storage hours|refresh|current video|deleted video|learn more/i.test(label)) continue;
                    storage.breakdown.push({ name: label, hours });
                }
            }

            const parseLabelMeta = (rawLabel, rowText) => {
                let label = (rawLabel || '').trim();
                let count = null;
                const countMatch = label.match(/\\((\\d+)\\)\\s*$/);
                if (countMatch) {
                    count = parseInt(countMatch[1], 10);
                    label = label.replace(/\\(\\d+\\)\\s*$/, '').trim();
                }
                const text = (rowText || label).replace(/\\s+/g, ' ').trim();
                const dateMatch = text.match(monthRe);
                const hrMatch = text.match(hrRe);
                if (dateMatch) label = label.replace(dateMatch[0], '').trim();
                if (hrMatch) label = label.replace(hrMatch[0], '').trim();
                label = label.replace(/\\s{2,}/g, ' ').trim();
                return {
                    label,
                    date: dateMatch ? dateMatch[0] : undefined,
                    hours: hrMatch ? parseFloat(hrMatch[1]) : undefined,
                    count,
                };
            };

            const parseJstreeLi = (li, path) => {
                const anchor = li.querySelector(':scope > a.jstree-anchor') || li.querySelector(':scope > a');
                const rowText = (li.innerText || '').replace(/\\s+/g, ' ').trim();
                const hrMatch = rowText.match(/([\\d.]+)\\s*hr\\b/i);
                let rawLabel = (anchor?.textContent || '').trim();
                if (!rawLabel) {
                    rawLabel = rowText.replace(/[\\d.]+\\s*hr\\b/i, '').trim();
                }
                const meta = parseLabelMeta(rawLabel, rowText);
                if (hrMatch && meta.hours == null) {
                    meta.hours = parseFloat(hrMatch[1]);
                }
                if (meta.date) meta.label = meta.label.replace(meta.date, '').replace(/\\s{2,}/g,' ').trim();
                const nodeId = li.id || anchor?.id || path;
                const seasonId = meta.label.match(/(\\d{4}-\\d{4})/)?.[1] || nodeId;
                return { id: seasonId, ...meta, children: [] };
            };

            let seasons = [];
            const libraryView = document.querySelector('#libraryView');
            const jstreeRoot =
                libraryView?.querySelector('ul.jstree-container-ul') ||
                rightCol?.querySelector('ul.jstree-container-ul') ||
                document.querySelector('ul.jstree-container-ul');
            if (jstreeRoot) {
                seasons = Array.from(jstreeRoot.querySelectorAll(':scope > li')).map((li, idx) =>
                    parseJstreeLi(li, String(idx))
                );
            }

            // Classic Manage Library table rows fallback
            if (seasons.length === 0 && rightCol) {
                const rows = rightCol.querySelectorAll('table tr, .ml__section tr');
                rows.forEach((row, idx) => {
                    const rowText = (row.innerText || '').replace(/\\s+/g, ' ').trim();
                    if (!/season/i.test(rowText)) return;
                    const meta = parseLabelMeta(rowText, rowText);
                    if (!meta.label) return;
                    const seasonId = meta.label.match(/(\\d{4}-\\d{4})/)?.[1] || String(idx);
                    seasons.push({ id: seasonId, ...meta, children: [] });
                });
            }

            // Body lines fallback — Classic Manage Library lists seasons as text rows
            if (seasons.length === 0) {
                const lines = (rightCol?.innerText || document.body?.innerText || '')
                    .split('\\n').map((l) => l.trim()).filter(Boolean);
                for (let i = 0; i < lines.length; i++) {
                    const m = lines[i].match(/^(\\d{4}-\\d{4})\\s+Season$/i);
                    if (!m) continue;
                    let hours = null;
                    if (i + 1 < lines.length) {
                        const hrLine = lines[i + 1].match(/^([\\d.]+)\\s*hr$/i);
                        if (hrLine) hours = parseFloat(hrLine[1]);
                    }
                    seasons.push({
                        id: m[1],
                        label: `${m[1]} Season`,
                        hours,
                        children: [],
                    });
                }
            }

            // Sort seasons descending (newest first) like Hudl
            seasons.sort((a, b) => (a.id < b.id ? 1 : -1));

            return { storage, seasons };
        }"""
        )
    except Exception as exc:
        print(f"Manage Library parse failed: {exc}")
        return {"storage": {}, "seasons": []}


_SEASON_TREE_STATE_JS = """
(seasonLabel) => {
    const monthRe = new RegExp('(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+(\\d{1,2})(?!\\d)', 'i');
    const gluedMonthRe = new RegExp('(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(\\d{1,2})(?!\\d)', 'i');
    const wordGluedMonthRe = new RegExp('[A-Za-z](Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+(\\d{1,2})(?!\\d)', 'i');
    const trailingMonthRe = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/i;
    const hrRe = /([\\d.]+)\\s*hr\\b/i;
    const seasonYearRe = /(\\d{4}-\\d{4})/;
    const stripCount = (s) => (s || '').replace(/\\s*\\(\\d+\\)\\s*$/, '').trim();
    const normalize = (s) =>
        stripCount((s || '').trim())
            .replace(/\\s+Season$/i, '')
            .replace(hrRe, '')
            .replace(monthRe, '')
            .replace(gluedMonthRe, '')
            .replace(trailingMonthRe, '')
            .replace(/\\s{2,}/g, ' ')
            .trim();
    const seasonKey = (s) => {
        const m = normalize(s).match(seasonYearRe);
        return m ? m[1] : normalize(s);
    };
    const getLiLabel = (li) => {
        const anchor = li.querySelector(':scope > a.jstree-anchor') || li.querySelector(':scope > a');
        if (anchor) return (anchor.textContent || '').trim();
        const clone = li.cloneNode(true);
        clone.querySelector('.toggler')?.remove();
        clone.querySelector('ul')?.remove();
        clone.querySelector('.jstree-ocl')?.remove();
        return (clone.textContent || '').replace(/\\s+/g, ' ').trim();
    };
    const labelsMatch = (a, b) => {
        const na = normalize(a);
        const nb = normalize(b);
        if (!na || !nb) return false;
        if (na === nb) return true;
        const ya = seasonKey(a);
        const yb = seasonKey(b);
        if (ya && yb && ya === yb) return true;
        return na.includes(nb) || nb.includes(na);
    };
    const target = seasonKey(seasonLabel);
    const root =
        document.querySelector('#libraryView') ||
        document.querySelector('.manage-library-right-column') ||
        document.querySelector('.tree') ||
        document.body;
    const selectors = [
        '#libraryView ul.jstree-container-ul > li',
        '#libraryView > ul > li',
        '.tree > ul.t2 > li',
        '#libraryView ul.t2 > li',
        'ul.jstree-container-ul > li',
        'li.toggle',
        'li.jstree-node',
    ];
    let seasonLi = null;
    for (const sel of selectors) {
        for (const li of root.querySelectorAll(sel)) {
            const raw = getLiLabel(li);
            if (labelsMatch(raw, seasonLabel) || labelsMatch(raw, target)) {
                seasonLi = li;
                break;
            }
        }
        if (seasonLi) break;
    }
    if (!seasonLi) return { found: false, expanded: false, childCount: 0 };
    const childUl = seasonLi.querySelector(':scope > ul');
    const childCount = childUl ? childUl.querySelectorAll(':scope > li').length : 0;
    // Only treat as expanded when Hudl shows the open state — not merely because
    // children exist in the DOM while the row still displays "+".
    const expanded =
        seasonLi.classList.contains('jstree-open') ||
        (seasonLi.classList.contains('toggle') && seasonLi.classList.contains('expanded'));
    return { found: true, expanded, childCount };
}
"""


async def _get_season_tree_state(page: Page, season_label: str) -> dict:
    try:
        return await page.evaluate(_SEASON_TREE_STATE_JS, season_label)
    except Exception:
        return {"found": False, "expanded": False, "childCount": 0}


_CLICK_EXPAND_SEASON_JS = """
(seasonLabel) => {
    const seasonYearRe = /(\\d{4}-\\d{4})/;
    const stripCount = (s) => (s || '').replace(/\\s*\\(\\d+\\)\\s*$/, '').trim();
    const normalize = (s) => stripCount(s).replace(/\\s+Season$/i, '').replace(/[\\d.]+\\s*hr\\b/i, '').replace(/\\s{2,}/g, ' ').trim();
    const seasonKey = (s) => {
        const m = normalize(s).match(seasonYearRe);
        return m ? m[1] : normalize(s);
    };
    const labelsMatch = (a, b) => {
        const na = normalize(a);
        const nb = normalize(b);
        if (!na || !nb) return false;
        if (na === nb) return true;
        const ya = seasonKey(a);
        const yb = seasonKey(b);
        return Boolean(ya && yb && ya === yb);
    };
    const getLiLabel = (li) => {
        const anchor = li.querySelector(':scope > a.jstree-anchor') || li.querySelector(':scope > a');
        if (anchor) return (anchor.textContent || '').trim();
        const clone = li.cloneNode(true);
        clone.querySelector('.toggler')?.remove();
        clone.querySelector('ul')?.remove();
        clone.querySelector('.jstree-ocl')?.remove();
        return (clone.textContent || '').replace(/\\s+/g, ' ').trim();
    };
    const flash = (li) => {
        const prev = li.getAttribute('style') || '';
        li.setAttribute('style', prev + ';outline:3px solid #0066cc!important;background:#c5ddf3!important;');
        setTimeout(() => { try { li.setAttribute('style', prev); } catch (_) {} }, 2500);
    };
    // TOP-LEVEL seasons only — never match nested folders under another year.
    const topLists = [
        ...document.querySelectorAll('.tree ul.t2'),
        ...document.querySelectorAll('#libraryView ul.t2'),
        ...document.querySelectorAll('#libraryView > ul'),
        ...document.querySelectorAll('.manage-library-right-column ul.jstree-container-ul'),
        ...document.querySelectorAll('ul.jstree-container-ul'),
    ];
    const seen = new Set();
    const candidates = [];
    for (const ul of topLists) {
        for (const li of Array.from(ul.children || [])) {
            if (!(li instanceof HTMLElement) || li.tagName !== 'LI' || seen.has(li)) continue;
            seen.add(li);
            candidates.push(li);
        }
    }
    const targetYear = seasonKey(seasonLabel);
    let seasonLi = null;
    for (const li of candidates) {
        const raw = getLiLabel(li);
        if (labelsMatch(raw, seasonLabel) || seasonKey(raw) === targetYear) {
            seasonLi = li;
            break;
        }
    }
    if (!seasonLi) return { ok: false, reason: 'season_not_found', scanned: candidates.length };
    try { seasonLi.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {}
    flash(seasonLi);

    const isOpen =
        seasonLi.classList.contains('jstree-open') ||
        (seasonLi.classList.contains('toggle') && seasonLi.classList.contains('expanded'));

    const anchor = seasonLi.querySelector(':scope > a.jstree-anchor, :scope > a');
    let clicked = 'flash';
    if (!isOpen) {
        const ocl = seasonLi.querySelector(':scope > .jstree-ocl');
        const toggler = seasonLi.querySelector(':scope > span .toggler, :scope > .toggler, :scope > span > .toggler');
        if (ocl) { ocl.click(); clicked = 'ocl'; }
        else if (toggler) { toggler.click(); clicked = 'toggler'; }
        else if (anchor) { anchor.click(); clicked = 'anchor'; }
        else { seasonLi.click(); clicked = 'li'; }
    }

    // Keep the opened year under the red banner (do not leave viewport on 2026-2027).
    try { seasonLi.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {}
    const childUl = seasonLi.querySelector(':scope > ul');
    const childCount = childUl ? childUl.querySelectorAll(':scope > li').length : 0;
    return { ok: true, alreadyOpen: isOpen, clicked, childCount, year: targetYear };
}
"""


async def _click_and_expand_season(page: Page, season_label: str) -> dict:
    """Click the exact season year on Hudl so the headed browser mirrors frontend."""
    # Primary: Classic Manage Library walker (same selectors as working parser).
    try:
        result = await _classic_mark_and_click(page, season_label, [])
        if result.get("ok"):
            for _ in range(15):
                state = await _get_season_tree_state(page, season_label)
                if state.get("expanded") or state.get("childCount", 0) > 0:
                    return {
                        "ok": True,
                        "clicked": "classic",
                        "childCount": state.get("childCount", 0),
                    }
                await asyncio.sleep(0.2)
            return {"ok": True, "clicked": "classic", "childCount": 0}
    except Exception as exc:
        print(f"Classic season click failed: {exc}")

    year_match = re.search(r"(\d{4}-\d{4})", season_label)
    year = year_match.group(1) if year_match else season_label.strip()

    # Prefer Classic Manage Library top-level seasons (screenshot tree).
    # Read ONLY the row label (not nested innerText) so huge open years stay fast.
    try:
        matched = await page.evaluate(
            """(year) => {
                const getLabel = (li) => {
                    const a = li.querySelector(':scope > a.jstree-anchor, :scope > a');
                    if (a) return (a.textContent || '').trim();
                    const clone = li.cloneNode(true);
                    clone.querySelector('ul')?.remove();
                    clone.querySelector('.toggler')?.remove();
                    return (clone.textContent || '').replace(/\\s+/g, ' ').trim();
                };
                const candidates = Array.from(document.querySelectorAll(
                    '#libraryView li.toggle, .tree li.toggle, .tree ul.t2 > li, #libraryView ul.t2 > li'
                ));
                let best = null;
                let bestDepth = Infinity;
                for (const li of candidates) {
                    const label = getLabel(li);
                    if (!label.includes(year)) continue;
                    if (!/\\d{4}-\\d{4}/.test(label) && !/Season/i.test(label)) continue;
                    let depth = 0;
                    let p = li.parentElement;
                    while (p) {
                        if (p.matches && p.matches('li.toggle, li.jstree-node')) depth += 1;
                        p = p.parentElement;
                    }
                    if (depth < bestDepth) {
                        bestDepth = depth;
                        best = li;
                    }
                }
                if (!best) return { found: false };
                best.setAttribute('data-sergio-season', year);
                return {
                    found: true,
                    label: getLabel(best),
                    open: best.classList.contains('expanded') || best.classList.contains('jstree-open'),
                };
            }""",
            year,
        )
        if matched.get("found"):
            target = page.locator(f'li[data-sergio-season="{year}"]').first
            await target.scroll_into_view_if_needed()
            await asyncio.sleep(0.2)
            if not matched.get("open"):
                toggler = target.locator(
                    ":scope > span .toggler, :scope > .toggler, "
                    ":scope > .jstree-ocl, :scope > span > .toggler"
                ).first
                if await toggler.count() > 0:
                    await toggler.click(timeout=8000, force=True)
                    print(f"Season '{season_label}': Playwright clicked + for {year}")
                else:
                    await target.click(timeout=8000, force=True)
                    print(f"Season '{season_label}': Playwright clicked row for {year}")
                await asyncio.sleep(0.55)
            else:
                print(f"Season '{season_label}': already open — scrolled into view")

            await target.scroll_into_view_if_needed()
            await asyncio.sleep(0.35)
            for _ in range(20):
                state = await _get_season_tree_state(page, season_label)
                if state.get("expanded") or state.get("childCount", 0) > 0:
                    return {
                        "ok": True,
                        "clicked": "playwright_locator",
                        "childCount": state.get("childCount", 0),
                    }
                await asyncio.sleep(0.2)
            return {"ok": True, "clicked": "playwright_locator", "childCount": 0}
    except Exception as exc:
        print(f"Season Playwright locator click failed: {exc}")

    # JS fallback (also top-level only)
    try:
        result = await page.evaluate(_CLICK_EXPAND_SEASON_JS, season_label)
    except Exception as exc:
        result = {"ok": False, "reason": str(exc)}

    if not result.get("ok"):
        clicked = await _click_manage_library_season(page, season_label)
        if not clicked:
            raise ValueError(f"Could not find season '{season_label}' on Manage Library page")
        print(f"Season '{season_label}': clicked via locator fallback")
        await asyncio.sleep(0.35)
        return {"ok": True, "clicked": "fallback"}

    if result.get("alreadyOpen"):
        print(
            f"Season '{season_label}': focused on Hudl "
            f"({result.get('childCount', 0)} children, already open)"
        )
        await asyncio.sleep(0.35)
        return result

    print(f"Season '{season_label}': clicked on Hudl via {result.get('clicked')}")
    await asyncio.sleep(0.45)
    for _ in range(20):
        state = await _get_season_tree_state(page, season_label)
        if state.get("expanded") or state.get("childCount", 0) > 0:
            return result
        await asyncio.sleep(0.2)
    return result


async def _click_manage_library_season(page: Page, label: str) -> bool:
    """Click/expand a season row on Classic Manage Library (locator fallback)."""
    state = await _get_season_tree_state(page, label)
    if state.get("found") and state.get("expanded"):
        return True

    year_match = re.search(r"(\d{4}-\d{4})", label)
    year = year_match.group(1) if year_match else None

    selectors = [
        f'.manage-library-right-column .jstree-anchor:text-is("{label}")',
        f'.manage-library-right-column .jstree-anchor:has-text("{label}")',
        f'.manage-library-right-column >> text="{label}"',
    ]
    if year:
        selectors.extend([
            f'.manage-library-right-column li:has(.jstree-anchor:has-text("{year}"))',
            f'li:has(.jstree-anchor:has-text("{year}"))',
        ])

    for sel in selectors:
        try:
            loc = page.locator(sel).first
            if await loc.count() > 0:
                li = loc if "li:" in sel else loc.locator("xpath=ancestor-or-self::li[1]").first
                if await li.count() == 0:
                    li = loc
                ocl = li.locator(":scope > .jstree-ocl").first
                if await ocl.count() > 0:
                    classes = (await li.get_attribute("class")) or ""
                    if "jstree-closed" in classes:
                        await ocl.click(timeout=8000)
                        return True
                    return True
                classes = (await li.get_attribute("class")) or ""
                if "collapsed" in classes:
                    await loc.click(timeout=8000)
                    return True
                if "expanded" in classes or "jstree-open" in classes:
                    return True
                await loc.click(timeout=8000)
                return True
        except Exception:
            continue

    try:
        search = year or label
        season_li = page.locator(f'li:has(.jstree-anchor:has-text("{search}"))').first
        if await season_li.count() > 0:
            classes = (await season_li.get_attribute("class")) or ""
            if "jstree-open" in classes or "expanded" in classes:
                return True
            ocl = season_li.locator(":scope > .jstree-ocl, .jstree-closed > .jstree-ocl").first
            if await ocl.count() > 0:
                await ocl.click(timeout=8000)
                return True
            anchor = season_li.locator(".jstree-anchor").first
            if await anchor.count() > 0:
                await anchor.click(timeout=8000)
                return True
    except Exception:
        pass

    try:
        loc = page.get_by_text(label, exact=True).first
        if await loc.count() > 0:
            await loc.click(timeout=8000)
            return True
    except Exception:
        pass

    return False


async def _parse_season_children_from_page(page: Page, season_label: str) -> list:
    """Parse folder/video rows under an expanded season (jstree, table, or text lines)."""
    try:
        return await page.evaluate(
            """(seasonLabel) => {
                const monthRe = new RegExp('(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+(\\d{1,2})(?!\\d)', 'i');
                const gluedMonthRe = new RegExp('(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(\\d{1,2})(?!\\d)', 'i');
                const trailingMonthRe = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/i;
                const hrRe = /([\\d.]+)\\s*hr\\b/i;
                const seasonRe = /^\\d{4}-\\d{4}\\s+Season$/i;
                const norm = (s) => (s || '').replace(/\\s+Season$/i, '').trim();

                const extractDate = (text) => {
                    const t = (text || '').replace(/\\s+/g, ' ').trim().split(hrRe)[0].trim();
                    const full = t.match(monthRe);
                    if (full) return `${full[1]} ${full[2]}`;
                    const glued = t.match(gluedMonthRe);
                    if (glued) return `${glued[1]} ${glued[2]}`;
                    const wordGlued = t.match(new RegExp('[A-Za-z](Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+(\\d{1,2})(?!\\d)', 'i'));
                    if (wordGlued) return `${wordGlued[1]} ${wordGlued[2]}`;
                    const trailing = t.match(trailingMonthRe);
                    if (trailing) return trailing[0];
                    return undefined;
                };

                const stripDates = (text) => {
                    let value = (text || '').replace(/\\s+/g, ' ').trim();
                    value = value
                        .replace(monthRe, '')
                        .replace(gluedMonthRe, '')
                        .replace(/([A-Za-z])(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/gi, '$1')
                        .replace(trailingMonthRe, '')
                        .replace(hrRe, '');
                    return value.replace(/\\s{2,}/g, ' ').trim();
                };

                const isFolderLi = (li) => {
                    if (li.classList.contains('toggle')) return true;
                    if (li.classList.contains('jstree-closed') || li.classList.contains('jstree-open')) return true;
                    if (li.querySelector(':scope > .toggler, :scope > span .toggler')) return true;
                    if (li.querySelector(':scope > .jstree-ocl')) return true;
                    return false;
                };

                const cleanLabel = (label, rowText) => {
                    const text = (rowText || label || '').replace(/\\s+/g, ' ').trim();
                    let value = stripDates(label);
                    const hrMatch = text.match(hrRe);
                    if (hrMatch) value = value.replace(hrMatch[0], '').trim();
                    return value.replace(/\\s{2,}/g, ' ').trim();
                };

                const parseMeta = (label, rowText) => {
                    const text = (rowText || label || '').replace(/\\s+/g, ' ').trim();
                    let name = cleanLabel(label, rowText);
                    let count = null;
                    const countMatch = name.match(/\\((\\d+)\\)\\s*$/);
                    if (countMatch) {
                        count = parseInt(countMatch[1], 10);
                        name = name.replace(/\\(\\d+\\)\\s*$/, '').trim();
                    }
                    const date = extractDate(text);
                    const hrMatch = text.match(hrRe);
                    return {
                        label: name,
                        date,
                        hours: hrMatch ? parseFloat(hrMatch[1]) : undefined,
                        count,
                    };
                };

                const getLiLabel = (li) => {
                    const anchor = li.querySelector(':scope > a.jstree-anchor') || li.querySelector(':scope > a');
                    if (anchor) return (anchor.textContent || '').trim();
                    const clone = li.cloneNode(true);
                    clone.querySelector('.toggler')?.remove();
                    clone.querySelector('ul')?.remove();
                    return (clone.textContent || '').replace(/\\s+/g, ' ').trim();
                };

                const parseJstreeLi = (li, path) => {
                    const rowText = (li.innerText || '').replace(/\\s+/g, ' ').trim();
                    const meta = parseMeta(getLiLabel(li), rowText);
                    const nodeId =
                        li.id ||
                        li.getAttribute('data-cat-id') ||
                        li.getAttribute('data-categoryid') ||
                        path;
                    const isLeaf =
                        !isFolderLi(li) &&
                        (li.classList.contains('jstree-leaf') ||
                            Boolean(li.querySelector('.jstree-checkbox, input[type="checkbox"]')));
                    if (isLeaf) {
                        return { id: String(nodeId), ...meta, selectable: true, children: [] };
                    }
                    return { id: String(nodeId), ...meta, children: [] };
                };

                const rightCol =
                    document.querySelector('.manage-library-right-column') ||
                    document.querySelector('.ml__section') ||
                    document.body;

                // 1) jstree under season node
                const hrRe2 = /([\\d.]+)\\s*hr\\b/i;
                const seasonYearRe = /(\\d{4}-\\d{4})/;
                const normalize = (s) => (s || '').replace(/\\s+Season$/i, '').replace(hrRe2, '').replace(monthRe, '').replace(/\\s*\\(\\d+\\)\\s*$/, '').replace(/\\s{2,}/g, ' ').trim();
                const seasonKey = (s) => {
                    const m = normalize(s).match(seasonYearRe);
                    return m ? m[1] : normalize(s);
                };
                const target = seasonKey(seasonLabel);
                const anchors = Array.from(document.querySelectorAll('.jstree-anchor'));
                const seasonAnchor = anchors.find((a) => {
                    const raw = (a.textContent || '').trim();
                    return seasonKey(raw) === target || normalize(raw) === normalize(seasonLabel);
                });
                if (seasonAnchor) {
                    const seasonLi = seasonAnchor.closest('li');
                    const childUl = seasonLi?.querySelector(':scope > ul');
                    if (childUl) {
                        return Array.from(childUl.querySelectorAll(':scope > li')).map((li, idx) =>
                            parseJstreeLi(li, `${seasonLabel}-${idx}`)
                        );
                    }
                }

                const toggleLis = Array.from(
                    document.querySelectorAll('#libraryView li.toggle, .tree li.toggle, .tree ul.t2 > li')
                );
                const seasonToggle = toggleLis.find((li) => {
                    const raw = getLiLabel(li);
                    return seasonKey(raw) === target || normalize(raw) === normalize(seasonLabel);
                });
                if (seasonToggle) {
                    const childUl = seasonToggle.querySelector(':scope > ul');
                    if (childUl) {
                        return Array.from(childUl.querySelectorAll(':scope > li')).map((li, idx) =>
                            parseJstreeLi(li, `${seasonLabel}-toggle-${idx}`)
                        );
                    }
                }

                // 2) Table rows after season header
                const children = [];
                const rows = Array.from(rightCol.querySelectorAll('tr'));
                let pastSeason = false;
                for (const row of rows) {
                    const rowText = (row.innerText || '').replace(/\\s+/g, ' ').trim();
                    if (!rowText) continue;
                    if (rowText === seasonLabel || rowText.startsWith(seasonLabel)) {
                        pastSeason = true;
                        continue;
                    }
                    if (pastSeason && seasonRe.test(rowText)) break;
                    if (!pastSeason || seasonRe.test(rowText)) continue;
                    const meta = parseMeta(rowText.replace(hrRe, '').replace(monthRe, ''), rowText);
                    if (!meta.label || /current video/i.test(meta.label)) continue;
                    children.push({ id: `${seasonLabel}-${children.length}`, ...meta, children: [] });
                }
                if (children.length) return children;

                // 3) Plain text lines (Hudl Classic fallback)
                const lines = (rightCol.innerText || '').split('\\n').map((l) => l.trim()).filter(Boolean);
                pastSeason = false;
                for (let i = 0; i < lines.length; i++) {
                    const line = lines[i];
                    if (line === seasonLabel || line === seasonLabel.replace(' Season', '')) {
                        pastSeason = true;
                        continue;
                    }
                    if (pastSeason && seasonRe.test(line)) break;
                    if (!pastSeason || seasonRe.test(line)) continue;
                    if (/^current video$/i.test(line)) continue;
                    if (/^[\d.]+\s*hr$/i.test(line)) continue;

                    const combined = [line, lines[i + 1] || '', lines[i + 2] || ''].join(' ');
                    let meta = parseMeta(line, combined);

                    if (!meta.date && lines[i + 1] && monthRe.test(lines[i + 1])) {
                        meta.date = lines[i + 1].match(monthRe)[0];
                        i += 1;
                        if (lines[i + 1] && hrRe.test(lines[i + 1])) {
                            meta.hours = parseFloat(lines[i + 1].match(hrRe)[1]);
                            i += 1;
                        }
                    } else if (!meta.hours && lines[i + 1] && hrRe.test(lines[i + 1])) {
                        meta.hours = parseFloat(lines[i + 1].match(hrRe)[1]);
                        i += 1;
                    }

                    if (!meta.label) continue;
                    children.push({ id: `${seasonLabel}-${children.length}`, ...meta, children: [] });
                }
                return children;
            }""",
            season_label,
        )
    except Exception as exc:
        print(f"Season children parse failed: {exc}")
        return []


async def _parse_jstree_season_children(page: Page, season_label: str) -> list:
    return await _parse_season_children_from_page(page, season_label)


async def _expand_season_nested_nodes(page: Page, season_label: str, max_clicks: int = 20) -> None:
    """Expand collapsed jstree folders under an already-open season."""
    try:
        season_li = page.locator(f'li:has(.jstree-anchor:has-text("{season_label}"))').first
        if await season_li.count() == 0:
            return
        clicks = 0
        while clicks < max_clicks:
            closed = season_li.locator(".jstree-closed > .jstree-ocl")
            if await closed.count() == 0:
                break
            await closed.first.click(timeout=2000)
            clicks += 1
            await asyncio.sleep(0.15)
    except Exception as exc:
        print(f"Nested season expand failed: {exc}")


async def _parse_expanded_season_children(page: Page, season_label: str) -> list:
    children = await _parse_jstree_season_children(page, season_label)
    if children:
        return children

    try:
        return await page.evaluate(
            """(seasonLabel) => {
                const monthRe = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d{1,2}\\b/i;
                const hrRe = /([\\d.]+)\\s*hr\\b/i;
                const children = [];
                const seen = new Set();

                const isSeasonRow = (text) => /^\\d{4}-\\d{4}(\\s+Season)?$/i.test(text.trim());

                const rows = Array.from(document.querySelectorAll(
                    '.manage-library-right-column li, .manage-library-right-column tr, .jstree-anchor'
                ));

                let pastSeason = false;
                for (const el of rows) {
                    const text = (el.textContent || '').trim().replace(/\\s+/g, ' ');
                    if (!text || text.length > 120) continue;

                    if (text === seasonLabel || text === seasonLabel.replace(' Season', '')) {
                        pastSeason = true;
                        continue;
                    }
                    if (pastSeason && isSeasonRow(text)) break;

                    if (!pastSeason) continue;
                    if (isSeasonRow(text)) continue;
                    if (seen.has(text)) continue;

                    const dateMatch = text.match(monthRe);
                    const hrMatch = text.match(hrRe);
                    const countMatch = text.match(/\\((\\d+)\\)\\s*$/);

                    seen.add(text);
                    children.push({
                        id: `${seasonLabel}-${children.length}`,
                        label: countMatch ? text.replace(/\\(\\d+\\)\\s*$/, '').trim() : text,
                        date: dateMatch ? dateMatch[0] : undefined,
                        hours: hrMatch ? parseFloat(hrMatch[1]) : undefined,
                        count: countMatch ? parseInt(countMatch[1], 10) : undefined,
                        children: [],
                    });

                    if (children.length >= 40) break;
                }

                return children;
            }""",
            season_label,
        )
    except Exception as exc:
        print(f"Season expand parse failed: {exc}")
        return []


async def _ensure_hudl_session(page: Page, *, probe_url: str | None = None) -> None:
    """Ensure Playwright browser is logged into Hudl before automation.

    Blank / about:blank lane tabs are NOT treated as logged-out — cookies live on
    the shared browser context. Prefer probing a Hudl URL (or the current page)
    and only run a full login if Hudl actually shows the identity form.
    """
    from app.automation.tasks.login import is_hudl_logged_in, is_login_page

    if await is_login_page(page):
        print("Hudl session expired — running login before Manage Library automation...")
        await login_run(page)
        await close_hudl_popups(page)
        return

    if await is_hudl_logged_in(page):
        return

    # New lane tabs start on about:blank. Shared cookies still authenticate —
    # probe a Hudl URL instead of forcing a full login dance.
    if probe_url:
        try:
            await page.goto(probe_url, wait_until="domcontentloaded", timeout=15000)
            await human_delay(0.3, 0.6)
        except Exception as exc:
            print(f"Manage Library session probe failed: {exc}")

    if await is_login_page(page):
        print("Hudl session expired — running login before Manage Library automation...")
        await login_run(page)
        await close_hudl_popups(page)
        return

    if await is_hudl_logged_in(page):
        print(f"Hudl session reused via cookies (page={page.url})")
        return

    # Last resort: full login (e.g. cookies cleared / daily session reset).
    print("Hudl session not active — running login before Manage Library automation...")
    await login_run(page)
    await close_hudl_popups(page)


async def _bring_hudl_page_to_front(page: Page) -> None:
    """Make the Playwright Hudl window visible so demo clicks are obvious."""
    try:
        await page.bring_to_front()
    except Exception:
        pass


# Classic Manage Library tree walker — same discovery path as the working season parser.
_CLASSIC_MARK_NODE_JS = """
({ seasonLabel, folderPath, collapseOthers }) => {
    const seasonYearRe = /(\\d{4}-\\d{4})/;
    const stripCount = (s) => (s || '').replace(/\\s*\\(\\d+\\)\\s*$/, '').trim();
    const normalize = (s) =>
        stripCount(s)
            .replace(/\\s+Season$/i, '')
            .replace(/[\\d.]+\\s*hr\\b/i, '')
            .replace(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d{1,2}\\b/gi, '')
            .replace(/\\s{2,}/g, ' ')
            .trim();
    const seasonKey = (s) => {
        const m = normalize(s).match(seasonYearRe);
        return m ? m[1] : normalize(s);
    };
    const getLabel = (li) => {
        const a = li.querySelector(':scope > a.jstree-anchor, :scope > a, :scope > span > a');
        if (a) return (a.textContent || '').trim();
        const clone = li.cloneNode(true);
        clone.querySelectorAll('ul, .toggler, .jstree-ocl').forEach((n) => n.remove());
        return (clone.textContent || '').replace(/\\s+/g, ' ').trim();
    };
    const directToggler = (li) =>
        li.querySelector(
            ':scope > .toggler, :scope > span > .toggler, :scope > span .toggler, :scope > .jstree-ocl'
        );
    const isOpen = (li) => {
        const t = directToggler(li);
        const txt = ((t && t.textContent) || '').replace(/\\s+/g, '');
        if (txt.indexOf('-') !== -1) return true;
        return (
            li.classList.contains('expanded') ||
            li.classList.contains('jstree-open')
        );
    };
    const toggleDepth = (li) => {
        let d = 0;
        let p = li.parentElement;
        while (p) {
            if (p.matches && p.matches('li.toggle, li.jstree-node, li.jstree-open, li.jstree-closed')) d += 1;
            p = p.parentElement;
        }
        return d;
    };
    const collapseOtherSeasons = (keepYear) => {
        const topCandidates = Array.from(
            document.querySelectorAll(
                '#libraryView li.toggle, .tree li.toggle, .tree ul.t2 > li, #libraryView ul.t2 > li, #libraryView > ul > li'
            )
        );
        for (const li of topCandidates) {
            const raw = getLabel(li);
            if (!seasonYearRe.test(raw) && !/Season/i.test(raw)) continue;
            if ((raw || '').includes(keepYear)) continue;
            if (toggleDepth(li) > 0) continue; // only top-level seasons
            if (!isOpen(li)) continue;
            const t = directToggler(li);
            if (t) t.click();
        }
    };

    document.querySelectorAll('[data-sergio-click-target]').forEach((el) => {
        el.removeAttribute('data-sergio-click-target');
    });

    const year = seasonKey(seasonLabel);
    if (collapseOthers !== false) {
        try { collapseOtherSeasons(year); } catch (_) {}
    }

    // SAME selectors that successfully parse season children on this page.
    const candidates = Array.from(
        document.querySelectorAll(
            '#libraryView li.toggle, .tree li.toggle, .tree ul.t2 > li, #libraryView ul.t2 > li, #libraryView > ul > li'
        )
    );
    let seasonLi = null;
    let bestDepth = Infinity;
    for (const li of candidates) {
        const raw = getLabel(li);
        if (seasonKey(raw) !== year && !raw.includes(year)) continue;
        if (!seasonYearRe.test(raw) && !/Season/i.test(raw)) continue;
        const depth = toggleDepth(li);
        if (depth < bestDepth) {
            bestDepth = depth;
            seasonLi = li;
        }
    }
    if (!seasonLi) {
        return { ok: false, reason: 'season_not_found', scanned: candidates.length };
    }

    let current = seasonLi;
    for (const part of folderPath || []) {
        const target = normalize(part);
        const childUl = current.querySelector(':scope > ul');
        if (!childUl) {
            return {
                ok: false,
                reason: 'parent_not_expanded',
                at: getLabel(current),
                need: part,
            };
        }
        const kids = Array.from(childUl.querySelectorAll(':scope > li'));
        let next = null;
        // Exact match only among DIRECT children of this parent (stay in this year/folder).
        for (const kid of kids) {
            if (normalize(getLabel(kid)) === target) {
                next = kid;
                break;
            }
        }
        // Unique prefix/contains fallback — only if exactly one child matches.
        if (!next && target) {
            const loose = kids.filter((kid) => {
                const n = normalize(getLabel(kid));
                return n === target || n.includes(target) || target.includes(n);
            });
            if (loose.length === 1) next = loose[0];
        }
        if (!next) {
            return {
                ok: false,
                reason: 'folder_not_found',
                parent: getLabel(current),
                need: part,
                kids: kids.slice(0, 12).map((k) => getLabel(k)),
            };
        }
        current = next;
    }

    current.setAttribute('data-sergio-click-target', '1');
    const t = directToggler(current);
    const tText = ((t && t.textContent) || '').replace(/\\s+/g, '');
    return {
        ok: true,
        label: getLabel(current),
        open: isOpen(current),
        hasToggler: !!t,
        togglerText: tText,
        pathLen: (folderPath || []).length,
        year,
    };
}
"""


async def _classic_mark_and_click(
    page: Page,
    season_label: str,
    folder_path: list[str] | None = None,
    *,
    collapse_others: bool = True,
) -> dict:
    """Mark the Classic tree node and click its [+] with a real Playwright mouse event."""
    path = folder_path or []
    marked = await page.evaluate(
        _CLASSIC_MARK_NODE_JS,
        {
            "seasonLabel": season_label,
            "folderPath": path,
            "collapseOthers": collapse_others,
        },
    )
    if not marked.get("ok"):
        print(
            f"Classic mark miss season={season_label!r} path={path!r}: {marked}"
        )
        return marked

    target = page.locator('[data-sergio-click-target="1"]').first
    await target.scroll_into_view_if_needed()
    await page.evaluate(
        """() => {
            const li = document.querySelector('[data-sergio-click-target="1"]');
            if (!li) return;
            li.style.outline = '4px solid #0066cc';
            li.style.background = '#c5ddf3';
            li.scrollIntoView({ block: 'center' });
            setTimeout(() => {
                try {
                    li.style.outline = '';
                    li.style.background = '';
                } catch (_) {}
            }, 1800);
        }"""
    )

    if marked.get("open"):
        print(
            f"Classic focus (already open): {season_label} → {'/'.join(path) or '(year)'}"
        )
        return {**marked, "clicked": False}

    await asyncio.sleep(0.08)

    toggler = target.locator(
        ":scope > .toggler, :scope > span > .toggler, "
        ":scope > span .toggler, :scope > .jstree-ocl"
    ).first
    if await toggler.count() > 0:
        await toggler.click(timeout=8000, force=True)
        print(f"Classic CLICK [+]: {season_label} → {'/'.join(path) or '(year)'}")
    else:
        await target.click(timeout=8000, force=True)
        print(f"Classic CLICK row: {season_label} → {'/'.join(path) or '(year)'}")

    await asyncio.sleep(0.35)
    return {**marked, "clicked": True}


async def _show_hudl_click_banner(
    page: Page, message: str, *, quick: bool = False
) -> None:
    """Big red banner ON the Hudl page so the click path is visible in-browser."""
    try:
        await page.bring_to_front()
        await page.evaluate(
            """(msg) => {
                let el = document.getElementById('sergio-hudl-click-banner');
                if (!el) {
                    el = document.createElement('div');
                    el.id = 'sergio-hudl-click-banner';
                    el.style.cssText = [
                        'position:fixed',
                        'top:0',
                        'left:0',
                        'right:0',
                        'z-index:2147483647',
                        'background:#E31E24',
                        'color:#fff',
                        'font:bold 22px/1.35 Arial,sans-serif',
                        'padding:18px 22px',
                        'box-shadow:0 6px 20px rgba(0,0,0,.45)',
                        'pointer-events:none',
                        'letter-spacing:0.2px',
                    ].join(';');
                    document.body.appendChild(el);
                }
                el.textContent = 'SERGIO CLICK → ' + msg;
                document.title = 'SERGIO | ' + msg;
            }""",
            message,
        )
        await asyncio.sleep(0.15 if quick else 0.55)
    except Exception as exc:
        print(f"Hudl click banner failed: {exc}")


async def _ensure_manage_library_page(page: Page, team_id: str | None) -> str:
    """Reuse the current Manage Library page when possible to skip login/navigation."""
    from app.automation.tasks.login import is_login_page

    resolved_team_id = await _resolve_manage_library_team_id(page, team_id)
    target_path = f"/manage/library/{resolved_team_id}"
    target_url = f"https://www.hudl.com{target_path}"

    prev_team = str(_ML_STICKY.get("team_id") or "")
    if prev_team and prev_team != str(resolved_team_id):
        _ml_sticky_reset()

    # Fast path: already on the right Manage Library page — keep tree state.
    if target_path in (page.url or "") and not await is_login_page(page):
        print(f"Manage Library: reusing open page {page.url}")
        # Skip popup scan + bring_to_front on every expand — huge latency win.
        _ML_STICKY["team_id"] = str(resolved_team_id)
        return resolved_team_id

    # Leaving / reloading the page clears sticky tree cursor.
    _ml_sticky_reset()

    # Not on the page yet (blank lane tab, other Hudl page, etc.).
    # Probe/navigate first using shared cookies; login only if redirected.
    await _ensure_hudl_session(page, probe_url=target_url)

    if target_path not in (page.url or ""):
        await close_hudl_popups(page)
        await _navigate_to_manage_library(page, resolved_team_id)
    else:
        await close_hudl_popups(page)

    await _bring_hudl_page_to_front(page)
    _ML_STICKY["team_id"] = str(resolved_team_id)
    return resolved_team_id


async def expand_manage_library_season(
    page: Page, team_id: str | None = None, season_label: str = ""
) -> dict:
    """Step 1: open the clicked YEAR on Hudl, then fetch only that year's folders."""
    label = season_label.strip()
    if not label:
        raise ValueError("season_label is required")

    async def _run() -> dict:
        resolved_team_id = await _ensure_manage_library_page(page, team_id)
        await _wait_for_manage_library_tree(page)

        # FAST PATH: year already open and folders already visible → return now.
        state = await _get_season_tree_state(page, label)
        if state.get("expanded") or int(state.get("childCount") or 0) > 0:
            try:
                children = await _wait_and_parse_season_children(
                    page, label, max_attempts=2
                )
            except Exception:
                children = []
            if children:
                print(
                    f"FAST PATH season already open '{label}': "
                    f"{len(children)} folders — skip click"
                )
                _ml_sticky_set(resolved_team_id, label, [])
                return {
                    "status": "ok",
                    "team_id": resolved_team_id,
                    "season_label": label,
                    "children": children,
                    "count": len(children),
                    "fast_path": True,
                }

        await _show_hudl_click_banner(page, f"Opening YEAR: {label}", quick=True)
        try:
            await _click_and_expand_season(page, label)
            try:
                await close_hudl_popups(page)
            except Exception as popup_exc:
                print(f"Season expand popup close ignored: {popup_exc}")
            try:
                children = await _wait_and_parse_season_children(
                    page, label, max_attempts=4
                )
            except Exception as parse_exc:
                print(f"Season parse error for {label!r}: {parse_exc}")
                children = []
        except Exception as exc:
            import traceback

            print(f"Season expand error for {label!r}: {exc}")
            traceback.print_exc()
            raise

        print(f"Season '{label}' open+fetch: {len(children)} folders")
        _ml_sticky_set(resolved_team_id, label, [])

        return {
            "status": "ok",
            "team_id": resolved_team_id,
            "season_label": label,
            "children": children,
            "count": len(children),
        }

    try:
        return await asyncio.wait_for(_run(), timeout=90.0)
    except asyncio.TimeoutError as exc:
        raise TimeoutError(f"Timed out expanding season '{label}' on Hudl.") from exc


def _strip_count_suffix(label: str) -> str:
    return re.sub(r"\s*\(\d+\)\s*$", "", (label or "").strip())


def _strip_date_from_label(label: str) -> str:
    cleaned = _strip_count_suffix(label)
    cleaned = re.sub(
        r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\b",
        "",
        cleaned,
        flags=re.IGNORECASE,
    )
    cleaned = re.sub(
        r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(\d{1,2})\b",
        "",
        cleaned,
        flags=re.IGNORECASE,
    )
    cleaned = re.sub(
        r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$",
        "",
        cleaned,
        flags=re.IGNORECASE,
    )
    return cleaned.strip()


def _normalize_folder_path(folder_path: list[str]) -> list[str]:
    return [_strip_date_from_label(p) for p in folder_path if _strip_date_from_label(p)]


_JSTREE_SCOPED_JS = """
({ seasonLabel, folderPath, action, clipLabel, desiredSelected }) => {
    const monthRe = new RegExp('(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+(\\d{1,2})(?!\\d)', 'i');
    const gluedMonthRe = new RegExp('(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(\\d{1,2})(?!\\d)', 'i');
    const wordGluedMonthRe = new RegExp('[A-Za-z](Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+(\\d{1,2})(?!\\d)', 'i');
    const trailingMonthRe = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/i;
    const hrRe = /([\\d.]+)\\s*hr\\b/i;
    const seasonYearRe = /(\\d{4}-\\d{4})/;
    const stripCount = (s) => (s || '').replace(/\\s*\\(\\d+\\)\\s*$/, '').trim();

    const normalizeForMatch = (s) => {
        let v = stripCount((s || '').trim());
        v = v.replace(/\\s+Season$/i, '').trim();
        v = v.replace(hrRe, '').trim();
        v = v.replace(monthRe, '').replace(gluedMonthRe, '').replace(trailingMonthRe, '').trim();
        return v.replace(/\\s{2,}/g, ' ').trim();
    };

    const seasonKey = (s) => {
        const m = normalizeForMatch(s).match(seasonYearRe);
        return m ? m[1] : normalizeForMatch(s);
    };

    const root =
        document.querySelector('#libraryView') ||
        document.querySelector('.manage-library-right-column') ||
        document.querySelector('.tree') ||
        document.body;

    const getLiLabel = (li) => {
        const anchor = li.querySelector(':scope > a.jstree-anchor') || li.querySelector(':scope > a');
        if (anchor) return (anchor.textContent || '').trim();
        const link = li.querySelector(':scope > span > a, :scope > a');
        if (link) return (link.textContent || '').trim();
        const clone = li.cloneNode(true);
        clone.querySelector('.toggler')?.remove();
        clone.querySelector('ul')?.remove();
        clone.querySelector('.jstree-ocl')?.remove();
        return (clone.textContent || '').replace(/\\s+/g, ' ').trim();
    };

    const isFolderLi = (li) => {
        if (li.classList.contains('toggle')) return true;
        if (li.classList.contains('jstree-closed') || li.classList.contains('jstree-open')) return true;
        if (li.querySelector(':scope > .toggler, :scope > span .toggler')) return true;
        if (li.querySelector(':scope > .jstree-ocl')) return true;
        return false;
    };

    const isLiLeaf = (li) => {
        if (isFolderLi(li)) return false;
        if (li.classList.contains('jstree-leaf')) return true;
        if (li.querySelector('.jstree-checkbox, input[type="checkbox"]')) return true;
        return !li.querySelector(':scope > ul');
    };

    const directToggler = (li) =>
        li.querySelector(
            ':scope > .toggler, :scope > span > .toggler, :scope > span .toggler, :scope > .jstree-ocl'
        );

    const expandLi = (li) => {
        // CRITICAL: only the ROW's own [+]/ never a nested child's toggler.
        // A descendant ".toggler" match was scrolling/clicking the wrong folder.
        try { li.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {}
        const prev = li.getAttribute('style') || '';
        li.setAttribute('style', prev + ';outline:3px solid #0066cc!important;background:#c5ddf3!important;');
        setTimeout(() => { try { li.setAttribute('style', prev); } catch (_) {} }, 2800);

        const toggler = directToggler(li);
        const ocl = li.querySelector(':scope > .jstree-ocl');
        const togglerText = ((toggler && toggler.textContent) || '').replace(/\\s+/g, '').trim();
        const showsPlus = togglerText === '+' || togglerText.indexOf('+') !== -1;
        const showsMinus = togglerText === '-' || togglerText.indexOf('-') !== -1;
        const isOpen =
            li.classList.contains('jstree-open') ||
            (li.classList.contains('toggle') && li.classList.contains('expanded')) ||
            showsMinus;
        const isCollapsed =
            !isOpen && (
                li.classList.contains('jstree-closed') ||
                li.classList.contains('collapsed') ||
                showsPlus ||
                (li.classList.contains('toggle') && !li.classList.contains('expanded'))
            );

        // Hudl Classic [+] — click ONLY this row's toggler so the website opens.
        if (isCollapsed || (!isOpen && toggler)) {
            if (toggler) { toggler.click(); return true; }
            if (ocl) { ocl.click(); return true; }
            const anchor = li.querySelector(':scope > a.jstree-anchor, :scope > a');
            if (anchor) { anchor.click(); return true; }
            li.click();
            return true;
        }

        // Already open ([-]) — flash + scroll only. Do NOT click (would collapse).
        return true;
    };

    const collapseOtherSeasons = (keepYear) => {
        const lists = [
            ...document.querySelectorAll('.tree ul.t2'),
            ...document.querySelectorAll('#libraryView > ul'),
            ...document.querySelectorAll('#libraryView ul.t2'),
        ];
        const seen = new Set();
        for (const ul of lists) {
            for (const li of Array.from(ul.children || [])) {
                if (!(li instanceof HTMLElement) || li.tagName !== 'LI' || seen.has(li)) continue;
                seen.add(li);
                const raw = getLiLabel(li);
                if (raw.includes(keepYear)) continue;
                const open =
                    li.classList.contains('expanded') || li.classList.contains('jstree-open');
                if (!open) continue;
                const t = directToggler(li);
                if (t) t.click();
            }
        }
    };

    const parseMeta = (rawLabel, rowText) => {
        let label = (rawLabel || '').trim();
        let count = null;
        const countMatch = label.match(/\\((\\d+)\\)\\s*$/);
        if (countMatch) {
            count = parseInt(countMatch[1], 10);
            label = label.replace(/\\(\\d+\\)\\s*$/, '').trim();
        }
        label = label.replace(monthRe, '').replace(gluedMonthRe, '').replace(/([A-Za-z])(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/gi, '$1').replace(trailingMonthRe, '').replace(hrRe, '').replace(/\\s{2,}/g, ' ').trim();
        const text = (rowText || rawLabel || label).split(hrRe)[0];
        const fullDate = text.match(monthRe);
        const gluedDate = text.match(gluedMonthRe);
        const wordGluedDate = text.match(wordGluedMonthRe);
        const trailingDate = text.match(trailingMonthRe);
        const dateMatch = fullDate || gluedDate || wordGluedDate || trailingDate;
        const hrMatch = (rowText || rawLabel || '').match(hrRe);
        return {
            label: stripCount(label),
            date: fullDate ? `${fullDate[1]} ${fullDate[2]}` : gluedDate ? `${gluedDate[1]} ${gluedDate[2]}` : wordGluedDate ? `${wordGluedDate[1]} ${wordGluedDate[2]}` : trailingDate ? trailingDate[0] : undefined,
            hours: hrMatch ? parseFloat(hrMatch[1]) : undefined,
            count,
        };
    };

    const labelsMatch = (a, b) => {
        const na = normalizeForMatch(a);
        const nb = normalizeForMatch(b);
        if (!na || !nb) return false;
        if (na === nb) return true;
        const ya = seasonKey(a);
        const yb = seasonKey(b);
        if (ya && yb && ya === yb) return true;
        if (na.includes(nb) || nb.includes(na)) return true;
        // Whitespace-insensitive fallback: handles stray spaces inside numeric
        // dates like "5/ 20 Tros IR" vs "5/20 Tros IR".
        const sa = na.replace(/\\s+/g, '').toLowerCase();
        const sb = nb.replace(/\\s+/g, '').toLowerCase();
        if (!sa || !sb) return false;
        return sa === sb || sa.includes(sb) || sb.includes(sa);
    };

    const findSeasonLi = (season) => {
        const seasonTarget = seasonKey(season);
        // Same discovery as the working season children parser.
        const candidates = Array.from(
            document.querySelectorAll(
                '#libraryView li.toggle, .tree li.toggle, .tree ul.t2 > li, #libraryView ul.t2 > li, #libraryView > ul > li'
            )
        );
        let best = null;
        let bestDepth = Infinity;
        for (const li of candidates) {
            const raw = getLiLabel(li);
            if (seasonKey(raw) !== seasonTarget && !labelsMatch(raw, season)) continue;
            let depth = 0;
            let p = li.parentElement;
            while (p) {
                if (p.matches && p.matches('li.toggle, li.jstree-node, li.jstree-open, li.jstree-closed')) depth += 1;
                p = p.parentElement;
            }
            if (depth < bestDepth) {
                bestDepth = depth;
                best = li;
            }
        }
        return best;
    };

    const findNodeLi = (season, path) => {
        let current = findSeasonLi(season);
        if (!current) return null;

        for (const folder of path || []) {
            const target = normalizeForMatch(folder);
            const childUl = current.querySelector(':scope > ul');
            if (!childUl) return null;
            const kids = Array.from(childUl.querySelectorAll(':scope > li'));
            let next = null;
            // Exact label match first — stay inside this parent folder only.
            for (const childLi of kids) {
                if (normalizeForMatch(getLiLabel(childLi)) === target) {
                    next = childLi;
                    break;
                }
            }
            if (!next) {
                for (const childLi of kids) {
                    const raw = getLiLabel(childLi);
                    if (labelsMatch(raw, folder) || labelsMatch(raw, target)) {
                        next = childLi;
                        break;
                    }
                }
            }
            if (!next) return null;
            current = next;
        }
        return current;
    };

    // Hudl Classic stores hover tooltip fields in title / data-original-title
    // (Name, Date, Created by, Shared with, # of Clips, Total Length, …).
    const extractItemMeta = (li, fallbackLabel, fallbackDate) => {
        const anchor =
            li.querySelector(':scope > a.jstree-anchor') ||
            li.querySelector(':scope > a') ||
            li.querySelector(':scope > span > a');
        const el = anchor || li;
        let raw =
            el.getAttribute('data-original-title') ||
            el.getAttribute('title') ||
            li.getAttribute('data-original-title') ||
            li.getAttribute('title') ||
            '';
        raw = String(raw)
            .replace(/<br\\s*\\/?>/gi, '\\n')
            .replace(/<\\/?(?:b|strong|span|div|p|i)[^>]*>/gi, '')
            .replace(/&nbsp;/gi, ' ')
            .replace(/&amp;/gi, '&')
            .replace(/&#10;/g, '\\n')
            .replace(/&#13;/g, '')
            .replace(/&lt;/gi, '<')
            .replace(/&gt;/gi, '>');

        const item = {};
        if (fallbackLabel) item.name = fallbackLabel;
        if (fallbackDate) item.date = fallbackDate;

        const apply = (key, value) => {
            if (value == null) return;
            const v = String(value).replace(/\\s+/g, ' ').trim();
            if (!v) return;
            const k = String(key || '').toLowerCase().trim();
            if (k === 'name') item.name = v;
            else if (k === 'date' || k === 'created on') item.date = item.date || v;
            else if (k === 'created by') item.created_by = v;
            else if (k === 'shared with') {
                item.shared_with = v.split(/,|\\n/).map((s) => s.trim()).filter(Boolean);
            } else if (k === '# of clips' || k === 'number of clips' || k === 'clips') {
                const n = parseInt(v, 10);
                if (!Number.isNaN(n)) item.clips = n;
            } else if (k === 'total length') item.total_length = v;
            else if (k === 'duration') item.duration = v;
            else if (k === 'angles') item.angles = v;
            else if (k === 'type') item.type = v;
            else if (k === 'tags') item.tags = v;
        };

        for (const line of raw.split(/\\r?\\n|;/)) {
            const m = line.match(/^\\s*([A-Za-z#][A-Za-z #/]*?)\\s*:\\s*(.+)\\s*$/);
            if (m) apply(m[1], m[2]);
        }

        for (const [attr, key] of [
            ['data-created-by', 'created by'],
            ['data-shared-with', 'shared with'],
            ['data-date', 'date'],
            ['data-clips', '# of clips'],
            ['data-total-length', 'total length'],
            ['data-angles', 'angles'],
        ]) {
            apply(key, el.getAttribute(attr) || li.getAttribute(attr));
        }

        try {
            if (window.jQuery) {
                const data = window.jQuery(li).data() || {};
                const od = data.jstree && data.jstree.original ? data.jstree.original : data.original || data;
                apply('created by', od.createdBy || od.created_by || od.CreatedBy);
                const shared = od.sharedWith || od.shared_with || od.SharedWith;
                apply(
                    'shared with',
                    Array.isArray(shared) ? shared.join(', ') : shared
                );
                apply('date', od.date || od.Date);
                apply('# of clips', od.clips || od.clipCount || od.ClipCount);
                apply('total length', od.totalLength || od.total_length);
                apply('angles', od.angles || od.Angles);
                apply('type', od.type || od.Type);
                apply('tags', od.tags || od.Tags);
            }
        } catch (_) {}

        if (!item.name && fallbackLabel) item.name = fallbackLabel;
        return item;
    };

    const parseDirectChildren = (nodeLi) => {
        const childUl = nodeLi.querySelector(':scope > ul');
        if (!childUl) return [];
        return Array.from(childUl.querySelectorAll(':scope > li')).map((childLi, idx) => {
            const rowText = (childLi.innerText || '').replace(/\\s+/g, ' ').trim();
            const raw = getLiLabel(childLi);
            const meta = parseMeta(raw, rowText);
            const itemMeta = extractItemMeta(childLi, meta.label, meta.date);
            const nodeId =
                childLi.id ||
                childLi.getAttribute('data-cat-id') ||
                childLi.getAttribute('data-categoryid') ||
                childLi.getAttribute('data-linkid') ||
                `${meta.label}-${idx}`;
            if (isLiLeaf(childLi)) {
                return {
                    id: String(nodeId),
                    label: meta.label,
                    date: meta.date || itemMeta.date,
                    selectable: true,
                    meta: itemMeta,
                    children: [],
                };
            }
            return {
                id: String(nodeId),
                label: meta.label,
                date: meta.date || itemMeta.date,
                hours: meta.hours,
                count: meta.count,
                meta: itemMeta,
                children: [],
            };
        });
    };

    // Expand opens the target node. Parents are opened by prior Python steps
    // (with waits) so Hudl Classic can lazy-load children between clicks.
    if (action === 'expand') {
        const year = seasonKey(seasonLabel);
        try { collapseOtherSeasons(year); } catch (_) {}

        const seasonLi = findSeasonLi(seasonLabel);
        if (!seasonLi) {
            return { ok: false, reason: 'season_not_found', expanded: false, children: [] };
        }
        expandLi(seasonLi);

        if (!folderPath || folderPath.length === 0) {
            try { seasonLi.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {}
            return { ok: true, expanded: true, children: [] };
        }

        const targetLi = findNodeLi(seasonLabel, folderPath);
        if (!targetLi) {
            return { ok: false, reason: 'node_not_found', expanded: false, children: [] };
        }
        expandLi(targetLi);
        try { targetLi.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {}
        return { ok: true, expanded: true, alreadyOpen: false, children: [] };
    }

    const li = findNodeLi(seasonLabel, folderPath || []);
    if (!li) {
        return { ok: false, reason: 'node_not_found', children: [] };
    }

    if (action === 'select_clip') {
        const childUl = li.querySelector(':scope > ul');
        if (!childUl) return { ok: false, reason: 'no_children', selected: false };
        const clipTarget = stripCount(clipLabel || '');
        const children = Array.from(childUl.querySelectorAll(':scope > li'));

        const clickChild = (childLi, method) => {
            try { childLi.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {}
            const prev = childLi.getAttribute('style') || '';
            childLi.setAttribute('style', prev + ';outline:3px solid #0066cc!important;background:#c5ddf3!important;');
            setTimeout(() => { try { childLi.setAttribute('style', prev); } catch (_) {} }, 1200);
            const cb = childLi.querySelector('.jstree-checkbox, input[type="checkbox"]');
            if (cb) {
                cb.click();
                return { ok: true, selected: true, method, children: [] };
            }
            const anchor = childLi.querySelector('.jstree-anchor, :scope > a');
            if (anchor) {
                anchor.click();
                return { ok: true, selected: true, method, children: [] };
            }
            childLi.click();
            return { ok: true, selected: true, method, children: [] };
        };

        // Pass 1: exact normalized match. Prevents "Off the Ball" from matching
        // when we actually want the sibling "Off the Ball 2" (and vice versa).
        const targetNorm = normalizeForMatch(clipTarget) || normalizeForMatch(clipLabel || '');
        if (targetNorm) {
            for (const childLi of children) {
                if (normalizeForMatch(getLiLabel(childLi)) === targetNorm) {
                    return clickChild(childLi, 'exact');
                }
            }
        }

        // Pass 2: loose fallback (substring / season-key match) for odd labels.
        for (const childLi of children) {
            const raw = getLiLabel(childLi);
            if (!labelsMatch(raw, clipTarget) && !labelsMatch(raw, clipLabel || '')) continue;
            return clickChild(childLi, 'loose');
        }
        return { ok: false, reason: 'clip_not_found', selected: false, children: [] };
    }

    if (action === 'set_clip') {
        const childUl = li.querySelector(':scope > ul');
        if (!childUl) return { ok: false, reason: 'no_children' };
        const children = Array.from(childUl.querySelectorAll(':scope > li'));
        const clipTarget = stripCount(clipLabel || '');
        const targetNorm = normalizeForMatch(clipTarget) || normalizeForMatch(clipLabel || '');

        let match = null;
        if (targetNorm) {
            for (const childLi of children) {
                if (normalizeForMatch(getLiLabel(childLi)) === targetNorm) {
                    match = childLi;
                    break;
                }
            }
        }
        if (!match) {
            for (const childLi of children) {
                const raw = getLiLabel(childLi);
                if (labelsMatch(raw, clipTarget) || labelsMatch(raw, clipLabel || '')) {
                    match = childLi;
                    break;
                }
            }
        }
        if (!match) return { ok: false, reason: 'clip_not_found' };

        const cbInput = match.querySelector('input[type="checkbox"]');
        const anchor = match.querySelector('.jstree-anchor');
        const isChecked = () => {
            if (match.classList.contains('jstree-checked')) return true;
            if (cbInput) return !!cbInput.checked;
            if (anchor && anchor.classList.contains('jstree-clicked')) return true;
            if (match.getAttribute('aria-selected') === 'true') return true;
            return false;
        };

        const current = isChecked();
        if (current === !!desiredSelected) {
            return { ok: true, changed: false, checked: current };
        }
        const clickable =
            match.querySelector('.jstree-checkbox, ins.jstree-checkbox, input[type="checkbox"]') ||
            anchor ||
            match;
        clickable.click();
        return { ok: true, changed: true, checked: !!desiredSelected };
    }

    return { ok: true, children: parseDirectChildren(li) };
}
"""


async def _jstree_scoped_action(
    page: Page,
    season_label: str,
    folder_path: list[str],
    action: str = "parse",
    clip_label: str | None = None,
    selected: bool = True,
) -> dict:
    try:
        payload = {
            "seasonLabel": season_label,
            "folderPath": folder_path,
            "action": action,
            "clipLabel": clip_label or "",
            "desiredSelected": bool(selected),
        }
        return await page.evaluate(_JSTREE_SCOPED_JS, payload)
    except Exception as exc:
        print(f"jstree scoped action failed: {exc}")
        return {"ok": False, "reason": str(exc), "children": []}


async def _expand_jstree_node_scoped(
    page: Page, season_label: str, folder_path: list[str]
) -> bool:
    result = await _jstree_scoped_action(page, season_label, folder_path, "expand")
    if result.get("ok") and result.get("expanded"):
        # Brief pause so headed browser shows the open state (no long demo hold).
        if not result.get("alreadyOpen"):
            await asyncio.sleep(0.2)
        return True
    return False


async def _parse_jstree_scoped_children(
    page: Page, season_label: str, folder_path: list[str]
) -> list:
    result = await _jstree_scoped_action(page, season_label, folder_path, "parse")
    if not result.get("ok"):
        print(f"jstree parse miss: season={season_label!r} path={folder_path!r} reason={result.get('reason')}")
        return []
    return result.get("children") or []


_CLIP_HOVER_META_JS = """
async ({ seasonLabel, folderPath, clipLabel }) => {
    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
    const seasonYearRe = /(\\d{4}-\\d{4})/;
    const monthRe = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d{1,2}\\b/i;
    const stripCount = (s) => (s || '').replace(/\\s*\\(\\d+\\)\\s*$/, '').trim();
    const normalize = (s) =>
        stripCount(s)
            .replace(/\\s+Season$/i, '')
            .replace(monthRe, '')
            .replace(/[\\d.]+\\s*hr\\b/i, '')
            .replace(/\\s{2,}/g, ' ')
            .trim();
    const seasonKey = (s) => {
        const m = normalize(s).match(seasonYearRe);
        return m ? m[1] : normalize(s);
    };
    const labelsMatch = (a, b) => {
        const na = normalize(a);
        const nb = normalize(b);
        if (!na || !nb) return false;
        if (na === nb) return true;
        const ya = seasonKey(a);
        const yb = seasonKey(b);
        if (ya && yb && ya === yb) return true;
        const sa = na.replace(/\\s+/g, '').toLowerCase();
        const sb = nb.replace(/\\s+/g, '').toLowerCase();
        return sa === sb || sa.includes(sb) || sb.includes(sa);
    };
    const getLiLabel = (li) => {
        const anchor = li.querySelector(':scope > a.jstree-anchor') || li.querySelector(':scope > a');
        if (anchor) return (anchor.textContent || '').trim();
        const clone = li.cloneNode(true);
        clone.querySelector('.toggler')?.remove();
        clone.querySelector('ul')?.remove();
        return (clone.textContent || '').replace(/\\s+/g, ' ').trim();
    };
    const isFolderLi = (li) => {
        if (li.classList.contains('toggle')) return true;
        if (li.classList.contains('jstree-closed') || li.classList.contains('jstree-open')) return true;
        if (li.querySelector(':scope > .toggler, :scope > span .toggler')) return true;
        if (li.querySelector(':scope > .jstree-ocl')) return true;
        return false;
    };
    const isLiLeaf = (li) => {
        if (isFolderLi(li)) return false;
        if (li.classList.contains('jstree-leaf')) return true;
        if (li.querySelector('.jstree-checkbox, input[type="checkbox"]')) return true;
        return !li.querySelector(':scope > ul');
    };

    const tipSelectors = [
        '#tiptip_content',
        '#tiptip_holder',
        '.tiptip_content',
        '.ui-tooltip-content',
        '.ui-tooltip',
        '.tooltip-inner',
        '.qtip-content',
        '[class*="tooltip"]',
    ];
    const readLiveTip = () => {
        for (const sel of tipSelectors) {
            for (const el of document.querySelectorAll(sel)) {
                const style = window.getComputedStyle(el);
                if (style && (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')) {
                    continue;
                }
                const t = (el.innerText || el.textContent || '').trim();
                if (t.length > 8) return t;
            }
        }
        return '';
    };

    const parseTip = (raw, fallbackLabel) => {
        const text = String(raw || '')
            .replace(/<br\\s*\\/?>/gi, '\\n')
            .replace(/<[^>]+>/g, '');
        const item = { name: fallbackLabel };
        for (const line of text.split(/\\r?\\n|;/)) {
            const m = line.match(/^\\s*([A-Za-z#][A-Za-z #/]*?)\\s*:\\s*(.+)\\s*$/);
            if (!m) continue;
            const k = m[1].toLowerCase().trim();
            const v = m[2].replace(/\\s+/g, ' ').trim();
            if (!v) continue;
            if (k === 'name') item.name = v;
            else if (k === 'date' || k === 'created on') item.date = v;
            else if (k === 'created by') item.created_by = v;
            else if (k === 'shared with') {
                item.shared_with = v.split(/,/).map((s) => s.trim()).filter(Boolean);
            } else if (k === '# of clips' || k === 'number of clips' || k === 'clips') {
                const n = parseInt(v, 10);
                if (!Number.isNaN(n)) item.clips = n;
            } else if (k === 'total length') item.total_length = v;
            else if (k === 'duration') item.duration = v;
            else if (k === 'angles') item.angles = v;
            else if (k === 'type') item.type = v;
            else if (k === 'tags') item.tags = v;
        }
        return item;
    };

    const readTitleMeta = (li, fallbackLabel) => {
        const anchor =
            li.querySelector(':scope > a.jstree-anchor') ||
            li.querySelector(':scope > a') ||
            li;
        let raw =
            anchor.getAttribute('data-original-title') ||
            anchor.getAttribute('title') ||
            li.getAttribute('title') ||
            '';
        raw = String(raw)
            .replace(/<br\\s*\\/?>/gi, '\\n')
            .replace(/&#10;/g, '\\n')
            .replace(/<[^>]+>/g, '');
        return parseTip(raw, fallbackLabel);
    };

    const findSeasonLi = () => {
        const target = seasonKey(seasonLabel);
        const candidates = Array.from(
            document.querySelectorAll(
                '#libraryView li.toggle, .tree li.toggle, .tree ul.t2 > li, #libraryView ul.t2 > li, #libraryView > ul > li'
            )
        );
        let best = null;
        let bestDepth = Infinity;
        for (const li of candidates) {
            const raw = getLiLabel(li);
            if (seasonKey(raw) !== target && !labelsMatch(raw, seasonLabel)) continue;
            let depth = 0;
            let p = li.parentElement;
            while (p) {
                if (p.matches && p.matches('li.toggle, li.jstree-node, li.jstree-open, li.jstree-closed')) depth += 1;
                p = p.parentElement;
            }
            if (depth < bestDepth) {
                bestDepth = depth;
                best = li;
            }
        }
        return best;
    };

    let current = findSeasonLi();
    if (!current) return { ok: false, reason: 'season_not_found' };
    for (const folder of folderPath || []) {
        const childUl = current.querySelector(':scope > ul');
        if (!childUl) return { ok: false, reason: 'path_not_found' };
        const kids = Array.from(childUl.querySelectorAll(':scope > li'));
        let next = null;
        for (const childLi of kids) {
            if (labelsMatch(getLiLabel(childLi), folder)) {
                next = childLi;
                break;
            }
        }
        if (!next) return { ok: false, reason: 'folder_not_found', folder };
        current = next;
    }

    const childUl = current.querySelector(':scope > ul');
    if (!childUl) return { ok: false, reason: 'no_children' };
    const kids = Array.from(childUl.querySelectorAll(':scope > li')).filter(isLiLeaf);
    const target = stripCount(clipLabel || '');
    let match = kids.find((li) => normalize(getLiLabel(li)) === normalize(target));
    if (!match) {
        match = kids.find((li) => labelsMatch(getLiLabel(li), target));
    }
    if (!match) return { ok: false, reason: 'clip_not_found' };

    const label = stripCount(getLiLabel(match));
    let meta = readTitleMeta(match, label);
    if (meta.created_by || meta.shared_with) {
        return { ok: true, label, meta, source: 'title' };
    }

    const anchor =
        match.querySelector(':scope > a.jstree-anchor') ||
        match.querySelector(':scope > a') ||
        match;
    try {
        anchor.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, cancelable: true, view: window }));
        anchor.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
    } catch (_) {}
    await sleep(180);
    const tip = readLiveTip();
    try {
        anchor.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, cancelable: true, view: window }));
        anchor.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true, cancelable: true, view: window }));
    } catch (_) {}

    if (tip) {
        const live = parseTip(tip, label);
        meta = { ...meta, ...Object.fromEntries(Object.entries(live).filter(([, v]) => v != null && v !== '')) };
        return { ok: true, label, meta, source: 'hover' };
    }
    return { ok: true, label, meta, source: 'title_partial' };
}
"""


async def get_manage_library_clip_meta(
    page: Page,
    team_id: str | None = None,
    season_label: str = "",
    folder_path: list[str] | None = None,
    label: str = "",
) -> dict:
    """Read Hudl hover tooltip fields (Created by, Shared with, …) for one clip."""
    season = (season_label or "").strip()
    clip = _strip_date_from_label(_strip_count_suffix((label or "").strip()))
    path = _normalize_folder_path([p.strip() for p in (folder_path or []) if p.strip()])
    if not season or not clip:
        raise ValueError("season_label and label are required")

    resolved_team_id = await _ensure_manage_library_page(page, team_id)
    await _wait_for_manage_library_tree(page)

    # Ensure the parent path is open so the clip row exists in the DOM.
    try:
        await _navigate_manage_library_path(page, season, path)
    except Exception as exc:
        print(f"clip meta navigate soft-fail: {exc}")

    try:
        result = await page.evaluate(
            _CLIP_HOVER_META_JS,
            {
                "seasonLabel": season,
                "folderPath": path,
                "clipLabel": clip,
            },
        )
    except Exception as exc:
        return {
            "status": "error",
            "team_id": resolved_team_id,
            "reason": str(exc),
            "meta": {"name": clip},
        }

    if not isinstance(result, dict):
        return {
            "status": "error",
            "team_id": resolved_team_id,
            "reason": "invalid_result",
            "meta": {"name": clip},
        }

    return {
        "status": "ok" if result.get("ok") else "error",
        "team_id": resolved_team_id,
        "season_label": season,
        "folder_path": path,
        "label": result.get("label") or clip,
        "meta": result.get("meta") or {"name": clip},
        "source": result.get("source"),
        "reason": result.get("reason"),
    }


async def _wait_and_parse_season_children(
    page: Page, season_label: str, max_attempts: int = 4
) -> list:
    """Wait briefly for Hudl lazy-load after season expand, then parse children.

    Returns as soon as a solid child list appears so year switches stay fast
    when the Manage Library page is already open.
    """

    def _score(children: list) -> tuple[int, int]:
        if not children:
            return (0, 0)
        folders = sum(
            1
            for c in children
            if not c.get("selectable")
            and (c.get("hours") is not None or c.get("count") is not None)
        )
        return (len(children), folders)

    best: list = []
    used_attempts = 0
    for attempt in range(max_attempts):
        used_attempts = attempt + 1
        candidates: list[list] = []
        scoped = await _parse_jstree_scoped_children(page, season_label, [])
        if scoped:
            candidates.append(scoped)
        page_children = await _parse_season_children_from_page(page, season_label)
        if page_children:
            candidates.append(page_children)

        for children in candidates:
            if _score(children) > _score(best):
                best = children

        # Fast exit: any real folder list is enough — don't burn 8 retries.
        if len(best) >= 3:
            break
        if best and attempt >= 1:
            break
        if attempt < max_attempts - 1:
            await asyncio.sleep(0.12)

    if best:
        print(
            f"Season '{season_label}' parsed {len(best)} children "
            f"(after {used_attempts} attempt{'s' if used_attempts != 1 else ''})"
        )
    return best


async def _wait_and_parse_folder_children(
    page: Page, season_label: str, folder_path: list[str], max_attempts: int = 4
) -> list:
    """Parse folder children only under the selected season path."""
    for attempt in range(max_attempts):
        children = await _parse_jstree_scoped_children(page, season_label, folder_path)
        if children:
            return children
        if attempt < max_attempts - 1:
            await asyncio.sleep(0.12)
    return []


async def _expand_folder_on_path(
    page: Page, season_label: str, prefix: list[str], folder: str
) -> bool:
    """Expand one folder ONLY under the selected season — never search other years."""
    target_path = prefix + [folder]

    # Primary: Classic visible click (real mouse + blue highlight).
    # collapse_others on year steps keeps Hudl scoped to THIS season only.
    try:
        classic = await _classic_mark_and_click(
            page,
            season_label,
            target_path,
            collapse_others=len(prefix) == 0,
        )
        if classic.get("ok"):
            print(f"Hudl CLICK open: season={season_label!r} → {'/'.join(target_path)}")
            return True
        # Parent still collapsed — open season/prefix then retry once.
        if classic.get("reason") == "parent_not_expanded":
            await _classic_mark_and_click(
                page, season_label, prefix, collapse_others=True
            )
            await asyncio.sleep(0.25)
            classic = await _classic_mark_and_click(
                page, season_label, target_path, collapse_others=False
            )
            if classic.get("ok"):
                print(f"Hudl CLICK open (retry): season={season_label!r} → {'/'.join(target_path)}")
                return True
    except Exception as exc:
        print(f"Classic folder click failed for {target_path}: {exc}")

    # Secondary: jstree scoped expand
    if await _expand_jstree_node_scoped(page, season_label, target_path):
        print(f"Hudl CLICK open: season={season_label!r} → {'/'.join(target_path)}")
        return True

    base = _strip_count_suffix(folder)
    year_match = re.search(r"(\d{4}-\d{4})", season_label)
    year = year_match.group(1) if year_match else season_label.replace(" Season", "").strip()

    try:
        # Scope ALL locator work to the selected season row — never page-wide.
        season_li = page.locator(
            f'.manage-library-right-column li:has(> a.jstree-anchor:has-text("{year}"))'
        ).first
        if await season_li.count() == 0:
            season_li = page.locator(
                f'#libraryView li:has(> a.jstree-anchor:has-text("{year}")), '
                f'li.toggle:has-text("{year}")'
            ).first
        if await season_li.count() == 0:
            print(
                f"Folder expand scoped miss: season={season_label!r} "
                f"path={target_path!r} (season row not found)"
            )
            return False

        scope = season_li
        for part in prefix:
            part_base = _strip_count_suffix(part)
            nested = scope.locator(
                f':scope > ul > li:has(> a.jstree-anchor:has-text("{part_base}")), '
                f':scope > ul > li.toggle:has-text("{part_base}")'
            ).first
            if await nested.count() == 0:
                nested = scope.locator(
                    f'li:has(> a.jstree-anchor:has-text("{part_base}")), '
                    f'li.toggle:has-text("{part_base}")'
                ).first
            if await nested.count() == 0:
                print(
                    f"Folder expand scoped miss: season={season_label!r} "
                    f"missing prefix part {part_base!r}"
                )
                return False
            scope = nested

        folder_li = scope.locator(
            f':scope > ul > li:has(> a.jstree-anchor:has-text("{base}")), '
            f':scope > ul > li.toggle:has-text("{base}")'
        ).first
        if await folder_li.count() == 0:
            folder_li = scope.locator(
                f'li:has(> a.jstree-anchor:has-text("{base}")), '
                f'li.toggle:has-text("{base}")'
            ).first
        if await folder_li.count() == 0:
            print(
                f"Folder expand scoped miss: season={season_label!r} "
                f"folder={base!r} not under this year"
            )
            return False

        await folder_li.scroll_into_view_if_needed()
        # Prefer THIS row's [+] toggler only — never a nested child's toggler.
        toggler = folder_li.locator(
            ":scope > .toggler, :scope > span > .toggler, :scope > span .toggler"
        ).first
        if await toggler.count() > 0:
            await toggler.click(timeout=8000, force=True)
            await asyncio.sleep(0.9)
            print(f"Hudl CLICK + toggler: {'/'.join(target_path)}")
            return True
        ocl = folder_li.locator(":scope > .jstree-ocl").first
        if await ocl.count() > 0:
            await ocl.click(timeout=8000, force=True)
            await asyncio.sleep(0.9)
            print(f"Hudl CLICK jstree-ocl: {'/'.join(target_path)}")
            return True
        anchor = folder_li.locator(":scope > a.jstree-anchor, :scope > a").first
        if await anchor.count() > 0:
            await anchor.click(timeout=8000, force=True)
            await asyncio.sleep(0.9)
            print(f"Hudl CLICK label: {'/'.join(target_path)}")
            return True
    except Exception as exc:
        print(f"Season-scoped folder click for '{folder}' failed: {exc}")
    return False


async def _ensure_season_expanded(page: Page, season_label: str) -> None:
    """Expand a season row only if it is not already open."""
    await page.locator("#libraryView, .manage-library-right-column").first.wait_for(
        state="attached", timeout=20000
    )
    state = await _get_season_tree_state(page, season_label)
    if state.get("expanded") or int(state.get("childCount") or 0) > 0:
        print(f"Season already open — skip year click: {season_label!r}")
        return
    await _click_and_expand_season(page, season_label)


async def _expand_jstree_node(page: Page, label: str) -> bool:
    """Expand a jstree or toggle-tree folder node by label."""
    base = _strip_count_suffix(label)
    try:
        toggle_li = page.locator(f'li.toggle.collapsed:has-text("{base}")').first
        if await toggle_li.count() > 0:
            toggler = toggle_li.locator(".toggler").first
            if await toggler.count() > 0:
                await toggler.click(timeout=8000)
                await human_delay(0.4, 0.8)
                return True

        li = page.locator(
            f'.manage-library-right-column li:has(.jstree-anchor:has-text("{base}"))'
        ).first
        if await li.count() == 0:
            li = page.locator(
                f'li.toggle:has-text("{base}"), li:has(.jstree-anchor:has-text("{base}"))'
            ).first
        if await li.count() == 0:
            return False

        classes = (await li.get_attribute("class")) or ""
        if "jstree-closed" in classes:
            ocl = li.locator(":scope > .jstree-ocl").first
            if await ocl.count() > 0:
                await ocl.click(timeout=8000)
            else:
                await li.locator(".jstree-anchor").first.click(timeout=8000)
            await human_delay(0.4, 0.8)
            return True

        if "collapsed" in classes:
            toggler = li.locator(".toggler").first
            if await toggler.count() > 0:
                await toggler.click(timeout=8000)
                await human_delay(0.4, 0.8)
                return True

        if "jstree-open" in classes or "expanded" in classes:
            return True

        anchor = li.locator(".jstree-anchor").first
        if await anchor.count() > 0:
            await anchor.click(timeout=8000)
            await human_delay(0.4, 0.8)
            return True
    except Exception as exc:
        print(f"Expand jstree node '{label}' failed: {exc}")
    return False


async def _parse_jstree_node_children(page: Page, label: str) -> list:
    """Parse direct children under an expanded jstree or toggle-tree node."""
    try:
        return await page.evaluate(
            """(label) => {
                const monthRe = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d{1,2}\\b/i;
                const gluedMonthRe = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(\\d{1,2})\\b/i;
                const trailingMonthRe = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/i;
                const hrRe = /([\\d.]+)\\s*hr\\b/i;
                const stripCount = (s) => (s || '').replace(/\\s*\\(\\d+\\)\\s*$/, '').trim();
                const normalize = (s) => stripCount(s).replace(monthRe, '').replace(gluedMonthRe, '').replace(trailingMonthRe, '').replace(hrRe, '').replace(/\\s{2,}/g, ' ').trim();
                const target = normalize(label);

                const getLiLabel = (li) => {
                    const anchor = li.querySelector(':scope > a.jstree-anchor') || li.querySelector(':scope > a');
                    if (anchor) return (anchor.textContent || '').trim();
                    const clone = li.cloneNode(true);
                    clone.querySelector('.toggler')?.remove();
                    clone.querySelector('ul')?.remove();
                    return (clone.textContent || '').replace(/\\s+/g, ' ').trim();
                };

                const isFolderLi = (li) => {
                    if (li.classList.contains('toggle')) return true;
                    if (li.classList.contains('jstree-closed') || li.classList.contains('jstree-open')) return true;
                    if (li.querySelector(':scope > .toggler, :scope > span .toggler')) return true;
                    if (li.querySelector(':scope > .jstree-ocl')) return true;
                    return false;
                };

                const isLiLeaf = (li) => {
                    if (isFolderLi(li)) return false;
                    if (li.classList.contains('jstree-leaf')) return true;
                    if (li.querySelector('.jstree-checkbox, input[type="checkbox"]')) return true;
                    return !li.querySelector(':scope > ul');
                };

                const lis = Array.from(document.querySelectorAll('li.toggle, li.jstree-node, li.jstree-leaf'));
                const li = lis.find((node) => {
                    const n = normalize(getLiLabel(node));
                    return n === target || n.includes(target) || target.includes(n);
                });
                if (!li) return [];
                const childUl = li.querySelector(':scope > ul');
                if (!childUl) return [];

                const parseMeta = (text, rowText) => {
                    const raw = (text || '').trim();
                    const combined = rowText || raw;
                    let name = stripCount(raw);
                    let count = null;
                    const m = raw.match(/\\((\\d+)\\)\\s*$/);
                    if (m) count = parseInt(m[1], 10);
                    name = name.replace(monthRe, '').replace(gluedMonthRe, '').replace(trailingMonthRe, '').replace(hrRe, '').replace(/\\s{2,}/g, ' ').trim();
                    const fullDate = combined.match(monthRe);
                    const gluedDate = combined.match(gluedMonthRe);
                    const trailingDate = combined.match(trailingMonthRe);
                    const hrMatch = combined.match(hrRe);
                    return {
                        label: name,
                        date: fullDate ? fullDate[0] : gluedDate ? `${gluedDate[1]} ${gluedDate[2]}` : trailingDate ? trailingDate[0] : undefined,
                        hours: hrMatch ? parseFloat(hrMatch[1]) : undefined,
                        count,
                    };
                };

                return Array.from(childUl.querySelectorAll(':scope > li')).map((childLi, idx) => {
                    const rowText = (childLi.innerText || '').replace(/\\s+/g, ' ').trim();
                    const text = getLiLabel(childLi);
                    const meta = parseMeta(text, rowText);
                    const nodeId =
                        childLi.id ||
                        childLi.getAttribute('data-cat-id') ||
                        childLi.getAttribute('data-categoryid') ||
                        `${target}-${idx}`;
                    if (isLiLeaf(childLi)) {
                        return {
                            id: String(nodeId),
                            label: meta.label,
                            date: meta.date,
                            selectable: true,
                            children: [],
                        };
                    }
                    return {
                        id: String(nodeId),
                        label: meta.label,
                        date: meta.date,
                        hours: meta.hours,
                        count: meta.count,
                        children: [],
                    };
                });
            }""",
            label,
        )
    except Exception as exc:
        print(f"Parse jstree children for '{label}' failed: {exc}")
        return []


_OPEN_PATH_DEPTH_JS = """
({ seasonLabel, folderPath }) => {
    const seasonYearRe = /(\\d{4}-\\d{4})/;
    const monthRe = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d{1,2}\\b/i;
    const stripCount = (s) => (s || '').replace(/\\s*\\(\\d+\\)\\s*$/, '').trim();
    const normalize = (s) => stripCount(s)
        .replace(/\\s+Season$/i, '')
        .replace(monthRe, '')
        .replace(/[\\d.]+\\s*hr\\b/i, '')
        .replace(/\\s{2,}/g, ' ')
        .trim();
    const seasonKey = (s) => {
        const m = normalize(s).match(seasonYearRe);
        return m ? m[1] : normalize(s);
    };
    const labelsMatch = (a, b) => {
        const na = normalize(a);
        const nb = normalize(b);
        if (!na || !nb) return false;
        if (na === nb) return true;
        const ya = seasonKey(a);
        const yb = seasonKey(b);
        if (ya && yb && ya === yb) return true;
        return na.includes(nb) || nb.includes(na);
    };
    const getLiLabel = (li) => {
        const anchor = li.querySelector(':scope > a.jstree-anchor') || li.querySelector(':scope > a');
        if (anchor) return (anchor.textContent || '').trim();
        const clone = li.cloneNode(true);
        clone.querySelector('.toggler')?.remove();
        clone.querySelector('ul')?.remove();
        return (clone.textContent || '').replace(/\\s+/g, ' ').trim();
    };
    const isOpen = (li) =>
        li.classList.contains('jstree-open') ||
        (li.classList.contains('toggle') && li.classList.contains('expanded'));

    const candidates = Array.from(document.querySelectorAll(
        '#libraryView li.toggle, .tree li.toggle, .tree ul.t2 > li, #libraryView ul.t2 > li'
    ));
    let current = null;
    let bestDepth = Infinity;
    const year = seasonKey(seasonLabel);
    for (const li of candidates) {
        const raw = getLiLabel(li);
        if (seasonKey(raw) !== year && !labelsMatch(raw, seasonLabel)) continue;
        let depth = 0;
        let p = li.parentElement;
        while (p) {
            if (p.matches && p.matches('li.toggle, li.jstree-node')) depth += 1;
            p = p.parentElement;
        }
        if (depth < bestDepth) { bestDepth = depth; current = li; }
    }
    if (!current) return { seasonFound: false, seasonOpen: false, openDepth: 0 };
    if (!isOpen(current)) return { seasonFound: true, seasonOpen: false, openDepth: 0 };

    let openDepth = 0;
    for (const part of (folderPath || [])) {
        const childUl = current.querySelector(':scope > ul');
        if (!childUl) break;
        let next = null;
        for (const child of childUl.querySelectorAll(':scope > li')) {
            if (labelsMatch(getLiLabel(child), part)) {
                next = child;
                break;
            }
        }
        if (!next || !isOpen(next)) break;
        current = next;
        openDepth += 1;
    }
    return { seasonFound: true, seasonOpen: true, openDepth };
}
"""


async def _count_open_folder_path_depth(
    page: Page, season_label: str, folder_path: list[str]
) -> dict:
    """How much of season → folders is already visibly open on Hudl."""
    try:
        return await page.evaluate(
            _OPEN_PATH_DEPTH_JS,
            {"seasonLabel": season_label, "folderPath": folder_path},
        )
    except Exception as exc:
        print(f"Open-path depth check failed: {exc}")
        return {"seasonFound": False, "seasonOpen": False, "openDepth": 0}


# Sticky Manage Library cursor — continue from last open path when possible.
_ML_STICKY: dict = {"team_id": "", "season": "", "path": []}


def _ml_sticky_reset() -> None:
    _ML_STICKY["team_id"] = ""
    _ML_STICKY["season"] = ""
    _ML_STICKY["path"] = []


def _ml_sticky_set(team_id: str | None, season: str, path: list[str]) -> None:
    _ML_STICKY["team_id"] = str(team_id or "").strip()
    _ML_STICKY["season"] = season
    _ML_STICKY["path"] = list(path)


async def _navigate_manage_library_path(
    page: Page, season_label: str, folder_path: list[str]
) -> None:
    """Expand only what is not already open — stay where Playwright already is.

    If year + parent folders are open, jump straight to the next unopened folder
    instead of re-checking the year from the top every click.
    """
    open_state = await _count_open_folder_path_depth(page, season_label, folder_path)
    season_open = bool(open_state.get("seasonOpen"))
    open_depth = max(0, min(int(open_state.get("openDepth") or 0), len(folder_path)))

    # Sticky memory: if DOM check is uncertain but we were just here, trust it.
    sticky_season = _ML_STICKY.get("season") == season_label
    sticky_path = list(_ML_STICKY.get("path") or [])
    if sticky_season and not season_open:
        # DOM may lag; season was just opened in this session.
        season_state = await _get_season_tree_state(page, season_label)
        season_open = bool(
            season_state.get("expanded") or int(season_state.get("childCount") or 0) > 0
        )
    if sticky_season and sticky_path and open_depth < len(sticky_path):
        # Longest common prefix with sticky path that still matches request.
        common = 0
        for a, b in zip(sticky_path, folder_path):
            if a.lower().strip() != b.lower().strip():
                break
            common += 1
        if common > open_depth:
            open_depth = common

    if season_open:
        print(
            f"Sticky navigate: season already open {season_label!r}, "
            f"openDepth={open_depth}/{len(folder_path)}"
        )
    else:
        await _show_hudl_click_banner(page, f"Opening YEAR: {season_label}", quick=True)
        await _ensure_season_expanded(page, season_label)
        await asyncio.sleep(0.2)
        open_depth = 0

    prefix: list[str] = list(folder_path[:open_depth])
    remaining = folder_path[open_depth:]

    if not remaining:
        # Target path already fully open — do not re-click; caller parses DOM.
        print(
            f"Sticky navigate: path already open "
            f"{season_label!r} → {'/'.join(folder_path)}"
        )
        _ml_sticky_set(_ML_STICKY.get("team_id"), season_label, folder_path)
        return

    for folder in remaining:
        path_label = " → ".join([season_label, *prefix, folder])
        await _show_hudl_click_banner(page, f"Clicking: {path_label}", quick=True)
        if not await _expand_folder_on_path(page, season_label, prefix, folder):
            raise ValueError(
                f"Could not expand folder '{folder}' inside season '{season_label}'"
            )
        await asyncio.sleep(0.2)
        # Wait briefly for children only under THIS season path.
        try:
            await page.wait_for_function(
                """({ seasonLabel, folderPath }) => {
                    const seasonYearRe = /(\\d{4}-\\d{4})/;
                    const monthRe = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d{1,2}\\b/i;
                    const stripCount = (s) => (s || '').replace(/\\s*\\(\\d+\\)\\s*$/, '').trim();
                    const normalize = (s) => stripCount(s)
                        .replace(/\\s+Season$/i, '')
                        .replace(monthRe, '')
                        .replace(/[\\d.]+\\s*hr\\b/i, '')
                        .replace(/\\s{2,}/g, ' ')
                        .trim();
                    const seasonKey = (s) => {
                        const m = normalize(s).match(seasonYearRe);
                        return m ? m[1] : normalize(s);
                    };
                    const labelsMatch = (a, b) => {
                        const na = normalize(a);
                        const nb = normalize(b);
                        if (!na || !nb) return false;
                        if (na === nb) return true;
                        const ya = seasonKey(a);
                        const yb = seasonKey(b);
                        if (ya && yb && ya === yb) return true;
                        return na.includes(nb) || nb.includes(na);
                    };
                    const getLiLabel = (li) => {
                        const anchor = li.querySelector(':scope > a.jstree-anchor') || li.querySelector(':scope > a');
                        if (anchor) return (anchor.textContent || '').trim();
                        const clone = li.cloneNode(true);
                        clone.querySelector('.toggler')?.remove();
                        clone.querySelector('ul')?.remove();
                        return (clone.textContent || '').replace(/\\s+/g, ' ').trim();
                    };
                    const candidates = Array.from(document.querySelectorAll(
                        '#libraryView li.toggle, .tree li.toggle, .tree ul.t2 > li, #libraryView ul.t2 > li'
                    ));
                    let current = null;
                    let bestDepth = Infinity;
                    const year = seasonKey(seasonLabel);
                    for (const li of candidates) {
                        const raw = getLiLabel(li);
                        if (seasonKey(raw) !== year && !labelsMatch(raw, seasonLabel)) continue;
                        let depth = 0;
                        let p = li.parentElement;
                        while (p) {
                            if (p.matches && p.matches('li.toggle, li.jstree-node')) depth += 1;
                            p = p.parentElement;
                        }
                        if (depth < bestDepth) { bestDepth = depth; current = li; }
                    }
                    if (!current) return false;
                    for (const part of (folderPath || [])) {
                        const childUl = current.querySelector(':scope > ul');
                        if (!childUl) return false;
                        let next = null;
                        for (const child of childUl.querySelectorAll(':scope > li')) {
                            if (labelsMatch(getLiLabel(child), part)) {
                                next = child;
                                break;
                            }
                        }
                        if (!next) return false;
                        current = next;
                    }
                    const ul = current.querySelector(':scope > ul');
                    return !!(ul && ul.querySelectorAll(':scope > li').length > 0);
                }""",
                {"seasonLabel": season_label, "folderPath": prefix + [folder]},
                timeout=2000,
            )
        except Exception:
            pass
        prefix.append(folder)

    _ml_sticky_set(_ML_STICKY.get("team_id"), season_label, folder_path)


async def expand_manage_library_folder(
    page: Page,
    team_id: str | None = None,
    season_label: str = "",
    folder_path: list[str] | None = None,
) -> dict:
    """Hierarchical drill-down on Hudl:
    1) open the selected YEAR
    2) search/open each folder ONLY inside that year
    3) deeper clicks search ONLY inside the parent folder
    """
    season = season_label.strip()
    path = _normalize_folder_path([p.strip() for p in (folder_path or []) if p.strip()])
    if not season:
        raise ValueError("season_label is required")
    if not path:
        raise ValueError("folder_path is required")

    async def _run() -> dict:
        resolved_team_id = await _ensure_manage_library_page(page, team_id)
        await _wait_for_manage_library_tree(page)

        target_folder = path[-1]

        # FAST PATH: children already visible in DOM at this path → respond immediately.
        existing = await _parse_jstree_scoped_children(page, season, path)
        if existing:
            print(
                f"FAST PATH folder already visible season={season!r} path={path!r}: "
                f"{len(existing)} children — skip navigate/click"
            )
            _ml_sticky_set(resolved_team_id, season, path)
            return {
                "status": "ok",
                "team_id": resolved_team_id,
                "season_label": season,
                "folder_path": path,
                "folder_label": target_folder,
                "children": existing,
                "count": len(existing),
                "fast_path": True,
            }

        # Need to open something new — sticky navigate only clicks missing nodes.
        await _navigate_manage_library_path(page, season, path)
        children = await _wait_and_parse_folder_children(
            page, season, path, max_attempts=3
        )
        if not children:
            children = await _parse_jstree_scoped_children(page, season, path)

        print(
            f"Folder drill-down season={season!r} path={path!r}: "
            f"{len(children)} children (sticky depth used)"
        )

        return {
            "status": "ok",
            "team_id": resolved_team_id,
            "season_label": season,
            "folder_path": path,
            "folder_label": target_folder,
            "children": children,
            "count": len(children),
        }

    try:
        return await asyncio.wait_for(_run(), timeout=90.0)
    except asyncio.TimeoutError as exc:
        raise TimeoutError(
            f"Timed out expanding folder '{'/'.join(path)}' on Hudl."
        ) from exc


async def _wait_for_manage_library_selection_bar(page: Page, timeout: float = 15000) -> bool:
    """Wait until Hudl shows the blue 'N Item(s) selected' action bar."""
    try:
        await page.wait_for_function(
            """() => {
                const text = (document.body.innerText || '').replace(/\\s+/g, ' ');
                return /\\d+\\s+Items?\\s+selected/i.test(text) || /Item\\s+selected/i.test(text);
            }""",
            timeout=timeout,
        )
        await human_delay(0.1, 0.2)
        return True
    except Exception:
        return False


_CLICK_ACTION_DOWNLOAD_JS = """
() => {
    const isVisible = (el) => {
        if (!el) return false;
        const rect = el.getBoundingClientRect();
        const style = window.getComputedStyle(el);
        return (
            rect.width > 0 &&
            rect.height > 0 &&
            style.visibility !== 'hidden' &&
            style.display !== 'none' &&
            style.opacity !== '0'
        );
    };
    const isActionBarContainer = (node) => {
        if (!node || !node.innerText) return false;
        const text = node.innerText.replace(/\\s+/g, ' ');
        return (
            /selected/i.test(text) &&
            (/Share/i.test(text) || /Unshare/i.test(text) || /Move/i.test(text))
        );
    };
    const tryClick = (el) => {
        if (!isVisible(el)) return false;
        el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
        if (typeof el.click === 'function') el.click();
        return true;
    };
    const nodes = Array.from(document.querySelectorAll('a, button, span, div, li'));
    for (const el of nodes) {
        if ((el.textContent || '').trim() !== 'Download') continue;
        let container = el;
        for (let depth = 0; depth < 10 && container; depth++) {
            if (isActionBarContainer(container)) {
                if (tryClick(el)) return { ok: true, method: 'action_bar_text' };
                break;
            }
            container = container.parentElement;
        }
    }
    for (const el of document.querySelectorAll('a, span, button')) {
        if ((el.textContent || '').trim() !== 'Download') continue;
        let parent = el.parentElement;
        while (parent) {
            const bg = window.getComputedStyle(parent).backgroundColor || '';
            if (bg.includes('102, 204') || bg.includes('0, 102, 204')) {
                if (tryClick(el)) return { ok: true, method: 'blue_bg' };
                break;
            }
            parent = parent.parentElement;
        }
    }
    return { ok: false, reason: 'download_link_not_found' };
}
"""


async def _locator_within_season(
    page: Page, season_label: str, folder_path: list[str] | None = None
):
    """Return a Playwright locator scoped to the selected season (and optional path).

    Fail closed: if any path segment is missing, return None (never a parent
    scope) so download/select cannot pick a clip under the wrong folder.
    """
    year_match = re.search(r"(\d{4}-\d{4})", season_label)
    year = year_match.group(1) if year_match else season_label.replace(" Season", "").strip()

    # Prefer Classic season rows, then jstree.
    season_li = page.locator(
        f'#libraryView > ul > li.toggle:has-text("{year}"), '
        f'.tree ul.t2 > li.toggle:has-text("{year}"), '
        f'li.toggle:has-text("{year} Season")'
    ).first
    if await season_li.count() == 0:
        season_li = page.locator(
            f'.manage-library-right-column li:has(> a.jstree-anchor:has-text("{year}"))'
        ).first
    if await season_li.count() == 0:
        season_li = page.locator(
            f'#libraryView li:has(> a.jstree-anchor:has-text("{year}"))'
        ).first
    if await season_li.count() == 0:
        return None

    scope = season_li
    for part in folder_path or []:
        part_base = _strip_count_suffix(part)
        # Direct children only — never search all descendants under the year.
        nested = scope.locator(
            f':scope > ul > li:has(> a.jstree-anchor:has-text("{part_base}")), '
            f':scope > ul > li.toggle:has-text("{part_base}")'
        ).first
        if await nested.count() == 0:
            print(
                f"locator_within_season miss: season={season_label!r} "
                f"missing part={part_base!r} under path={folder_path!r}"
            )
            return None
        scope = nested
    return scope


async def _quick_select_manage_library_clip(
    page: Page, season_label: str, folder_path: list[str], clip_label: str
) -> bool:
    """Fast path: select a clip only inside the selected season/folder."""
    path = _normalize_folder_path(folder_path)
    base = _strip_date_from_label(_strip_count_suffix(clip_label))

    # Scoped JS finds the clip inside THIS year only.
    result = await _jstree_scoped_action(
        page, season_label, path, "select_clip", clip_label=base
    )
    if result.get("ok") and result.get("selected"):
        await human_delay(0.1, 0.2)
        if await _wait_for_manage_library_selection_bar(page, timeout=5000):
            return True

    # Locator fallback — still confined to the selected season path.
    scope = await _locator_within_season(page, season_label, path)
    if scope is None:
        return False
    clip_row = scope.locator(
        f'li:has(> a.jstree-anchor:text-is("{base}"))'
    ).first
    if await clip_row.count() == 0:
        clip_row = scope.locator(
            f'li:has(> a.jstree-anchor:has-text("{base}"))'
        ).first
    try:
        if await clip_row.count() > 0 and await clip_row.is_visible():
            target = clip_row.locator(
                '.jstree-checkbox, ins.jstree-checkbox, input[type="checkbox"], .jstree-anchor'
            ).first
            if await target.count() == 0:
                target = clip_row
            await target.click(timeout=4000)
            await human_delay(0.1, 0.2)
            if await _wait_for_manage_library_selection_bar(page, timeout=5000):
                return True
    except Exception:
        pass

    return False


async def _select_manage_library_clip(
    page: Page, season_label: str, folder_path: list[str], clip_label: str
) -> bool:
    """Select a clip checkbox only under the selected season/folder path."""
    path = _normalize_folder_path(folder_path)
    base = _strip_date_from_label(_strip_count_suffix(clip_label))

    result = await _jstree_scoped_action(
        page, season_label, path, "select_clip", clip_label=base
    )
    if result.get("ok") and result.get("selected"):
        await human_delay(0.1, 0.2)
        if await _wait_for_manage_library_selection_bar(page, timeout=8000):
            return True

    scope = await _locator_within_season(page, season_label, path)
    if scope is None:
        return False

    clip_row = scope.locator(f'li:has(> a.jstree-anchor:text-is("{base}"))').first
    if await clip_row.count() == 0:
        clip_row = scope.locator(f'li:has(> a.jstree-anchor:has-text("{base}"))').first
    if await clip_row.count() == 0:
        clip_row = scope.locator(f'li.jstree-leaf:has-text("{base}")').first

    async def _click_and_verify(click_coro) -> bool:
        try:
            await click_coro
            await human_delay(0.1, 0.2)
            return await _wait_for_manage_library_selection_bar(page, timeout=8000)
        except Exception:
            return False

    if await clip_row.count() > 0:
        for sel in (
            ".jstree-checkbox",
            "ins.jstree-checkbox",
            "i.jstree-checkbox",
            'input[type="checkbox"]',
            ".jstree-anchor",
        ):
            target = clip_row.locator(sel).first
            if await target.count() > 0:
                if await _click_and_verify(target.click(timeout=8000)):
                    return True

        if await _click_and_verify(clip_row.click(timeout=8000)):
            return True

    print(
        f"Scoped clip select miss: season={season_label!r} path={path!r} "
        f"clip={base!r} reason={result.get('reason')}"
    )
    return False


async def _click_manage_library_action_download(page: Page) -> bool:
    """Click Download in the blue selection action bar."""
    if not await _wait_for_manage_library_selection_bar(page, timeout=12000):
        print("Manage Library: selection bar not visible before Download click")

    playwright_locators = [
        page.locator("text=/\\d+ Items? selected/i")
        .locator("xpath=ancestor::div[1]")
        .get_by_text("Download", exact=True)
        .first,
        page.locator('div:has-text("selected")').get_by_text("Download", exact=True).first,
        page.locator(".manage-library-right-column")
        .get_by_role("link", name="Download", exact=True)
        .first,
        page.locator('div:has-text("selected") >> a:text-is("Download")').first,
    ]
    for i, loc in enumerate(playwright_locators):
        try:
            if await loc.count() > 0 and await loc.is_visible():
                await loc.click(timeout=8000)
                print(f"Manage Library: clicked action bar Download via Playwright attempt {i + 1}")
                await human_delay(0.3, 0.5)
                return True
        except Exception as exc:
            print(f"Action bar Download Playwright attempt {i + 1} failed: {exc}")

    for attempt in range(4):
        try:
            result = await page.evaluate(_CLICK_ACTION_DOWNLOAD_JS)
            if result.get("ok"):
                print(f"Manage Library: clicked action bar Download via {result.get('method')}")
                await human_delay(0.3, 0.5)
                return True
        except Exception as exc:
            print(f"Action bar Download JS attempt {attempt + 1} failed: {exc}")
        await asyncio.sleep(0.2)

    return False


_WAIT_VISIBLE_DOWNLOAD_MODAL_JS = """
() => {
    const isVisible = (el) => {
        if (!el) return false;
        const rect = el.getBoundingClientRect();
        const style = window.getComputedStyle(el);
        return (
            rect.width > 20 &&
            rect.height > 20 &&
            style.display !== 'none' &&
            style.visibility !== 'hidden' &&
            parseFloat(style.opacity || '1') > 0.05
        );
    };

    // Hudl Classic Manage Library uses #downloadSettingsDlg.modalPopup
    const classic = document.querySelector('#downloadSettingsDlg, #beginDownloadButton');
    if (classic) {
        const modal =
            classic.id === 'downloadSettingsDlg'
                ? classic
                : classic.closest('#downloadSettingsDlg, .modalPopup') || classic;
        if (isVisible(modal) || isVisible(document.querySelector('#beginDownloadButton'))) {
            return true;
        }
        // Modal may be display:block but small until animation finishes — treat presence + modalBG as open
        const bg = document.querySelector('#modalBG.modalBackground, #modalBG');
        if (bg && isVisible(bg) && document.querySelector('#downloadSettingsDlg')) {
            return true;
        }
    }

    for (const modal of document.querySelectorAll(
        '#downloadSettingsDlg, .modalPopup, .modal, [role="dialog"], .ui-dialog'
    )) {
        const text = modal.innerText || '';
        if (!text.includes('Choose a Download Format') && modal.id !== 'downloadSettingsDlg') {
            continue;
        }
        if (!isVisible(modal)) continue;
        if (
            modal.id === 'downloadSettingsDlg' ||
            modal.classList.contains('modalPopup') ||
            modal.classList.contains('in') ||
            modal.classList.contains('show') ||
            modal.getAttribute('aria-hidden') === 'false'
        ) {
            return true;
        }
    }

    for (const heading of document.querySelectorAll('h1, h2, h3, .modal-title, .header')) {
        if (!(heading.textContent || '').includes('Choose a Download Format')) continue;
        if (!isVisible(heading)) continue;
        const modal =
            heading.closest('#downloadSettingsDlg') ||
            heading.closest('.modalPopup') ||
            heading.closest('.modal') ||
            heading.closest('[role="dialog"]') ||
            heading.closest('.ui-dialog');
        if (modal && isVisible(modal)) return true;
    }
    return false;
}
"""


_CONFIRM_DOWNLOAD_MODAL_JS = """
({ downloadFormat, includeBreakdown }) => {
    const isVisible = (el) => {
        if (!el) return false;
        const rect = el.getBoundingClientRect();
        const style = window.getComputedStyle(el);
        return (
            rect.width > 20 &&
            rect.height > 20 &&
            style.display !== 'none' &&
            style.visibility !== 'hidden' &&
            parseFloat(style.opacity || '1') > 0.05
        );
    };

    const isDownloadLabel = (text) =>
        /^Download$/i.test((text || '').replace(/\\s+/g, ' ').trim());

    const findVisibleModal = () => {
        const byId = document.querySelector('#downloadSettingsDlg');
        if (byId) {
            if (isVisible(byId) || isVisible(document.querySelector('#beginDownloadButton'))) {
                return byId;
            }
            const bg = document.querySelector('#modalBG');
            if (bg && isVisible(bg)) return byId;
            // Classic modal can stay in DOM with non-zero size even when partially ready
            const style = window.getComputedStyle(byId);
            if (style.display !== 'none' && style.visibility !== 'hidden') return byId;
        }

        for (const modal of document.querySelectorAll(
            '.modalPopup, .modal, [role="dialog"], .ui-dialog'
        )) {
            const text = modal.innerText || '';
            if (!text.includes('Choose a Download Format')) continue;
            if (!isVisible(modal)) continue;
            return modal;
        }

        for (const heading of document.querySelectorAll('h1, h2, h3, .modal-title, .header')) {
            if (!(heading.textContent || '').includes('Choose a Download Format')) continue;
            if (!isVisible(heading)) continue;
            const modal =
                heading.closest('#downloadSettingsDlg') ||
                heading.closest('.modalPopup') ||
                heading.closest('.modal') ||
                heading.closest('[role="dialog"]') ||
                heading.closest('.ui-dialog') ||
                heading.closest('.modal-dialog')?.parentElement;
            if (modal && isVisible(modal)) return modal;
        }
        return null;
    };

    const findDownloadButton = (modal) => {
        const byId = document.querySelector('#beginDownloadButton');
        if (byId && (isVisible(byId) || byId.offsetParent !== null || byId.getClientRects().length)) {
            return byId;
        }

        const roots = [modal];
        const dialog = modal.querySelector('.modal-dialog, .modal-content, .footer');
        if (dialog) roots.push(dialog);
        const footer = modal.querySelector('.modal-footer, .footer');
        if (footer) roots.unshift(footer);

        for (const root of roots) {
            for (const btn of root.querySelectorAll(
                'button, a, input[type="submit"], input[type="button"], .btn, [role="button"]'
            )) {
                const text = (
                    btn.textContent ||
                    btn.value ||
                    btn.getAttribute('aria-label') ||
                    ''
                ).replace(/\\s+/g, ' ').trim();
                if (!isDownloadLabel(text)) continue;
                if (!isVisible(btn) && btn.id !== 'beginDownloadButton') continue;
                return btn;
            }
        }
        return null;
    };

    const ensureCheckboxChecked = (checkbox) => {
        if (!checkbox || checkbox.disabled) return false;
        if (checkbox.checked) return true;
        checkbox.focus();
        checkbox.click();
        if (!checkbox.checked) {
            checkbox.checked = true;
            checkbox.dispatchEvent(new Event('input', { bubbles: true }));
            checkbox.dispatchEvent(new Event('change', { bubbles: true }));
        }
        return !!checkbox.checked;
    };

    const modal = findVisibleModal();
    if (!modal) return { ok: false, reason: 'visible_modal_not_found' };

    const formatLabels = {
        multiple: 'Multiple Files per Playlist',
        single: 'Single File per Playlist',
        presentation: 'Presentation',
    };
    const targetLabel = formatLabels[downloadFormat] || formatLabels.multiple;

    const clickByText = (root, text) => {
        for (const el of root.querySelectorAll('label, span, div, li, input[type="radio"]')) {
            const value = (el.textContent || el.value || '').trim();
            if (!value.includes(text)) continue;
            if (el.tagName === 'INPUT') {
                el.checked = true;
                el.dispatchEvent(new Event('change', { bubbles: true }));
                el.click();
                return true;
            }
            const input = el.querySelector('input[type="radio"]');
            if (input) {
                input.checked = true;
                input.dispatchEvent(new Event('change', { bubbles: true }));
                input.click();
                return true;
            }
            el.click();
            return true;
        }
        return false;
    };

    clickByText(modal, targetLabel);

    let breakdownChecked = false;
    if (includeBreakdown && downloadFormat !== 'presentation') {
        // Prefer the checkbox under the selected "Multiple/Single Files" option
        const checkboxes = Array.from(modal.querySelectorAll('input[type="checkbox"]'));
        for (const cb of checkboxes) {
            if (cb.disabled) continue;
            const label = cb.closest('label') || cb.parentElement;
            const nearby = (label?.textContent || cb.nextSibling?.textContent || '').trim();
            const rowText = (cb.closest('div, li, tr, label')?.textContent || nearby).trim();
            if (rowText.includes('Include breakdown data')) {
                breakdownChecked = ensureCheckboxChecked(cb);
                if (breakdownChecked) break;
            }
        }
        if (!breakdownChecked) {
            for (const cb of checkboxes) {
                if (cb.disabled) continue;
                breakdownChecked = ensureCheckboxChecked(cb);
                if (breakdownChecked) break;
            }
        }
    }

    const downloadBtn = findDownloadButton(modal);
    if (!downloadBtn) return { ok: false, reason: 'download_button_not_found' };

    downloadBtn.dispatchEvent(
        new MouseEvent('click', { bubbles: true, cancelable: true, view: window })
    );
    downloadBtn.click();
    return {
        ok: true,
        method: modal.id === 'downloadSettingsDlg' ? 'classic_downloadSettingsDlg' : 'visible_modal',
        breakdownChecked: includeBreakdown ? breakdownChecked : null,
        downloadButtonId: downloadBtn.id || null,
    };
}
"""


# Dismisses the green "We'll email you a link when your video is ready to
# download." confirmation banner by clicking its close (×) icon.
# Hudl mounts this under .manage-library-notification-wrapper.
_DISMISS_DOWNLOAD_BANNER_JS = """
() => {
    const isVisible = (el) => {
        if (!el) return false;
        const rect = el.getBoundingClientRect();
        const style = window.getComputedStyle(el);
        return (
            rect.width > 4 &&
            rect.height > 4 &&
            style.display !== 'none' &&
            style.visibility !== 'hidden' &&
            parseFloat(style.opacity || '1') > 0.05
        );
    };

    const clickEl = (el) => {
        el.dispatchEvent(
            new MouseEvent('click', { bubbles: true, cancelable: true, view: window })
        );
        if (typeof el.click === 'function') el.click();
    };

    const looksLikeClose = (el) => {
        const label = (
            (el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('title'))) || ''
        ).toLowerCase();
        const cls = (el.className && el.className.toString ? el.className.toString() : '').toLowerCase();
        const txt = (el.textContent || '').trim();
        return (
            label.includes('close') ||
            label.includes('dismiss') ||
            cls.includes('close') ||
            cls.includes('dismiss') ||
            cls.includes('uni-dismiss') ||
            txt === '\\u00d7' ||
            txt === '\\u2715' ||
            txt === 'x' ||
            txt === 'X'
        );
    };

    // Prefer Hudl's notification wrapper from the Manage Library DOM.
    const wrappers = Array.from(
        document.querySelectorAll(
            '.manage-library-notification-wrapper, [class*="manage-library-notification"]'
        )
    );
    for (const wrapper of wrappers) {
        if (!isVisible(wrapper)) continue;
        const text = (wrapper.textContent || '');
        if (!text.includes('email you a link') && !text.includes('ready to download')) {
            continue;
        }
        const closes = wrapper.querySelectorAll(
            'button.uni-dismiss, .uni-dismiss, button, a, span, i, [role="button"], [class*="close"], [class*="dismiss"]'
        );
        for (const el of closes) {
            if (!looksLikeClose(el) && el.tagName !== 'BUTTON') continue;
            if (!isVisible(el)) continue;
            // Prefer true dismiss controls over random buttons.
            if (looksLikeClose(el) || closes.length === 1) {
                clickEl(el);
                return { ok: true, method: 'notification_wrapper_close' };
            }
        }
        // Last resort inside the wrapper: right-most compact clickable.
        let best = null;
        let bestRight = -Infinity;
        for (const el of closes) {
            if (!isVisible(el)) continue;
            const txt = (el.textContent || '').trim();
            if (txt.length > 3 && !looksLikeClose(el)) continue;
            const rect = el.getBoundingClientRect();
            if (rect.right > bestRight) {
                bestRight = rect.right;
                best = el;
            }
        }
        if (best) {
            clickEl(best);
            return { ok: true, method: 'notification_wrapper_rightmost' };
        }
    }

    // Fallback: any visible banner with the confirmation text.
    const BANNER_TEXT = 'email you a link';
    let banner = null;
    for (const el of document.querySelectorAll(
        '.flash, .flashMessage, .notification, .alert, .toast, .banner, [role="alert"], [class*="flash"], [class*="notif"], [class*="banner"]'
    )) {
        const text = (el.textContent || '').trim();
        if (!text.includes(BANNER_TEXT)) continue;
        if (!isVisible(el)) continue;
        if (!banner || banner.contains(el)) banner = el;
    }
    if (!banner) return { ok: false, reason: 'banner_not_found' };

    for (const el of banner.querySelectorAll(
        'button, a, span, i, svg, [role="button"], .close, .uni-dismiss, [class*="close"], [class*="dismiss"]'
    )) {
        if (!looksLikeClose(el)) continue;
        if (!isVisible(el)) continue;
        clickEl(el);
        return { ok: true, method: 'banner_close_icon' };
    }

    return { ok: false, reason: 'close_icon_not_found' };
}
"""


# Closes the blue "N item(s) selected" action bar via Hudl's exact dismiss button:
# <button id="manageLibraryActionBarDismiss" class="uni-dismiss uni-actionbar__dismiss">
_DISMISS_SELECTION_BAR_JS = """
() => {
    const isVisible = (el) => {
        if (!el) return false;
        const rect = el.getBoundingClientRect();
        const style = window.getComputedStyle(el);
        return (
            rect.width > 4 &&
            rect.height > 4 &&
            style.display !== 'none' &&
            style.visibility !== 'hidden' &&
            parseFloat(style.opacity || '1') > 0.05
        );
    };

    const clickEl = (el) => {
        el.dispatchEvent(
            new MouseEvent('click', { bubbles: true, cancelable: true, view: window })
        );
        if (typeof el.click === 'function') el.click();
    };

    // Exact Hudl Classic selector from DevTools.
    const byId = document.querySelector('#manageLibraryActionBarDismiss');
    if (byId && isVisible(byId)) {
        clickEl(byId);
        return { ok: true, method: 'manageLibraryActionBarDismiss' };
    }

    const byClass = document.querySelector(
        '#manageLibraryActionBar button.uni-actionbar__dismiss, #manageLibraryActionBar .uni-dismiss, '
        + '.uni-actionbar__dismiss, .uni-dismiss'
    );
    if (byClass && isVisible(byClass)) {
        clickEl(byClass);
        return { ok: true, method: 'uni-actionbar__dismiss' };
    }

    // Locate the blue action bar: by id, or by the "N item(s) selected" text.
    let bar = document.querySelector(
        '#manageLibraryActionBar.uni-actionbar--is-open, #manageLibraryActionBar, .uni-actionbar--is-open, .uni-actionbar'
    );
    if (!bar || !isVisible(bar)) {
        const all = Array.from(document.querySelectorAll('div, section, header, nav'));
        for (const node of all) {
            if (!isVisible(node)) continue;
            const text = (node.innerText || '').replace(/\\s+/g, ' ').trim();
            if (/\\d+\\s+items?\\s+selected/i.test(text) || /item\\s+selected/i.test(text)) {
                // Pick the tightest matching container (smallest that still holds the text).
                bar = node;
            }
        }
    }
    if (!bar || !isVisible(bar)) {
        return { ok: false, reason: 'selection_bar_not_found' };
    }

    const actionWords = ['share', 'unshare', 'download', 'copy', 'move', 'delete'];

    // Collect visible clickable candidates inside the bar.
    const candidates = Array.from(
        bar.querySelectorAll('button, a, [role="button"], .uni-dismiss, svg, i, span')
    ).filter(isVisible);

    // 1) Anything that explicitly looks like a dismiss/close control.
    for (const el of candidates) {
        const cls = String(el.className || '');
        const aria = (el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('title'))) || '';
        if (
            el.id === 'manageLibraryActionBarDismiss' ||
            /dismiss|close/i.test(cls) ||
            /dismiss|close/i.test(aria)
        ) {
            const target = el.closest('button, a, [role="button"]') || el;
            clickEl(target);
            return { ok: true, method: 'actionbar_dismiss_match' };
        }
    }

    // 2) Otherwise click the right-most icon-only control (the "X" cross) that
    //    is NOT one of the labelled action buttons.
    const clickable = Array.from(
        bar.querySelectorAll('button, a, [role="button"]')
    ).filter(isVisible);

    let best = null;
    let bestRight = -Infinity;
    for (const el of clickable) {
        const txt = (el.textContent || '').trim().toLowerCase();
        if (actionWords.includes(txt)) continue;
        // Icon-only close buttons usually have little/no text.
        if (txt.length > 2 && !/^[x\\u00d7\\u2715\\u2716]$/i.test(txt)) continue;
        const right = el.getBoundingClientRect().right;
        if (right > bestRight) {
            bestRight = right;
            best = el;
        }
    }
    if (best) {
        clickEl(best);
        return { ok: true, method: 'actionbar_rightmost_icon' };
    }

    return { ok: false, reason: 'close_icon_not_found' };
}
"""


async def _dismiss_manage_library_selection_bar(page: Page) -> None:
    """Close Hudl's blue selection action bar via #manageLibraryActionBarDismiss.

    Best-effort only: logged and swallowed on failure.
    """
    # Prefer Playwright's direct click on the known Hudl id.
    for attempt in range(4):
        try:
            dismiss = page.locator("#manageLibraryActionBarDismiss")
            if await dismiss.count() > 0 and await dismiss.first.is_visible():
                await dismiss.first.click(timeout=2000, force=True)
                await asyncio.sleep(0.15)
                print(
                    "Manage Library: dismissed selection bar via #manageLibraryActionBarDismiss"
                )
                return
        except Exception:
            pass

        try:
            result = await page.evaluate(_DISMISS_SELECTION_BAR_JS)
        except Exception as exc:
            result = {"ok": False, "reason": f"evaluate_error:{exc}"}

        if result.get("ok"):
            await asyncio.sleep(0.15)
            print(
                f"Manage Library: dismissed selection bar via {result.get('method', 'js')}"
            )
            return
        if result.get("reason") == "selection_bar_not_found":
            return
        await asyncio.sleep(0.25)

    print("Manage Library: selection bar not dismissed (best-effort)")


async def _dismiss_manage_library_download_banner(page: Page) -> None:
    """Close Hudl's green "we'll email you a link" confirmation banner (× icon).

    Best-effort only: the download is already triggered, so any failure here is
    logged and swallowed rather than failing the job.
    """
    # Keep this short — the banner often auto-dismisses, and holding the shared
    # Manage Library lane here blocks folder browsing.
    for attempt in range(4):
        # Wait briefly for the green banner to appear after Download confirm.
        try:
            wrapper = page.locator(".manage-library-notification-wrapper")
            if await wrapper.count() > 0 and await wrapper.first.is_visible():
                close_btn = wrapper.first.locator(
                    "button.uni-dismiss, .uni-dismiss, button[class*='close'], "
                    "button[class*='dismiss'], button, [role='button']"
                )
                count = await close_btn.count()
                for i in range(count):
                    btn = close_btn.nth(i)
                    if not await btn.is_visible():
                        continue
                    try:
                        await btn.click(timeout=1500, force=True)
                        print(
                            "Manage Library: dismissed download banner via notification wrapper"
                        )
                        return
                    except Exception:
                        continue
        except Exception:
            pass

        try:
            result = await page.evaluate(_DISMISS_DOWNLOAD_BANNER_JS)
        except Exception as exc:
            result = {"ok": False, "reason": f"evaluate_error:{exc}"}

        if result.get("ok"):
            print(
                f"Manage Library: dismissed download banner via {result.get('method', 'js')}"
            )
            return

        if result.get("reason") == "banner_not_found":
            await asyncio.sleep(0.25)
            continue
        await asyncio.sleep(0.2)

    print("Manage Library: download confirmation banner not dismissed (best-effort)")


async def _wait_for_visible_download_modal(page: Page, timeout: float = 15000) -> bool:
    """Wait until Hudl's download format modal is actually visible (not the hidden template)."""
    try:
        await page.wait_for_function(
            _WAIT_VISIBLE_DOWNLOAD_MODAL_JS,
            timeout=timeout,
        )
        return True
    except Exception:
        return False


async def _confirm_manage_library_download_modal(
    page: Page, download_format: str = "multiple", include_breakdown: bool = True
) -> None:
    """Confirm Hudl download format modal after the action bar Download click.

    Hudl Classic uses #downloadSettingsDlg / #beginDownloadButton (not Bootstrap .modal.in).
    """
    payload = {"downloadFormat": download_format, "includeBreakdown": include_breakdown}
    last_reason = "unknown"

    if not await _wait_for_visible_download_modal(page, timeout=15000):
        print("Manage Library: visible download modal not detected yet, retrying action bar click")
        await _click_manage_library_action_download(page)
        await _wait_for_visible_download_modal(page, timeout=10000)

    # Prefer Hudl Classic selectors from the live DOM (#downloadSettingsDlg).
    classic_modal = page.locator("#downloadSettingsDlg")
    if await classic_modal.count() > 0:
        try:
            await classic_modal.wait_for(state="visible", timeout=8000)
        except Exception:
            pass

        try:
            if download_format == "single":
                opt = classic_modal.get_by_text("Single File per Playlist", exact=False).first
            elif download_format == "presentation":
                opt = classic_modal.get_by_text("Presentation", exact=True).first
            else:
                opt = classic_modal.get_by_text("Multiple Files per Playlist", exact=False).first
            await opt.click(timeout=8000, force=True)
            await human_delay(0.1, 0.2)

            if include_breakdown and download_format in ("multiple", "single"):
                # Check the enabled "Include breakdown data" checkbox (not the disabled one under Single).
                checked = await classic_modal.evaluate(
                    """(modal) => {
                        const boxes = Array.from(modal.querySelectorAll('input[type="checkbox"]'));
                        for (const cb of boxes) {
                            if (cb.disabled) continue;
                            const row = cb.closest('label, div, li, tr') || cb.parentElement;
                            const text = (row?.textContent || '').trim();
                            if (!text.includes('Include breakdown data')) continue;
                            if (!cb.checked) {
                                cb.click();
                                if (!cb.checked) {
                                    cb.checked = true;
                                    cb.dispatchEvent(new Event('change', { bubbles: true }));
                                }
                            }
                            return !!cb.checked;
                        }
                        return false;
                    }"""
                )
                print(f"Manage Library: include breakdown checkbox checked={checked}")
                await human_delay(0.1, 0.2)

            begin_btn = page.locator("#beginDownloadButton")
            if await begin_btn.count() > 0:
                await begin_btn.click(timeout=8000, force=True)
                print("Manage Library: confirmed download modal via #beginDownloadButton")
                await human_delay(0.3, 0.5)
                return

            download_btn = classic_modal.locator(
                'input[type="button"][value="Download"], input.submit[value="Download"], button:has-text("Download")'
            ).last
            if await download_btn.count() > 0:
                await download_btn.click(timeout=8000, force=True)
                print("Manage Library: confirmed download modal via classic footer Download")
                await human_delay(0.3, 0.5)
                return
        except Exception as exc:
            last_reason = f"classic_playwright_confirm_failed:{exc}"
            print(f"Manage Library: classic modal confirm failed: {exc}")

    visible_modal = page.locator(".modal.in, .modal.show").filter(
        has_text="Choose a Download Format"
    ).first
    if await visible_modal.count() == 0:
        visible_modal = page.locator(".modalPopup, .modal, [role='dialog']").filter(
            has_text="Choose a Download Format"
        ).locator("visible=true").first

    if await visible_modal.count() > 0:
        try:
            if download_format == "single":
                opt = visible_modal.get_by_text("Single File per Playlist", exact=False).first
            elif download_format == "presentation":
                opt = visible_modal.get_by_text("Presentation", exact=True).first
            else:
                opt = visible_modal.get_by_text("Multiple Files per Playlist", exact=False).first
            await opt.click(timeout=8000, force=True)
            await human_delay(0.1, 0.2)

            if include_breakdown and download_format in ("multiple", "single"):
                try:
                    await visible_modal.evaluate(
                        """(modal) => {
                            for (const cb of modal.querySelectorAll('input[type="checkbox"]')) {
                                if (cb.disabled) continue;
                                const row = cb.closest('label, div, li, tr') || cb.parentElement;
                                if (!(row?.textContent || '').includes('Include breakdown data')) continue;
                                if (!cb.checked) {
                                    cb.click();
                                    if (!cb.checked) {
                                        cb.checked = true;
                                        cb.dispatchEvent(new Event('change', { bubbles: true }));
                                    }
                                }
                                return true;
                            }
                            return false;
                        }"""
                    )
                except Exception:
                    pass

            for btn_sel in (
                "#beginDownloadButton",
                'input[type="button"][value="Download"]',
                'input.submit[value="Download"]',
                '.footer input[value="Download"]',
                '.modal-footer button:has-text("Download")',
                'button:has-text("Download")',
            ):
                download_btn = (
                    page.locator(btn_sel).first
                    if btn_sel.startswith("#")
                    else visible_modal.locator(btn_sel).last
                )
                if await download_btn.count() > 0:
                    await download_btn.click(timeout=8000, force=True)
                    print(
                        f"Manage Library: confirmed download modal via Playwright ({btn_sel})"
                    )
                    await human_delay(0.3, 0.5)
                    return
        except Exception as exc:
            last_reason = f"playwright_confirm_failed:{exc}"
            print(f"Manage Library: Playwright modal confirm failed: {exc}")

    for attempt in range(12):
        await human_delay(0.15, 0.3)
        result = await page.evaluate(_CONFIRM_DOWNLOAD_MODAL_JS, payload)
        if result.get("ok"):
            print(
                f"Manage Library: confirmed download modal via {result.get('method', 'js')} "
                f"(breakdown={result.get('breakdownChecked')}, btn={result.get('downloadButtonId')})"
            )
            await human_delay(0.3, 0.5)
            return
        last_reason = result.get("reason", "unknown")
        print(f"Download modal confirm attempt {attempt + 1}: {last_reason}")
        if last_reason == "visible_modal_not_found" and attempt in (3, 7):
            await _click_manage_library_action_download(page)
            await _wait_for_visible_download_modal(page, timeout=8000)
        await asyncio.sleep(0.25)

    raise ValueError(f"Could not confirm Hudl download format modal: {last_reason}")


async def set_manage_library_clip_selection(
    page: Page,
    team_id: str | None = None,
    season_label: str = "",
    path: list[str] | None = None,
    label: str = "",
    selected: bool = True,
) -> dict:
    """Mirror a single frontend clip selection onto the Hudl Manage Library page.

    Checks (selected=True) or unchecks (selected=False) the clip's checkbox so the
    open Hudl page reflects exactly what the user selects in the frontend.
    """
    season = season_label.strip()
    clip_label = (label or "").strip()
    if not season:
        raise ValueError("season_label is required")
    if not clip_label:
        raise ValueError("label is required")

    norm_path = _normalize_folder_path(
        [p.strip() for p in (path or []) if p and p.strip()]
    )
    base = _strip_date_from_label(_strip_count_suffix(clip_label))

    async def _run() -> dict:
        # Fast, DOM-only mirror. The clip the user just toggled is already
        # visible (expanding the folder on the frontend expanded it on this tab
        # too), so a scoped select is enough. We intentionally do NOT navigate
        # here: the download flow re-selects every clip from a clean state, so a
        # miss is harmless, and a slow/cancelled navigation would both delay the
        # download and risk leaving the Hudl page in a broken state.
        if "/manage/library/" not in (page.url or ""):
            return {
                "status": "miss",
                "team_id": team_id,
                "season_label": season,
                "label": base,
                "selected": selected,
                "changed": False,
                "reason": "not_on_manage_library_page",
            }

        result = await _jstree_scoped_action(
            page, season, norm_path, "set_clip", clip_label=base, selected=selected
        )

        ok = bool(result.get("ok"))
        print(
            f"Manage Library select: season={season!r} path={norm_path!r} "
            f"label={base!r} selected={selected} ok={ok} "
            f"changed={result.get('changed')} reason={result.get('reason')}"
        )
        return {
            "status": "ok" if ok else "miss",
            "team_id": team_id,
            "season_label": season,
            "label": base,
            "selected": selected,
            "changed": bool(result.get("changed")),
            "checked": result.get("checked"),
            "reason": result.get("reason"),
        }

    # Best-effort: bound the work tightly and never raise. The download flow
    # re-selects clips itself, so a miss/timeout here is harmless.
    try:
        return await asyncio.wait_for(_run(), timeout=10.0)
    except asyncio.TimeoutError:
        print(
            f"Manage Library select timed out (non-fatal): "
            f"season={season!r} label={base!r}"
        )
        return {
            "status": "timeout",
            "team_id": team_id,
            "season_label": season,
            "label": base,
            "selected": selected,
            "changed": False,
        }
    except Exception as exc:
        print(f"Manage Library select error (non-fatal): {exc}")
        return {
            "status": "error",
            "team_id": team_id,
            "season_label": season,
            "label": base,
            "selected": selected,
            "changed": False,
            "reason": str(exc),
        }


# Detects Hudl's post-download outcome from VISIBLE notification banners only.
# Scanning all of document.body is wrong — leftover error text from an earlier
# failed attempt stays in the DOM and falsely fails every subsequent download.
# The red "Uh oh, something didn't work" strip is often a plain top-of-page div
# without flash/banner classes, so we also scan short visible strips near the top.
_DOWNLOAD_OUTCOME_JS = """
() => {
    const isVisible = (el) => {
        if (!el) return false;
        const rect = el.getBoundingClientRect();
        const style = window.getComputedStyle(el);
        return (
            rect.width > 4 &&
            rect.height > 4 &&
            style.display !== 'none' &&
            style.visibility !== 'hidden' &&
            parseFloat(style.opacity || '1') > 0.05
        );
    };
    const errRe = /try that download again|uh oh,? something didn.?t work/i;
    const okRe = /email you a link|ready to download/i;
    const nodes = Array.from(document.querySelectorAll(
        '.manage-library-notification-wrapper, [class*="manage-library-notification"], '
        + '.flash, .flashMessage, .notification, .alert, .toast, .banner, [role="alert"], '
        + '[class*="flash"], [class*="notif"], [class*="banner"], '
        + 'div, section, header, aside'
    ));
    let sawError = false;
    for (const node of nodes) {
        if (!isVisible(node)) continue;
        const text = (node.innerText || node.textContent || '').replace(/\\s+/g, ' ').trim();
        if (!text || text.length > 220) continue;
        // Prefer success — a green banner means this attempt worked.
        if (okRe.test(text)) return 'success';
        if (errRe.test(text)) sawError = true;
    }
    return sawError ? 'error' : 'pending';
}
"""

# Clicks the × on Hudl's red "something didn't work" error strip so a retry
# starts from a clean page.
_DISMISS_DOWNLOAD_ERROR_JS = """
() => {
    const isVisible = (el) => {
        if (!el) return false;
        const rect = el.getBoundingClientRect();
        const style = window.getComputedStyle(el);
        return (
            rect.width > 2 &&
            rect.height > 2 &&
            style.display !== 'none' &&
            style.visibility !== 'hidden' &&
            parseFloat(style.opacity || '1') > 0.05
        );
    };
    const clickEl = (el) => {
        el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
        if (typeof el.click === 'function') el.click();
    };
    const errRe = /(try that download again|something did\\S* ?work)/i;
    const nodes = Array.from(document.querySelectorAll('div, section, header, aside'));
    let banner = null;
    for (const node of nodes) {
        if (!isVisible(node)) continue;
        const text = (node.innerText || '').replace(/\\s+/g, ' ').trim();
        if (errRe.test(text) && text.length < 200) banner = node;
    }
    if (!banner) return { ok: false, reason: 'error_banner_not_found' };
    const candidates = Array.from(
        banner.querySelectorAll('button, a, span, i, svg, [role="button"], [class*="close"], [class*="dismiss"]')
    ).filter(isVisible);
    let best = null;
    let bestRight = -Infinity;
    for (const el of candidates) {
        const txt = (el.textContent || '').trim();
        const cls = String(el.className || '').toLowerCase();
        const isClose =
            /close|dismiss/.test(cls) || txt === '\\u00d7' || txt === '\\u2715' || /^x$/i.test(txt);
        if (!isClose && txt.length > 2) continue;
        const right = el.getBoundingClientRect().right;
        if (right > bestRight) {
            bestRight = right;
            best = el;
        }
    }
    if (best) {
        clickEl(best);
        return { ok: true };
    }
    return { ok: false, reason: 'error_close_not_found' };
}
"""


async def _wait_for_download_outcome(page: Page, timeout: float = 12.0) -> str:
    """Poll for Hudl's post-download banner. Returns 'success' | 'error' | 'unknown'."""
    import time as _t

    deadline = _t.monotonic() + timeout
    while _t.monotonic() < deadline:
        try:
            outcome = await page.evaluate(_DOWNLOAD_OUTCOME_JS)
        except Exception:
            outcome = "pending"
        if outcome in ("success", "error"):
            return outcome
        await asyncio.sleep(0.25)
    return "unknown"


async def _dismiss_manage_library_download_error(page: Page) -> None:
    """Best-effort close of Hudl's red 'something didn't work' error strip."""
    try:
        result = await page.evaluate(_DISMISS_DOWNLOAD_ERROR_JS)
        if result.get("ok"):
            print("Manage Library: dismissed red download error banner")
            await asyncio.sleep(0.2)
    except Exception:
        pass


async def download_manage_library_items(
    page: Page,
    team_id: str | None = None,
    season_label: str = "",
    items: list[dict] | None = None,
    download_format: str = "multiple",
    include_breakdown: bool = True,
) -> dict:
    """Navigate Hudl Manage Library, select clips, and trigger email download."""
    season = season_label.strip()
    selected = items or []
    if not season:
        raise ValueError("season_label is required")
    if not selected:
        raise ValueError("items is required")

    async def _run() -> dict:
        resolved_team_id = await _ensure_manage_library_page(page, team_id)
        await _wait_for_manage_library_tree(page)

        # Clear any leftover red/green banners from a previous attempt so they
        # can't poison this run's outcome check.
        await _dismiss_manage_library_download_error(page)
        await _dismiss_manage_library_download_banner(page)

        # Remove any stale Hudl selections left by an earlier action. The list
        # received from the frontend is then selected from a clean state, which
        # guarantees that Playwright downloads exactly those rows and no others.
        await _dismiss_manage_library_selection_bar(page)

        await _show_hudl_click_banner(
            page, f"Download: opening {season} (year only)"
        )
        # Always open THIS year first and collapse other years on Hudl.
        await _classic_mark_and_click(page, season, [], collapse_others=True)
        await asyncio.sleep(0.4)

        selected_labels: list[str] = []
        last_path: list[str] | None = None
        for item in selected:
            path = _normalize_folder_path(
                [p.strip() for p in (item.get("path") or []) if p.strip()]
            )
            clip_label = _strip_date_from_label(
                _strip_count_suffix((item.get("label") or "").strip())
            )
            if not clip_label:
                continue

            print(
                f"Manage Library download: selecting clip "
                f"season={season!r} path={path!r} label={clip_label!r}"
            )
            await _show_hudl_click_banner(
                page,
                f"Download select: {season} → {' → '.join([*path, clip_label])}",
            )

            # When path changes, open year → folders again (scoped to this year).
            if path != last_path:
                await _navigate_manage_library_path(page, season, path)
                last_path = path

            ok = await _quick_select_manage_library_clip(page, season, path, clip_label)
            if not ok:
                print("Manage Library download: quick select missed, re-navigating")
                await _navigate_manage_library_path(page, season, path)
                ok = await _select_manage_library_clip(page, season, path, clip_label)
            if not ok:
                raise ValueError(
                    f"Could not select clip '{clip_label}' at path {'/'.join(path) or '(season root)'}"
                )
            selected_labels.append(clip_label)
            await human_delay(0.08, 0.18)

        if not selected_labels:
            raise ValueError("No clips were selected for download")

        if not await _wait_for_manage_library_selection_bar(page, timeout=15000):
            raise ValueError(
                "Clip selection did not activate the Hudl action bar (no 'Item selected' banner)"
            )

        if not await _click_manage_library_action_download(page):
            raise ValueError("Could not click Download in Manage Library action bar")

        # Confirm download. On Hudl's red error banner, retry once without
        # "Include breakdown data" — that checkbox often triggers "try that
        # download again" for playlists that don't support breakdown export.
        outcome = "unknown"
        used_breakdown = include_breakdown
        attempt_flags = [include_breakdown]
        if include_breakdown:
            attempt_flags.append(False)
        else:
            attempt_flags.append(include_breakdown)

        for attempt, breakdown_flag in enumerate(attempt_flags[:2]):
            used_breakdown = breakdown_flag
            await _confirm_manage_library_download_modal(
                page,
                download_format=download_format,
                include_breakdown=breakdown_flag,
            )
            outcome = await _wait_for_download_outcome(page, timeout=8.0)
            if outcome == "success":
                break
            if outcome != "error":
                # Neither banner — button click usually still queued the email.
                # One last error sweep before accepting unknown.
                late = await _wait_for_download_outcome(page, timeout=2.0)
                if late == "error":
                    outcome = "error"
                elif late == "success":
                    outcome = "success"
                    break
                else:
                    break

            print(
                f"Manage Library: Hudl reported a download error "
                f"(attempt {attempt + 1}/2, include_breakdown={breakdown_flag}); retrying"
            )
            await _dismiss_manage_library_download_error(page)
            await human_delay(0.5, 0.8)
            if not await _click_manage_library_action_download(page):
                break

        if outcome == "error":
            raise ValueError(
                "Hudl rejected the download (\"something didn't work\"). "
                "Please try again in a moment."
            )

        # Close the green "we'll email you a link" confirmation banner (× icon).
        await _dismiss_manage_library_download_banner(page)

        # Close the blue "N item(s) selected" action bar (× icon).
        await _dismiss_manage_library_selection_bar(page)

        return {
            "status": "ok",
            "team_id": resolved_team_id,
            "season_label": season,
            "items": selected_labels,
            "format": download_format,
            "include_breakdown": used_breakdown,
            "message": "We'll email you a link when your video is ready to download.",
        }

    try:
        return await asyncio.wait_for(_run(), timeout=120.0)
    except asyncio.TimeoutError as exc:
        raise TimeoutError("Timed out downloading from Hudl Manage Library.") from exc


async def get_manage_library(page: Page, team_id: str | None = None) -> dict:
    """
    Navigate to Hudl Classic Manage Library and return storage + season tree data.
    """
    async def _run() -> dict:
        resolved_team_id = await _ensure_manage_library_page(page, team_id)
        url = page.url if f"/manage/library/{resolved_team_id}" in page.url else await _navigate_to_manage_library(page, resolved_team_id)

        parsed = await _parse_manage_library_page(page)
        seasons = parsed.get("seasons") or []

        if not seasons:
            body_preview = ""
            try:
                body_preview = (await page.locator("body").inner_text())[:500]
            except Exception:
                pass
            print(f"Manage Library parse returned 0 seasons. Body preview: {body_preview!r}")

        return {
            "status": "ok",
            "url": url,
            "team_id": resolved_team_id,
            "storage": parsed.get("storage") or {},
            "seasons": seasons,
            "seasons_count": len(seasons),
        }

    try:
        return await asyncio.wait_for(_run(), timeout=90.0)
    except asyncio.TimeoutError as exc:
        raise TimeoutError(
            "Timed out loading Hudl Manage Library. Try again or reduce library size."
        ) from exc

