"""
Reusable helpers to detect whether an attachments/files dialog is open and
to count the number of downloadable items visible on screen.

These helpers use the application's configured OpenAI analyzer to run a
strict JSON-only prompt and return parsed, typed results.

Public API:
- analyze_dialog_open_and_count(app) -> tuple[bool, int]
"""

from __future__ import annotations

from typing import Tuple, List, Optional
import logging
import re


def _build_dialog_count_prompt() -> str:
    # Keep this prompt consistent with the Xero attach-files flow usage
    return (
        "You are an extraction engine. Output STRICT JSON only (no markdown fences, no extra text).\n"
        "Return exactly: {\"dialog_open\": <true|false>, \"file_count\": <integer 0+>, \"files\": [<string>...]}\n"
        "\n"
        "Rules:\n"
        "1) If a files dialog is visible (e.g., 'RELATED FILES'), set dialog_open=true; else false.\n"
        "2) When dialog_open=true: STRICTLY bound the content area from the dialog header text 'RELATED FILES' (or variants)\n"
        "   DOWN TO but NOT including the footer lines: '+ Add from file library...' and '+ Upload files...'.\n"
        "   Work ONLY within these bounds. Anything above the header or below the footers must be ignored.\n"
        "3) Count rows using VISIBLE MARKERS:\n"
        "   - Preferred: count the small close \"X\" icons aligned at the far right of each row.\n"
        "   - If the \"X\" icons are not clearly visible, count the left-side file-type icons/thumbnails.\n"
        "   This count is your REQUIRED number of filenames to extract (the row count).\n"
        "4) Extract ONLY the TOP-LINE filename text from each row, in TOP-TO-BOTTOM order. Ignore subtitles (uploaded/by/date/size).\n"
        "5) Include EVERY visible row, including partially visible first/last rows. The bottom-most row directly above the footer\n"
        "   MUST be included. DO NOT omit any interior row for any reason.\n"
        "6) Validation: First determine the row count using step 3. Then set file_count = files.length and ENSURE it equals the row count.\n"
        "   - If files.length is less than the row count, you MUST re-scan and include the missing filenames; never return fewer rows than counted.\n"
        "   - DO NOT cap at any number. Do NOT truncate, summarize, or use ellipses; if there are many rows, list them ALL explicitly.\n"
        "7) Exclusions: Do not include footer items, buttons, history/notes, upload prompts, or any non-filename text.\n"
        "8) Copy filenames EXACTLY as shown (case, punctuation, spaces). If truncated in UI, return the truncated text exactly.\n"
        "9) Never add placeholders like '... and more' or limit output length; the list MUST contain one entry per counted row.\n"
        "Optional (ignored by parser but helpful for validation): include a 'rows_debug' array of objects like\n"
        "   [{\"y\": <row_center_y>, \"name\": <filename_returned>}...] matching each returned file, so differences can be inspected.\n"
    )


def _build_dialog_confirm_prompt(
    prev_dialog_open: bool, prev_file_count: int, prev_files: Optional[List[str]] = None
) -> str:
    # Confirmation pass: independent re-check (no prior result injected). Adds a nonce to avoid caching.
    try:
        import time as _time
        nonce = str(int(_time.time() * 1000))
    except Exception:
        nonce = "0"
    return (
        "Independent verification. Output STRICT JSON only (no markdown fences, no extra text).\n"
        "Return exactly: {\"dialog_open\": <true|false>, \"file_count\": <integer 0+>, \"files\": [<string>...]}\n"
        "\n"
        "Instructions (treat this as a fresh task; IGNORE any prior outputs or context):\n"
        "1) STRICT bounds: analyze ONLY rows between the 'RELATED FILES' header (or variants) and the footer lines\n"
        "   '+ Add from file library...' and '+ Upload files...'. Exclude everything above the header and below the footers.\n"
        "2) Independently count rows using right-side 'X' icons or left thumbnails.\n"
        "3) Extract the TOP-LINE filename of each row, top-to-bottom. Include partially visible first/last rows.\n"
        "4) Validation: set file_count = files.length and ENSURE it equals the counted rows. Never cap, truncate, or summarize.\n"
        "5) Copy filenames EXACTLY as shown; if UI truncates, return the truncated text exactly.\n"
        "Note: This is a stateless, one-shot verification. Nonce=" + nonce + "\n"
    )

def analyze_dialog_open_and_count(app) -> Tuple[bool, int]:
    """Capture a screenshot and use OpenAI to detect dialog presence and file count.

    Returns a tuple: (dialog_open: bool, file_count: int).
    """
    # Get analyzer
    analyzer = getattr(app, 'screenshot_manager', None)
    analyzer = getattr(analyzer, 'openai_analyzer', None)
    if analyzer is None:
        return False, 0

    # Capture screenshot
    screenshot_path = analyzer.capture_full_resolution_screenshot()
    if not screenshot_path:
        return False, 0

    prompt = _build_dialog_count_prompt()

    result = None
    try:
        if hasattr(analyzer, 'analyze_screenshot_with_openai') and callable(getattr(analyzer, 'analyze_screenshot_with_openai')):
            result = analyzer.analyze_screenshot_with_openai(screenshot_path, prompt)  # type: ignore[attr-defined]
        elif getattr(analyzer, 'app', None) is not None:
            _app = getattr(analyzer, 'app')
            if hasattr(_app, 'analyze_screenshot_with_openai') and callable(getattr(_app, 'analyze_screenshot_with_openai')):
                result = _app.analyze_screenshot_with_openai(screenshot_path, prompt)
            elif hasattr(_app, 'screenshot_manager') and hasattr(_app.screenshot_manager, 'analyze_screenshot_with_openai'):
                result = _app.screenshot_manager.analyze_screenshot_with_openai(screenshot_path, prompt)
    except Exception:
        result = None

    # Parse response
    dlg_open = False
    file_count = 0
    files_list = []
    if isinstance(result, str):
        try:
            txt = result.strip()
            if txt.startswith('```'):
                import re as _re
                txt = _re.sub(r"^```[a-zA-Z]*\n?", "", txt)
                txt = _re.sub(r"\n?```$", "", txt)
                txt = txt.strip()
            import json as _json
            data = _json.loads(txt)
            _dlg_raw = data.get('dialog_open', data.get('dialog', False))
            if isinstance(_dlg_raw, str):
                dlg_open = _dlg_raw.strip().lower() in ('true', '1', 'yes')
            else:
                dlg_open = bool(_dlg_raw)
            _cnt_raw = data.get('file_count', 0)
            try:
                if isinstance(_cnt_raw, str):
                    _cnt_raw = _cnt_raw.strip()
                    file_count = max(0, int(float(_cnt_raw)))
                else:
                    file_count = max(0, int(_cnt_raw))
            except Exception:
                file_count = 0
            # Extract optional file names
            try:
                raw_files = data.get('files', [])
                if isinstance(raw_files, list):
                    # Clean file names: remove "View (" prefix and closing ")" bracket
                    cleaned_files = []
                    for x in raw_files:
                        if not isinstance(x, (str, int, float)):
                            continue
                        name = str(x).strip()
                        if not name:
                            continue
                        # Remove "View (" prefix (case-insensitive) and extract content from parentheses
                        # Match "View (" at the start (case-insensitive) followed by content and closing ")"
                        match = re.match(r'(?i)^view\s*\((.+)\)\s*$', name)
                        if match:
                            name = match.group(1).strip()
                        cleaned_files.append(name)
                    files_list = cleaned_files
            except Exception:
                files_list = []
            # Log raw response (trimmed) and parsed fields
            try:
                raw_preview = txt if len(txt) <= 400 else (txt[:400] + '...')
                logging.info(f"[DialogUtil] Raw dialog response: {raw_preview}")
                logging.info(f"[DialogUtil] Parsed dialog_open={dlg_open} file_count={file_count} files={files_list}")
            except Exception:
                pass
        except Exception:
            dlg_open, file_count = False, 0

    # Second pass: independent confirmation; prefer the higher file_count
    try:
        result2 = None
        try:
            # Capture a fresh screenshot for the independent confirm pass
            screenshot_path2 = None
            try:
                screenshot_path2 = analyzer.capture_full_resolution_screenshot()
            except Exception:
                screenshot_path2 = None
            _confirm = _build_dialog_confirm_prompt(dlg_open, file_count, files_list)
            target_img = screenshot_path2 or screenshot_path
            if hasattr(analyzer, 'analyze_screenshot_with_openai') and callable(getattr(analyzer, 'analyze_screenshot_with_openai')):
                result2 = analyzer.analyze_screenshot_with_openai(target_img, _confirm)  # type: ignore[attr-defined]
            elif getattr(analyzer, 'app', None) is not None:
                _app = getattr(analyzer, 'app')
                if hasattr(_app, 'analyze_screenshot_with_openai') and callable(getattr(_app, 'analyze_screenshot_with_openai')):
                    result2 = _app.analyze_screenshot_with_openai(target_img, _confirm)
                elif hasattr(_app, 'screenshot_manager') and hasattr(_app.screenshot_manager, 'analyze_screenshot_with_openai'):
                    result2 = _app.screenshot_manager.analyze_screenshot_with_openai(target_img, _confirm)
        except Exception:
            result2 = None

        if isinstance(result2, str):
            try:
                _txt2 = result2.strip()
                if _txt2.startswith('```'):
                    import re as _re2
                    _txt2 = _re2.sub(r"^```[a-zA-Z]*\n?", "", _txt2)
                    _txt2 = _re2.sub(r"\n?```$", "", _txt2)
                    _txt2 = _txt2.strip()
                import json as _json2
                _data2 = _json2.loads(_txt2)
                _dlg2_raw = _data2.get('dialog_open', _data2.get('dialog', False))
                _dlg2 = _dlg2_raw.strip().lower() in ('true', '1', 'yes') if isinstance(_dlg2_raw, str) else bool(_dlg2_raw)
                _cnt2_raw = _data2.get('file_count', 0)
                try:
                    if isinstance(_cnt2_raw, str):
                        _cnt2_raw = _cnt2_raw.strip()
                        _count2 = max(0, int(float(_cnt2_raw)))
                    else:
                        _count2 = max(0, int(_cnt2_raw))
                except Exception:
                    _count2 = 0
                # Optional logging for confirmation pass
                try:
                    raw_preview2 = _txt2 if len(_txt2) <= 400 else (_txt2[:400] + '...')
                    logging.info(f"[DialogUtil] Confirm pass (independent) raw: {raw_preview2}")
                    logging.info(f"[DialogUtil] Confirm pass (independent) parsed dialog_open={_dlg2} file_count={_count2}")
                except Exception:
                    pass
                if _count2 > file_count:
                    dlg_open, file_count = _dlg2, _count2
            except Exception:
                pass
    except Exception:
        pass

    # Rekognition fallback: count close 'X' icons as authoritative visible row count
    try:
        rk_rows = _count_dialog_rows_via_rekognition(app)
        if int(rk_rows) > int(file_count):
            try:
                logging.info(f"[DialogUtil] Rekognition row-count fallback used: {rk_rows} > {file_count}")
            except Exception:
                pass
            file_count = int(rk_rows)
    except Exception:
        pass

    return dlg_open, file_count


