# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
enc_field_inspector.py — Visual Field Inspector | Reporter.

Field validation of chart content: the user right-clicks a spot in the Chart
Viewer where reality and chart disagree → "Report chart error…" → a reporter
dialog collects the discrepancy type, a short description and optional
support photos, and produces ONE self-contained HTML report (clean-canvas
screenshot with a red circle + WGS84 DM position label, embedded photos, and
a VISIBLE machine-readable JSON summary — no hidden content, no script tags:
mail gateways frown on those). Sending opens the user's own e-mail client
with the report attached (Windows Simple MAPI; a mailto+Explorer fallback
covers clients without MAPI) — nothing ever leaves the machine silently, and
drafts queue fine offline in the field.

Four discrepancy types:
  1  not in the field   — charted object appears gone/removed
  2  not in the chart   — object exists in the field but is not charted
  3  different characteristics — charted vs observed properties differ
  4  different position — object exists but stands elsewhere
For types 1/3/4 the point-geometry objects near the clicked spot (class +
object id) are included in the report.
"""

import base64
import json
import os
import sys
import time
from urllib.parse import quote

try:
    from qgis.core import Qgis, QgsMessageLog
except (ImportError, ValueError):                       # headless import guard
    Qgis = None
    QgsMessageLog = None

DISCREPANCY_TYPES = [
    (1, "Not in the field",
     "the charted object appears to be gone or removed"),
    (2, "Not in the chart",
     "an object exists in the field but is not charted"),
    (3, "Different characteristics",
     "charted and observed properties differ (colour, topmark, light…)"),
    (4, "Different position",
     "the object exists but stands somewhere else"),
]
MAX_DESCRIPTION = 500
MAX_PHOTOS = 2
MAX_PHOTO_BYTES = 10 * 1024 * 1024                       # 10 MB total
# Field capture is deliberately forgiving — you point at what you are
# reporting, not click it to the metre: a harbour light on a mole ~40 m away,
# or an area edge across a channel entrance, must still make the list.
OBJECT_RADIUS_M = 50.0                                   # point-object capture
OBJECT_LINE_TOL_M = 25.0                                 # line/area capture


def _log(msg, level=None):
    if QgsMessageLog is None:
        return
    QgsMessageLog.logMessage(str(msg), "ENCore GIS",
                             level if level is not None else Qgis.Info)


# ── pure helpers (headless-testable) ─────────────────────────────────────────
def format_dm(lat, lon):
    """WGS84 position as degrees + decimal minutes (the hydrographic DM,d
    convention), e.g. ``51° 55.123′ N   004° 28.456′ E``."""
    def one(v, pos, neg, width):
        hemi = pos if v >= 0 else neg
        v = abs(float(v))
        d = int(v)
        m = (v - d) * 60.0
        if m >= 59.9995:                                 # carry the rounding
            d += 1
            m = 0.0
        return f"{d:0{width}d}° {m:06.3f}′ {hemi}"
    return f"{one(lat, 'N', 'S', 2)}   {one(lon, 'E', 'W', 3)}"


def clamp_description(text):
    text = (text or "").strip()
    return text[:MAX_DESCRIPTION]


def validate_photos(paths):
    """(accepted paths, error message or None) under the photo policy:
    at most MAX_PHOTOS images, MAX_PHOTO_BYTES total."""
    paths = [p for p in (paths or []) if p and os.path.isfile(p)]
    if len(paths) > MAX_PHOTOS:
        return paths[:MAX_PHOTOS], f"at most {MAX_PHOTOS} photos — extra ignored"
    total = sum(os.path.getsize(p) for p in paths)
    if total > MAX_PHOTO_BYTES:
        return [], (f"photos total {total // (1024 * 1024)} MB — the limit "
                    f"is {MAX_PHOTO_BYTES // (1024 * 1024)} MB")
    return paths, None


def _b64_file(path):
    with open(path, "rb") as fh:
        return base64.b64encode(fh.read()).decode("ascii")


def _mime_for(path):
    ext = os.path.splitext(path)[1].lower()
    return {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
            ".gif": "image/gif", ".webp": "image/webp"}.get(ext, "image/jpeg")


def _esc(s):
    return (str(s).replace("&", "&amp;").replace("<", "&lt;")
            .replace(">", "&gt;"))


REPORT_DATA_ID = "encore-field-report-data"


def build_report_html(payload, screenshot_png_b64="", photos=None):
    """The self-contained report HTML. ``payload`` is the structured summary
    (also rendered VERBATIM in a visible <pre> block at the bottom — human-
    transparent AND machine-parseable; deliberately no <script> tags and no
    hidden content). ``photos`` = [(filename, mime, b64), …]."""
    p = payload
    rows = [
        ("Reported by", p.get("author", "")),
        ("Timestamp (local)", p.get("timestamp_local", "")),
        ("Timestamp (UTC)", p.get("timestamp_utc", "")),
        ("Discrepancy position", p.get("position_dm", "")),
        ("Observer position (GNSS)", p.get("gnss_dm") or "not available"),
        ("Observer → position distance",
         (f"{p['gnss_distance_m']:.0f} m" if p.get("gnss_distance_m")
          is not None else "n/a")),
        ("Discrepancy type",
         f"{p.get('type_code', '')} — {p.get('type_label', '')}"),
        ("Keep reporter informed", "yes" if p.get("keep_informed") else "no"),
        ("Plugin", f"ENCoreGIS | pro {p.get('plugin_version', '')}"),
    ]
    trs = "\n".join(
        f"<tr><th>{_esc(k)}</th><td>{_esc(v)}</td></tr>" for k, v in rows)
    objs = p.get("objects") or []
    obj_html = ""
    if objs:
        items = "\n".join(
            f"<tr><td>{_esc(o.get('cls', ''))}</td>"
            f"<td>{_esc(o.get('name', ''))}</td>"
            f"<td>{_esc(o.get('objid', ''))}</td>"
            f"<td>{_esc(round(float(o.get('dist_m', 0))))} m</td></tr>"
            for o in objs)
        obj_html = (
            "<h2>Charted objects at the position</h2>"
            "<p>All charted objects at the reported spot, any geometry, "
            "excluding skin-of-the-earth base coverage:</p>"
            "<table><tr><th>Class</th><th>Name</th><th>Object id</th>"
            f"<th>Distance</th></tr>{items}</table>")
    shot_html = ""
    if screenshot_png_b64:
        shot_html = (
            "<h2>Chart at the reported position</h2>"
            f'<img src="data:image/png;base64,{screenshot_png_b64}" '
            'alt="chart screenshot" style="max-width:100%;'
            'border:1px solid #ccc;">')
    photo_html = ""
    for name, mime, b64 in (photos or []):
        photo_html += (
            f'<figure><img src="data:{mime};base64,{b64}" '
            f'alt="{_esc(name)}" style="max-width:100%;border:1px solid #ccc;">'
            f"<figcaption>{_esc(name)}</figcaption></figure>")
    if photo_html:
        photo_html = "<h2>Support photos</h2>" + photo_html
    data = json.dumps(p, indent=1, ensure_ascii=False)
    return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8">
<title>Field inspection report — {_esc(p.get('timestamp_local', ''))}</title>
<style>
 body {{ font-family: "Segoe UI", Arial, sans-serif; color: #26313c;
        max-width: 860px; margin: 24px auto; padding: 0 16px; }}
 h1 {{ color: #12293f; border-bottom: 2px solid #c9992f; padding-bottom: 6px; }}
 h2 {{ color: #1c3d60; margin-top: 24px; }}
 table {{ border-collapse: collapse; }}
 th, td {{ text-align: left; padding: 4px 12px 4px 0; vertical-align: top; }}
 th {{ color: #1c3d60; white-space: nowrap; }}
 pre {{ background: #eef2f6; padding: 12px; overflow-x: auto;
       font-size: 12px; }}
 .notice {{ color: #5c6b7a; font-size: 13px; margin-top: 24px;
           border-top: 1px solid #d5dde5; padding-top: 8px; }}
</style></head><body>
<h1>Visual Field Inspector — chart error report</h1>
<table>{trs}</table>
<h2>Description</h2>
<p>{_esc(p.get('description', '')) or '(none)'}</p>
{obj_html}
{shot_html}
{photo_html}
<h2>Machine-readable summary</h2>
<p>The structured content of this report, in the open, for automated
processing:</p>
<pre id="{REPORT_DATA_ID}">{_esc(data)}</pre>
<p class="notice">Generated by ENCoreGIS | pro — Visual Field Inspector.
NOT FOR NAVIGATION. This report reflects a field observation by the named
reporter and is not an official notice.</p>
</body></html>
"""


