"""
Minimal Rekognition tiler: splits an image into 1x5 tiles (with small overlap),
performs LINE-first matching with WORD n-gram fallback on each tile, and returns
the strongest include match in global coordinates. Optional debug artifacts are saved to the
provided debug folder.

This mirrors the behavior of rek_clicker_line_match.py provided by the user,
kept intentionally simple and isolated.
"""

from __future__ import annotations

import re
from io import BytesIO
import json
from typing import Optional, Tuple, List

from PIL import Image, ImageDraw
try:
    from decimal import Decimal as _Decimal  # Rekognition returns Decimals in JSON
except Exception:
    _Decimal = None  # type: ignore

def _json_safe(obj):
    """Recursively convert boto Rekognition responses to JSON-serializable types.

    - Coerce Decimal to float to avoid json.dump errors.
    - Leave other types unchanged.
    """
    try:
        if _Decimal is not None and isinstance(obj, _Decimal):
            try:
                return float(obj)
            except Exception:
                return str(obj)
        if isinstance(obj, dict):
            return {k: _json_safe(v) for k, v in obj.items()}
        if isinstance(obj, list):
            return [_json_safe(v) for v in obj]
        return obj
    except Exception:
        return obj


def _norm(s: str) -> str:
    return re.sub(r"\s+", " ", (s or '').lower()).strip()


def _soft_norm(s: str) -> str:
    """Lowercase and remove punctuation; keep letters, digits, spaces.
    Example: "Less: Payment (25 Jan 2023)" -> "less payment 25 jan 2023".
    """
    s = (s or '').lower()
    s = re.sub(r"[^a-z0-9]+", " ", s)
    s = re.sub(r"\s+", " ", s).strip()
    return s


def _extract_date_norm(s: str) -> Optional[str]:
    """Return a canonical date string DDMMYYYY if a date-like pattern is found.
    Supports: 15 Feb 2023, 15/02/2023, 15-02-23, 15.02.2023
    """
    try:
        txt = (s or '').lower()
        # month-name date
        m = re.search(r"\b(\d{1,2})\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{2,4})\b", txt)
        if m:
            d = int(m.group(1)); mon = m.group(2)[:3]
            mon_map = {"jan":1,"feb":2,"mar":3,"apr":4,"may":5,"jun":6,"jul":7,"aug":8,"sep":9,"oct":10,"nov":11,"dec":12}
            mm = mon_map.get(mon, 0)
            y = int(m.group(3)); y = 2000 + y if y < 100 else y
            return f"{d:02d}{mm:02d}{y:04d}"
        # numeric date
        m2 = re.search(r"\b(\d{1,2})[./\\\-](\d{1,2})[./\\\-](\d{2,4})\b", txt)
        if m2:
            d = int(m2.group(1)); mm = int(m2.group(2)); y = int(m2.group(3)); y = 2000 + y if y < 100 else y
            return f"{d:02d}{mm:02d}{y:04d}"
        return None
    except Exception:
        return None


def _tokens(s: str) -> list[str]:
    return re.findall(r"[a-z0-9]+", (s or '').lower())


def _is_subsequence(needle_tokens: list[str], hay_tokens: list[str]) -> bool:
    if not needle_tokens:
        return False
    i = 0
    for t in hay_tokens:
        if i < len(needle_tokens) and t == needle_tokens[i]:
            i += 1
            if i == len(needle_tokens):
                return True
    return False


def _image_to_bytes(image: Image.Image) -> bytes:
    buf = BytesIO()
    image.save(buf, format='PNG')
    return buf.getvalue()


def _match_builder(query: str, is_regex: bool):
    q_norm = _norm(query)
    q_soft = _soft_norm(query)
    pattern = re.compile(query, re.I) if is_regex else None
    target_tokens = _tokens(query)

    def matches(txt: str) -> bool:
        txt_raw = txt or ''
        low_raw = txt_raw.lower()
        if pattern:
            return bool(pattern.search(txt_raw))
        # Direct raw substring (case-insensitive) – simplest "includes" rule
        if query.lower() in low_raw:
            return True
        if q_norm in _norm(txt_raw):
            return True
        # Soft normalization (ignore punctuation differences like "Less:" prefix)
        if q_soft and q_soft in _soft_norm(txt_raw):
            return True
        line_tokens = _tokens(txt_raw)
        if len(target_tokens) >= 2:
            if _is_subsequence(target_tokens, line_tokens):
                return True
            if _is_subsequence([target_tokens[0], target_tokens[-1]], line_tokens):
                return True
        else:
            return bool(target_tokens) and target_tokens[0] in line_tokens
        return False

    return matches