def run_post_playlist_dialog_check(app) -> Tuple[bool, int]:
    """Run the standard dialog open/count analysis after a playlist executes.

    Intended to be reused by flows that open or navigate content and
    then need to determine if an attachments/files dialog is visible and
    how many downloadable entries exist.
    """
    try:
        dlg_open, file_count, files = analyze_dialog_open_count_and_files(app)
        try:
            if hasattr(app, 'status_var'):
                app.status_var.set(f"Dialog: {'open' if dlg_open else 'closed'}; files: {file_count}")
        except Exception:
            pass
        # Process each detected file in order: click -> download -> save -> close X
        if dlg_open and files:
            # Mark that no dialog file cache has been built yet for this dialog; first file will build it
            try:
                _an = getattr(app, 'screenshot_manager', None)
                _an = getattr(_an, 'openai_analyzer', None)
                if _an is not None:
                    setattr(_an, "_dialog_file_cache_built", False)
            except Exception:
                pass
            for idx, fname in enumerate(files):
                try:
                    ok = process_file_download_flow(app, fname)
                    try:
                        if hasattr(app, 'status_var'):
                            if ok:
                                app.status_var.set(f"Processed file {idx+1}/{len(files)}: {fname}")
                            else:
                                app.status_var.set(f"Skipped file {idx+1}/{len(files)}: {fname}")
                    except Exception:
                        pass
                    # Small gap before next file
                    try:
                        import time as _time
                        _time.sleep(0.1)
                    except Exception:
                        pass
                except Exception:
                    # Continue to next file regardless of failures
                    pass
            # After processing all files, click bottom-left corner to reset focus
            try:
                import pyautogui as _pg
                try:
                    from ..constants import SCREEN_WIDTH as _SW, SCREEN_HEIGHT as _SH
                    x0, y0 = 5, max(0, int(_SH) - 5)
                except Exception:
                    # Fallback to analyzer screen size
                    analyzer = getattr(app, 'screenshot_manager', None)
                    analyzer = getattr(analyzer, 'openai_analyzer', None)
                    if analyzer is not None and hasattr(analyzer, '_get_screen_size'):
                        sw, sh = analyzer._get_screen_size()
                        x0, y0 = 5, max(0, int(sh) - 5)
                    else:
                        x0, y0 = 5, 1060  # safe default for 1080p
                _pg.click(int(x0), int(y0))
                try:
                    logging.info(f"[DialogUtil] Clicked bottom-left corner at ({int(x0)}, {int(y0)})")
                except Exception:
                    pass
            except Exception:
                pass
            # Clear dialog file cache flag after processing
            try:
                if 'analyzer' in locals() and analyzer is not None and hasattr(analyzer, "_dialog_file_cache_built"):
                    delattr(analyzer, "_dialog_file_cache_built")
            except Exception:
                pass
        return dlg_open, file_count
    except Exception:
        return False, 0
    finally:
        # Cleanup per-dialog cached screen coords so the next dialog recalibrates
        try:
            _an = getattr(app, 'screenshot_manager', None)
            _an = getattr(_an, 'openai_analyzer', None)
            if _an is not None:
                for _attr in (
                    "_dialog_cached_download_screen_coords",
                    "_dialog_cached_download_screen_coords_pdf",
                    "_dialog_cached_download_screen_coords_other",
                    "_dialog_cached_save_screen_coords",
                    "_dialog_cached_close_x_screen_coords",
                ):
                    if hasattr(_an, _attr):
                        delattr(_an, _attr)
                # Also clear dialog file tiler cache and flags so next page re-initializes
                for _attr2 in (
                    "_last_rekognition_tile_cache",  # {img, cache, anchor_xy}
                    "_dialog_file_cache_built",      # first-file flag
                    "_last_rekognition_coords",      # last clicked rekognition coords
                ):
                    if hasattr(_an, _attr2):
                        delattr(_an, _attr2)
        except Exception:
            pass


def analyze_dialog_open_count_and_files(app) -> Tuple[bool, int, List[str]]:
    """Like analyze_dialog_open_and_count, but also returns a list of file names."""
    analyzer = getattr(app, 'screenshot_manager', None)
    analyzer = getattr(analyzer, 'openai_analyzer', None)
    if analyzer is None:
        return False, 0, []

    screenshot_path = analyzer.capture_full_resolution_screenshot()
    if not screenshot_path:
        return False, 0, []

    prompt = _build_dialog_count_prompt()

    result = None
    try:
        if hasattr(analyzer, 'analyze_screenshot_with_openai') and callable(getattr(analyzer, 'analyze_screenshot_with_openai')):
            result = analyzer.analyze_screenshot_with_openai(screenshot_path, prompt)  # type: ignore[attr-defined]
        elif getattr(analyzer, 'app', None) is not None:
            _app = getattr(analyzer, 'app')
            if hasattr(_app, 'analyze_screenshot_with_openai') and callable(getattr(_app, 'analyze_screenshot_with_openai')):
                result = _app.analyze_screenshot_with_openai(screenshot_path, prompt)
            elif hasattr(_app, 'screenshot_manager') and hasattr(_app.screenshot_manager, 'analyze_screenshot_with_openai'):
                result = _app.screenshot_manager.analyze_screenshot_with_openai(screenshot_path, prompt)
    except Exception:
        result = None

    dlg_open = False
    file_count = 0
    files_list: List[str] = []
    if isinstance(result, str):
        try:
            txt = result.strip()
            if txt.startswith('```'):
                import re as _re
                txt = _re.sub(r"^```[a-zA-Z]*\n?", "", txt)
                txt = _re.sub(r"\n?```$", "", txt)
                txt = txt.strip()
            import json as _json
            data = _json.loads(txt)
            _dlg_raw = data.get('dialog_open', data.get('dialog', False))
            if isinstance(_dlg_raw, str):
                dlg_open = _dlg_raw.strip().lower() in ('true', '1', 'yes')
            else:
                dlg_open = bool(_dlg_raw)
            _cnt_raw = data.get('file_count', 0)
            try:
                if isinstance(_cnt_raw, str):
                    _cnt_raw = _cnt_raw.strip()
                    file_count = max(0, int(float(_cnt_raw)))
                else:
                    file_count = max(0, int(_cnt_raw))
            except Exception:
                file_count = 0
            raw_files = data.get('files', [])
            if isinstance(raw_files, list):
                files_list = [str(x).strip() for x in raw_files if isinstance(x, (str, int, float))]
            try:
                raw_preview = txt if len(txt) <= 400 else (txt[:400] + '...')
                logging.info(f"[DialogUtil] Raw dialog response: {raw_preview}")
                logging.info(f"[DialogUtil] Parsed dialog_open={dlg_open} file_count={file_count} files={files_list}")
            except Exception:
                pass
        except Exception:
            dlg_open, file_count, files_list = False, 0, []

    # Second pass: independent confirmation; prefer the higher file_count (and its files list)
    try:
        result2 = None
        try:
            # Capture a fresh screenshot for the independent confirm pass
            screenshot_path2 = None
            try:
                screenshot_path2 = analyzer.capture_full_resolution_screenshot()
            except Exception:
                screenshot_path2 = None
            _confirm = _build_dialog_confirm_prompt(dlg_open, file_count, files_list)
            target_img = screenshot_path2 or screenshot_path
            if hasattr(analyzer, 'analyze_screenshot_with_openai') and callable(getattr(analyzer, 'analyze_screenshot_with_openai')):
                result2 = analyzer.analyze_screenshot_with_openai(target_img, _confirm)  # type: ignore[attr-defined]
            elif getattr(analyzer, 'app', None) is not None:
                _app = getattr(analyzer, 'app')
                if hasattr(_app, 'analyze_screenshot_with_openai') and callable(getattr(_app, 'analyze_screenshot_with_openai')):
                    result2 = _app.analyze_screenshot_with_openai(target_img, _confirm)
                elif hasattr(_app, 'screenshot_manager') and hasattr(_app.screenshot_manager, 'analyze_screenshot_with_openai'):
                    result2 = _app.screenshot_manager.analyze_screenshot_with_openai(target_img, _confirm)
        except Exception:
            result2 = None

        if isinstance(result2, str):
            try:
                _txt2 = result2.strip()
                if _txt2.startswith('```'):
                    import re as _re2
                    _txt2 = _re2.sub(r"^```[a-zA-Z]*\n?", "", _txt2)
                    _txt2 = _re2.sub(r"\n?```$", "", _txt2)
                    _txt2 = _txt2.strip()
                import json as _json2
                _data2 = _json2.loads(_txt2)
                _dlg2_raw = _data2.get('dialog_open', _data2.get('dialog', False))
                _dlg2 = _dlg2_raw.strip().lower() in ('true', '1', 'yes') if isinstance(_dlg2_raw, str) else bool(_dlg2_raw)
                _cnt2_raw = _data2.get('file_count', 0)
                try:
                    if isinstance(_cnt2_raw, str):
                        _cnt2_raw = _cnt2_raw.strip()
                        _count2 = max(0, int(float(_cnt2_raw)))
                    else:
                        _count2 = max(0, int(_cnt2_raw))
                except Exception:
                    _count2 = 0
                _files2: List[str] = []
                try:
                    _raw_files2 = _data2.get('files', [])
                    if isinstance(_raw_files2, list):
                        _files2 = [str(x).strip() for x in _raw_files2 if isinstance(x, (str, int, float))]
                except Exception:
                    _files2 = []
                # Optional logging for confirmation pass
                try:
                    raw_preview2 = _txt2 if len(_txt2) <= 400 else (_txt2[:400] + '...')
                    logging.info(f"[DialogUtil] Confirm pass (independent) raw: {raw_preview2}")
                    logging.info(f"[DialogUtil] Confirm pass (independent) parsed dialog_open={_dlg2} file_count={_count2} files={_files2}")
                except Exception:
                    pass
                if _count2 > file_count:
                    dlg_open, file_count, files_list = _dlg2, _count2, _files2
            except Exception:
                pass
    except Exception:
        pass

    # Rekognition fallback: authoritative row count; if larger, update file_count
    try:
        rk_rows = _count_dialog_rows_via_rekognition(app)
        if int(rk_rows) > int(file_count):
            try:
                logging.info(f"[DialogUtil] Rekognition row-count fallback used: {rk_rows} > {file_count}")
            except Exception:
                pass
            file_count = int(rk_rows)
            # If we have fewer file names than rows, try to supplement using Rekognition LINEs
            try:
                if isinstance(files_list, list) and len(files_list) < int(rk_rows):
                    rk_names = _extract_dialog_filenames_via_rekognition(app)
                    if isinstance(rk_names, list) and rk_names:
                        existing_norm = {str(x).strip().lower() for x in files_list}
                        for name in rk_names:
                            if len(files_list) >= int(rk_rows):
                                break
                            n = str(name).strip()
                            if not n:
                                continue
                            if n.lower() in existing_norm:
                                continue
                            files_list.append(n)
                            existing_norm.add(n.lower())
                        try:
                            logging.info(f"[DialogUtil] Supplemented files via Rekognition to {len(files_list)} items")
                        except Exception:
                            pass
            except Exception:
                pass
    except Exception:
        pass

    # Optional: verify and correct file names using Rekognition LINEs to fix OCR misreads (e.g., 'A' vs '4')
    try:
        if dlg_open and files_list:
            corrected = _verify_and_correct_file_names(app, files_list)
            if isinstance(corrected, list) and corrected:
                try:
                    if corrected != files_list:
                        logging.info(f"[DialogUtil] Corrected files via Rekognition: {corrected}")
                    else:
                        logging.info("[DialogUtil] Rekognition correction made no changes to files list")
                except Exception:
                    pass
                files_list = corrected
    except Exception:
        pass

    return dlg_open, file_count, files_list