def extract_report_json(html):
    """Parse the visible machine-readable block back out of a report (the
    phase-2 API path reads the same file)."""
    marker = f'id="{REPORT_DATA_ID}">'
    i = html.index(marker) + len(marker)
    j = html.index("</pre>", i)
    raw = html[i:j]
    raw = (raw.replace("&lt;", "<").replace("&gt;", ">")
           .replace("&amp;", "&"))
    return json.loads(raw)


def mailto_url(to, subject, body):
    return (f"mailto:{quote(to or '')}?subject={quote(subject or '')}"
            f"&body={quote(body or '')}")


def report_dir():
    """Where report HTML files are kept (inside the QGIS profile)."""
    try:
        from qgis.core import QgsApplication
        base = QgsApplication.qgisSettingsDirPath()
    except Exception:                                   # noqa: BLE001
        base = None
    if not base:
        base = os.path.expanduser("~")
    d = os.path.join(base, "enc_field_reports")
    os.makedirs(d, exist_ok=True)
    return d


# ── e-mail hand-off (user's own client; nothing sends silently) ─────────────
def _send_via_mapi(to, subject, body, attachment):
    """Windows Simple MAPI: opens the default mail client's compose window
    with the report attached. Returns True when the dialog was shown (a user
    abort counts as handled — they chose not to send)."""
    if sys.platform != "win32":
        return False
    import ctypes
    from ctypes import (Structure, POINTER, byref, c_char_p, c_ulong,
                        c_void_p, pointer)

    class MapiRecipDesc(Structure):
        _fields_ = [("ulReserved", c_ulong), ("ulRecipClass", c_ulong),
                    ("lpszName", c_char_p), ("lpszAddress", c_char_p),
                    ("ulEIDSize", c_ulong), ("lpEntryID", c_void_p)]

    class MapiFileDesc(Structure):
        _fields_ = [("ulReserved", c_ulong), ("flFlags", c_ulong),
                    ("nPosition", c_ulong), ("lpszPathName", c_char_p),
                    ("lpszFileName", c_char_p), ("lpFileType", c_void_p)]

    class MapiMessage(Structure):
        _fields_ = [("ulReserved", c_ulong), ("lpszSubject", c_char_p),
                    ("lpszNoteText", c_char_p), ("lpszMessageType", c_char_p),
                    ("lpszDateReceived", c_char_p),
                    ("lpszConversationID", c_char_p), ("flFlags", c_ulong),
                    ("lpOriginator", c_void_p), ("nRecipCount", c_ulong),
                    ("lpRecips", POINTER(MapiRecipDesc)),
                    ("nFileCount", c_ulong),
                    ("lpFiles", POINTER(MapiFileDesc))]

    try:
        mapi = ctypes.WinDLL("mapi32")
        enc = "mbcs"
        recip = MapiRecipDesc(0, 1, to.encode(enc),
                              ("SMTP:" + to).encode(enc), 0, None)
        fdesc = MapiFileDesc(0, 0, 0xFFFFFFFF, attachment.encode(enc),
                             os.path.basename(attachment).encode(enc), None)
        msg = MapiMessage(0, subject.encode(enc), body.encode(enc),
                          None, None, None, 0, None,
                          1, pointer(recip), 1, pointer(fdesc))
        MAPI_LOGON_UI, MAPI_DIALOG = 0x1, 0x8
        rc = mapi.MAPISendMail(0, 0, byref(msg),
                               MAPI_LOGON_UI | MAPI_DIALOG, 0)
        if rc in (0, 1):                                # SUCCESS / USER_ABORT
            return True
        _log(f"Field report: MAPISendMail returned {rc} — using fallback",
             Qgis.Warning if Qgis else None)
        return False
    except Exception as e:                              # noqa: BLE001
        _log(f"Field report: MAPI unavailable ({e}) — using fallback",
             Qgis.Warning if Qgis else None)
        return False


