def apply_image_redaction(image, entities, word_boxes=None):
    """Black out image regions that contain detected PII entities.

    Args:
        image: PIL Image (RGB).
        entities: list of dicts with 'start' and 'end' char offsets into the OCR text.
        word_boxes: optional list of dicts produced by build_word_boxes(); when None
                    the image is returned unchanged (no OCR data available).
    """
    if not entities or not word_boxes:
        return image

    try:
        from PIL import ImageDraw
    except ImportError:
        return image

    img = image.copy()
    draw = ImageDraw.Draw(img)

    for entity in entities:
        e_start = int(entity["start"])
        e_end = int(entity["end"])
        for box in word_boxes:
            w_start = box["char_start"]
            w_end = box["char_end"]
            # redact word box if it overlaps the entity span
            if w_start < e_end and w_end > e_start:
                x, y, w, h = box["left"], box["top"], box["width"], box["height"]
                draw.rectangle([x, y, x + w, y + h], fill="black")

    return img


def apply_face_blur(image, enabled=False, blur_strength=23):
    """Blur detected face regions using OpenCV Haar cascades.

    If OpenCV/Numpy are unavailable, or no faces are found, the original image
    is returned unchanged.
    """
    if not enabled:
        return image

    try:
        import cv2
        import numpy as np
        from PIL import Image
    except ImportError:
        return image

    rgb = image.convert("RGB")
    arr = np.array(rgb)
    bgr = cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)

    cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
    face_cascade = cv2.CascadeClassifier(cascade_path)
    if face_cascade.empty():
        return image

    faces = face_cascade.detectMultiScale(
        gray,
        scaleFactor=1.1,
        minNeighbors=5,
        minSize=(30, 30),
    )
    if len(faces) == 0:
        return image

    kernel = max(3, int(blur_strength))
    if kernel % 2 == 0:
        kernel += 1

    out = bgr.copy()
    for x, y, w, h in faces:
        roi = out[y:y + h, x:x + w]
        if roi.size == 0:
            continue
        out[y:y + h, x:x + w] = cv2.GaussianBlur(roi, (kernel, kernel), 0)

    rgb_out = cv2.cvtColor(out, cv2.COLOR_BGR2RGB)
    return Image.fromarray(rgb_out)


def apply_signature_blur(image, enabled=False, blur_strength=23, word_boxes=None, source_image=None):
    """Blur likely handwritten signature regions using contour heuristics.

    Signatures are detected as clusters of dark ink strokes that are NOT
    covered by OCR-recognized words.  Passing word_boxes (from build_word_boxes)
    is strongly recommended — any candidate region that overlaps with a
    recognized word box is treated as printed text and skipped.

    source_image: if provided, contour detection runs on this image (e.g. the
    original before text redaction) while blur is applied to `image`.  This
    prevents black redaction patches from corrupting Otsu thresholding.

    OpenCV is required; if unavailable the original image is returned unchanged.
    """
    if not enabled:
        return image

    try:
        import cv2
        import numpy as np
        from PIL import Image
    except ImportError:
        return image

    # Detection runs on source_image (original) if supplied; blur target is image.
    detect_img = (source_image if source_image is not None else image).convert("RGB")
    detect_arr = np.array(detect_img)
    detect_bgr = cv2.cvtColor(detect_arr, cv2.COLOR_RGB2BGR)
    gray = cv2.cvtColor(detect_bgr, cv2.COLOR_BGR2GRAY)

    img_h, img_w = gray.shape
    img_area = img_h * img_w

    # Threshold: isolate dark ink on light background (on original, not redacted)
    _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

    # Blur target array (may differ from detect_img if redacted)
    rgb = image.convert("RGB")
    arr = np.array(rgb)

    # Dilate to merge nearby strokes into a single blob.
    # Small kernel / single iteration avoids merging dense card content.
    dil_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (10, 5))
    dilated = cv2.dilate(thresh, dil_kernel, iterations=1)

    contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    # Pass 1: layout-context detection — find "Firma/Signature/Unterschrift" labels
    # and blur the ink region to their right / on the same line.
    label_rects = []
    if word_boxes:
        label_rects = _find_label_signature_regions(
            word_boxes, thresh, img_w, img_h
        )

    sig_rects = []
    for cnt in contours:
        x, y, w, h = cv2.boundingRect(cnt)
        area = w * h
        aspect = w / max(h, 1)

        # Shape heuristics — must be a compact, wide-ish blob
        if area < 0.003 * img_area:
            continue
        if area > 0.30 * img_area:
            continue
        if aspect < 2.0 or aspect > 14:
            continue
        if h > 0.15 * img_h:
            continue

        # Ink density: fraction of dark pixels within the bounding box.
        # Multi-line text blocks have higher density than a single signature stroke.
        roi_thresh = thresh[y:y + h, x:x + w]
        ink_density = roi_thresh.mean() / 255
        if ink_density > 0.15:
            continue  # too dense → multiple lines of text

        # Multi-line band check: count distinct horizontal ink bands.
        # A real signature is a single contiguous stroke band; multi-line text is 2+.
        row_ink = (roi_thresh > 0).mean(axis=1)
        in_band = False
        n_bands = 0
        for rv in row_ink:
            if rv > 0.015 and not in_band:
                in_band = True
                n_bands += 1
            elif rv <= 0.015:
                in_band = False
        if n_bands >= 3:
            continue  # multiple text lines

        # OCR exclusion: if OCR recognized more than half the ink pixels in
        # this region as words, it is printed/typed text, not a signature.
        if word_boxes and _ocr_ink_coverage(x, y, w, h, word_boxes, roi_thresh) > 0.55:
            continue

        sig_rects.append((x, y, w, h))

    # Merge contour-detected and label-context rects, deduplicate heavily overlapping ones
    all_rects = sig_rects + [r for r in label_rects if not _overlaps_any(r, sig_rects)]

    if not all_rects:
        return image

    kernel_size = max(3, int(blur_strength))
    if kernel_size % 2 == 0:
        kernel_size += 1

    out = arr.copy()
    for x, y, w, h in all_rects:
        roi = out[y:y + h, x:x + w]
        if roi.size == 0:
            continue
        out[y:y + h, x:x + w] = cv2.GaussianBlur(roi, (kernel_size, kernel_size), 0)

    return Image.fromarray(out)