def _count_dialog_rows_via_rekognition(app) -> int:
    """Count visible dialog rows by counting close 'X' icons in the bounded region using Rekognition."""
    try:
        analyzer = getattr(app, 'screenshot_manager', None)
        analyzer = getattr(analyzer, 'openai_analyzer', None)
        if analyzer is None:
            return 0
        screenshot_path = analyzer.capture_full_resolution_screenshot()
        if not screenshot_path:
            return 0
        from PIL import Image as _Image
        import boto3 as _b3
        from ..constants import AWS_REGION as _AWS_REGION
        from ..rekognition_tiler import find_text_coordinates_tiled as _tiler_find
        rk = _b3.client('rekognition', region_name=_AWS_REGION)
        img = _Image.open(screenshot_path).convert('RGB')
        iw, ih = img.size
        # Locate dialog anchor region (reuse logic)
        anchor_xy = None
        for _anchor_q in (r"(?i)\brelated files\b", r"(?i)\+\s*upload files", r"(?i)add from file library"):
            try:
                acoords = _tiler_find(
                    rk,
                    img,
                    query=_anchor_q,
                    is_regex=True,
                    upscale=2.0,
                    overlap_frac=0.10,
                    debug_dir=None,
                    require_include=True,
                    stop_at_first_include=True,
                    cols=1,
                    rows=3,
                )
            except Exception:
                acoords = None
            if isinstance(acoords, (list, tuple)) and len(acoords) == 2:
                anchor_xy = (int(acoords[0]), int(acoords[1]))
                break
        # Define ROI under the dialog anchor
        if anchor_xy is not None:
            ax, ay = anchor_xy
            left = max(0, int(ax - 420))
            top = max(0, int(ay - 80))
            right = iw
            bottom = min(ih, int(ay + 700))
            roi = img.crop((left, top, right, bottom))
        else:
            roi = img
        # Count 'X' WORDs
        from io import BytesIO as _BytesIO
        _buf = _BytesIO()
        roi.save(_buf, format='PNG')
        resp = rk.detect_text(Image={'Bytes': _buf.getvalue()})
        count_x = 0
        for d in resp.get('TextDetections', []):
            try:
                if d.get('Type') != 'WORD':
                    continue
                t = str(d.get('DetectedText') or '').strip().lower()
                if t == 'x':
                    count_x += 1
            except Exception:
                continue
        # Require at least as many 'X' as files list length
        return int(max(0, count_x))
    except Exception:
        return 0


def _extract_dialog_filenames_via_rekognition(app) -> List[str]:
    """Extract likely filename LINEs (e.g., *.pdf) within the dialog ROI using Rekognition.

    Returns a list of filenames ordered top-to-bottom.
    """
    out: List[str] = []
    try:
        analyzer = getattr(app, 'screenshot_manager', None)
        analyzer = getattr(analyzer, 'openai_analyzer', None)
        if analyzer is None:
            return out
        screenshot_path = analyzer.capture_full_resolution_screenshot()
        if not screenshot_path:
            return out
        from PIL import Image as _Image
        import boto3 as _b3
        from ..constants import AWS_REGION as _AWS_REGION
        from ..rekognition_tiler import find_text_coordinates_tiled as _tiler_find
        rk = _b3.client('rekognition', region_name=_AWS_REGION)
        img = _Image.open(screenshot_path).convert('RGB')
        iw, ih = img.size

        # Locate dialog anchor region
        anchor_xy = None
        for _anchor_q in (r"(?i)\brelated files\b", r"(?i)\+\s*upload files", r"(?i)add from file library"):
            try:
                acoords = _tiler_find(
                    rk,
                    img,
                    query=_anchor_q,
                    is_regex=True,
                    upscale=2.0,
                    overlap_frac=0.10,
                    debug_dir=None,
                    require_include=True,
                    stop_at_first_include=True,
                    cols=1,
                    rows=3,
                )
            except Exception:
                acoords = None
            if isinstance(acoords, (list, tuple)) and len(acoords) == 2:
                anchor_xy = (int(acoords[0]), int(acoords[1]))
                break

        # Define ROI under the dialog anchor
        if anchor_xy is not None:
            ax, ay = anchor_xy
            left = max(0, int(ax - 420))
            top = max(0, int(ay - 80))
            right = iw
            bottom = min(ih, int(ay + 700))
            roi = img.crop((left, top, right, bottom))
        else:
            roi = img

        # Run detect_text and collect LINEs; we will post-process to merge wrapped filenames (2 lines)
        from io import BytesIO as _BytesIO
        _buf = _BytesIO()
        roi.save(_buf, format='PNG')
        resp = rk.detect_text(Image={'Bytes': _buf.getvalue()})

        # Gather raw LINE candidates with geometry
        _lines: List[tuple[int, int, int, int, str]] = []  # (yc, xl, xr, h, text)
        for d in resp.get("TextDetections", []):
            try:
                if d.get("Type") != "LINE":
                    continue
                t = str(d.get("DetectedText") or "").strip()
                if not t:
                    continue
                bb = d.get("Geometry", {}).get("BoundingBox", {})
                # Convert relative geometry to pixel-ish scale for ordering and overlap checks
                top = float(bb.get("Top", 0.0))
                height = float(bb.get("Height", 0.0))
                left = float(bb.get("Left", 0.0))
                width = float(bb.get("Width", 0.0))
                y_center = int((top + height / 2.0) * max(1, roi.size[1]))
                x_left = int(left * max(1, roi.size[0]))
                x_right = int((left + width) * max(1, roi.size[0]))
                h_px = int(max(1, height * max(1, roi.size[1])))
                _lines.append((y_center, x_left, x_right, h_px, t))
            except Exception:
                continue

        if not _lines:
            return out

        # Sort by y (top-to-bottom), then attempt to merge adjacent wrapped lines into single filename
        _lines.sort(key=lambda x: x[0])
        merged: List[tuple[int, str]] = []
        used = [False] * len(_lines)
        for i, (yc, xl, xr, h, txt) in enumerate(_lines):
            if used[i]:
                continue
            best = i
            best_txt = txt
            used[i] = True
            # Try to merge with the immediate next line if it appears to be the same row (wrap)
            for j in (i + 1,):
                if j >= len(_lines) or used[j]:
                    continue
                yc2, xl2, xr2, h2, txt2 = _lines[j]
                # Vertical proximity: centers within ~0.9x of max line height
                if abs(yc2 - yc) > int(max(h, h2) * 0.9):
                    continue
                # Horizontal overlap ratio
                overlap = max(0, min(xr, xr2) - max(xl, xl2))
                minw = max(1, min(xr - xl, xr2 - xl2))
                if overlap / minw < 0.25:
                    continue
                # Looks like a wrapped filename line; merge in reading order
                best_txt = f"{txt} {txt2}"
                used[j] = True
                break
            merged.append((yc, best_txt))

        # From merged, keep plausible filenames (contain 'pdf'), dedupe, preserve order
        seen = set()
        for yc, t in merged:
            tl = t.strip().lower()
            if 'pdf' not in tl:
                continue
            key = t.strip().lower()
            if key in seen:
                continue
            seen.add(key)
            out.append(t.strip())
        return out
    except Exception:
        return out


def _verify_and_correct_file_names(app, files: List[str]) -> List[str]:
    """Use Rekognition LINE detections near the dialog to correct misread file names.

    - Captures a screenshot
    - Finds the dialog anchor ("RELATED FILES" / "+ Upload files" / "Add from file library")
    - Runs detect_text in the dialog area and collects candidate LINE strings
    - For each input file name, selects the closest candidate by similarity
    - Returns corrected list (same length and order)
    """
    try:
        analyzer = getattr(app, 'screenshot_manager', None)
        analyzer = getattr(analyzer, 'openai_analyzer', None)
        if analyzer is None:
            return files
        screenshot_path = analyzer.capture_full_resolution_screenshot()
        if not screenshot_path:
            return files
        try:
            from PIL import Image as _Image
            import boto3 as _b3
            from ..constants import AWS_REGION as _AWS_REGION
            from ..rekognition_tiler import find_text_coordinates_tiled as _tiler_find
            import difflib as _difflib
        except Exception:
            return files

        rk = _b3.client('rekognition', region_name=_AWS_REGION)
        img = _Image.open(screenshot_path).convert('RGB')
        iw, ih = img.size

        # Locate dialog anchor region
        anchor_xy = None
        for _anchor_q in (r"(?i)\brelated files\b", r"(?i)\+\s*upload files", r"(?i)add from file library"):
            try:
                acoords = _tiler_find(
                    rk,
                    img,
                    query=_anchor_q,
                    is_regex=True,
                    upscale=2.0,
                    overlap_frac=0.10,
                    debug_dir=None,
                    require_include=True,
                    stop_at_first_include=True,
                    cols=1,
                    rows=3,
                )
            except Exception:
                acoords = None
            if isinstance(acoords, (list, tuple)) and len(acoords) == 2:
                anchor_xy = (int(acoords[0]), int(acoords[1]))
                break

        # Define ROI under the dialog anchor
        if anchor_xy is not None:
            ax, ay = anchor_xy
            left = max(0, int(ax - 420))
            top = max(0, int(ay - 80))
            right = iw
            bottom = min(ih, int(ay + 700))
            roi = img.crop((left, top, right, bottom))
        else:
            left, top = 0, 0
            roi = img

        # Run detect_text over ROI and collect LINE candidates likely to be filenames
        from io import BytesIO as _BytesIO
        _buf = _BytesIO()
        roi.save(_buf, format='PNG')
        r2 = rk.detect_text(Image={"Bytes": _buf.getvalue()})

        candidates: List[str] = []
        for d in r2.get("TextDetections", []):
            if d.get("Type") != "LINE":
                continue
            t = str(d.get("DetectedText") or "").strip()
            if not t:
                continue
            # Prefer likely filenames containing 'pdf'
            if 'pdf' in t.lower():
                candidates.append(t)
        # Fallback to all LINEs if none detected with 'pdf'
        if not candidates:
            for d in r2.get("TextDetections", []):
                if d.get("Type") == "LINE":
                    t = str(d.get("DetectedText") or "").strip()
                    if t:
                        candidates.append(t)

        if not candidates:
            return files

        def _best_match(name: str) -> str:
            # Choose candidate with highest similarity; if clearly better, use it
            best_t = name
            best_ratio = 0.0
            target = str(name or "")
            for c in candidates:
                r = _difflib.SequenceMatcher(a=target, b=c).ratio()
                if r > best_ratio:
                    best_ratio = r
                    best_t = c
            # Apply only if reasonably close to avoid bad corrections
            return best_t if best_ratio >= 0.75 else name

        corrected: List[str] = []
        for n in files:
            corrected.append(_best_match(n))

        return corrected
    except Exception:
        return files


