"""Xero attach-files flow helpers.

This module contains a small set of focused functions that implement the
"Attach files/Files" dialog automation previously embedded in
`autoclicker/xero_supporting_docs.py`.

Public entrypoint:
- run_xero_attach_files_flow(playback_mgr)
"""

from __future__ import annotations

from typing import Any, Dict, List, Optional, Tuple
from .attach_files_dialog_utils import _build_dialog_count_prompt
from .attach_files_dialog_utils import _build_dialog_confirm_prompt


def _get_analyzer(playback_mgr) -> Any | None:
    """Return an OpenAIAnalyzer instance bound to the app, creating it if needed."""
    try:
        from ..openai_analyzer import OpenAIAnalyzer
    except Exception:
        try:
            import logging as _logging

            _logging.error("[Playback] Analyzer unavailable for Xero attach-files flow")
        except Exception:
            pass
        return None

    app = getattr(playback_mgr, "app", None)
    if app is None:
        return None

    analyzer = getattr(app.screenshot_manager, "openai_analyzer", None)
    if analyzer is None:
        try:
            analyzer = OpenAIAnalyzer(app)
        except Exception:
            try:
                import logging as _logging

                _logging.error("[Playback] Analyzer init failed for Xero attach-files flow")
            except Exception:
                pass
            return None
    return analyzer


def _create_debug_dir(analyzer) -> Optional[object]:
    try:
        import time as _time
        from pathlib import Path as _Path

        base_dir = _Path(analyzer._get_debug_dir())
        debug_parent = base_dir / f"attach_files_{int(_time.time()*1000)}"
        debug_parent.mkdir(parents=True, exist_ok=True)
        return debug_parent
    except Exception:
        return None