def find_text_coordinates_tiled(
    rek_client,
    full_img: Image.Image,
    query: str,
    is_regex: bool = False,
    upscale: float = 2.0,
    overlap_frac: float = 0.08,
    debug_dir: Optional[str] = None,
    require_include: bool = False,
    require_exact: bool = False,
    exclude_points: Optional[List[Tuple[int, int]]] = None,
    exclude_radius: int = 24,
    stop_at_first_include: bool = False,
    cols: int = 1,
    rows: int = 5,
) -> Optional[Tuple[int, int]]:
    """Search text using 1x5 tiling and return best match in global coords.

    - LINE-first, then WORD n-gram within a line (ParentId) similar to user's script.
    - Saves debug tiles to debug_dir if provided.
    """
    # Sanitize inputs
    upscale = max(1.0, float(upscale or 1.0))
    overlap_frac = max(0.0, min(float(overlap_frac or 0.0), 0.5))
    matches = _match_builder(query, is_regex)

    # Prepare grid (default 1x5)
    cols = max(1, int(cols or 1))
    rows = max(1, int(rows or 1))
    w, h = full_img.size
    tile_w = w // max(1, cols)
    tile_h = h // max(1, rows)
    ox = int(tile_w * overlap_frac)
    oy = int(tile_h * overlap_frac)

    # Optional: capture a single full-image detect_text payload for debugging
    if debug_dir:
        try:
            resp_full = rek_client.detect_text(Image={'Bytes': _image_to_bytes(full_img)})
            base = f"{debug_dir.rstrip('/').rstrip('\\')}/raw_detect_text_full"
            try:
                with open(base + ".json", 'w', encoding='utf-8') as _ff:
                    json.dump(_json_safe(resp_full), _ff, ensure_ascii=False, indent=2)
            except Exception:
                pass
            try:
                with open(base + ".txt", 'w', encoding='utf-8') as _fft:
                    _fft.write(json.dumps(_json_safe(resp_full), ensure_ascii=False, indent=2))
            except Exception:
                pass
        except Exception:
            pass

    # Collect raw responses for ALL tiles so we can persist one combined file
    tile_raw_dump = []

    # Helper to search one tile image
    # Returns: (include_best_coords, include_conf) or (None, None) and
    #          (fallback_best_coords, fallback_conf) as second tuple element
    def search_one(pil_img: Image.Image, suffix: str = "", tile_left: int = 0, tile_top: int = 0) -> Tuple[Optional[Tuple[int, int]], Optional[float], Optional[Tuple[int, int]], Optional[float], List[dict]]:
        up = pil_img
        scale = upscale
        if scale > 1.0:
            try:
                up = pil_img.resize((int(pil_img.width * scale), int(pil_img.height * scale)), Image.LANCZOS)
            except Exception:
                up = pil_img
                scale = 1.0
        resp = rek_client.detect_text(Image={'Bytes': _image_to_bytes(up)})
        try:
            tile_raw_dump.append({
                'suffix': suffix or '',
                'tile_left': int(tile_left),
                'tile_top': int(tile_top),
                'upscale': scale,
                'width': int(up.size[0]),
                'height': int(up.size[1]),
                'response': _json_safe(resp),
            })
        except Exception:
            pass
        # Save raw Rekognition response for this tile
        if debug_dir:
            try:
                base = f"{debug_dir.rstrip('/').rstrip('\\')}/raw_response{suffix or ''}"
                with open(base + ".json", 'w', encoding='utf-8') as f:
                    json.dump(_json_safe(resp), f, ensure_ascii=False, indent=2)
                # Also write a .txt copy for environments that only upload .txt files
                try:
                    with open(base + ".txt", 'w', encoding='utf-8') as f2:
                        f2.write(json.dumps(_json_safe(resp), ensure_ascii=False, indent=2))
                except Exception:
                    pass
            except Exception:
                pass
        iw, ih = up.size
        # LINE pass: prefer any line match; if require_exact, only accept exact-equal line
        lines = [d for d in resp.get('TextDetections', []) if d.get('Type') == 'LINE']
        q_low = (query or '').lower()
        q_soft = _soft_norm(query)
        include_lines = []
        for d in lines:
            txt = str(d.get('DetectedText') or '')
            low = txt.lower()
            soft = _soft_norm(txt)
            if require_exact:
                if (q_low and q_low == low) or (q_soft and q_soft == soft):
                    include_lines.append(d)
            else:
                if (q_low and q_low in low) or (q_soft and q_soft in soft):
                    include_lines.append(d)
        include_best = None
        include_conf = None
        if include_lines:
            # Choose the first include whose GLOBAL center is not near any excluded point
            chosen_line = None
            for cand in include_lines:
                try:
                    bb = cand['Geometry']['BoundingBox']
                    cx = int((bb['Left'] + bb['Width'] / 2) * iw)
                    cy = int((bb['Top'] + bb['Height'] / 2) * ih)
                    gx = tile_left + int(cx / scale)
                    gy = tile_top + int(cy / scale)
                    if not _is_near(gx, gy):
                        chosen_line = cand
                        break
                except Exception:
                    continue
            if chosen_line is None:
                # No eligible include in this tile
                include_best = None
                include_conf = None
            else:
                bb = chosen_line['Geometry']['BoundingBox']
                cx = int((bb['Left'] + bb['Width'] / 2) * iw)
                cy = int((bb['Top'] + bb['Height'] / 2) * ih)
                include_best = (int(cx / scale), int(cy / scale))
                include_conf = float(chosen_line.get('Confidence', 0) or 0)
                if debug_dir:
                    try:
                        with open(f"{debug_dir.rstrip('/').rstrip('\\')}/best_match_line{suffix or ''}.json", 'w', encoding='utf-8') as f:
                            json.dump(_json_safe(chosen_line), f, ensure_ascii=False, indent=2)
                    except Exception:
                        pass
                # If configured, return immediately on the first include found in this tile
                if stop_at_first_include:
                    return include_best, include_conf, None, None, resp.get('TextDetections', [])
        # Multi-line LINE pass: try concatenating adjacent lines (2-3) to match queries that wrap
        if include_best is None and lines:
            try:
                # Sort by reading order: top, then left
                def _line_key(d):
                    bb = d.get('Geometry', {}).get('BoundingBox', {})
                    return (float(bb.get('Top', 0) or 0), float(bb.get('Left', 0) or 0))
                sorted_lines = sorted(lines, key=_line_key)
                for win in (2, 3):
                    if len(sorted_lines) < win:
                        continue
                    for i in range(0, len(sorted_lines) - win + 1):
                        grp = sorted_lines[i:i+win]
                        try:
                            # Enforce spatial adjacency to avoid random non-wrapping matches
                            def _bbox(dct):
                                b = dct.get('Geometry', {}).get('BoundingBox', {})
                                return (
                                    float(b.get('Left', 0) or 0),
                                    float(b.get('Top', 0) or 0),
                                    float(b.get('Width', 0) or 0),
                                    float(b.get('Height', 0) or 0),
                                )
                            ok_spatial = True
                            for a, b in zip(grp, grp[1:]):
                                l1, t1, w1, h1 = _bbox(a)
                                l2, t2, w2, h2 = _bbox(b)
                                r1 = l1 + w1; r2 = l2 + w2
                                # Horizontal overlap ratio relative to min width
                                overlap = max(0.0, min(r1, r2) - max(l1, l2))
                                minw = max(1e-6, min(w1, w2))
                                horiz_ok = (overlap / minw) >= 0.35
                                # Vertical distance threshold relative to avg height
                                vgap = abs(t2 - t1)
                                avg_h = max(1e-6, (h1 + h2) / 2.0)
                                vert_ok = vgap <= (1.5 * avg_h)
                                if not (horiz_ok and vert_ok):
                                    ok_spatial = False
                                    break
                            if not ok_spatial:
                                continue
                            combined_text = ' '.join([str(g.get('DetectedText') or '') for g in grp])
                            low = (combined_text or '').lower()
                            soft = _soft_norm(combined_text)
                            is_match = False
                            if require_exact:
                                if (q_low and q_low == low) or (q_soft and q_soft == soft):
                                    is_match = True
                            else:
                                if (q_low and q_low in low) or (q_soft and (soft and q_soft in soft)):
                                    is_match = True
                            if not is_match:
                                continue
                            # Union bounding box across lines (pixel coords in up image space)
                            xs = []; ys = []; xe = []; ye = []
                            for cand in grp:
                                bb = cand['Geometry']['BoundingBox']
                                xs.append(bb['Left'] * iw)
                                ys.append(bb['Top'] * ih)
                                xe.append((bb['Left'] + bb['Width']) * iw)
                                ye.append((bb['Top'] + bb['Height']) * ih)
                            left_px, top_px, right_px, bottom_px = min(xs), min(ys), max(xe), max(ye)
                            cx = int(((left_px + right_px) / 2) / max(1.0, scale))
                            cy = int(((top_px + bottom_px) / 2) / max(1.0, scale))
                            gx = tile_left + cx
                            gy = tile_top + cy
                            if not _is_near(gx, gy):
                                include_best = (cx, cy)
                                # Use average confidence across group as a proxy
                                include_conf = float(sum(float(c.get('Confidence', 0) or 0) for c in grp)) / float(len(grp))
                                if debug_dir:
                                    try:
                                        with open(f"{debug_dir.rstrip('/').rstrip('\\')}/best_match_multiline{suffix or ''}.json", 'w', encoding='utf-8') as f:
                                            json.dump(_json_safe({
                                                'combined_text': combined_text,
                                                'window': win,
                                                'group': grp,
                                                'spatial': 'ok',
                                            }), f, ensure_ascii=False, indent=2)
                                    except Exception:
                                        pass
                                if stop_at_first_include:
                                    return include_best, include_conf, None, None, resp.get('TextDetections', [])
                                # Break out once we find the first valid multi-line include
                                raise StopIteration
                        except Exception:
                            continue
            except StopIteration:
                pass

        # record fallback (highest-confidence line in this tile) even if include exists
        fallback_line = max(lines, key=lambda d: float(d.get('Confidence', 0) or 0), default=None)
        fallback_best = None
        fallback_conf = None
        if fallback_line is not None:
            bb = fallback_line['Geometry']['BoundingBox']
            cx = int((bb['Left'] + bb['Width'] / 2) * iw)
            cy = int((bb['Top'] + bb['Height'] / 2) * ih)
            fallback_best = (int(cx / scale), int(cy / scale))
            fallback_conf = float(fallback_line.get('Confidence', 0) or 0)
        if include_best is not None:
            return include_best, include_conf, fallback_best, fallback_conf, resp.get('TextDetections', [])
        # WORD n-gram pass grouped by ParentId
        from collections import defaultdict
        words_by_parent = defaultdict(list)
        for d in resp.get('TextDetections', []):
            if d.get('Type') == 'WORD':
                words_by_parent[d.get('ParentId')].append(d)
        for _, words in words_by_parent.items():
            try:
                words.sort(key=lambda w: w['Geometry']['BoundingBox']['Left'])
            except Exception:
                pass
            tokens = [w['DetectedText'] for w in words]
            for i in range(len(tokens)):
                acc = []
                for j in range(i, min(i + 8, len(tokens))):
                    acc.append(tokens[j])
                    phrase = ' '.join(acc)
                    soft_eq = _soft_norm(phrase) == _soft_norm(query)
                    exact_eq = (phrase or '').lower() == (query or '').lower()
                    # When require_exact is True, accept exact OR soft-normalized equality
                    if (require_exact and (exact_eq or soft_eq)) or (not require_exact and matches(phrase)):
                        xs, ys, xe, ye = [], [], [], []
                        for k in range(i, j + 1):
                            bb = words[k]['Geometry']['BoundingBox']
                            xs.append(bb['Left'] * iw)
                            ys.append(bb['Top'] * ih)
                            xe.append((bb['Left'] + bb['Width']) * iw)
                            ye.append((bb['Top'] + bb['Height']) * ih)
                        left, top, right, bottom = min(xs), min(ys), max(xe), max(ye)
                        cx = (left + right) / 2
                        cy = (top + bottom) / 2
                        if debug_dir:
                            try:
                                best_words = {
                                    "phrase": phrase,
                                    "indices": [list(range(i, j + 1))],
                                    "words": words,
                                }
                                with open(f"{debug_dir.rstrip('/').rstrip('\\')}/best_match_words{suffix or ''}.json", 'w', encoding='utf-8') as f:
                                    json.dump(_json_safe(best_words), f, ensure_ascii=False, indent=2)
                            except Exception:
                                pass
                        # Treat WORD phrase as include. If it's an exact phrase match, boost confidence
                        gx = tile_left + int(cx / scale)
                        gy = tile_top + int(cy / scale)
                        if not _is_near(gx, gy):
                            word_conf = 95.0 if (exact_eq or soft_eq or require_exact) else 70.0
                            return (int(cx / scale), int(cy / scale)), word_conf, fallback_best, fallback_conf, resp.get('TextDetections', [])
        # Cross-line WORD stitching: scan all WORD tokens in reading order (Top, Left)
        try:
            words_all = [d for d in resp.get('TextDetections', []) if d.get('Type') == 'WORD']
            def _w_key(wd):
                bb = wd.get('Geometry', {}).get('BoundingBox', {})
                return (float(bb.get('Top', 0) or 0), float(bb.get('Left', 0) or 0))
            words_all.sort(key=_w_key)
            max_span = 12
            for i in range(len(words_all)):
                acc_texts = []
                acc_boxes = []
                for j in range(i, min(i + max_span, len(words_all))):
                    wd = words_all[j]
                    acc_texts.append(str(wd.get('DetectedText') or ''))
                    acc_boxes.append(wd.get('Geometry', {}).get('BoundingBox', {}))
                    phrase = ' '.join(acc_texts)
                    soft_eq = _soft_norm(phrase) == _soft_norm(query)
                    exact_eq = (phrase or '').lower() == (query or '').lower()
                    if (require_exact and (exact_eq or soft_eq)) or (not require_exact and matches(phrase)):
                        xs, ys, xe, ye = [], [], [], []
                        for bb in acc_boxes:
                            xs.append((bb.get('Left', 0) or 0) * iw)
                            ys.append((bb.get('Top', 0) or 0) * ih)
                            xe.append(((bb.get('Left', 0) or 0) + (bb.get('Width', 0) or 0)) * iw)
                            ye.append(((bb.get('Top', 0) or 0) + (bb.get('Height', 0) or 0)) * ih)
                        left, top, right, bottom = min(xs), min(ys), max(xe), max(ye)
                        cx = (left + right) / 2
                        cy = (top + bottom) / 2
                        gx = tile_left + int(cx / scale)
                        gy = tile_top + int(cy / scale)
                        if not _is_near(gx, gy):
                            if debug_dir:
                                try:
                                    with open(f"{debug_dir.rstrip('/').rstrip('\\')}/best_match_words_crossline{suffix or ''}.json", 'w', encoding='utf-8') as f:
                                        json.dump(_json_safe({
                                            'phrase': phrase,
                                            'start_index': i,
                                            'end_index': j,
                                        }), f, ensure_ascii=False, indent=2)
                                except Exception:
                                    pass
                            word_conf = 95.0 if (exact_eq or soft_eq or require_exact) else 70.0
                            return (int(cx / scale), int(cy / scale)), word_conf, fallback_best, fallback_conf, resp.get('TextDetections', [])
        except Exception:
            pass
        return None, None, fallback_best, fallback_conf, resp.get('TextDetections', [])

    first_coords: Optional[Tuple[int, int]] = None
    def _is_near(px: int, py: int) -> bool:
        try:
            pts = exclude_points or []
            r2 = max(1, int(exclude_radius)) ** 2
            for ex, ey in pts:
                dx = int(px) - int(ex)
                dy = int(py) - int(ey)
                if dx * dx + dy * dy <= r2:
                    return True
            return False
        except Exception:
            return False
    best_include_conf: float = -1.0
    best_include_coords: Optional[Tuple[int, int]] = None
    best_fallback_conf: float = -1.0
    best_fallback_coords: Optional[Tuple[int, int]] = None
    # Optional run summary for debugging/analysis
    debug_summary = None
    if debug_dir:
        try:
            debug_summary = {
                "grid": {"cols": cols, "rows": rows},
                "overlap_frac": overlap_frac,
                "upscale": upscale,
                "query": query,
                "is_regex": bool(is_regex),
                "require_include": bool(require_include),
                "require_exact": bool(require_exact),
                "stop_at_first_include": bool(stop_at_first_include),
                "exclude_radius": int(exclude_radius),
                "tiles": [],
                "selected": None,
            }
        except Exception:
            debug_summary = None

    for r in range(rows):
        for c in range(cols):
            left_nom = c * tile_w
            top_nom = r * tile_h
            left = max(0, left_nom - (ox if c > 0 else 0))
            top = max(0, top_nom - (oy if r > 0 else 0))
            right = min(w, (w if c == cols - 1 else (left_nom + tile_w + (ox if c < cols - 1 else 0))))
            bottom = min(h, (h if r == rows - 1 else (top_nom + tile_h + (oy if r < rows - 1 else 0))))
            if right <= left or bottom <= top:
                continue
            box = (left, top, right, bottom)
            tile = full_img.crop(box)

            # Debug: save tile
            if debug_dir:
                try:
                    path = f"{debug_dir.rstrip('/').rstrip('\\')}/tile_{c+1}_{r+1}.png"
                    tile.save(path)
                except Exception:
                    pass

            inc, inc_conf, fb, fb_conf, _ = search_one(tile, suffix=f"_tile_{c+1}_{r+1}", tile_left=left, tile_top=top)
            if inc is not None and isinstance(inc, tuple):
                gx, gy = left + inc[0], top + inc[1]
                # Skip if near an excluded point
                if not _is_near(gx, gy):
                    # Prefer the include with highest confidence across all tiles
                    if stop_at_first_include:
                        if debug_summary is not None:
                            try:
                                debug_summary["tiles"].append({
                                    "tile": [c + 1, r + 1],
                                    "include": {"coords_global": [gx, gy], "conf": float(inc_conf or 0)},
                                    "fallback": {"coords_global": [left + (fb[0] if fb else 0), top + (fb[1] if fb else 0)]} if fb else None,
                                    "near_excluded": False,
                                })
                                debug_summary["selected"] = {"tile": [c + 1, r + 1], "coords": [gx, gy], "reason": "first_include"}
                                import json as _json
                                base = f"{debug_dir.rstrip('/').rstrip('\\')}/summary"
                                with open(base + ".json", 'w', encoding='utf-8') as _sf:
                                    _sf.write(_json.dumps(debug_summary, ensure_ascii=False, indent=2))
                                try:
                                    with open(base + ".txt", 'w', encoding='utf-8') as _sft:
                                        _sft.write(_json.dumps(debug_summary, ensure_ascii=False, indent=2))
                                except Exception:
                                    pass
                            except Exception:
                                pass
                        return (gx, gy)
                    if float(inc_conf or 0) >= best_include_conf:
                        best_include_conf = float(inc_conf or 0)
                        best_include_coords = (gx, gy)
                if debug_summary is not None:
                    try:
                        debug_summary["tiles"].append({
                            "tile": [c + 1, r + 1],
                            "include": {"coords_global": [gx, gy], "conf": float(inc_conf or 0)},
                            "fallback": {"coords_global": [left + (fb[0] if fb else 0), top + (fb[1] if fb else 0)]} if fb else None,
                            "near_excluded": True,
                        })
                    except Exception:
                        pass
            if fb is not None and isinstance(fb, tuple):
                gx, gy = left + fb[0], top + fb[1]
                if not _is_near(gx, gy):
                    if float(fb_conf or 0) > best_fallback_conf:
                        best_fallback_conf = float(fb_conf or 0)
                        best_fallback_coords = (gx, gy)
                if debug_summary is not None:
                    try:
                        debug_summary["tiles"].append({
                            "tile": [c + 1, r + 1],
                            "include": None,
                            "fallback": {"coords_global": [gx, gy], "conf": float(fb_conf or 0)},
                            "near_excluded": False,
                        })
                    except Exception:
                        pass

    # If strict include is required, do not return fallback
    if require_include:
        final_coords = best_include_coords
    else:
        # Choose best include across all tiles; else highest-confidence fallback
        final_coords = best_include_coords if best_include_coords is not None else best_fallback_coords

    # Optional debug overlay and decision summary for final coords
    if debug_dir and final_coords is not None:
        try:
            overlay = full_img.copy()
            draw = ImageDraw.Draw(overlay)
            x, y = final_coords
            r = 12
            draw.line([(x - 20, y), (x + 20, y)], fill=(255, 0, 0), width=3)
            draw.line([(x, y - 20), (x, y + 20)], fill=(255, 0, 0), width=3)
            draw.ellipse([(x - r, y - r), (x + r, y + r)], outline=(255, 0, 0), width=3)
            overlay.save(f"{debug_dir.rstrip('/').rstrip('\\')}/overlay.png")
        except Exception:
            pass
    if debug_dir:
        try:
            import json as _json
            if debug_summary is None:
                debug_summary = {}
            debug_summary["final_coords"] = list(final_coords) if final_coords is not None else None
            if best_include_coords is not None:
                debug_summary.setdefault("best_include", {})["coords"] = list(best_include_coords)
                debug_summary["best_include"]["conf"] = float(best_include_conf)
            if best_fallback_coords is not None:
                debug_summary.setdefault("best_fallback", {})["coords"] = list(best_fallback_coords)
                debug_summary["best_fallback"]["conf"] = float(best_fallback_conf)
            base = f"{debug_dir.rstrip('/').rstrip('\\')}/summary"
            with open(base + ".json", 'w', encoding='utf-8') as _sf:
                _sf.write(_json.dumps(debug_summary, ensure_ascii=False, indent=2))
            try:
                with open(base + ".txt", 'w', encoding='utf-8') as _sft:
                    _sft.write(_json.dumps(debug_summary, ensure_ascii=False, indent=2))
            except Exception:
                pass
            # Write a combined raw payload for all tiles to a single file for easy S3 viewing
            try:
                with open(f"{debug_dir.rstrip('/').rstrip('\\')}/raw_detect_text_all_tiles.json", 'w', encoding='utf-8') as _af:
                    _af.write(_json.dumps(tile_raw_dump, ensure_ascii=False, indent=2))
            except Exception:
                pass
            try:
                with open(f"{debug_dir.rstrip('/').rstrip('\\')}/raw_detect_text_all_tiles.txt", 'w', encoding='utf-8') as _aft:
                    _aft.write(_json.dumps(tile_raw_dump, ensure_ascii=False, indent=2))
            except Exception:
                pass
        except Exception:
            pass

    return final_coords