_SIGNATURE_LABEL_TOKENS = frozenset({
    "firma", "unterschrift", "signature", "signed", "sign", "firme",
    "solicitante", "alumno", "autorizado", "autorizada",
})


def _find_label_signature_regions(word_boxes, thresh, img_w, img_h):
    """Return bounding rects of ink regions that follow a signature-label word.

    Looks for words matching _SIGNATURE_LABEL_TOKENS (case-insensitive) or
    ending with a colon, then returns the ink bounding box to the right of the
    label on the same line (within ±20 % of the label's vertical centre).
    """
    import numpy as np
    rects = []
    for box in word_boxes:
        word_clean = box["word"].rstrip(":.,").lower()
        if word_clean not in _SIGNATURE_LABEL_TOKENS:
            continue
        lx2 = box["left"] + box["width"]
        ly_centre = box["top"] + box["height"] // 2
        # Vertical band: ±40 % of line height around the label centre
        line_h = box["height"]
        y_lo = max(0, ly_centre - int(line_h * 1.2))
        y_hi = min(img_h, ly_centre + int(line_h * 1.5))
        # Horizontal: from right edge of label to end of image
        x_lo = lx2
        x_hi = img_w

        roi = thresh[y_lo:y_hi, x_lo:x_hi]
        if roi.size == 0:
            continue
        # Find the bounding box of all ink in this strip
        ys, xs = np.where(roi > 0)
        if len(xs) == 0:
            continue
        rx = x_lo + int(xs.min())
        ry = y_lo + int(ys.min())
        rw = int(xs.max()) - int(xs.min()) + 1
        rh = int(ys.max()) - int(ys.min()) + 1
        # Must be at least 3 % of image width and not absurdly tall
        if rw < 0.03 * img_w:
            continue
        if rh > 0.20 * img_h:
            continue
        rects.append((rx, ry, rw, rh))
    return rects


def _overlaps_any(rect, others, min_iou=0.3):
    """Return True if rect overlaps any rect in others above min_iou."""
    x, y, w, h = rect
    for ox, oy, ow, oh in others:
        ix1 = max(x, ox)
        iy1 = max(y, oy)
        ix2 = min(x + w, ox + ow)
        iy2 = min(y + h, oy + oh)
        if ix2 <= ix1 or iy2 <= iy1:
            continue
        inter = (ix2 - ix1) * (iy2 - iy1)
        union = w * h + ow * oh - inter
        if inter / max(union, 1) >= min_iou:
            return True
    return False


def _ocr_overlap_fraction(x, y, w, h, word_boxes):
    """Return the fraction of (x,y,w,h) covered by OCR word boxes."""
    total_overlap = 0
    rx2, ry2 = x + w, y + h
    for box in word_boxes:
        bx, by = box["left"], box["top"]
        bx2 = bx + box["width"]
        by2 = by + box["height"]
        ix1 = max(x, bx)
        iy1 = max(y, by)
        ix2 = min(rx2, bx2)
        iy2 = min(ry2, by2)
        if ix2 > ix1 and iy2 > iy1:
            total_overlap += (ix2 - ix1) * (iy2 - iy1)
    region_area = max(w * h, 1)
    return total_overlap / region_area