def _click_attach_files_label(analyzer, app, screenshot_path, debug_parent) -> Tuple[bool, Optional[str]]:
    """Try to click the Attach files/Files label using cached coords or Rekognition.

    Returns a tuple (clicked: bool, matched_label: Optional[str]).
    matched_label is only populated when a Rekognition label match occurred.
    """
    try:
        import logging as _logging
    except Exception:
        _logging = None  # type: ignore

    # Try cached coordinates first
    try:
        cached = getattr(app, "_cached_attach_files_coords", None)
    except Exception:
        cached = None

    if isinstance(cached, (list, tuple)) and len(cached) == 2:
        try:
            import pyautogui as _pg

            _pg.moveTo(int(cached[0]), int(cached[1]), duration=0.2)
            _pg.click(int(cached[0]), int(cached[1]))
            return True, None
        except Exception:
            pass

    # Fall back to Rekognition tiler search
    try:
        from ..constants import ATTACH_FILES_LABELS as _LABELS
    except Exception:
        _LABELS = ["Attach files", "Files"]

    # Build Rekognition tile cache once and reuse for all candidate labels
    try:
        from PIL import Image as _Image
        import boto3 as _boto3
        try:
            from ..constants import AWS_REGION as _AWS_REGION
        except Exception:
            _AWS_REGION = "us-east-1"  # type: ignore
        from ..rekognition_tiler import (
            build_tile_detect_text_cache as _build_cache,
            find_text_coordinates_from_tile_cache as _find_from_cache,
        )
    except Exception:
        return False, None

    # Prefer a previously built tile cache if present (from Payment/Batch prechecks)
    _reuse_bundle = None
    try:
        _reuse_bundle = getattr(analyzer, "_last_rekognition_tile_cache", None)
    except Exception:
        _reuse_bundle = None
    if isinstance(_reuse_bundle, dict) and _reuse_bundle.get("img") is not None and _reuse_bundle.get("cache") is not None:
        # Reuse existing cache from Payment/Batch prechecks
        _img = _reuse_bundle.get("img")
        _cache = _reuse_bundle.get("cache")
    else:
        # Build cache if not present (should only happen if Payment/Batch prechecks didn't run or failed)
        if screenshot_path is None:
            try:
                screenshot_path = analyzer.capture_full_resolution_screenshot()
            except Exception:
                screenshot_path = None
        if screenshot_path:
            try:
                _img = _Image.open(screenshot_path).convert("RGB")
                _rk = _boto3.client("rekognition", region_name=_AWS_REGION if isinstance(_AWS_REGION, str) else "us-east-1")
                _cache = _build_cache(
                    _rk,
                    _img,
                    upscale=2.0,
                    overlap_frac=0.10,
                    cols=1,
                    rows=5,
                    debug_dir=str(debug_parent) if debug_parent else None,
                )
                try:
                    setattr(analyzer, "_last_rekognition_tile_cache", {"img": _img, "cache": _cache})
                except Exception:
                    pass
            except Exception:
                _img = None
                _cache = []
        else:
            _img = None
            _cache = []

    # Ensure we have valid image and cache before searching
    if _img is None or not _cache:
        return False, None

    for label in _LABELS:
        try:
            # Log request (single Rekognition pass; reusing cached tile responses)
            try:
                msg = (
                    f"[AttachFiles] Rekognition tiler request: label='{label}' "
                    f"(cached) screenshot='{screenshot_path}' debug_dir='{str(debug_parent) if debug_parent else 'None'}'"
                )
                if _logging is not None:
                    try:
                        _logging.info(msg)
                    except Exception:
                        pass
                if hasattr(analyzer, "openai_logger") and analyzer.openai_logger:
                    analyzer.openai_logger.info(msg)
            except Exception:
                pass

            # For "Files" (singular label), use regex with word boundaries to ensure it only matches "Files" as a standalone word
            # This prevents matching "File" (singular) in phrases like "File uploaded" or "Uploaded file:"
            # For multi-word labels like "Attach files", use normal substring matching
            if label.strip().lower() == "files":
                # Use regex with word boundaries to match "Files" as a complete word only
                coords = _find_from_cache(
                    _img,
                    _cache,
                    r"(?i)\bFiles\b",
                    is_regex=True,
                    require_include=True,
                    require_exact=False,
                    stop_at_first_include=True,
                    debug_dir=str(debug_parent) if debug_parent else None,
                )
            else:
                # For multi-word labels, use normal substring matching
                coords = _find_from_cache(
                    _img,
                    _cache,
                    label,
                    is_regex=False,
                    require_include=True,
                    require_exact=False,
                    stop_at_first_include=True,
                    debug_dir=str(debug_parent) if debug_parent else None,
                )
            if isinstance(coords, (list, tuple)) and len(coords) == 2:
                x, y = int(coords[0]), int(coords[1])
                try:
                    setattr(analyzer, "_last_rekognition_coords", (x, y))
                except Exception:
                    pass
                # Click at coords with app temporarily hidden
                try:
                    restore = analyzer._temporarily_hide_app()
                except Exception:
                    restore = None
                try:
                    import pyautogui as _pg
                    _pg.moveTo(x, y, duration=0.3)
                    _pg.click(x, y)
                finally:
                    try:
                        if callable(restore):
                            restore()
                    except Exception:
                        pass
                if _logging is not None:
                    try:
                        _logging.info(f"[Playback] Clicked attach/files label via cached-tiler: '{label}'")
                    except Exception:
                        pass
                # Cache for subsequent reuse
                try:
                    app._cached_attach_files_coords = (x, y)
                except Exception:
                    pass
                # Keep status as 'Playing' so follow-up scans run
                try:
                    if hasattr(app, "status_var") and app.status_var.get() != "Playing":
                        app.status_var.set("Playing")
                except Exception:
                    pass
                return True, label
        except Exception:
            pass

    return False, None