def build_tile_detect_text_cache(
    rek_client,
    full_img: Image.Image,
    upscale: float = 2.0,
    overlap_frac: float = 0.08,
    cols: int = 1,
    rows: int = 5,
    debug_dir: Optional[str] = None,
) -> List[dict]:
    """Precompute Rekognition detect_text for all tiles once and return raw tile responses.

    Each entry contains:
    - tile_left, tile_top: top-left of tile in full image coordinates
    - upscale: scale factor applied to the tile image
    - width, height: dimensions of the (possibly upscaled) tile image passed to Rekognition
    - response: JSON-safe Rekognition response (TextDetections)
    """
    upscale = max(1.0, float(upscale or 1.0))
    overlap_frac = max(0.0, min(float(overlap_frac or 0.0), 0.5))
    cols = max(1, int(cols or 1))
    rows = max(1, int(rows or 1))
    w, h = full_img.size
    tile_w = w // max(1, cols)
    tile_h = h // max(1, rows)
    ox = int(tile_w * overlap_frac)
    oy = int(tile_h * overlap_frac)
    cache: List[dict] = []
    # Optional: persist a full-image detect_text payload for debugging
    if debug_dir:
        try:
            resp_full = rek_client.detect_text(Image={'Bytes': _image_to_bytes(full_img)})
            base = f"{debug_dir.rstrip('/').rstrip('\\')}/raw_detect_text_full"
            try:
                with open(base + ".json", 'w', encoding='utf-8') as _ff:
                    json.dump(_json_safe(resp_full), _ff, ensure_ascii=False, indent=2)
            except Exception:
                pass
            try:
                with open(base + ".txt", 'w', encoding='utf-8') as _fft:
                    _fft.write(json.dumps(_json_safe(resp_full), ensure_ascii=False, indent=2))
            except Exception:
                pass
        except Exception:
            pass
    for r in range(rows):
        for c in range(cols):
            left_nom = c * tile_w
            top_nom = r * tile_h
            left = max(0, left_nom - (ox if c > 0 else 0))
            top = max(0, top_nom - (oy if r > 0 else 0))
            right = min(w, (w if c == cols - 1 else (left_nom + tile_w + (ox if c < cols - 1 else 0))))
            bottom = min(h, (h if r == rows - 1 else (top_nom + tile_h + (oy if r < rows - 1 else 0))))
            if right <= left or bottom <= top:
                continue
            box = (left, top, right, bottom)
            tile = full_img.crop(box)
            up = tile
            scale = upscale
            if scale > 1.0:
                try:
                    up = tile.resize((int(tile.width * scale), int(tile.height * scale)), Image.LANCZOS)
                except Exception:
                    up = tile
                    scale = 1.0
            resp = None
            try:
                resp = rek_client.detect_text(Image={'Bytes': _image_to_bytes(up)})
            except Exception:
                resp = {"TextDetections": []}
            entry = {
                'tile_left': int(left),
                'tile_top': int(top),
                'upscale': float(scale),
                'width': int(up.size[0]),
                'height': int(up.size[1]),
                'response': _json_safe(resp),
                'col': int(c + 1),
                'row': int(r + 1),
            }
            cache.append(entry)
            if debug_dir:
                try:
                    base = f"{debug_dir.rstrip('/').rstrip('\\')}/raw_response_tile_{c+1}_{r+1}"
                    with open(base + ".json", 'w', encoding='utf-8') as f:
                        json.dump(_json_safe(resp), f, ensure_ascii=False, indent=2)
                    try:
                        with open(base + ".txt", 'w', encoding='utf-8') as f2:
                            f2.write(json.dumps(_json_safe(resp), ensure_ascii=False, indent=2))
                    except Exception:
                        pass
                except Exception:
                    pass
    # Also store combined dump for convenience
    if debug_dir:
        try:
            base = f"{debug_dir.rstrip('/').rstrip('\\')}/raw_detect_text_all_tiles"
            with open(base + ".json", 'w', encoding='utf-8') as _af:
                json.dump(cache, _af, ensure_ascii=False, indent=2)
            try:
                with open(base + ".txt", 'w', encoding='utf-8') as _aft:
                    _aft.write(json.dumps(cache, ensure_ascii=False, indent=2))
            except Exception:
                pass
        except Exception:
            pass
    return cache