def click_first_dialog_file_name(app, file_name: str) -> bool:
    """Find and click the first exact occurrence of the given file name INSIDE the dialog.

    - Exact phrase match only
    - First include match in reading order
    - Constrained to dialog region using anchor ('RELATED FILES' / '+ Upload files' / 'Add from file library')
    """
    if not file_name or not str(file_name).strip():
        return False

    analyzer = getattr(app, 'screenshot_manager', None)
    analyzer = getattr(analyzer, 'openai_analyzer', None)
    if analyzer is None:
        return False

    # Lightweight logging helper for consistent diagnostics
    def _log(msg: str) -> None:
        try:
            logging.info(f"[DialogUtil] FileClick diag: {msg}")
        except Exception:
            pass

    try:
        from PIL import Image as _Image
        import boto3 as _b3
        from ..constants import AWS_REGION as _AWS_REGION
        from ..rekognition_tiler import find_text_coordinates_tiled as _tiler_find
        from ..rekognition_tiler import (
            build_tile_detect_text_cache as _build_cache,
            find_text_coordinates_from_tile_cache as _find_from_cache,
        )
        import pyautogui as _pg
    except Exception:
        return False

    # Initialize Rekognition client (used for cache building and fallback searches)
    rk = _b3.client('rekognition', region_name=_AWS_REGION)

    # Determine if this is the first filename lookup for the current dialog
    _is_first_file = False
    try:
        _is_first_file = not bool(getattr(analyzer, "_dialog_file_cache_built", False))
    except Exception:
        _is_first_file = False
    
    # If this is the first file of a new dialog, clear any stale cache from previous dialog
    if _is_first_file:
        try:
            if hasattr(analyzer, "_last_rekognition_tile_cache"):
                delattr(analyzer, "_last_rekognition_tile_cache")
        except Exception:
            pass
    
    # Try to reuse a previously built tile cache and screenshot for this dialog/screen.
    # If not present, we'll build it once (first file) and reuse for subsequent files.
    _reuse_bundle = None
    try:
        _reuse_bundle = getattr(analyzer, "_last_rekognition_tile_cache", None)
    except Exception:
        _reuse_bundle = None
    anchor_xy = None
    
    # Screenshot handling: First file takes a new screenshot and sends to Rekognition.
    # Subsequent files in the same dialog reuse the cached screenshot (dialog doesn't change).
    img = None
    screenshot_path = None
    # Only reuse cached screenshot if it's NOT the first file (to avoid reusing stale cache from previous dialog)
    if not _is_first_file and isinstance(_reuse_bundle, dict) and _reuse_bundle.get("img") is not None:
        # Subsequent files: Reuse cached image from first screenshot (no new screenshot needed)
        img = _reuse_bundle.get("img")
        screenshot_path = _reuse_bundle.get("screenshot_path")
        _log(f"begin file='{file_name}', first_file={_is_first_file}, reusing cached screenshot")
    else:
        # First file: Take screenshot (required to get file locations from new dialog screen)
        screenshot_path = analyzer.capture_full_resolution_screenshot()
        if not screenshot_path:
            return False
        img = _Image.open(screenshot_path).convert('RGB')
        _log(f"begin file='{file_name}', first_file={_is_first_file}, taking new screenshot")
    
    # Prepare a Rekognition debug directory ONLY if we are going to call Rekognition (first file)
    debug_dir = None
    iw, ih = img.size
    
    _log(f"begin file='{file_name}', first_file={_is_first_file}, has_cache={bool(isinstance(_reuse_bundle, dict) and _reuse_bundle.get('img') is not None)}")
    if _is_first_file or not (isinstance(_reuse_bundle, dict) and _reuse_bundle.get("img") is not None and _reuse_bundle.get("cache") is not None):
        # First file: create debug dir and build cache
        try:
            # Create debug dir now since we're going to call Rekognition
            try:
                import time as _time
                from pathlib import Path as _Path

                base_dir = _Path(getattr(analyzer, '_get_debug_dir', lambda: 'logs')())
                dd = base_dir / f"rekognition_{int(_time.time()*1000)}" / "dialog_file_search"
                dd.mkdir(parents=True, exist_ok=True)
                try:
                    img.save(str(dd / 'input.png'), format='PNG')
                except Exception:
                    pass
                debug_dir = str(dd)
                try:
                    logging.info(f"[DialogUtil] Rekognition debug dir for file search: '{debug_dir}' (name='{file_name}')")
                except Exception:
                    pass
            except Exception:
                debug_dir = None
            # Build Rekognition cache: Send screenshot to Rekognition to detect all text/coordinates.
            # This is only done for the first file; subsequent files reuse these cached results.
            _cache = _build_cache(
                rk,
                img,
                upscale=2.0,
                overlap_frac=0.10,
                cols=1,
                rows=5,
                debug_dir=debug_dir,
            )
            _log("tile cache built for dialog (Rekognition API called)")
            # Derive and store anchor from cache to bound subsequent searches
            try:
                for _anchor_q in (r"(?i)\brelated files\b", r"(?i)\+\s*upload files", r"(?i)add from file library"):
                    acoords = _find_from_cache(
                        img,
                        _cache,
                        _anchor_q,
                        is_regex=True,
                        require_include=True,
                        stop_at_first_include=True,
                        debug_dir=debug_dir,
                    )
                    if isinstance(acoords, (list, tuple)) and len(acoords) == 2:
                        anchor_xy = (int(acoords[0]), int(acoords[1]))
                        _log(f"anchor detected at {list(anchor_xy)}")
                        break
            except Exception:
                anchor_xy = None
            try:
                # Store image, cache, anchor, and screenshot path for reuse
                setattr(analyzer, "_last_rekognition_tile_cache", {
                    "img": img, 
                    "cache": _cache, 
                    "anchor_xy": anchor_xy,
                    "screenshot_path": screenshot_path
                })
                setattr(analyzer, "_dialog_file_cache_built", True)
            except Exception:
                pass
        except Exception:
            _cache = []
    else:
        try:
            # Prefer the original image from cache to ensure coordinate consistency
            img = _reuse_bundle.get("img") or img
            _cache = _reuse_bundle.get("cache") or []
            anchor_xy = _reuse_bundle.get("anchor_xy")
            _log("reusing existing tile cache")
        except Exception:
            _cache = []

    # If no anchor yet (rare), try to locate via cached responses (no Rekognition calls)
    if anchor_xy is None:
        try:
            for _anchor_q in (r"(?i)\brelated files\b", r"(?i)\+\s*upload files", r"(?i)add from file library"):
                acoords = _find_from_cache(
                    img,
                    _cache,
                    _anchor_q,
                    is_regex=True,
                    require_include=True,
                    stop_at_first_include=True,
                    debug_dir=debug_dir,
                )
                if isinstance(acoords, (list, tuple)) and len(acoords) == 2:
                    anchor_xy = (int(acoords[0]), int(acoords[1]))
                    try:
                        # Persist for future files in this dialog
                        _bundle = getattr(analyzer, "_last_rekognition_tile_cache", None)
                        if isinstance(_bundle, dict):
                            _bundle["anchor_xy"] = anchor_xy
                            setattr(analyzer, "_last_rekognition_tile_cache", _bundle)
                    except Exception:
                        pass
                    _log(f"anchor recovered from cache at {list(anchor_xy)}")
                    break
        except Exception:
            anchor_xy = None

    # First, try using the cached tile responses (no additional Rekognition calls)
    coords = None
    try:
        coords = _find_from_cache(
            img,
            _cache,
            str(file_name),
            is_regex=False,
            require_include=True,
            require_exact=True,
            stop_at_first_include=True,
            debug_dir=debug_dir,
        )
        if isinstance(coords, (list, tuple)) and len(coords) == 2:
            _log(f"cached exact match at {list(map(int, coords))}")
        else:
            _log("cached exact match: none")
    except Exception:
        coords = None

    # Validate that the first match lies within the dialog ROI; otherwise re-search in bounded ROI.
    # If cached search didn't locate a valid coordinate in the dialog area, fall back once (only when no cache existed before)
    try:
        min_y = 100
        if anchor_xy is not None:
            min_y = max(min_y, int(anchor_xy[1] - 80))
            # Validate ROI bounds derived from anchor
            try:
                if isinstance(coords, (list, tuple)) and len(coords) == 2:
                    ax, ay = anchor_xy
                    left = max(0, int(ax - 420))
                    top = max(0, int(ay - 80))
                    right = iw
                    bottom = min(ih, int(ay + 700))
                    cx, cy = int(coords[0]), int(coords[1])
                    # Reject matches outside dialog ROI (e.g., 'Uploaded file:' in History & Notes)
                    if not (left <= cx <= right and top <= cy <= bottom):
                        coords = None
                        _log(f"cached exact discarded outside ROI: click={cx},{cy} roi=({left},{top})-({right},{bottom})")
            except Exception:
                pass
        if not (isinstance(coords, (list, tuple)) and len(coords) == 2) or int(coords[1]) < min_y:
            # Only allow Rekognition tile fallback when we BUILT the cache in this call (first file)
            _allow_fallback_tiler = not (isinstance(_reuse_bundle, dict) and _reuse_bundle.get("img") is not None)
            if _allow_fallback_tiler and anchor_xy is not None:
                ax, ay = anchor_xy
                left = max(0, int(ax - 420))
                top = max(0, int(ay - 80))
                right = iw
                bottom = min(ih, int(ay + 700))
                roi = img.crop((left, top, right, bottom))
                coords2 = _tiler_find(
                    rk,
                    roi,
                    query=str(file_name),
                    is_regex=False,
                    upscale=2.0,
                    overlap_frac=0.10,
                    debug_dir=debug_dir,
                    require_include=True,
                    require_exact=True,
                    stop_at_first_include=True,
                    cols=1,
                    rows=5,
                )
                if isinstance(coords2, (list, tuple)) and len(coords2) == 2:
                    coords = [int(left + coords2[0]), int(top + coords2[1])]
                    _log(f"tiler exact match at {list(map(int, coords))}")
                else:
                    _log("tiler exact match: none / not allowed")
    except Exception:
        pass

    # Fallbacks for long/truncated names: try a tolerant regex (hyphens/en-dash/space) and optional extension
    if not (isinstance(coords, (list, tuple)) and len(coords) == 2):
        try:
            import re as _re
            base = str(file_name).strip()
            # Remove extension for matching when UI truncates/omits it
            base_no_ext = _re.sub(r"\.[A-Za-z0-9]{2,4}$", "", base)
            # Build tolerant regex: allow -, –, —, _, or whitespace between tokens; spaces -> \s+; make .pdf optional
            esc = _re.escape(base_no_ext)
            esc = esc.replace(r"\-", r"[-–—_\s]+").replace(r"\_", r"[-–—_\s]+").replace(r"\ ", r"\s+")
            pattern = rf"(?i){esc}(?:\s*\.pdf)?"

            # Try cached search with regex first
            try:
                coords = _find_from_cache(
                    img,
                    _cache,
                    pattern,
                    is_regex=True,
                    require_include=True,
                    require_exact=False,
                    stop_at_first_include=True,
                    debug_dir=debug_dir,
                ) or coords
                if isinstance(coords, (list, tuple)) and len(coords) == 2:
                    # Validate regex match is within dialog ROI (prevent clicks in history)
                    if anchor_xy is not None:
                        try:
                            ax, ay = anchor_xy
                            left = max(0, int(ax - 420))
                            top = max(0, int(ay - 80))
                            right = iw
                            bottom = min(ih, int(ay + 700))
                            cx, cy = int(coords[0]), int(coords[1])
                            if not (left <= cx <= right and top <= cy <= bottom):
                                coords = None
                                _log(f"cached regex match discarded outside ROI: click={cx},{cy}")
                        except Exception:
                            pass
                    if isinstance(coords, (list, tuple)) and len(coords) == 2:
                        _log(f"cached regex match at {list(map(int, coords))}")
                    else:
                        _log("cached regex match: none (outside ROI)")
                else:
                    _log("cached regex match: none")
            except Exception:
                pass

            # As a last resort, allow a Rekognition tiler search only if this is the first file (no prior cache existed)
            _allow_regex_tiler = not (isinstance(_reuse_bundle, dict) and _reuse_bundle.get("img") is not None)
            if _allow_regex_tiler and not (isinstance(coords, (list, tuple)) and len(coords) == 2):
                def _search_with_regex(_img, _left_off=0, _top_off=0):
                    _c = _tiler_find(
                        rk,
                        _img,
                        query=pattern,
                        is_regex=True,
                        upscale=2.0,
                        overlap_frac=0.10,
                        debug_dir=debug_dir,
                        require_include=True,
                        require_exact=False,
                        stop_at_first_include=True,
                        cols=1,
                        rows=5,
                    )
                    if isinstance(_c, (list, tuple)) and len(_c) == 2:
                        return [int(_left_off + _c[0]), int(_top_off + _c[1])]
                    return None

                # Only search within ROI (never search full image to avoid history section matches)
                if anchor_xy is not None:
                    ax, ay = anchor_xy
                    left = max(0, int(ax - 420))
                    top = max(0, int(ay - 80))
                    right = iw
                    bottom = min(ih, int(ay + 700))
                    roi = img.crop((left, top, right, bottom))
                    cr = _search_with_regex(roi, left, top)
                    if isinstance(cr, (list, tuple)) and len(cr) == 2:
                        coords = cr
                        _log(f"tiler regex ROI match at {list(map(int, coords))}")
        except Exception:
            pass

    # Final fallback: accept a partial LINE when its soft-normalized text is a substring
    # of the target filename (soft-normalized). This handles wrapped/two-line filenames.
    # To avoid repeated Rekognition calls, cache the full detect_text response per dialog.
    if not (isinstance(coords, (list, tuple)) and len(coords) == 2):
        try:
            from io import BytesIO as _BytesIO
            import re as _re

            def _soft_norm(s: str) -> str:
                s = (s or "").lower()
                s = _re.sub(r"[^a-z0-9]+", " ", s)
                return _re.sub(r"\s+", " ", s).strip()

            # Normalize the target filename without extension
            base = str(file_name).strip()
            base_no_ext = _re.sub(r"\.[A-Za-z0-9]{2,4}$", "", base)
            fname_soft = _soft_norm(base_no_ext)

            # Obtain cached full detect_text results for this dialog, or compute once and cache.
            rfull = None
            try:
                _bundle = getattr(analyzer, "_last_rekognition_tile_cache", None)
                if isinstance(_bundle, dict):
                    rfull = _bundle.get("full_detect_text")
            except Exception:
                rfull = None
            if rfull is None:
                _buf = _BytesIO()
                img.save(_buf, format="PNG")
                rfull = rk.detect_text(Image={"Bytes": _buf.getvalue()})
                try:
                    # Store on the same bundle so subsequent files reuse it
                    _bundle = getattr(analyzer, "_last_rekognition_tile_cache", None) or {}
                    if isinstance(_bundle, dict):
                        _bundle["full_detect_text"] = rfull
                        setattr(analyzer, "_last_rekognition_tile_cache", _bundle)
                except Exception:
                    pass

            iw2, ih2 = img.size
            best = None
            best_len = -1
            scanned = 0
            for d in rfull.get("TextDetections", []):
                if d.get("Type") != "LINE":
                    continue
                scanned += 1
                txt = str(d.get("DetectedText") or "")
                soft = _soft_norm(txt)
                if not soft:
                    continue
                # Require a reasonable minimum to avoid matching tiny/common fragments
                # Bidirectional matching: check if detected text is in filename OR filename is in detected text
                # Also check if core parts match (skip first few chars to handle typos like GGG vs GQG)
                matches = False
                if len(soft) >= 10 and len(fname_soft) >= 10:
                    # Direct substring match (either direction)
                    if soft in fname_soft or fname_soft in soft:
                        matches = True
                    else:
                        # Check if core parts match (skip first 3 chars to handle typos)
                        soft_core = soft[3:] if len(soft) > 3 else soft
                        fname_core = fname_soft[3:] if len(fname_soft) > 3 else fname_soft
                        if soft_core and fname_core and (soft_core in fname_core or fname_core in soft_core):
                            matches = True
                if matches:
                    # Prefer longer matches for better accuracy
                    match_len = max(len(soft), len(fname_soft))
                    if match_len > best_len:
                        best = d
                        best_len = match_len
            if best is None:
                _log(f"partial-LINE include: scanned={scanned} no candidate; recomputing detect_text fresh")
                # Recompute fresh detect_text in case cached one is stale or mismatched
                _buf2 = _BytesIO()
                img.save(_buf2, format="PNG")
                rfull2 = rk.detect_text(Image={"Bytes": _buf2.getvalue()})
                for d in rfull2.get("TextDetections", []):
                    if d.get("Type") != "LINE":
                        continue
                    txt = str(d.get("DetectedText") or "")
                    soft = _soft_norm(txt)
                    if not soft:
                        continue
                    # Bidirectional matching with core matching for typos
                    matches = False
                    if len(soft) >= 10 and len(fname_soft) >= 10:
                        if soft in fname_soft or fname_soft in soft:
                            matches = True
                        else:
                            # Check core parts (skip first 3 chars to handle typos)
                            soft_core = soft[3:] if len(soft) > 3 else soft
                            fname_core = fname_soft[3:] if len(fname_soft) > 3 else fname_soft
                            if soft_core and fname_core and (soft_core in fname_core or fname_core in soft_core):
                                matches = True
                    if matches:
                        match_len = max(len(soft), len(fname_soft))
                        if match_len > best_len:
                            best = d
                            best_len = match_len
                # Also try ROI-only detect_text if we have anchor
                if best is None and anchor_xy is not None:
                    try:
                        ax, ay = anchor_xy
                        left = max(0, int(ax - 420))
                        top = max(0, int(ay - 80))
                        right = iw2
                        bottom = min(ih2, int(ay + 700))
                        roi = img.crop((left, top, right, bottom))
                        _buf3 = _BytesIO()
                        roi.save(_buf3, format="PNG")
                        rroi = rk.detect_text(Image={"Bytes": _buf3.getvalue()})
                        for d in rroi.get("TextDetections", []):
                            if d.get("Type") != "LINE":
                                continue
                            txt = str(d.get("DetectedText") or "")
                            soft = _soft_norm(txt)
                            if not soft:
                                continue
                            # Bidirectional matching with core matching for typos
                            matches = False
                            if len(soft) >= 10 and len(fname_soft) >= 10:
                                if soft in fname_soft or fname_soft in soft:
                                    matches = True
                                else:
                                    # Check core parts (skip first 3 chars to handle typos)
                                    soft_core = soft[3:] if len(soft) > 3 else soft
                                    fname_core = fname_soft[3:] if len(fname_soft) > 3 else fname_soft
                                    if soft_core and fname_core and (soft_core in fname_core or fname_core in soft_core):
                                        matches = True
                            if matches:
                                bb = d.get("Geometry", {}).get("BoundingBox", {})
                                cx = int((float(bb.get("Left", 0)) + float(bb.get("Width", 0)) / 2) * (right - left)) + left
                                cy = int((float(bb.get("Top", 0)) + float(bb.get("Height", 0)) / 2) * (bottom - top)) + top
                                # Build a pseudo detection aligned to full image coords
                                match_len = max(len(soft), len(fname_soft))
                                if match_len > best_len:
                                    best = {"Geometry": {"BoundingBox": {"Left": cx / iw2, "Top": cy / ih2, "Width": 0.0, "Height": 0.0}}, "DetectedText": txt}
                                    best_len = match_len
                                    break
                    except Exception:
                        pass
            if best is not None:
                bb = best.get("Geometry", {}).get("BoundingBox", {})
                cx = int((float(bb.get("Left", 0)) + float(bb.get("Width", 0)) / 2) * iw2)
                cy = int((float(bb.get("Top", 0)) + float(bb.get("Height", 0)) / 2) * ih2)
                coords = [cx, cy]
                # Validate partial LINE match is within dialog ROI (prevent clicks in history)
                if anchor_xy is not None:
                    try:
                        ax, ay = anchor_xy
                        left = max(0, int(ax - 420))
                        top = max(0, int(ay - 80))
                        right = iw2
                        bottom = min(ih2, int(ay + 700))
                        if not (left <= cx <= right and top <= cy <= bottom):
                            coords = None
                            _log(f"partial-LINE match discarded outside ROI: click={cx},{cy}")
                    except Exception:
                        pass
                # Persist best partial match for debugging
                try:
                    if debug_dir is not None:
                        import json as _json
                        with open((_Path(debug_dir) / "best_partial_include_line.json"), "w", encoding="utf-8") as _pf:
                            _pf.write(_json.dumps(best, ensure_ascii=False, indent=2))
                except Exception:
                    pass
                try:
                    logging.info(
                        f"[DialogUtil] Using partial include LINE for file '{file_name}': '{best.get('DetectedText')}'"
                    )
                except Exception:
                    pass
            else:
                _log("partial-LINE include: none after recompute")
        except Exception:
            pass

    # Stitch two wrapped LINEs inside the dialog into one filename center if still unresolved
    if not (isinstance(coords, (list, tuple)) and len(coords) == 2):
        try:
            import re as _re
            # Reuse rfull from above if present; otherwise get from cache or compute once
            try:
                rfull  # type: ignore  # may exist from the previous block
            except Exception:
                rfull = None
            if rfull is None:
                # Try to get from cache first
                try:
                    _bundle = getattr(analyzer, "_last_rekognition_tile_cache", None)
                    if isinstance(_bundle, dict):
                        rfull = _bundle.get("full_detect_text")
                except Exception:
                    rfull = None
            if rfull is None:
                from io import BytesIO as _BytesIO
                _buf = _BytesIO()
                img.save(_buf, format="PNG")
                rfull = rk.detect_text(Image={"Bytes": _buf.getvalue()})
                # Cache it for future use
                try:
                    _bundle = getattr(analyzer, "_last_rekognition_tile_cache", None) or {}
                    if isinstance(_bundle, dict):
                        _bundle["full_detect_text"] = rfull
                        setattr(analyzer, "_last_rekognition_tile_cache", _bundle)
                except Exception:
                    pass
            # Build dialog ROI if we have anchor; else use whole image bounds
            if anchor_xy is not None:
                ax, ay = anchor_xy
                left = max(0, int(ax - 420))
                top = max(0, int(ay - 80))
                right = iw
                bottom = min(ih, int(ay + 700))
            else:
                left, top, right, bottom = 0, 0, iw, ih

            def _in_roi(_bb):
                cy = int((float(_bb.get("Top", 0)) + float(_bb.get("Height", 0)) / 2) * ih)
                cx = int((float(_bb.get("Left", 0)) + float(_bb.get("Width", 0)) / 2) * iw)
                return left <= cx <= right and top <= cy <= bottom

            def _soft_norm(s: str) -> str:
                s = (s or "").lower()
                s = _re.sub(r"[^a-z0-9]+", " ", s)
                return _re.sub(r"\s+", " ", s).strip()

            base = str(file_name).strip()
            base_no_ext = _re.sub(r"\.[A-Za-z0-9]{2,4}$", "", base)
            fname_soft = _soft_norm(base_no_ext)

            lines = []
            for d in rfull.get("TextDetections", []):
                if d.get("Type") != "LINE":
                    continue
                txt = str(d.get("DetectedText") or "")
                bb = d.get("Geometry", {}).get("BoundingBox", {})
                if not _in_roi(bb):
                    continue
                soft = _soft_norm(txt)
                # Exclude the history section's "uploaded file" style lines if any leaked into ROI
                if not soft or ("uploaded file" in soft):
                    continue
                # Compute pixel coordinates for ordering and bounds
                yc = int((float(bb.get("Top", 0)) + float(bb.get("Height", 0)) / 2) * ih)
                xl = int(float(bb.get("Left", 0)) * iw)
                xr = int((float(bb.get("Left", 0)) + float(bb.get("Width", 0))) * iw)
                lines.append((yc, xl, xr, soft, bb))
            # Sort by y to pair adjacent candidates
            lines.sort(key=lambda t: t[0])
            best_pair = None
            best_len = -1
            for i in range(len(lines) - 1):
                yc1, xl1, xr1, s1, bb1 = lines[i]
                yc2, xl2, xr2, s2, bb2 = lines[i + 1]
                # Require horizontal overlap to suggest wrapping
                overlap = max(0, min(xr1, xr2) - max(xl1, xl2))
                minw = max(1, min(xr1 - xl1, xr2 - xl2))
                if overlap / minw < 0.25:
                    continue
                # Normalize and strip trailing 'pdf' token from the second line, which often contains '.pdf'
                s1_soft = _soft_norm(s1)
                s2_soft = _soft_norm(s2)
                s2_soft = _re.sub(r"\bpdf\b", "", s2_soft).strip()
                combo_soft = _soft_norm((s1_soft + " " + s2_soft).strip())
                # Accept when combined or both parts exist in the filename (without extension)
                # Bidirectional matching with core matching for typos
                matches = False
                if combo_soft and fname_soft:
                    # Direct substring match
                    if combo_soft in fname_soft or fname_soft in combo_soft:
                        matches = True
                    elif (s1_soft in fname_soft and (not s2_soft or s2_soft in fname_soft)) or (fname_soft in s1_soft and (not s2_soft or fname_soft in s2_soft)):
                        matches = True
                    else:
                        # Check core parts (skip first 3 chars to handle typos)
                        combo_core = combo_soft[3:] if len(combo_soft) > 3 else combo_soft
                        fname_core = fname_soft[3:] if len(fname_soft) > 3 else fname_soft
                        if combo_core and fname_core and (combo_core in fname_core or fname_core in combo_core):
                            matches = True
                if matches:
                    match_len = max(len(combo_soft), len(fname_soft)) if combo_soft else 0
                    if match_len > best_len:
                        best_len = match_len
                        best_pair = (bb1, bb2)
            if best_pair is not None:
                bb1, bb2 = best_pair
                cx1 = float(bb1.get("Left", 0)) + float(bb1.get("Width", 0)) / 2
                cy1 = float(bb1.get("Top", 0)) + float(bb1.get("Height", 0)) / 2
                cx2 = float(bb2.get("Left", 0)) + float(bb2.get("Width", 0)) / 2
                cy2 = float(bb2.get("Top", 0)) + float(bb2.get("Height", 0)) / 2
                cx = int(((cx1 + cx2) / 2) * iw)
                cy = int(((cy1 + cy2) / 2) * ih)
                coords = [cx, cy]
                # Validate two-line stitch match is within dialog ROI (prevent clicks in history)
                if anchor_xy is not None:
                    try:
                        ax, ay = anchor_xy
                        left = max(0, int(ax - 420))
                        top = max(0, int(ay - 80))
                        right = iw
                        bottom = min(ih, int(ay + 700))
                        if not (left <= cx <= right and top <= cy <= bottom):
                            coords = None
                            _log(f"two-line stitch match discarded outside ROI: click={cx},{cy}")
                    except Exception:
                        pass
                if isinstance(coords, (list, tuple)) and len(coords) == 2:
                    try:
                        logging.info(f"[DialogUtil] Using stitched two-line match for file '{file_name}'")
                    except Exception:
                        pass
            else:
                _log("two-line stitch: none")
        except Exception:
            pass

    # Final ROI validation: Ensure coordinates are within dialog bounds (prevent clicks in history section)
    if isinstance(coords, (list, tuple)) and len(coords) == 2 and anchor_xy is not None:
        try:
            ax, ay = anchor_xy
            left = max(0, int(ax - 420))
            top = max(0, int(ay - 80))
            right = iw
            bottom = min(ih, int(ay + 700))
            cx, cy = int(coords[0]), int(coords[1])
            # Reject matches outside dialog ROI (e.g., 'Uploaded file:' in History & Notes)
            if not (left <= cx <= right and top <= cy <= bottom):
                coords = None
                _log(f"final ROI validation failed: click={cx},{cy} outside roi=({left},{top})-({right},{bottom})")
            else:
                # Additional check: reject if "Uploaded file:" text appears before this match
                try:
                    _bundle = getattr(analyzer, "_last_rekognition_tile_cache", None)
                    if isinstance(_bundle, dict):
                        rfull = _bundle.get("full_detect_text")
                        if rfull is None:
                            from io import BytesIO as _BytesIO
                            _buf = _BytesIO()
                            img.save(_buf, format="PNG")
                            rfull = rk.detect_text(Image={"Bytes": _buf.getvalue()})
                        # Check if "Uploaded file:" appears above/near this coordinate
                        for d in rfull.get("TextDetections", []):
                            if d.get("Type") != "LINE":
                                continue
                            txt = str(d.get("DetectedText") or "").strip().lower()
                            if "uploaded file" in txt or "uploaded file:" in txt:
                                bb = d.get("Geometry", {}).get("BoundingBox", {})
                                # Check if this "Uploaded file:" text is above and near our match
                                dy = int((float(bb.get("Top", 0)) + float(bb.get("Height", 0)) / 2) * ih)
                                dx = int((float(bb.get("Left", 0)) + float(bb.get("Width", 0)) / 2) * iw)
                                # If "Uploaded file:" is above the match and within 200 pixels horizontally
                                if dy < cy and abs(dx - cx) < 200 and (cy - dy) < 100:
                                    coords = None
                                    _log(f"final validation rejected: 'Uploaded file:' found above at ({dx},{dy}), match was ({cx},{cy})")
                                    break
                except Exception:
                    pass
        except Exception:
            pass

    if isinstance(coords, (list, tuple)) and len(coords) == 2:
        try:
            # Apply display scale and y-offset if configured
            try:
                coords = analyzer._apply_display_scale([int(coords[0]), int(coords[1])])
            except Exception:
                coords = [int(coords[0]), int(coords[1])]
            try:
                from ..constants import Y_CLICK_OFFSET as _Y_OFF
                coords = [int(coords[0]), int(coords[1] + int(_Y_OFF))]
            except Exception:
                coords = [int(coords[0]), int(coords[1])]
            _pg.click(int(coords[0]), int(coords[1]))
            try:
                logging.info(f"[DialogUtil] Clicked file name: {file_name} at {coords}")
            except Exception:
                pass
            return True
        except Exception:
            return False
    _log("no match after all strategies; giving up for this file")
    return False