def _analyze_dialog_count(analyzer, screenshot_path: str, prompt: str) -> Tuple[bool, int]:
    try:
        import json as _json
    except Exception:
        return False, 0

    result: Optional[str] = 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

    file_count = 0
    dlg_open = False
    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()
            data = _json.loads(txt)
            _dlg_raw = data.get("dialog_open", data.get("dialog", False))
            dlg_open = _dlg_raw.strip().lower() in ("true", "1", "yes") if isinstance(_dlg_raw, str) else 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
        except Exception:
            dlg_open, file_count = False, 0

    # Second pass: independent confirmation prompt (no previous result injected); prefer higher count
    try:
        result2: Optional[str] = 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, None)
            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()
                _data2 = _json.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] + "...")
                    import logging as _logging
                    _logging.info(f"[AttachFiles] Confirm pass (independent) raw: {raw_preview2}")
                    _logging.info(f"[AttachFiles] 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 for authoritative row count
    try:
        from .attach_files_dialog_utils import _count_dialog_rows_via_rekognition as _rk_row_count
        _app = getattr(analyzer, "app", None)
        if _app is not None:
            rk_rows = _rk_row_count(_app)
        else:
            rk_rows = 0
        if int(rk_rows) > int(file_count):
            try:
                import logging as _logging
                _logging.info(f"[AttachFiles] 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_zero_files_flows(playback_mgr, analyzer, prompt: str) -> None:
    """Run fallback/no-dialog flows and terminate the attach-files flow.

    This mirrors the behavior in the original implementation where these
    branches return after executing their respective playlists.
    """
    try:
        import logging as _logging
        import time as _time
    except Exception:
        return

    app = getattr(playback_mgr, "app", None)
    if app is None:
        return
    # Simplified fallback: run the SUBPLAYLIST_NO_FILES_PLAYLIST_ID playlist, then invoke the post-playlist dialog flow
    try:
        try:
            from ..constants import SUBPLAYLIST_NO_FILES_PLAYLIST_ID as _NOF
            nof_id = int(_NOF)
        except Exception:
            nof_id = 0
        target_id = nof_id
        if target_id <= 0:
            _logging.warning("[AttachFiles] No fallback playlist id configured; skipping fallback execution")
            return
        try:
            if hasattr(app, "status_var"):
                app.status_var.set("Playing")
        except Exception:
            pass
        _logging.info(f"[AttachFiles] Fallback: running SUBPLAYLIST_NO_FILES_PLAYLIST_ID={target_id}")
        playback_mgr._execute_playlist_by_id(target_id)
        # After running fallback playlist, execute the same post-playlist dialog steps
        try:
            from ..xero_util.attach_files_dialog_utils import run_post_playlist_dialog_check as _post
            _post(app)
        except Exception:
            pass
        return
    except Exception:
        try:
            _logging.exception("[AttachFiles] Simplified fallback execution failed")
        except Exception:
            pass
        return


def _analyze_and_click_attachments(playback_mgr, analyzer) -> None:
    """Analyze current Files dialog and click each detected 'View' or link row once."""
    try:
        import logging as _logging
        import os
        import time as _time
        from io import BytesIO as _BytesIO
        from pathlib import Path as _Path

        import boto3 as _boto3
        from PIL import Image as _Image
    except Exception:
        return

    app = getattr(playback_mgr, "app", None)
    if app is None:
        return

    # Cache current attach-files coords
    try:
        attach_coords = getattr(app, "_cached_attach_files_coords", None)
    except Exception:
        attach_coords = None

    # Extract link names via Sage helpers
    try:
        from ..sage_attachments_finder import (
            analyze_attachment_names_from_current_dialog as _analyze,
        )
    except Exception:
        return

    try:
        from ..constants import FILES_DIALOG_STABILIZE_DELAY_SEC as _STAB

        _stab = float(_STAB)
    except Exception:
        _stab = 0.01
    try:
        _time.sleep(_stab)
    except Exception:
        pass

    try:
        result = _analyze(app, analyzer)
        links = (result.get("links") if isinstance(result, dict) else []) or []
    except Exception:
        links = []
    try:
        _cnt = len(links)
        _preview = ", ".join([str(x) for x in links[:3]])
        if len(links) > 3:
            _preview += ", ..."
        _logging.info(
            f"[AttachFiles] Detected {_cnt} attachment name(s) from prompt: {_preview}"
        )
        if hasattr(app, "status_var"):
            app.status_var.set(f"Attachments: {_cnt} found")
    except Exception:
        pass

    # Capture current screen for Rekognition
    screenshot_v = analyzer.capture_full_resolution_screenshot()
    if not screenshot_v:
        try:
            _logging.warning("[AttachFiles] Screenshot failed before Rekognition")
        except Exception:
            pass
        return
    try:
        img = _Image.open(screenshot_v).convert("RGB")
        buf = _BytesIO()
        img.save(buf, format="PNG")
        image_bytes = buf.getvalue()
    except Exception:
        try:
            _logging.exception("[AttachFiles] Failed to prepare image for Rekognition")
        except Exception:
            pass
        return

    # Debug directory for rows
    view_dbg = None
    root_dbg = None
    click_log_path = None
    try:
        base_dir = _Path(analyzer._get_debug_dir())
        # Use the same 'rekognition_<ts>' prefix as other flows so S3 pick-up is consistent
        parent_root = base_dir / f"rekognition_{int(_time.time()*1000)}"
        parent = parent_root / "view_rows"
        parent.mkdir(parents=True, exist_ok=True)
        view_dbg = parent
        root_dbg = parent_root
        click_log_path = root_dbg / "rekognition_clicks.txt"
        with open(view_dbg / "prepared_image.png", "wb") as _f:
            _f.write(image_bytes)
        import json as _json

        meta = {
            "screenshot_path": screenshot_v,
            "image_size": f"{img.size[0]}x{img.size[1]}",
            "bytes": len(image_bytes),
            "detected_links": links,
        }
        (view_dbg / "request.txt").write_text(
            _json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
        )
    except Exception:
        view_dbg = None

    # Rekognition detect_text on full image
    try:
        try:
            from ..constants import AWS_REGION as _AWS_REGION
        except Exception:
            _AWS_REGION = "us-east-1"
        rk = _boto3.client("rekognition", region_name=_AWS_REGION)
        resp = rk.detect_text(Image={"Bytes": image_bytes})
    except Exception:
        try:
            _logging.exception("[AttachFiles] Rekognition detect_text failed")
        except Exception:
            pass
        return

    iw, ih = img.size

    # Try to locate the dialog heading/anchor to bound filename searches
    anchor_xy = None
    try:
        from ..rekognition_tiler import find_text_coordinates_tiled as _tiler_find
        # Prefer 'RELATED FILES'; fall back to '+ Upload files' / 'Add from file library'
        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=str(view_dbg) if view_dbg is not None else 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
    except Exception:
        anchor_xy = None

    # Collect potential 'View' WORD points and LINE entries
    points: List[List[int]] = []
    line_entries: List[dict] = []
    try:
        for d in resp.get("TextDetections", []):
            if d.get("Type") == "WORD":
                txt = str(d.get("DetectedText") or "").strip().lower()
                if txt == "view":
                    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)
                    points.append([cx, cy])
            elif d.get("Type") == "LINE":
                line_entries.append(d)
    except Exception:
        points = []

    # Row-by-name approach using tiler over cropped bands
    matched_entries: List[dict] = []
    matched_file_indices: set = set()  # Track which file indices were successfully matched
    try:
        from ..rekognition_tiler import find_text_coordinates_tiled as _tiler_find
    except Exception:
        _tiler_find = None
    if links and _tiler_find is not None:
        for i, name in enumerate(links):
            # Prefer Rekognition tiler across the FULL image and stop at the FIRST include
            # so when the filename appears in two places (dialog vs History & Notes), we
            # click the top-most occurrence (the dialog entry) rather than the bottom one.
            try:
                # Try exact match first
                coords_fn = _tiler_find(
                    rk,
                    img,
                    query=str(name or ""),
                    is_regex=False,
                    upscale=2.0,
                    overlap_frac=0.10,
                    debug_dir=str(view_dbg / f"row_by_name_{i+1}") if view_dbg is not None else None,
                    require_include=True,
                    require_exact=True,
                    stop_at_first_include=True,
                    cols=1,
                    rows=5,
                )
                # If exact match fails, try with require_include but not require_exact (for long filenames)
                if not isinstance(coords_fn, (list, tuple)) or len(coords_fn) != 2:
                    coords_fn = _tiler_find(
                        rk,
                        img,
                        query=str(name or ""),
                        is_regex=False,
                        upscale=2.0,
                        overlap_frac=0.10,
                        debug_dir=str(view_dbg / f"row_by_name_{i+1}_flex") if view_dbg is not None else None,
                        require_include=True,
                        require_exact=False,
                        stop_at_first_include=True,
                        cols=1,
                        rows=5,
                    )
            except Exception:
                # Fallback: use the analyzer helper
                try:
                    coords_fn = analyzer.find_text_coordinates_rekognition(screenshot_v, name)
                except Exception:
                    coords_fn = None
            # If the first match is outside the dialog (e.g., browser tab title), constrain to a dialog ROI
            try:
                if (not isinstance(coords_fn, (list, tuple)) or len(coords_fn) != 2) and anchor_xy is not None:
                    # Search within a bounded region below/near the anchor
                    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))
                    local_dir = None
                    try:
                        if view_dbg is not None:
                            local_dir = view_dbg / f"row_by_name_{i+1}_roi"
                            local_dir.mkdir(parents=True, exist_ok=True)
                    except Exception:
                        local_dir = None
                    coords_local = _tiler_find(
                        rk,
                        roi,
                        query=str(name or ""),
                        is_regex=False,
                        upscale=2.0,
                        overlap_frac=0.10,
                        debug_dir=str(local_dir) if local_dir is not None else None,
                        require_include=True,
                        require_exact=True,
                        stop_at_first_include=True,
                        cols=1,
                        rows=5,
                    )
                    # If exact match fails in ROI, try flexible match
                    if not isinstance(coords_local, (list, tuple)) or len(coords_local) != 2:
                        coords_local = _tiler_find(
                            rk,
                            roi,
                            query=str(name or ""),
                            is_regex=False,
                            upscale=2.0,
                            overlap_frac=0.10,
                            debug_dir=str(local_dir / "flex") if local_dir is not None else None,
                            require_include=True,
                            require_exact=False,
                            stop_at_first_include=True,
                            cols=1,
                            rows=5,
                        )
                    if isinstance(coords_local, (list, tuple)) and len(coords_local) == 2:
                        coords_fn = [int(left + coords_local[0]), int(top + coords_local[1])]
                # If we have a coord but it is far above the anchor (likely tab/title), prefer ROI search
                if isinstance(coords_fn, (list, tuple)) and len(coords_fn) == 2 and anchor_xy is not None:
                    ax, ay = anchor_xy
                    if int(coords_fn[1]) < int(max(0, ay - 120)):
                        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))
                        local_dir = None
                        try:
                            if view_dbg is not None:
                                local_dir = view_dbg / f"row_by_name_{i+1}_roi2"
                                local_dir.mkdir(parents=True, exist_ok=True)
                        except Exception:
                            local_dir = None
                        coords_local = _tiler_find(
                            rk,
                            roi,
                            query=str(name or ""),
                            is_regex=False,
                            upscale=2.0,
                            overlap_frac=0.10,
                            debug_dir=str(local_dir) if local_dir is not None else None,
                            require_include=True,
                            require_exact=True,
                            stop_at_first_include=True,
                            cols=1,
                            rows=5,
                        )
                        # If exact match fails in ROI2, try flexible match
                        if not isinstance(coords_local, (list, tuple)) or len(coords_local) != 2:
                            coords_local = _tiler_find(
                                rk,
                                roi,
                                query=str(name or ""),
                                is_regex=False,
                                upscale=2.0,
                                overlap_frac=0.10,
                                debug_dir=str(local_dir / "flex") if local_dir is not None else None,
                                require_include=True,
                                require_exact=False,
                                stop_at_first_include=True,
                                cols=1,
                                rows=5,
                            )
                        if isinstance(coords_local, (list, tuple)) and len(coords_local) == 2:
                            coords_fn = [int(left + coords_local[0]), int(top + coords_local[1])]
            except Exception:
                pass
            # Reject accidental matches in the browser title/tab area (too high on screen)
            try:
                min_y = 100
                if anchor_xy is not None:
                    min_y = max(min_y, int(anchor_xy[1] - 80))
                if isinstance(coords_fn, (list, tuple)) and len(coords_fn) == 2:
                    if int(coords_fn[1]) < min_y:
                        coords_fn = None
            except Exception:
                pass
            if isinstance(coords_fn, (list, tuple)) and len(coords_fn) == 2:
                x = int(coords_fn[0])
                y = int(coords_fn[1])
                band = 32
                row_top = max(0, y - band)
                row_bot = min(ih, y + band)
                row_left = min(max(0, x + 8), iw)
                roi = img.crop((row_left, row_top, iw, row_bot))
                # Run tiler on the row crop for the word 'View'
                local_dir = None
                try:
                    if view_dbg is not None:
                        local_dir = view_dbg / f"row_by_name_{i+1}"
                        local_dir.mkdir(parents=True, exist_ok=True)
                except Exception:
                    local_dir = None
                try:
                    coords_local = _tiler_find(
                        rk,
                        roi,
                        query=r"(?i)\bview\b",
                        is_regex=True,
                        upscale=2.0,
                        overlap_frac=0.10,
                        debug_dir=str(local_dir) if local_dir is not None else None,
                        require_include=False,
                    )
                except Exception:
                    coords_local = None
                if isinstance(coords_local, (list, tuple)) and len(coords_local) == 2:
                    gx = int(row_left + coords_local[0])
                    gy = int(row_top + coords_local[1])
                    matched_entries.append(
                        {
                            "coords": [gx, gy],
                            "row": (row_left, row_top, iw, row_bot),
                            "fname_x": x,
                            "name": str(name),
                            "dbg_dir": str(local_dir) if local_dir is not None else None,
                            "method": "row_by_name",
                        }
                    )
                    matched_file_indices.add(i)  # Mark this file index as matched

    # Fallback: align by LINE content, then scan row crop for files that weren't matched
    unmatched_indices = [i for i in range(len(links)) if i not in matched_file_indices]
    if unmatched_indices:
        try:
            _logging.info(f"[AttachFiles] Primary method matched {len(matched_file_indices)}/{len(links)} files. Attempting fallback for {len(unmatched_indices)} unmatched file(s): {[links[i] for i in unmatched_indices]}")
        except Exception:
            pass
    if unmatched_indices and links and line_entries:
        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()

        norm_lines: List[dict] = []
        for d in line_entries:
            try:
                txt = str(d.get("DetectedText") or "")
                bb = d.get("Geometry", {}).get("BoundingBox", {})
                cx = (float(bb.get("Left", 0)) + float(bb.get("Width", 0)) / 2) * iw
                cy = (float(bb.get("Top", 0)) + float(bb.get("Height", 0)) / 2) * ih
                norm_lines.append({"norm": _soft_norm(txt), "cx": float(cx), "cy": float(cy), "det": d})
            except Exception:
                continue
        matched_lines: List[Optional[dict]] = []
        # Only process unmatched files
        for idx in unmatched_indices:
            if idx >= len(links):
                continue
            name = links[idx]
            s_norm = _soft_norm(name)
            best = None
            best_score = -1
            for ln in norm_lines:
                t = ln["norm"]
                if not t:
                    continue
                if s_norm in t or t in s_norm:
                    score = min(len(s_norm), len(t))
                    if score > best_score:
                        best_score = score
                        best = ln
            matched_lines.append(best)
        for list_idx, ln in enumerate(matched_lines):
            if not ln:
                continue
            # Map back to original file index
            idx = unmatched_indices[list_idx] if list_idx < len(unmatched_indices) else None
            if idx is None or idx >= len(links):
                continue
            try:
                bb = ln["det"].get("Geometry", {}).get("BoundingBox", {})
                top = int(float(bb.get("Top", 0)) * ih)
                height = int(float(bb.get("Height", 0)) * ih)
                left_px = int(float(bb.get("Left", 0)) * iw)
                width_px = int(float(bb.get("Width", 0)) * iw)
                right_px = left_px + width_px
                pad = max(8, int(0.025 * ih))
                row_top = max(0, top - pad)
                row_bot = min(ih, top + height + pad)
                row_h = max(24, row_bot - row_top)
                row_left = min(max(0, right_px + 8), iw)
                row_right = iw
                roi = img.crop((row_left, row_top, row_right, row_top + row_h))
                _buf = _BytesIO()
                roi.save(_buf, format="PNG")
                row_bytes = _buf.getvalue()
                r2 = rk.detect_text(Image={"Bytes": row_bytes})
                try:
                    if view_dbg is not None:
                        with open(view_dbg / f"row_{idx+1}.png", "wb") as _rf:
                            _rf.write(row_bytes)
                        import json as _json

                        with open(view_dbg / f"raw_response_row_{idx+1}.json", "w", encoding="utf-8") as _jf:
                            _jf.write(_json.dumps(r2, ensure_ascii=False, indent=2))
                except Exception:
                    pass
                cx_line = ln["cx"]
                best = None
                best_dx = -1
                for dd in r2.get("TextDetections", []):
                    if dd.get("Type") != "WORD":
                        continue
                    t = str(dd.get("DetectedText") or "").strip().lower()
                    if t != "view":
                        continue
                    bb2 = dd.get("Geometry", {}).get("BoundingBox", {})
                    cx2 = int(
                        (float(bb2.get("Left", 0)) + float(bb2.get("Width", 0)) / 2)
                        * (row_right - row_left)
                    )
                    cy2 = int((float(bb2.get("Top", 0)) + float(bb2.get("Height", 0)) / 2) * row_h)
                    gx, gy = row_left + cx2, row_top + cy2
                    dx = gx - cx_line
                    if dx >= 0 and dx > best_dx:
                        best_dx = dx
                        best = [gx, gy]
                    elif best is None and dx < 0:
                        if dx > best_dx:
                            best_dx = dx
                            best = [gx, gy]
                if best is not None:
                    matched_entries.append(
                        {
                            "coords": best,
                            "row": (row_left, row_top, row_right, row_top + row_h),
                            "fname_x": int(ln["cx"]),
                            "name": str(links[idx]) if idx < len(links) else None,
                            "dbg_dir": str(view_dbg) if view_dbg is not None else None,
                            "method": "row_line_align",
                        }
                    )
                    matched_file_indices.add(idx)  # Mark this file index as matched
            except Exception:
                continue
    elif unmatched_indices and links:
        # If line_entries is empty, log a warning but don't fail silently
        try:
            _logging.warning(f"[AttachFiles] Fallback cannot run: {len(unmatched_indices)} unmatched file(s) but no LINE entries available. Files: {[links[i] for i in unmatched_indices]}")
        except Exception:
            pass

    # Deduplicate nearby entries
    def _dedupe_entries(entries: List[dict], tol: int = 12) -> List[dict]:
        out: List[dict] = []
        for e in entries:
            p = e.get("coords") or [0, 0]
            skip = False
            for q in out:
                pq = q.get("coords") or [0, 0]
                dx = p[0] - pq[0]
                dy = p[1] - pq[1]
                if dx * dx + dy * dy <= tol * tol:
                    skip = True
                    break
            if not skip:
                out.append(e)
        return out

    matched_entries = _dedupe_entries(matched_entries)

    # Only click rows we successfully matched by filename → 'View' alignment.
    # Do NOT fall back to generic 'View' word points to avoid misclicks.
    targets = matched_entries
    
    # Log which files were matched vs unmatched
    try:
        matched_names = [t.get("name") for t in targets]
        unmatched_names = [links[i] for i in range(len(links)) if i not in matched_file_indices]
        if unmatched_names:
            _logging.warning(f"[AttachFiles] Matched {len(matched_names)}/{len(links)} files. Unmatched: {unmatched_names}")
        else:
            _logging.info(f"[AttachFiles] Successfully matched all {len(links)} files: {matched_names}")
    except Exception:
        pass
    
    if not targets:
        try:
            _logging.info(
                "[AttachFiles] No filename rows matched; skipping clicks (no fallback 'View' clicks)"
            )
        except Exception:
            pass
        return

    try:
        import pyautogui as _pg
    except Exception:
        return

    try:
        import os

        wait_after_click = float(os.getenv("SAGE_SIMPLE_CLICK_WAIT_SEC", ".1"))
    except Exception:
        wait_after_click = .1

    try:
        targets.sort(key=lambda e: (e["coords"][1], e["coords"][0]))
        _logging.info(f"[AttachFiles] Found {len(targets)} 'View' link candidate(s); clicking")
    except Exception:
        pass

    clicked_count = 0
    for idx, entry in enumerate(targets):
        cx, cy = entry["coords"][0], entry["coords"][1]
        try:
            coords = [int(cx), int(cy)]
            try:
                coords = analyzer._apply_display_scale(coords)
            except Exception:
                pass
            try:
                from ..constants import Y_CLICK_OFFSET as _Y_OFF

                coords = [int(coords[0]), int(coords[1] + int(_Y_OFF))]
            except Exception:
                pass
            restore = None
            try:
                restore = getattr(analyzer, "_temporarily_hide_app", None)
                if callable(restore):
                    restore = restore()
            except Exception:
                restore = None
            _pg.click(int(coords[0]), int(coords[1]))
        finally:
            try:
                if callable(restore):
                    restore()
            except Exception:
                pass
        clicked_count += 1

        # Log the click (file name, coords, method, and debug folder)
        try:
            if click_log_path is not None:
                click_log_path.parent.mkdir(parents=True, exist_ok=True)
                with open(click_log_path, "a", encoding="utf-8") as _lf:
                    _lf.write(
                        f"click_index={clicked_count} name='{entry.get('name')}' coords={coords} method={entry.get('method')} dbg_dir='{entry.get('dbg_dir')}'\n"
                    )
            # Also drop a small marker in the row debug dir (if available)
            if entry.get("dbg_dir"):
                try:
                    with open(_Path(entry["dbg_dir"]) / "chosen_click.txt", "w", encoding="utf-8") as _rf:
                        _rf.write(f"clicked coords={coords} name='{entry.get('name')}' method={entry.get('method')}\n")
                except Exception:
                    pass
        except Exception:
            pass

        # Attempt to trigger 'Download' if present on the subsequent view (with caching)
        try:
            _time.sleep(0.3)
        except Exception:
            pass
        try:
            from ..xero_util.attach_files_dialog_utils import click_download_link_if_present as _click_download

            _click_download(app)
            # Wait after download click
            try:
                _time.sleep(3.0)
            except Exception:
                pass
        except Exception:
            pass

        # # Try Save/Confirm modal (Sage-style) with caching
        # try:
        #     from ..xero_util.attach_files_dialog_utils import run_save_dialog_once_if_present as _save_once

        #     # _save_once(app)
        # except Exception:
            # pass

        # After Save, small wait then try to click the close 'X' (with caching)
        try:
            try:
                from ..constants import X_AFTER_SAVE_DELAY_SEC as _XWAIT

                _xwait = float(_XWAIT)
            except Exception:
                _xwait = 0.1
            try:
                _time.sleep(_xwait)
            except Exception:
                pass
            try:
                from ..xero_util.attach_files_dialog_utils import click_close_x_if_present as _click_close

                _click_close(app)
            except Exception:
                pass
        except Exception:
            pass

        try:
            _time.sleep(0.3)
        except Exception:
            pass

        # If more items remain, re-open the Files dialog via cached coords
        if idx < len(targets) - 1 and isinstance(attach_coords, (list, tuple)) and len(attach_coords) == 2:
            try:
                # Wait for any closing animations to complete
                try:
                    _time.sleep(0.1)
                except Exception:
                    pass
                # Click Files button to re-open the dialog
                _pg.moveTo(int(attach_coords[0]), int(attach_coords[1]), duration=0.2)
                _pg.click(int(attach_coords[0]), int(attach_coords[1]))
                # Wait for dialog to stabilize after re-opening
                try:
                    from ..constants import FILES_DIALOG_STABILIZE_DELAY_SEC as _STAB
                    _stab = float(_STAB)
                except Exception:
                    _stab = 0.1
                try:
                    _time.sleep(_stab)
                except Exception:
                    pass
            except Exception:
                pass

    try:
        _logging.info(f"[AttachFiles] Clicked {clicked_count} 'View' link(s)")
    except Exception:
        pass