def _ocr_ink_coverage(x, y, w, h, word_boxes, roi_thresh, min_conf=30):
    """Return fraction of ink pixels in roi_thresh covered by high-confidence OCR word boxes.

    Only word boxes with conf >= min_conf are counted as recognized text.
    conf=0 words (OCR guesses on cursive/unclear ink) are excluded so that
    handwritten signatures are not incorrectly classified as printed text.
    """
    import numpy as np
    ink_total = int((roi_thresh > 0).sum())
    if ink_total == 0:
        return 0.0

    mask = np.zeros_like(roi_thresh, dtype=np.uint8)
    rx2, ry2 = x + w, y + h
    for box in word_boxes:
        if box.get("conf", 100) < min_conf:
            continue
        bx, by = box["left"], box["top"]
        bx2 = bx + box["width"]
        by2 = by + box["height"]
        ix1 = max(x, bx) - x
        iy1 = max(y, by) - y
        ix2 = min(rx2, bx2) - x
        iy2 = min(ry2, by2) - y
        if ix2 > ix1 and iy2 > iy1:
            mask[iy1:iy2, ix1:ix2] = 255

    covered_ink = int(((roi_thresh > 0) & (mask > 0)).sum())
    return covered_ink / ink_total


def _extract_valid_words(data):
    words = []
    n = len(data["text"])
    for i in range(n):
        raw_word = data["text"][i]
        try:
            conf = int(data["conf"][i])
        except Exception:
            conf = -1
        if conf == -1:
            continue
        word = raw_word.strip()
        if not word:
            continue
        words.append({
            "left": int(data["left"][i]),
            "top": int(data["top"][i]),
            "width": int(data["width"][i]),
            "height": int(data["height"][i]),
            "word": word,
            "conf": conf,
            "block_num": int(data["block_num"][i]),
            "par_num": int(data["par_num"][i]),
            "line_num": int(data["line_num"][i]),
        })
    return words


def _build_boxes_with_cursor(words):
    boxes = []
    cursor = 0
    prev_block = None
    prev_par = None
    prev_line = None

    for w in words:
        if prev_block is not None:
            if w["block_num"] != prev_block or w["par_num"] != prev_par or w["line_num"] != prev_line:
                cursor += 1  # newline between lines/paragraphs/blocks
            else:
                cursor += 1  # space between words on the same line

        start = cursor
        end = start + len(w["word"])
        boxes.append({
            "left": w["left"],
            "top": w["top"],
            "width": w["width"],
            "height": w["height"],
            "word": w["word"],
            "char_start": start,
            "char_end": end,
        })
        cursor = end
        prev_block = w["block_num"]
        prev_par = w["par_num"]
        prev_line = w["line_num"]

    return boxes


def _find_word_offset(text, word, cursor):
    idx = text.find(word, cursor)
    if idx != -1:
        return idx
    lower_text = text.lower()
    lower_word = word.lower()
    return lower_text.find(lower_word, cursor)


def _build_boxes_aligned(words, ocr_text):
    boxes = []
    cursor = 0
    for w in words:
        word = w["word"]
        idx = _find_word_offset(ocr_text, word, cursor)
        if idx == -1:
            ascii_quote = word.replace("’", "'").replace("`", "'")
            idx = _find_word_offset(ocr_text, ascii_quote, cursor)
        if idx == -1:
            stripped = word.strip(".,;:()[]{}")
            if stripped:
                idx = _find_word_offset(ocr_text, stripped, cursor)
                if idx != -1:
                    word = stripped
        if idx == -1:
            continue

        end = idx + len(word)
        boxes.append({
            "left": w["left"],
            "top": w["top"],
            "width": w["width"],
            "height": w["height"],
            "word": w["word"],
            "char_start": idx,
            "char_end": end,
        })
        cursor = end
    return boxes


def build_word_boxes(image, ocr_text=None):
    """Run pytesseract and return per-word bounding boxes with char offsets.

    Returns a list of dicts:
        {left, top, width, height, char_start, char_end}
    where char_start/char_end are offsets into the plain text that
    pytesseract.image_to_string() would produce for the same image.
    """
    try:
        import pytesseract
    except ImportError:
        return []

    data = pytesseract.image_to_data(image, output_type=pytesseract.Output.DICT)
    words = _extract_valid_words(data)
    if not words:
        return []

    if ocr_text is not None:
        aligned = _build_boxes_aligned(words, ocr_text)
        # If alignment succeeded for most words, prefer it (best visual accuracy).
        if len(aligned) >= max(3, int(len(words) * 0.5)):
            return aligned

    # Fallback for custom OCR text mismatch or severe OCR noise.
    return _build_boxes_with_cursor(words)
