"""surfice_gold.py: synthetic mill test certificates and a scorer for "plausible misreadings".

Released by AInnoverse Tech Centre LLP (SuRFIcE, www.constrovet.com) under the MIT licence, with the
technical note at https://www.constrovet.com/pages/learning-from-the-field.html

What it does
- Renders invented reinforcement mill test certificates (6 fields, 2 layouts, 3 fonts) and degrades
  them the way phone photos and poor scans are (rotation, half resolution, blur, heavy JPEG, uneven
  light). Every certificate carries its ground truth. All values are invented.
- Scores any reader's text: values found, values missing, and "plausible corruptions": a wrong
  number one digit or one decimal point away from a true value (16.0 read as 160, 500 as 5000).
  Such numbers pass a check that compares a quoted value with the same OCR text.

Requires: Pillow, and the DejaVu fonts (fonts-dejavu on Debian/Ubuntu). Optional: pytesseract and
the tesseract binary to run the demo; reportlab for the mixed-page PDF.
Run the demo:  python surfice_gold.py
"""
# MIT License
#
# Copyright (c) 2026 AInnoverse Tech Centre LLP
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
# associated documentation files (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge, publish, distribute,
# sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all copies or
# substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
# NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
# OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

import re
import io
import random
from dataclasses import dataclass, field
from pathlib import Path

from PIL import Image, ImageDraw, ImageFilter, ImageFont

_FONT_DIR = "/usr/share/fonts/truetype/dejavu/"
FONTS = {"sans": "DejaVuSans", "serif": "DejaVuSerif", "mono": "DejaVuSansMono"}
LAYOUTS = ("table", "keyvalue")
DEGRADATIONS = ("clean", "rotate4", "half_res", "blur", "jpeg30", "uneven_light")


@dataclass(frozen=True)
class Field:
    prop: str
    value: str
    unit: str
    label: str = ""


@dataclass
class Certificate:
    seed: int
    fields: list[Field] = field(default_factory=list)
    grade: str = "Fe 500D"
    standard: str = "IS 1786"

    @property
    def context(self) -> list[str]:
        """Numbers printed on the page that are not field values (seed, grade, standard)."""
        return [str(self.seed), "500", "1786"]


@dataclass
class Case:
    seed: int
    layout: str
    font: str
    degradation: str
    image: Image.Image
    fields: list[Field]
    context: list[str] = field(default_factory=list)


def certificate(seed: int) -> Certificate:
    r = random.Random(seed)
    ys = r.randint(510, 600)
    return Certificate(seed, [
        Field("heat_no", str(r.randint(1000, 9999)), "", "Heat No."),
        Field("bar_size", str(r.choice([10, 12, 16, 20, 25])), "mm", "Size"),
        Field("yield_strength", str(ys), "N/mm2", "0.2% Proof stress"),
        Field("tensile_strength", str(ys + r.randint(50, 90)), "N/mm2", "Tensile strength"),
        Field("elongation", f"{r.uniform(14.5, 22.0):.1f}", "%", "Elongation"),
        Field("carbon", f"{r.uniform(0.15, 0.25):.2f}", "%", "Carbon"),
    ])