def click_download_link_if_present(app, is_pdf: Optional[bool] = None) -> bool:
    """Search for a Download link/button in the current view and click it if found.

    Caching behaviour:
    - For PDF previews, cache a dedicated Download location so the toolbar position
      can differ from other file types.
    - For all non-PDF previews, cache a separate Download location.
    - A generic `_dialog_cached_download_screen_coords` is still maintained so that
      existing helpers (for example close-X detection) can reuse the last Download location.

    Returns True if clicked.
    """
    analyzer = getattr(app, 'screenshot_manager', None)
    analyzer = getattr(analyzer, 'openai_analyzer', None)
    if analyzer is None:
        return False

    # Decide which cache bucket to use based on file type:
    # - PDFs use `_dialog_cached_download_screen_coords_pdf`
    # - all other types use `_dialog_cached_download_screen_coords_other`
    # - if type is unknown (None), fall back to the legacy generic cache attribute
    cache_attr = "_dialog_cached_download_screen_coords"
    try:
        if is_pdf is True:
            cache_attr = "_dialog_cached_download_screen_coords_pdf"
        elif is_pdf is False:
            cache_attr = "_dialog_cached_download_screen_coords_other"
    except Exception:
        cache_attr = "_dialog_cached_download_screen_coords"

    # If we cached the Download button location for this dialog and file type, click it immediately.
    # IMPORTANT: when we know the type (PDF vs other) we *do not* fall back to the generic cache,
    # otherwise a PDF might reuse a non-PDF location (or vice versa). For unknown type (None),
    # we keep the old behaviour and use the generic cache.
    try:
        _cached = getattr(analyzer, cache_attr, None)
        if not (isinstance(_cached, (list, tuple)) and len(_cached) == 2) and is_pdf is None:
            _cached = getattr(analyzer, "_dialog_cached_download_screen_coords", None)
    except Exception:
        _cached = None
    if isinstance(_cached, (list, tuple)) and len(_cached) == 2:
        try:
            import pyautogui as _pg
            # Small delay before using cached click for stability
            try:
                import time as _time
                _time.sleep(0.2)
            except Exception:
                pass
            _pg.click(int(_cached[0]), int(_cached[1]))
            try:
                logging.info(f"[DialogUtil] Clicked Download (cached) at screen={list(map(int, _cached))}")
            except Exception:
                pass
            return True
        except Exception:
            pass

    screenshot_path = analyzer.capture_full_resolution_screenshot()
    if not screenshot_path:
        return False

    # Use Rekognition exact WORD first, then tiler include for 'Download'
    try:
        from PIL import Image as _Image
        import boto3 as _boto3
        from ..constants import AWS_REGION as _AWS_REGION
        from ..rekognition_tiler import find_text_coordinates_tiled as _tiler_find
        from ..rekognition_tiler import find_exact_word_coordinates as _find_exact_word
        import pyautogui as _pg
    except Exception:
        return False

    try:
        try:
            logging.info("[DialogUtil] Searching for 'Download' button/link")
        except Exception:
            pass
        rk = _boto3.client('rekognition', region_name=_AWS_REGION)
        img = _Image.open(screenshot_path).convert('RGB')
        # Prepare a debug directory so we can inspect raw Rekognition outputs
        debug_dir = None
        try:
            import time as _time
            from pathlib import Path as _Path

            base_dir = _Path(getattr(analyzer, '_get_debug_dir', lambda: 'logs')())
            dd = base_dir / f"rekognition_{int(_time.time()*1000)}"
            dd.mkdir(parents=True, exist_ok=True)
            # Persist a copy of the input image for reference
            try:
                img.save(str(dd / 'input.png'), format='PNG')
            except Exception:
                pass
            debug_dir = str(dd)
            try:
                logging.info(f"[DialogUtil] Rekognition debug dir for 'Download': '{debug_dir}'")
            except Exception:
                pass
        except Exception:
            debug_dir = None
        # First, try to find an exact WORD equal to 'download'
        coords = _find_exact_word(rk, img, target_word='download', min_confidence=85.0, debug_dir=None)
        if not (isinstance(coords, (list, tuple)) and len(coords) == 2):
            # Fallback: strict include matching requiring exact-equal line, stop at first
            try:
                _rows = 8
                logging.info(f"[DialogUtil] Rekognition tiler grid for 'Download': cols=1 rows={_rows}")
            except Exception:
                _rows = 8
            coords = _tiler_find(
                rk,
                img,
                query="Download",
                is_regex=False,
                upscale=2.0,
                overlap_frac=0.10,
                debug_dir=debug_dir,
                require_include=True,
                # Allow includes to match variants like "Download file"
                require_exact=False,
                stop_at_first_include=True,
                # Use a 1x3 grid like the close 'X' for better locality
                cols=1,
                rows=_rows,
            )
    except Exception:
        coords = None

    if isinstance(coords, (list, tuple)) and len(coords) == 2:
        try:
            # Preserve original image-space coordinates for validation
            img_px, img_py = int(coords[0]), int(coords[1])

            # Apply display scale and y-offset if configured (screen-space for clicking)
            try:
                coords = analyzer._apply_display_scale([int(coords[0]), int(coords[1])])
            except Exception:
                coords = [int(coords[0]), int(coords[1])]
            try:
                from ..constants import Y_CLICK_OFFSET as _Y_OFF
                coords = [int(coords[0]), int(coords[1] + int(_Y_OFF))]
            except Exception:
                coords = [int(coords[0]), int(coords[1])]
            # Validate a clear 'download' signal near the ORIGINAL image-space location
            try:
                from io import BytesIO as _BytesIO
                _buf = _BytesIO()
                img.save(_buf, format='PNG')
                _resp = rk.detect_text(Image={'Bytes': _buf.getvalue()})
                iw, ih = img.size
                px, py = int(img_px), int(img_py)
                # Save the full detect_text response for inspection
                try:
                    if debug_dir is not None:
                        import json as _json
                        from pathlib import Path as _Path

                        _Path(debug_dir).mkdir(parents=True, exist_ok=True)
                        (_Path(debug_dir) / 'full_detect_text.json').write_text(
                            _json.dumps(_resp, ensure_ascii=False, indent=2), encoding='utf-8'
                        )
                except Exception:
                    pass

                found_ok = False
                # WORD-level exact match within radius
                r2_word = 64 * 64
                for d in _resp.get('TextDetections', []):
                    try:
                        if d.get('Type') != 'WORD':
                            continue
                        txt = str(d.get('DetectedText') or '').strip().lower()
                        if txt != 'download':
                            continue
                        bb = d.get('Geometry', {}).get('BoundingBox', {})
                        wx = int((bb.get('Left', 0) + bb.get('Width', 0) / 2) * iw)
                        wy = int((bb.get('Top', 0) + bb.get('Height', 0) / 2) * ih)
                        dx = wx - px
                        dy = wy - py
                        if dx * dx + dy * dy <= r2_word:
                            found_ok = True
                            break
                    except Exception:
                        continue
                # LINE-level include match within a more generous radius
                if not found_ok:
                    r2_line = 140 * 140
                    for d in _resp.get('TextDetections', []):
                        try:
                            if d.get('Type') != 'LINE':
                                continue
                            txt = str(d.get('DetectedText') or '').strip().lower()
                            if 'download' not in txt:
                                continue
                            bb = d.get('Geometry', {}).get('BoundingBox', {})
                            wx = int((bb.get('Left', 0) + bb.get('Width', 0) / 2) * iw)
                            wy = int((bb.get('Top', 0) + bb.get('Height', 0) / 2) * ih)
                            dx = wx - px
                            dy = wy - py
                            if dx * dx + dy * dy <= r2_line:
                                found_ok = True
                                break
                        except Exception:
                            continue
                if not found_ok:
                    try:
                        logging.info("[DialogUtil] Validation failed (no WORD/LINE near image coords); proceeding to click via tiler coords as fallback")
                    except Exception:
                        pass
                    # Fall through to click using tiler-derived screen coordinates
            except Exception:
                return False
            _pg.click(int(coords[0]), int(coords[1]))
            # Cache screen-space coordinates for subsequent files in this dialog.
            # We always update both the type-specific bucket (if known) and the
            # generic attribute used by existing helpers such as Close-X.
            try:
                setattr(analyzer, cache_attr, (int(coords[0]), int(coords[1])))
            except Exception:
                pass
            try:
                setattr(analyzer, "_dialog_cached_download_screen_coords", (int(coords[0]), int(coords[1])))
            except Exception:
                pass
            try:
                logging.info(f"[DialogUtil] Clicked Download at screen={coords} (image={img_px},{img_py})")
            except Exception:
                pass
            return True
        except Exception:
            return False
    try:
        logging.info("[DialogUtil] 'Download' not found on screen")
    except Exception:
        pass
    return False