def send_report_email(to, subject, body, attachment):
    """Open the user's e-mail client with the report. Primary: Simple MAPI
    (attachment prefilled). Fallback: a mailto draft + the report selected in
    Explorer for a one-drag attach. Returns a short status string."""
    if _send_via_mapi(to, subject, body, attachment):
        return "e-mail opened in your mail client (report attached)"
    try:
        from qgis.PyQt.QtGui import QDesktopServices
        from qgis.PyQt.QtCore import QUrl
        QDesktopServices.openUrl(QUrl(mailto_url(
            to, subject,
            body + "\n\n[Please attach the report file that was just "
                   "highlighted in the file explorer.]")))
    except Exception as e:                              # noqa: BLE001
        _log(f"Field report: mailto failed: {e}",
             Qgis.Warning if Qgis else None)
    try:
        if sys.platform == "win32":
            import subprocess                           # nosec B404
            subprocess.Popen(                           # nosec B603, B607
                ["explorer", "/select,", os.path.normpath(attachment)])
    except Exception:                                   # noqa: BLE001
        pass
    return ("e-mail draft opened — attach the report file highlighted in "
            "the file explorer")


# ── screenshot annotation + the reporter dialog (GUI) ────────────────────────
def annotate_screenshot(image, x, y, label):
    """Draw the red discrepancy circle + DM position label onto the clean
    canvas grab. ``image`` is a QImage, (x, y) the click in device pixels."""
    from qgis.PyQt.QtGui import QPainter, QPen, QColor, QFont
    from qgis.PyQt.QtCore import QPointF, Qt as _Qt
    img = image.convertToFormat(image.Format_ARGB32)
    # QPainter works in LOGICAL coordinates and applies the image's device-
    # pixel ratio itself — so (x, y) are logical, and the on-canvas clamping
    # below must use the LOGICAL image size too.
    dpr = img.devicePixelRatio() or 1.0
    lw, lh = img.width() / dpr, img.height() / dpr
    p = QPainter(img)
    p.setRenderHint(QPainter.Antialiasing)
    pen = QPen(QColor(200, 0, 0), 3)
    p.setPen(pen)
    p.setBrush(_Qt.NoBrush)
    r = 22
    p.drawEllipse(QPointF(x, y), r, r)
    font = QFont("Segoe UI", 10)
    font.setBold(True)
    p.setFont(font)
    fm = p.fontMetrics()
    tw = fm.horizontalAdvance(label) + 10
    th = fm.height() + 6
    tx = x + r + 8
    ty = y - th / 2
    if tx + tw > lw:                                     # keep on-canvas
        tx = x - r - 8 - tw
    ty = max(2, min(ty, lh - th - 2))
    p.fillRect(int(tx), int(ty), int(tw), int(th), QColor(255, 255, 255, 220))
    p.setPen(QPen(QColor(200, 0, 0), 1))
    p.drawRect(int(tx), int(ty), int(tw), int(th))
    p.drawText(int(tx + 5), int(ty + th - fm.descent() - 3), label)
    p.end()
    return img