def run_xero_attach_files_flow(playback_mgr, screenshot_path=None) -> None:
    """Run the Xero attach-files dialog flow then analyze+click attachments (no playlists).
    
    Args:
        playback_mgr: PlaybackManager instance
        screenshot_path: Optional screenshot path to reuse. If None, captures a new screenshot.
    """
    try:
        import logging as _logging
    except Exception:
        return

    app = getattr(playback_mgr, "app", None)
    if app is None:
        return

    analyzer = _get_analyzer(playback_mgr)
    if analyzer is None:
        return

    # Try clicking the Attach files / Files label via Rekognition (with cached coords first)
    try:
        clicked_label = False
        matched_label = None
        if screenshot_path is None:
            screenshot_path = analyzer.capture_full_resolution_screenshot()
        debug_parent = _create_debug_dir(analyzer)
        # If the "Attach files" label is visible, end early without clicking
        try:
            if screenshot_path:
                try:
                    from ..constants import AWS_REGION as _AWS_REGION
                except Exception:
                    _AWS_REGION = "us-east-1"
                try:
                    from ..rekognition_tiler import find_text_coordinates_tiled as _tiler_find
                    from PIL import Image as _Image
                    import boto3 as _boto3
                except Exception:
                    _tiler_find = None  # type: ignore
                if _tiler_find is not None:
                    rk = _boto3.client("rekognition", region_name=_AWS_REGION)
                    img_full = _Image.open(screenshot_path).convert("RGB")
                    coords_af = _tiler_find(
                        rk,
                        img_full,
                        query=r"(?i)\battach files\b",
                        is_regex=True,
                        upscale=2.0,
                        overlap_frac=0.10,
                        debug_dir=str(debug_parent) if debug_parent is not None else None,
                        require_include=True,
                    )
                    if isinstance(coords_af, (list, tuple)) and len(coords_af) == 2:
                        _logging.info(
                            "[AttachFiles] Detected 'Attach files' label; finishing without clicking items"
                        )
                        return
        except Exception:
            pass
        if not clicked_label:
            clicked_label, matched_label = _click_attach_files_label(analyzer, app, screenshot_path, debug_parent)
    except Exception as e:
        try:
            _logging.error(f"[Playback] Attach/files pre-check failed: {e}")
        except Exception:
            pass

    # If we did not click the label (no Rekognition match / no cached click), run fallback flows.
    if not clicked_label:
        try:
            prompt = _build_dialog_count_prompt()
        except Exception:
            prompt = ""
        try:
            _logging.info(
                "[AttachFiles] Label not clicked; running fallback/no-dialog flows"
            )
        except Exception:
            pass
        _run_post_zero_files_flows(playback_mgr, analyzer, prompt)
        return

    # If we clicked an "Attach files" label, end the process immediately (no files available yet)
    try:
        if isinstance(matched_label, str) and matched_label.lower().strip() == "attach files":
            _logging.info("[AttachFiles] Matched 'Attach files' label; finishing without clicking items")
            return
    except Exception:
        pass

    # After opening the dialog, analyze and click attachment links using the Sage finder helpers
    try:
        _analyze_and_click_attachments(playback_mgr, analyzer)
    except Exception:
        try:
            _logging.exception("[AttachFiles] Analyze+click flow failed")
        except Exception:
            pass
    finally:
        # Clear any cached coordinates so the next invoice re-detects locations
        try:
            if hasattr(app, "_cached_attach_files_coords"):
                delattr(app, "_cached_attach_files_coords")
        except Exception:
            pass
        try:
            if "analyzer" in locals() and hasattr(analyzer, "_last_rekognition_coords"):
                delattr(analyzer, "_last_rekognition_coords")
        except Exception:
            pass