def process_file_download_flow(app, file_name: str) -> bool:
    """Run the per-file sequence: click file -> click Download -> save once -> close X.

    Returns True if the file click succeeded (subsequent steps are best-effort).
    """
    # Click the file name
    clicked = False
    try:
        clicked = click_first_dialog_file_name(app, file_name)
        if clicked:
            try:
                logging.info(f"[DialogUtil] Opened file view for: {file_name}")
            except Exception:
                pass
    except Exception:
        clicked = False

    if not clicked:
        return False

    # Allow the view to render
    try:
        import time as _time
        _time.sleep(0.4)
    except Exception:
        pass

    # Try to click Download
    download_clicked = False
    try:
        # Derive a simple type flag so we can keep separate cached Download locations
        # for PDF vs non-PDF previews (their toolbars differ).
        try:
            _name = str(file_name or "").strip().lower()
            _is_pdf = _name.endswith(".pdf")
        except Exception:
            _is_pdf = False
        download_clicked = click_download_link_if_present(app, is_pdf=_is_pdf)
        if download_clicked:
            # Wait for download to initiate before proceeding
            try:
                _time.sleep(0.5)
            except Exception:
                pass
    except Exception:
        pass

    # Attempt save modal once
    # try:
    #     _time.sleep(0.1)
    # except Exception:
    #     pass
    # try:
    #     run_save_dialog_once_if_present(app)
    # except Exception:
    #     pass

    # Close X - wait longer for download dialog/view to stabilize
    try:
        _time.sleep(1.0)  # Increased from 0.3s to allow download to complete and view to stabilize
    except Exception:
        pass
    try:
        closed = click_close_x_if_present(app)
        if not closed:
            try:
                logging.warning(f"[DialogUtil] Failed to click close X after processing file: {file_name}")
            except Exception:
                pass
    except Exception as e:
        try:
            logging.warning(f"[DialogUtil] Exception while clicking close X for {file_name}: {e}")
        except Exception:
            pass

    return True