def find_text_coordinates_from_tile_cache(
    full_img: Image.Image,
    tile_cache: List[dict],
    query: str,
    *,
    is_regex: bool = False,
    require_include: bool = False,
    require_exact: bool = False,
    exclude_points: Optional[List[Tuple[int, int]]] = None,
    exclude_radius: int = 24,
    stop_at_first_include: bool = False,
    debug_dir: Optional[str] = None,
) -> Optional[Tuple[int, int]]:
    """Search coordinates using precomputed tile detect_text responses (no Rekognition calls)."""
    matches = _match_builder(query, is_regex)
    w, h = full_img.size
    def _is_near(px: int, py: int) -> bool:
        try:
            pts = exclude_points or []
            r2 = max(1, int(exclude_radius)) ** 2
            for ex, ey in pts:
                dx = int(px) - int(ex)
                dy = int(py) - int(ey)
                if dx * dx + dy * dy <= r2:
                    return True
            return False
        except Exception:
            return False

    best_include_conf: float = -1.0
    best_include_coords: Optional[Tuple[int, int]] = None
    best_fallback_conf: float = -1.0
    best_fallback_coords: Optional[Tuple[int, int]] = None

    q_low = (query or '').lower()
    q_soft = _soft_norm(query)

    for entry in tile_cache:
        try:
            left = int(entry.get('tile_left', 0))
            top = int(entry.get('tile_top', 0))
            iw = int(entry.get('width', 0))
            ih = int(entry.get('height', 0))
            scale = float(entry.get('upscale', 1.0) or 1.0)
            resp = entry.get('response') or {}
            lines = [d for d in (resp.get('TextDetections') or []) if d.get('Type') == 'LINE']
            words = [d for d in (resp.get('TextDetections') or []) if d.get('Type') == 'WORD']
            include_best = None
            include_conf = None
            include_lines = []
            # Search LINE types first
            for d in lines:
                txt = str(d.get('DetectedText') or '')
                low = txt.lower()
                soft = _soft_norm(txt)
                if require_exact:
                    if (q_low and q_low == low) or (q_soft and q_soft == soft):
                        include_lines.append(d)
                else:
                    if (q_low and q_low in low) or (q_soft and (q_soft in soft)):
                        include_lines.append(d)
            # Also search WORD types (e.g., standalone "Files" word)
            include_words = []
            for d in words:
                txt = str(d.get('DetectedText') or '')
                low = txt.lower()
                soft = _soft_norm(txt)
                if require_exact:
                    if (q_low and q_low == low) or (q_soft and q_soft == soft):
                        include_words.append(d)
                else:
                    # Use the match function which handles both regex and substring matching
                    if matches(txt):
                        include_words.append(d)
            chosen_line = None
            # Try LINE matches first
            for cand in include_lines:
                try:
                    bb = cand['Geometry']['BoundingBox']
                    cx = int((bb['Left'] + bb['Width'] / 2) * iw)
                    cy = int((bb['Top'] + bb['Height'] / 2) * ih)
                    gx = left + int(cx / max(1.0, scale))
                    gy = top + int(cy / max(1.0, scale))
                    if not _is_near(gx, gy):
                        chosen_line = cand
                        break
                except Exception:
                    continue
            # If no LINE match, try WORD matches
            if chosen_line is None:
                for cand in include_words:
                    try:
                        bb = cand['Geometry']['BoundingBox']
                        cx = int((bb['Left'] + bb['Width'] / 2) * iw)
                        cy = int((bb['Top'] + bb['Height'] / 2) * ih)
                        gx = left + int(cx / max(1.0, scale))
                        gy = top + int(cy / max(1.0, scale))
                        if not _is_near(gx, gy):
                            chosen_line = cand
                            break
                    except Exception:
                        continue
            if chosen_line is not None:
                bb = chosen_line['Geometry']['BoundingBox']
                cx = int((bb['Left'] + bb['Width'] / 2) * iw)
                cy = int((bb['Top'] + bb['Height'] / 2) * ih)
                include_best = (int(cx / max(1.0, scale)), int(cy / max(1.0, scale)))
                include_conf = float(chosen_line.get('Confidence', 0) or 0)
                if stop_at_first_include:
                    return (left + include_best[0], top + include_best[1])
            # Fallback: highest-confidence line or word
            fallback_line = max(lines, key=lambda d: float(d.get('Confidence', 0) or 0), default=None)
            fallback_word = max(words, key=lambda d: float(d.get('Confidence', 0) or 0), default=None)
            fallback_best = None
            fallback_conf = None
            # Prefer LINE over WORD for fallback
            fallback_cand = fallback_line if fallback_line is not None else fallback_word
            if fallback_cand is not None:
                bb = fallback_cand['Geometry']['BoundingBox']
                cx = int((bb['Left'] + bb['Width'] / 2) * iw)
                cy = int((bb['Top'] + bb['Height'] / 2) * ih)
                fallback_best = (int(cx / max(1.0, scale)), int(cy / max(1.0, scale)))
                fallback_conf = float(fallback_cand.get('Confidence', 0) or 0)
            if include_best is not None:
                gx = left + include_best[0]
                gy = top + include_best[1]
                if not _is_near(gx, gy):
                    if stop_at_first_include:
                        return (gx, gy)
                    if float(include_conf or 0) >= best_include_conf:
                        best_include_conf = float(include_conf or 0)
                        best_include_coords = (gx, gy)
            if fallback_best is not None:
                gx = left + fallback_best[0]
                gy = top + fallback_best[1]
                if not _is_near(gx, gy):
                    if float(fallback_conf or 0) > best_fallback_conf:
                        best_fallback_conf = float(fallback_conf or 0)
                        best_fallback_coords = (gx, gy)
        except Exception:
            continue

    final_coords = best_include_coords if (require_include or best_include_coords is not None) else best_fallback_coords
    # Optional overlay for cache path
    if debug_dir and final_coords is not None:
        try:
            overlay = full_img.copy()
            draw = ImageDraw.Draw(overlay)
            x, y = final_coords
            r = 12
            draw.line([(x - 20, y), (x + 20, y)], fill=(255, 0, 0), width=3)
            draw.line([(x, y - 20), (x, y + 20)], fill=(255, 0, 0), width=3)
            draw.ellipse([(x - r, y - r), (x + r, y + r)], outline=(255, 0, 0), width=3)
            overlay.save(f"{debug_dir.rstrip('/').rstrip('\\')}/overlay_cached.png")
        except Exception:
            pass
    return final_coords