def _png_b64(image):
    from qgis.PyQt.QtCore import QBuffer, QByteArray, QIODevice
    ba = QByteArray()
    buf = QBuffer(ba)
    buf.open(QIODevice.WriteOnly)
    image.save(buf, "PNG")
    buf.close()
    return base64.b64encode(bytes(ba)).decode("ascii")


class FieldReportDialog:
    """'Visual Field Inspector | Reporter' — collects the report, writes the
    self-contained HTML and hands it to the user's mail client. Plain class
    wrapping a QDialog so the module stays importable headless."""

    def __init__(self, parent, payload, screenshot_image, objects):
        from qgis.PyQt.QtWidgets import (
            QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
            QRadioButton, QButtonGroup, QPlainTextEdit, QCheckBox,
            QListWidget, QPushButton, QFileDialog, QMessageBox)
        from qgis.core import QgsSettings
        self._payload = payload
        self._shot = screenshot_image
        self._objects = objects or []
        self._QFileDialog = QFileDialog
        self._QMessageBox = QMessageBox
        s = QgsSettings()
        dlg = QDialog(parent)
        dlg.setWindowTitle("Visual Field Inspector | Reporter")
        dlg.setMinimumWidth(520)
        lay = QVBoxLayout(dlg)
        lay.addWidget(QLabel("Reporting address:"))
        self.addr = QLineEdit(s.value("encoregis/field_report_address", "",
                                      type=str))
        self.addr.setPlaceholderText("chart-errors@authority.example")
        lay.addWidget(self.addr)
        lay.addWidget(QLabel("Type of discrepancy:"))
        self.type_group = QButtonGroup(dlg)
        for code, label, expl in DISCREPANCY_TYPES:
            rb = QRadioButton(f"{code} — {label}: {expl}")
            self.type_group.addButton(rb, code)
            lay.addWidget(rb)
        lay.addWidget(QLabel(f"Short description (max {MAX_DESCRIPTION} "
                             "characters):"))
        self.desc = QPlainTextEdit()
        self.desc.setPlaceholderText("What did you observe?")
        self.desc.setFixedHeight(90)
        lay.addWidget(self.desc)
        self.inform = QCheckBox("Keep me informed about this report")
        lay.addWidget(self.inform)
        ph_row = QHBoxLayout()
        ph_row.addWidget(QLabel(f"Support photos (max {MAX_PHOTOS}, "
                                f"{MAX_PHOTO_BYTES // (1024 * 1024)} MB "
                                "total):"))
        add_b = QPushButton("Add…")
        add_b.clicked.connect(self._add_photos)
        ph_row.addWidget(add_b)
        rm_b = QPushButton("Remove")
        rm_b.clicked.connect(self._remove_photo)
        ph_row.addWidget(rm_b)
        lay.addLayout(ph_row)
        self.photos = QListWidget()
        self.photos.setFixedHeight(52)
        lay.addWidget(self.photos)
        notice = QLabel(f"This name:  <b>{payload.get('author', '')}</b>  "
                        "will be incorporated in the notification.")
        notice.setWordWrap(True)
        lay.addWidget(notice)
        btns = QHBoxLayout()
        btns.addStretch()
        cancel_b = QPushButton("Cancel")
        cancel_b.clicked.connect(dlg.reject)
        clear_b = QPushButton("Clear")
        clear_b.clicked.connect(self._clear)
        send_b = QPushButton("Send")
        send_b.setDefault(True)
        send_b.clicked.connect(self._send)
        for b in (cancel_b, clear_b, send_b):
            btns.addWidget(b)
        lay.addLayout(btns)
        self.dlg = dlg

    def exec(self):
        return self.dlg.exec()

    # -- interactions --------------------------------------------------------
    def _add_photos(self):
        paths, _f = self._QFileDialog.getOpenFileNames(
            self.dlg, "Support photos", os.path.expanduser("~"),
            "Images (*.jpg *.jpeg *.png *.gif *.webp)")
        current = [self.photos.item(i).text()
                   for i in range(self.photos.count())]
        ok, err = validate_photos(current + list(paths))
        if err:
            self._QMessageBox.warning(self.dlg, "Support photos", err)
        self.photos.clear()
        for p in ok:
            self.photos.addItem(p)

    def _remove_photo(self):
        for it in self.photos.selectedItems():
            self.photos.takeItem(self.photos.row(it))

    def _clear(self):
        for rb in self.type_group.buttons():
            self.type_group.setExclusive(False)
            rb.setChecked(False)
            self.type_group.setExclusive(True)
        self.desc.setPlainText("")
        self.inform.setChecked(False)
        self.photos.clear()

    def _send(self):
        from qgis.core import QgsSettings
        to = (self.addr.text() or "").strip()
        code = self.type_group.checkedId()
        if not to or "@" not in to:
            self._QMessageBox.warning(self.dlg, "Reporting address",
                                      "Please enter the reporting e-mail "
                                      "address.")
            return
        if code < 1:
            self._QMessageBox.warning(self.dlg, "Discrepancy type",
                                      "Please select the type of "
                                      "discrepancy (1–4).")
            return
        QgsSettings().setValue("encoregis/field_report_address", to)
        p = dict(self._payload)
        p["type_code"] = code
        p["type_label"] = next(lbl for c, lbl, _e in DISCREPANCY_TYPES
                               if c == code)
        p["description"] = clamp_description(self.desc.toPlainText())
        p["keep_informed"] = self.inform.isChecked()
        # charted point objects only make sense when something IS charted
        # there (types 1/3/4)
        p["objects"] = self._objects if code in (1, 3, 4) else []
        photo_paths = [self.photos.item(i).text()
                       for i in range(self.photos.count())]
        photo_paths, err = validate_photos(photo_paths)
        if err:
            self._QMessageBox.warning(self.dlg, "Support photos", err)
            return
        p["photos"] = [os.path.basename(x) for x in photo_paths]
        photos = [(os.path.basename(x), _mime_for(x), _b64_file(x))
                  for x in photo_paths]
        html = build_report_html(p, _png_b64(self._shot), photos)
        stamp = time.strftime("%Y%m%d_%H%M%S")
        out = os.path.join(report_dir(), f"fieldreport_{stamp}.html")
        with open(out, "w", encoding="utf-8") as fh:
            fh.write(html)
        subject = (f"fieldinspection report by {p.get('author', '')} — "
                   f"{p.get('timestamp_local', '')}")
        body = (f"Field inspection report — {p['type_code']} "
                f"({p['type_label']}) at {p.get('position_dm', '')}.\n"
                f"See the attached report.\n\nReported by "
                f"{p.get('author', '')} with ENCoreGIS | pro. "
                "NOT FOR NAVIGATION.")
        status = send_report_email(to, subject, body, out)
        _log(f"Field report written: {out}")
        self._QMessageBox.information(
            self.dlg, "Visual Field Inspector",
            f"Report saved:\n{out}\n\n{status}")
        self.dlg.accept()