def run_save_dialog_once_if_present(app) -> bool:
    """One-shot check for a Save/Confirm modal and click Save if present.

    Returns True if a Save-like button was clicked.
    """
    analyzer = getattr(app, 'screenshot_manager', None)
    analyzer = getattr(analyzer, 'openai_analyzer', None)
    if analyzer is None:
        return False
    # Import modal save helper up-front for detection/polling
    try:
        from ..modal_handler import click_save_dialog_once as _save_once
    except Exception:
        _save_once = None  # type: ignore
    # If we cached the Save button location, wait for modal then click (or let helper click when ready)
    try:
        _cached = getattr(analyzer, "_dialog_cached_save_screen_coords", None)
    except Exception:
        _cached = None
    if isinstance(_cached, (list, tuple)) and len(_cached) == 2:
        try:
            # Poll briefly for the save modal to appear; allow helper to click if it detects it
            if _save_once is not None:
                try:
                    import time as _time
                    _deadline = _time.time() + 3.0  # wait up to 3 seconds
                    while _time.time() < _deadline:
                        try:
                            _res = _save_once(analyzer)
                        except Exception:
                            _res = {"checked": True, "clicked": False}
                        try:
                            if bool((_res or {}).get("clicked", False)):
                                # Cache coords updated by modal handler via _last_rekognition_coords
                                try:
                                    last = getattr(analyzer, "_last_rekognition_coords", None)
                                    if isinstance(last, (list, tuple)) and len(last) == 2:
                                        try:
                                            sx, sy = analyzer._apply_display_scale([int(last[0]), int(last[1])])
                                        except Exception:
                                            sx, sy = int(last[0]), int(last[1])
                                        try:
                                            from ..constants import Y_CLICK_OFFSET as _Y_OFF
                                            sy = int(sy + int(_Y_OFF))
                                        except Exception:
                                            sy = int(sy)
                                        setattr(analyzer, "_dialog_cached_save_screen_coords", (int(sx), int(sy)))
                                except Exception:
                                    pass
                                return True
                        except Exception:
                            pass
                        _time.sleep(0.1)
                except Exception:
                    pass
            import pyautogui as _pg
            # Small delay before using cached click for stability
            try:
                import time as _time
                _time.sleep(0.1)
            except Exception:
                pass
            _pg.click(int(_cached[0]), int(_cached[1]))
            try:
                logging.info(f"[DialogUtil] Clicked Save (cached) at screen={list(map(int, _cached))}")
            except Exception:
                pass
            return True
        except Exception:
            pass
    if _save_once is None:
        return False

    try:
        result = _save_once(analyzer)
    except Exception:
        result = {"checked": True, "clicked": False}

    try:
        logging.info(f"[DialogUtil] Save dialog result: {result}")
    except Exception:
        pass
    try:
        clicked = bool(result.get("clicked", False))
        # If we clicked via modal handler, capture last Rekognition coords for subsequent reuse
        if clicked:
            try:
                last = getattr(analyzer, "_last_rekognition_coords", None)
                if isinstance(last, (list, tuple)) and len(last) == 2:
                    # Apply display scale and Y offset to convert to screen coords for reuse
                    try:
                        sx, sy = analyzer._apply_display_scale([int(last[0]), int(last[1])])
                    except Exception:
                        sx, sy = int(last[0]), int(last[1])
                    try:
                        from ..constants import Y_CLICK_OFFSET as _Y_OFF
                        sy = int(sy + int(_Y_OFF))
                    except Exception:
                        sy = int(sy)
                    setattr(analyzer, "_dialog_cached_save_screen_coords", (int(sx), int(sy)))
            except Exception:
                pass
        return clicked
    except Exception:
        return False