def _font(name: str, size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
    return ImageFont.truetype(_FONT_DIR + FONTS[name] + ("-Bold" if bold else "") + ".ttf", size)


def render(cert: Certificate, *, layout: str, font: str) -> Image.Image:
    body, head = _font(font, 26), _font(font, 28, bold=True)
    lines = [(f"MILL TEST CERTIFICATE (SYNTHETIC, seed {cert.seed})", head),
             (f"Grade: {cert.grade}   Standard: {cert.standard}", body)]
    if layout == "table":
        lines.append((f"{'Property':<22}{'Result':<14}Unit", head))
        lines += [(f"{f.label:<22}{f.value:<14}{f.unit}", body) for f in cert.fields]
    elif layout == "keyvalue":
        lines += [(f"{f.label}: {f.value} {f.unit}".rstrip(), body) for f in cert.fields]
    else:
        raise ValueError(f"layout must be one of {LAYOUTS}")
    img = Image.new("RGB", (1300, 80 + 50 * len(lines)), "white")
    d = ImageDraw.Draw(img)
    for i, (text, f) in enumerate(lines):
        d.text((30, 25 + 50 * i), text, fill="black", font=f)
    return img


def degrade(img: Image.Image, kind: str) -> Image.Image:
    if kind == "clean":
        return img
    if kind == "rotate4":
        return img.rotate(4, expand=True, fillcolor="white")
    if kind == "half_res":
        return img.resize((img.width // 2, img.height // 2))
    if kind == "blur":
        return img.filter(ImageFilter.GaussianBlur(2))
    if kind == "jpeg30":
        b = io.BytesIO()
        img.resize((int(img.width * 0.4), int(img.height * 0.4))).save(b, "JPEG", quality=30)
        return Image.open(io.BytesIO(b.getvalue())).convert("RGB")
    if kind == "uneven_light":
        shade = Image.linear_gradient("L").rotate(90).resize(img.size)
        return Image.composite(img, Image.new("RGB", img.size, (90, 90, 90)), shade.point(lambda v: 255 - int(v * 0.55)))
    raise ValueError(f"degradation must be one of {DEGRADATIONS}")


def cases(seeds=(1, 2, 3), layouts=LAYOUTS, fonts=tuple(FONTS), degradations=DEGRADATIONS):
    for seed in seeds:
        cert = certificate(seed)
        for layout in layouts:
            for font in fonts:
                base = render(cert, layout=layout, font=font)
                for kind in degradations:
                    yield Case(seed, layout, font, kind, degrade(base, kind), cert.fields, cert.context)


def mixed_page_pdf(cert: Certificate, path: Path) -> Path:
    """A typed transmittal line above a pasted certificate image: the 'mixed page' of the audit."""
    from reportlab.lib.pagesizes import A4
    from reportlab.lib.utils import ImageReader
    from reportlab.pdfgen import canvas

    img = render(cert, layout="table", font="sans")
    c = canvas.Canvas(str(path), pagesize=A4)
    c.setFont("Helvetica", 12)
    c.drawString(50, 800, "Transmittal: submittal attached, with the mill certificate below")
    c.drawImage(ImageReader(img), 40, 420, width=515, height=515 * img.height / img.width)
    c.showPage()
    c.save()
    return path


# --- scoring ---

_NUMBER = re.compile(r"\d+(?:\.\d+)?")
# numbers that belong to a property's name, not to its value
_LABEL_NUMBERS = re.compile(r"0\.2\s*%\s*proof", re.I)


def _edit_distance(a: str, b: str) -> int:
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
        prev = cur
    return prev[-1]


def is_corruption(truth: str, read: str) -> bool:
    if truth == read:
        return False
    if truth.replace(".", "") == read.replace(".", ""):
        return True  # same digits, decimal point lost or moved
    return _edit_distance(truth, read) == 1


@dataclass
class Score:
    found: list[str]
    missing: list[str]
    corruptions: list[tuple[str, str, str]]  # (property, true value, value read)


def score_text(text: str, truth: list[Field], context: list[str] = ()) -> Score:
    """`context` lists numbers printed on the page that are not field values (the grade, the
    standard, a reference number); reading them correctly is never a corruption."""
    read = _NUMBER.findall(_LABEL_NUMBERS.sub(" ", text))
    true_values = {f.value for f in truth} | set(context)
    found = [f.prop for f in truth if f.value in read]
    missing = [f.prop for f in truth if f.value not in read]
    corruptions = [(f.prop, f.value, r) for f in truth if f.prop in missing
                   for r in dict.fromkeys(read) if r not in true_values and is_corruption(f.value, r)]
    return Score(found, missing, corruptions)


if __name__ == "__main__":
    import pytesseract  # optional: only the demo reads images
    totals = {}
    for c in cases(seeds=(1,)):
        s = score_text(pytesseract.image_to_string(c.image), c.fields, c.context)
        t = totals.setdefault(c.degradation, [0, 0, 0])
        t[0] += len(s.found); t[1] += len(c.fields); t[2] += len(s.corruptions)
    print("degradation      found   plausible corruptions")
    for k, (f, n, bad) in totals.items():
        print(f"{k:<16} {f:>3}/{n:<4} {bad}")