def find_exact_word_coordinates(
    rek_client,
    full_img: Image.Image,
    target_word: str,
    min_confidence: float = 90.0,
    debug_dir: Optional[str] = None,
) -> Optional[Tuple[int, int]]:
    """Return center coordinates of the exact WORD equal to target_word (case-insensitive)
    only if Rekognition reports confidence >= min_confidence. Searches the full image (no tiling).
    """
    try:
        up = full_img
        resp = rek_client.detect_text(Image={'Bytes': _image_to_bytes(up)})
        iw, ih = up.size
        tw = (target_word or '').strip().lower()
        if not tw:
            return None
        best = None
        best_conf = -1.0
        for d in resp.get('TextDetections', []):
            if d.get('Type') != 'WORD':
                continue
            txt = str(d.get('DetectedText') or '').strip()
            if txt.lower() == tw:
                conf = float(d.get('Confidence', 0) or 0)
                if conf >= min_confidence and conf > best_conf:
                    bb = d['Geometry']['BoundingBox']
                    cx = int((bb['Left'] + bb['Width'] / 2) * iw)
                    cy = int((bb['Top'] + bb['Height'] / 2) * ih)
                    best = (cx, cy)
                    best_conf = conf
        if debug_dir:
            try:
                with open(f"{debug_dir.rstrip('/').rstrip('\\')}/exact_word_debug.json", 'w', encoding='utf-8') as f:
                    json.dump({"target": target_word, "min_conf": min_confidence, "found_conf": best_conf}, f, ensure_ascii=False, indent=2)
            except Exception:
                pass
        return best
    except Exception:
        return None