def click_close_x_if_present(app) -> bool:
    """Search for a lone 'X' (close affordance) and click if found using Rekognition tiler.

    Returns True if clicked.
    """
    analyzer = getattr(app, 'screenshot_manager', None)
    analyzer = getattr(analyzer, 'openai_analyzer', None)
    if analyzer is None:
        return False

    # If we cached the Close 'X' location for this dialog, click it immediately
    try:
        _cached = getattr(analyzer, "_dialog_cached_close_x_screen_coords", None)
    except Exception:
        _cached = None
    if isinstance(_cached, (list, tuple)) and len(_cached) == 2:
        try:
            import pyautogui as _pg
            # Small delay before using cached click for stability
            try:
                import time as _time
                _time.sleep(0.1)
            except Exception:
                pass
            _pg.click(int(_cached[0]), int(_cached[1]))
            try:
                logging.info(f"[DialogUtil] Clicked close X (cached) at {list(map(int, _cached))}")
            except Exception:
                pass
            return True
        except Exception:
            pass

    screenshot_path = analyzer.capture_full_resolution_screenshot()
    if not screenshot_path:
        try:
            logging.warning("[DialogUtil] Close X: Screenshot capture failed")
        except Exception:
            pass
        return False

    # Create debug directory for close X detection
    debug_dir = None
    try:
        import time as _time
        from pathlib import Path as _Path
        base_dir = _Path(getattr(analyzer, '_get_debug_dir', lambda: 'logs')())
        debug_dir = base_dir / f"rekognition_{int(_time.time()*1000)}" / "close_x_search"
        debug_dir.mkdir(parents=True, exist_ok=True)
        try:
            logging.info(f"[DialogUtil] Close X debug directory: '{debug_dir}'")
        except Exception:
            pass
    except Exception:
        debug_dir = None

    try:
        from PIL import Image as _Image
        import boto3 as _boto3
        from ..constants import AWS_REGION as _AWS_REGION
        from ..rekognition_tiler import find_text_coordinates_tiled as _tiler_find
        from ..rekognition_tiler import find_exact_word_coordinates as _find_exact_word
        import pyautogui as _pg
        import json as _json
    except Exception:
        return False

    try:
        logging.info(f"[DialogUtil] Close X: Starting detection, screenshot='{screenshot_path}'")
        rk = _boto3.client('rekognition', region_name=_AWS_REGION)
        img = _Image.open(screenshot_path).convert('RGB')
        iw, ih = img.size
        logging.info(f"[DialogUtil] Close X: Image size {iw}x{ih}")
        
        # Save input image to debug directory
        if debug_dir is not None:
            try:
                img.save(str(debug_dir / 'input_image.png'), format='PNG')
                logging.info(f"[DialogUtil] Close X: Saved input image to '{debug_dir / 'input_image.png'}'")
            except Exception:
                pass
        
        # Get Download button location as reference point
        download_ref = None
        try:
            _cached_dl = getattr(analyzer, "_dialog_cached_download_screen_coords", None)
            if isinstance(_cached_dl, (list, tuple)) and len(_cached_dl) == 2:
                # Download button screen coords (approximate image coords - display scale might have been applied)
                # For reference, we'll use these as-is since they're close enough
                download_ref = (int(_cached_dl[0]), int(_cached_dl[1]))
                logging.info(f"[DialogUtil] Close X: Download button reference at {download_ref}")
            else:
                logging.info("[DialogUtil] Close X: No Download button reference available")
        except Exception:
            pass
        
        # Find the FIRST valid WORD "x" (not LINE) - prioritize near Download button
        from io import BytesIO as _BytesIO
        _buf = _BytesIO()
        img.save(_buf, format='PNG')
        logging.info("[DialogUtil] Close X: Calling Rekognition detect_text...")
        resp = rk.detect_text(Image={'Bytes': _buf.getvalue()})
        
        # Save full Rekognition response to debug directory
        if debug_dir is not None:
            try:
                resp_file = debug_dir / 'rekognition_response.json'
                with open(resp_file, 'w', encoding='utf-8') as f:
                    f.write(_json.dumps(resp, ensure_ascii=False, indent=2))
                logging.info(f"[DialogUtil] Close X: Saved Rekognition response to '{resp_file}' ({len(resp.get('TextDetections', []))} detections)")
            except Exception:
                pass
        
        # Log all WORD detections for debugging
        all_x_words = []
        for d in resp.get('TextDetections', []):
            if d.get('Type') == 'WORD':
                txt = str(d.get('DetectedText') or '').strip()
                if len(txt) == 1 and txt.lower() == 'x':
                    conf = float(d.get('Confidence', 0) or 0)
                    bb = d.get('Geometry', {}).get('BoundingBox', {})
                    cx = int((float(bb.get('Left', 0)) + float(bb.get('Width', 0)) / 2) * iw)
                    cy = int((float(bb.get('Top', 0)) + float(bb.get('Height', 0)) / 2) * ih)
                    all_x_words.append({
                        'text': txt,
                        'confidence': conf,
                        'coords': (cx, cy),
                        'y': cy,
                        'in_browser_tab': cy < 150
                    })
        
        if all_x_words:
            logging.info(f"[DialogUtil] Close X: Found {len(all_x_words)} WORD 'x' detection(s): {all_x_words}")
        else:
            logging.warning("[DialogUtil] Close X: No WORD 'x' detections found in Rekognition response")
        
        # First pass: look for X's near Download button (if available)
        coords = None
        if download_ref is not None:
            dl_x, dl_y = download_ref
            logging.info(f"[DialogUtil] Close X: First pass - searching near Download button at {download_ref}")
            for d in resp.get('TextDetections', []):
                if d.get('Type') != 'WORD':  # Only WORD, not LINE
                    continue
                txt = str(d.get('DetectedText') or '').strip()
                # Only match standalone single-character "x" (case-insensitive), not "x" in words like "Xero"
                if len(txt) != 1 or txt.lower() != 'x':
                    continue
                # conf = float(d.get('Confidence', 0) or 0)
                # if conf < 50.0:  # Lowered from 85.0 to 50.0 to accept more detections
                #     logging.debug(f"[DialogUtil] Close X: Rejected '{txt}' at confidence {conf:.1f} (< 50.0)")
                #     continue
                bb = d.get('Geometry', {}).get('BoundingBox', {})
                cx = int((float(bb.get('Left', 0)) + float(bb.get('Width', 0)) / 2) * iw)
                cy = int((float(bb.get('Top', 0)) + float(bb.get('Height', 0)) / 2) * ih)
                
                # Reject browser tab area (top 150px)
                if cy < 150:
                    logging.info(f"[DialogUtil] Close X: Rejected '{txt}' at ({cx}, {cy}) - in browser tab area (Y={cy} < 150)")
                    continue
                
                # Check if this X is near the Download button (above and within reasonable distance)
                dx = abs(cx - dl_x)
                dy = abs(cy - dl_y)
                logging.info(f"[DialogUtil] Close X: Candidate '{txt}' at ({cx}, {cy}) conf={conf:.1f}, distance from Download: dx={dx}, dy={dy}, cy<dl_y={cy < dl_y}")
                if cy < dl_y and dx < 300 and dy < 200:
                    coords = [cx, cy]
                    logging.info(f"[DialogUtil] Close X: SELECTED near Download button at ({cx}, {cy}) conf={conf:.1f}")
                    break
                else:
                    logging.info(f"[DialogUtil] Close X: Rejected '{txt}' at ({cx}, {cy}) - not near Download (cy<dl_y={cy < dl_y}, dx<300={dx < 300}, dy<200={dy < 200})")
            if coords is None:
                logging.info("[DialogUtil] Close X: First pass completed - no match near Download button")
        else:
            logging.info("[DialogUtil] Close X: Skipping first pass - no Download button reference")
        
        # Second pass: if no match near Download, find first valid X (not in browser tab area)
        if coords is None:
            logging.info("[DialogUtil] Close X: Second pass - searching for first valid X (not in browser tab area)")
            for d in resp.get('TextDetections', []):
                if d.get('Type') != 'WORD':  # Only WORD, not LINE
                    continue
                txt = str(d.get('DetectedText') or '').strip()
                # Only match standalone single-character "x" (case-insensitive), not "x" in words like "Xero"
                if len(txt) != 1 or txt.lower() != 'x':
                    continue
                conf = float(d.get('Confidence', 0) or 0)
                if conf < 70.0:  # Lowered from 85.0 to 70.0 to accept more detections
                    logging.debug(f"[DialogUtil] Close X: Rejected '{txt}' at confidence {conf:.1f} (< 70.0)")
                    continue
                bb = d.get('Geometry', {}).get('BoundingBox', {})
                cx = int((float(bb.get('Left', 0)) + float(bb.get('Width', 0)) / 2) * iw)
                cy = int((float(bb.get('Top', 0)) + float(bb.get('Height', 0)) / 2) * ih)
                
                # Reject browser tab area (top 150px)
                if cy < 150:
                    logging.info(f"[DialogUtil] Close X: Rejected '{txt}' at ({cx}, {cy}) - in browser tab area (Y={cy} < 150)")
                    continue
                
                # Found first valid X
                coords = [cx, cy]
                logging.info(f"[DialogUtil] Close X: SELECTED first valid at ({cx}, {cy}) conf={conf:.1f}")
                break
            if coords is None:
                logging.warning("[DialogUtil] Close X: Second pass completed - no valid X found (all rejected or none detected)")
        
        # Fallback if no valid X found in detect_text
        if coords is None:
            logging.warning("[DialogUtil] Close X: No valid X found in detect_text, trying fallback methods...")
            # Fallback: try exact word search with lower confidence
            logging.info("[DialogUtil] Close X: Fallback 1 - trying find_exact_word (min_confidence=70.0)...")
            coords = _find_exact_word(rk, img, target_word='x', min_confidence=70.0, debug_dir=str(debug_dir) if debug_dir else None)
            if isinstance(coords, (list, tuple)) and len(coords) == 2:
                # Check if fallback result is in browser tab area
                if coords[1] < 150:
                    logging.warning(f"[DialogUtil] Close X: Fallback 1 found X at {coords} but it's in browser tab area (Y={coords[1]} < 150), rejecting")
                    coords = None
                else:
                    logging.info(f"[DialogUtil] Close X: Fallback 1 found valid X at {coords}")
            
            # Fallback 2: tiler search - search all tiles and filter results
            if coords is None:
                logging.info("[DialogUtil] Close X: Fallback 1 failed or rejected, trying tiler search (all tiles)...")
                # Search without stop_at_first_include to get all matches, then filter
                tiler_coords = _tiler_find(
                    rk,
                    img,
                    query="X",
                    is_regex=False,
                    upscale=2.0,
                    overlap_frac=0.10,
                    debug_dir=str(debug_dir) if debug_dir else None,
                    require_include=True,
                    require_exact=True,
                    stop_at_first_include=False,  # Changed to False to search all tiles
                    cols=1,
                    rows=5,
                )
                # Note: tiler_find with stop_at_first_include=False might return multiple or None
                # For now, if we get coords, validate them
                if isinstance(tiler_coords, (list, tuple)) and len(tiler_coords) == 2:
                    if tiler_coords[1] < 150:
                        logging.warning(f"[DialogUtil] Close X: Fallback 2 (tiler) found X at {tiler_coords} but it's in browser tab area (Y={tiler_coords[1]} < 150), rejecting")
                        coords = None
                    else:
                        coords = tiler_coords
                        logging.info(f"[DialogUtil] Close X: Fallback 2 (tiler) found valid X at {coords}")
                else:
                    logging.warning("[DialogUtil] Close X: Fallback 2 (tiler) found no X or invalid result")
            
            if coords is None:
                logging.warning("[DialogUtil] Close X: All fallback methods failed - no valid X found")
    except Exception as e:
        try:
            logging.error(f"[DialogUtil] Close X: Exception during detection: {e}", exc_info=True)
        except Exception:
            pass
        coords = None

    if isinstance(coords, (list, tuple)) and len(coords) == 2:
        try:
            # Store original image-space coordinates
            orig_coords_img = [int(coords[0]), int(coords[1])]
            logging.info(f"[DialogUtil] Close X: Final coordinates from detection: {orig_coords_img}")
            
            # Double-check: reject if somehow in browser tab area (shouldn't happen with new filtering)
            if orig_coords_img[1] < 150:
                logging.warning(f"[DialogUtil] Close X: REJECTED - found in browser tab area (Y={orig_coords_img[1]} < 150)")
                return False
            
            # Use image coordinates directly - no transformation needed if screenshot is at screen resolution
            # Only apply Y offset if configured (for calibration)
            screen_coords = [int(orig_coords_img[0]), int(orig_coords_img[1])]
            y_offset_applied = 0
            try:
                from ..constants import Y_CLICK_OFFSET as _Y_OFF
                y_offset_applied = int(_Y_OFF)
                screen_coords = [int(screen_coords[0]), int(screen_coords[1] + y_offset_applied)]
                logging.info(f"[DialogUtil] Close X: Applied Y offset {y_offset_applied} - image: {orig_coords_img} -> screen: {screen_coords}")
            except Exception:
                logging.info(f"[DialogUtil] Close X: No Y offset configured - using image coordinates as screen: {screen_coords}")
            
            # Save final coordinates to debug directory
            if debug_dir is not None:
                try:
                    result_file = debug_dir / 'selected_coords.json'
                    result_data = {
                        'image_coords': orig_coords_img,
                        'screen_coords': screen_coords,
                        'y_offset_applied': y_offset_applied,
                        'screenshot_path': str(screenshot_path),
                        'image_size': f"{iw}x{ih}"
                    }
                    with open(result_file, 'w', encoding='utf-8') as f:
                        f.write(_json.dumps(result_data, ensure_ascii=False, indent=2))
                    logging.info(f"[DialogUtil] Close X: Saved final coordinates to '{result_file}'")
                except Exception:
                    pass
            
            logging.info(f"[DialogUtil] Close X: Attempting click at screen coordinates {screen_coords}")
            _pg.click(int(screen_coords[0]), int(screen_coords[1]))
            
            # Cache screen-space coordinates for subsequent files
            try:
                setattr(analyzer, "_dialog_cached_close_x_screen_coords", (int(screen_coords[0]), int(screen_coords[1])))
                logging.info(f"[DialogUtil] Close X: Cached screen coordinates {screen_coords} for future use")
            except Exception:
                pass
            
            logging.info(f"[DialogUtil] Close X: SUCCESS - Clicked at screen={screen_coords} (image={orig_coords_img})")
            return True
        except Exception as e:
            logging.error(f"[DialogUtil] Close X: Exception while clicking: {e}", exc_info=True)
            return False
    
    # Log when no coordinates found
    logging.warning(f"[DialogUtil] Close X: FAILED - no coordinates returned from Rekognition. Debug dir: '{debug_dir}'")
    return False



