"""A form submission as a PDF document, and the receipt email that can carry
it (FormSettings.document / .receipt).

The document is the form's own template (FormDocument.body: plain text
where "{field}" is that answer as a person reads it, "{answers}" a table of
the answers, {form} / {workspace} / {submitted_at}), under its title, with
the workspace and form named at the top and the footer at the bottom. A
signature answer is drawn as the signature. Answers read as people see
them: a choice from a datasource by its label, a person by name, a file by
its name, an address on lines.

Text prints in settings.pdf_font_path, else the first common system font
found, else the PDF base font (Latin letters only). reportlab draws
characters one by one, so scripts that join letters (Malayalam, Hindi …)
show their letters but not every joined form.

The receipt (job "forms.receipt") goes to the address in the receipt's
email field once the submission is submitted — queued in the same
transaction, sent by the job worker through the workspace's first enabled
Email (SMTP) connector, retried if the mail server fails."""

import asyncio
import base64
import binascii
import html
import io
import logging
import re
import smtplib
import uuid
from datetime import datetime
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
from typing import Any

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core import jobs
from app.core.config import settings
from app.crud import record_sql
from app.models.identity import Tenant, User
from app.models.master import MasterRecord
from app.models.upload import UploadedFile
from app.models.workflow import Form, FormSubmission

log = logging.getLogger("flowfabric.forms")

RECEIPT_JOB = "forms.receipt"
_TOKEN = re.compile(r"\{([A-Za-z_][\w.]*)\}")
_NO_ANSWER = {"section", "panel", "tabs", "accordion", "wizard", "row", "column", "group_end", "button", "hidden"}
_FONT_CANDIDATES = (
    "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
    "/usr/share/fonts/noto/NotoSans-Regular.ttf",
    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
    "/usr/share/fonts/dejavu/DejaVuSans.ttf",
    "C:/Windows/Fonts/Nirmala.ttf",
    "C:/Windows/Fonts/arial.ttf",
)
_font_name: str | None = None


# ---- Answers as people read them -----------------------------------------------------------------


def _blank(v: Any) -> bool:
    return v is None or v == "" or v == [] or v == {}


async def answer_texts(db: AsyncSession, tenant_id: uuid.UUID, field_defs: list[dict], values: dict) -> dict[str, str]:
    """field name -> its answer as text (blank answers left out)."""
    record_ids: set[uuid.UUID] = set()
    user_ids: set[uuid.UUID] = set()
    file_ids: set[uuid.UUID] = set()

    def ids(value: Any) -> list[uuid.UUID]:
        out = []
        for v in value if isinstance(value, list) else [value]:
            try:
                out.append(uuid.UUID(str(v)))
            except ValueError:
                pass
        return out

    for f in field_defs:
        v = values.get(f.get("name"))
        if _blank(v):
            continue
        if f.get("master_id"):
            record_ids.update(ids(v))
        elif f.get("type") == "user":
            user_ids.update(ids(v))
        elif f.get("type") in ("file_upload", "image_upload", "attachment", "multi_attachment", "camera"):
            file_ids.update(ids(v))

    labels: dict[str, dict] = {}
    if record_ids:
        rows = await db.execute(
            select(MasterRecord.id, MasterRecord.field_values).where(MasterRecord.tenant_id == tenant_id, record_sql.ids_any(MasterRecord.id, sorted(record_ids)))
        )
        labels = {str(rid): fv or {} for rid, fv in rows.all()}
    names: dict[str, str] = {}
    if user_ids:
        rows = await db.execute(select(User.id, User.name).where(User.tenant_id == tenant_id, record_sql.ids_any(User.id, sorted(user_ids))))
        names = {str(uid): name for uid, name in rows.all()}
    files: dict[str, str] = {}
    if file_ids:
        rows = await db.execute(
            select(UploadedFile.id, UploadedFile.filename).where(UploadedFile.tenant_id == tenant_id, record_sql.ids_any(UploadedFile.id, sorted(file_ids)))
        )
        files = {str(fid): name for fid, name in rows.all()}

    out: dict[str, str] = {}
    for f in field_defs:
        name, kind, v = f.get("name"), f.get("type"), values.get(f.get("name"))
        if kind in _NO_ANSWER or _blank(v):
            continue
        if f.get("master_id"):
            display = f.get("master_display_field") or ""
            items = v if isinstance(v, list) else [v]
            text = ", ".join(str(labels.get(str(i), {}).get(display) or i) for i in items)
        elif kind == "user":
            text = ", ".join(names.get(str(i), str(i)) for i in (v if isinstance(v, list) else [v]))
        elif kind in ("file_upload", "image_upload", "attachment", "multi_attachment", "camera"):
            text = ", ".join(files.get(str(i), "(file)") for i in (v if isinstance(v, list) else [v]))
        elif kind in ("checkbox", "toggle"):
            text = "Yes" if v is True else "No"
        elif kind == "consent":
            text = "Agreed" if v is True else "Not agreed"
        elif kind == "signature":
            text = "Signed"
        elif kind == "address" and isinstance(v, dict):
            parts = [v.get("line1"), v.get("line2"), ", ".join(p for p in (v.get("city"), v.get("state"), v.get("postal_code")) if p), v.get("country")]
            text = "\n".join(p for p in parts if p)
        elif kind == "matrix" and isinstance(v, dict):
            text = "\n".join(f"{row}: {v[row]}" for row in f.get("matrix_rows") or [] if not _blank(v.get(row)))
        elif kind == "ranking" and isinstance(v, list):
            text = "\n".join(f"{i}. {item}" for i, item in enumerate(v, 1))
        elif kind == "daterange" and isinstance(v, dict):
            text = f"{v.get('from') or '…'} – {v.get('to') or '…'}"
        elif kind == "duration" and isinstance(v, dict):
            text = f"{v.get('amount', '')} {v.get('unit', '')}".strip()
        elif kind in ("subform", "dynamic_grid") and isinstance(v, list):
            text = f"{len(v)} row{'s' if len(v) != 1 else ''}"
        elif kind in ("rich_text", "html"):
            text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", str(v))).strip()
        elif isinstance(v, list):
            text = ", ".join(str(i) for i in v)
        elif isinstance(v, dict):
            text = ", ".join(f"{k}: {i}" for k, i in v.items())
        else:
            text = str(v)
        out[name] = text
    return out


def fill(template: str, texts: dict[str, str], extra: dict[str, str], fields: set[str]) -> str:
    """"{field}" / {form} / {workspace} / {submitted_at} filled in — a field
    left blank as empty text; a name that's none of these stays as it was,
    so a typo shows instead of vanishing."""

    def sub(m: re.Match) -> str:
        key = m.group(1)
        if key in extra:
            return extra[key]
        return texts.get(key, "") if key in fields else m.group(0)

    return _TOKEN.sub(sub, template)


# ---- The PDF -------------------------------------------------------------------------------------


def _font() -> str:
    """The registered font's name — a TrueType font when one is found."""
    global _font_name
    if _font_name:
        return _font_name
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont

    for path in (settings.pdf_font_path.strip(), *_FONT_CANDIDATES):
        if path and Path(path).is_file():
            try:
                pdfmetrics.registerFont(TTFont("FormBody", path))
                _font_name = "FormBody"
                return _font_name
            except Exception:  # noqa: BLE001 — an unreadable font: try the next one
                log.warning("couldn't use %s as the PDF font", path)
    _font_name = "Helvetica"
    return _font_name


def _para_text(text: str) -> str:
    return html.escape(text).replace("\n", "<br/>")


def _signature_image(value: Any, width: float):
    """A signature answer ("data:image/png;base64,…") as a picture, or None."""
    from reportlab.platypus import Image

    if not isinstance(value, str) or not value.startswith("data:image/"):
        return None
    try:
        raw = base64.b64decode(value.split(",", 1)[1], validate=False)
        img = Image(io.BytesIO(raw))
    except (IndexError, binascii.Error, OSError, ValueError):
        return None
    ratio = img.imageHeight / img.imageWidth if img.imageWidth else 0.4
    img.drawWidth, img.drawHeight = width, width * ratio
    return img


def build_pdf(*, workspace: str, form_name: str, submitted_at: datetime | None, document: dict, field_defs: list[dict], values: dict, texts: dict[str, str], reference: str | None) -> bytes:
    from reportlab.lib import colors
    from reportlab.lib.pagesizes import A4
    from reportlab.lib.styles import ParagraphStyle
    from reportlab.lib.units import mm
    from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle

    font = _font()
    small = ParagraphStyle("small", fontName=font, fontSize=8.5, leading=11, textColor=colors.HexColor("#64748b"))
    title_style = ParagraphStyle("title", fontName=font, fontSize=16, leading=20, spaceAfter=4, textColor=colors.HexColor("#0f172a"))
    body = ParagraphStyle("body", fontName=font, fontSize=10, leading=14, spaceAfter=6, textColor=colors.HexColor("#1e293b"))
    cell = ParagraphStyle("cell", parent=body, spaceAfter=0)
    label = ParagraphStyle("label", parent=cell, textColor=colors.HexColor("#475569"))

    when = submitted_at.strftime("%d %b %Y, %H:%M") if submitted_at else ""
    extra = {"form": form_name, "workspace": workspace, "submitted_at": when}
    names = {f.get("name") for f in field_defs}
    width = A4[0] - 36 * mm

    wanted = [n for n in document.get("fields") or [] if n in texts] or list(texts)
    by_name = {f.get("name"): f for f in field_defs}

    def answers_table():
        rows = []
        for name in wanted:
            f = by_name.get(name, {})
            shown = _signature_image(values.get(name), 45 * mm) if f.get("type") == "signature" else None
            rows.append([Paragraph(_para_text(f.get("label") or name), label), shown or Paragraph(_para_text(texts[name]), cell)])
        if not rows:
            return Paragraph(_para_text("No answers."), body)
        table = Table(rows, colWidths=[width * 0.35, width * 0.65])
        table.setStyle(
            TableStyle(
                [
                    ("VALIGN", (0, 0), (-1, -1), "TOP"),
                    ("LINEBELOW", (0, 0), (-1, -1), 0.4, colors.HexColor("#e2e8f0")),
                    ("TOPPADDING", (0, 0), (-1, -1), 5),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
                ]
            )
        )
        return table

    story: list = [
        Paragraph(_para_text(f"{workspace} · {form_name}"), small),
        Spacer(1, 3 * mm),
        Paragraph(_para_text(fill(document.get("title") or form_name, texts, extra, names)), title_style),
    ]
    meta = " · ".join(p for p in (f"Reference {reference}" if reference else "", f"Submitted {when}" if when else "") if p)
    if meta:
        story += [Paragraph(_para_text(meta), small), Spacer(1, 5 * mm)]

    template = (document.get("body") or "").strip() or "{answers}"
    # Each "{answers}" becomes the table; the text around it, paragraphs.
    for i, chunk in enumerate(template.split("{answers}")):
        if i:
            story += [answers_table(), Spacer(1, 4 * mm)]
        for para in re.split(r"\n\s*\n", fill(chunk, texts, extra, names).strip()):
            if para.strip():
                story.append(Paragraph(_para_text(para.strip()), body))

    footer = fill(document.get("footer") or "", texts, extra, names).strip()

    def on_page(canvas, doc):
        canvas.saveState()
        canvas.setFont(font, 8)
        canvas.setFillColor(colors.HexColor("#94a3b8"))
        if footer:
            canvas.drawString(18 * mm, 10 * mm, footer[:160])
        canvas.drawRightString(A4[0] - 18 * mm, 10 * mm, f"{doc.page}")
        canvas.restoreState()

    out = io.BytesIO()
    pdf = SimpleDocTemplate(out, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=18 * mm, title=form_name, author=workspace)
    pdf.build(story, onFirstPage=on_page, onLaterPages=on_page)
    return out.getvalue()


def reference_of(field_defs: list[dict], values: dict) -> str | None:
    for f in field_defs:
        if f.get("type") == "autonumber" and not _blank(values.get(f.get("name"))):
            return str(values[f["name"]])
    return None


async def submission_pdf(db: AsyncSession, tenant_id: uuid.UUID, form: Form, submission: FormSubmission, values: dict | None = None) -> bytes:
    """The submission's document — `values` (e.g. without fields hidden from
    the reader) or its stored answers. Built off the event loop."""
    tenant = await db.get(Tenant, tenant_id)
    values = submission.field_values if values is None else values
    texts = await answer_texts(db, tenant_id, form.field_definitions, values)
    document = ((form.settings or {}).get("document") or {})
    return await asyncio.to_thread(
        build_pdf,
        workspace=tenant.name if tenant else "",
        form_name=form.name,
        submitted_at=submission.submitted_at,
        document=document,
        field_defs=form.field_definitions,
        values=values,
        texts=texts,
        reference=reference_of(form.field_definitions, values),
    )


def document_enabled(form: Form) -> bool:
    return bool(((form.settings or {}).get("document") or {}).get("enabled"))


# ---- The receipt ---------------------------------------------------------------------------------


def receipt_problem(field_defs: list[dict], receipt: dict | None) -> str | None:
    """Why a form's receipt settings can't work, or None."""
    if not receipt or not receipt.get("enabled"):
        return None
    to = next((f for f in field_defs if f.get("name") == receipt.get("to_field")), None)
    if to is None or to.get("type") != "email":
        return "Choose the email field the receipt goes to."
    return None


async def queue_receipt(db: AsyncSession, tenant_id: uuid.UUID, form: Form, submission: FormSubmission) -> bool:
    """Queues the receipt for a just-submitted submission, in the caller's
    transaction — when the form sends one and the address is filled in."""
    receipt = (form.settings or {}).get("receipt") or {}
    if not receipt.get("enabled") or receipt_problem(form.field_definitions, receipt):
        return False
    if _blank((submission.field_values or {}).get(receipt.get("to_field"))):
        return False
    await jobs.enqueue(db, RECEIPT_JOB, {"submission_id": str(submission.id)}, tenant_id=tenant_id, dedupe_key=f"{RECEIPT_JOB}:{submission.id}")
    return True


def _send_sync(config: dict, to_addr: str, subject: str, body: str, attachment: tuple[str, bytes] | None) -> None:
    host = config.get("host")
    from_addr = config.get("from") or config.get("username")
    if not host or not from_addr:
        raise ValueError("connector is missing host/from configuration")
    msg = MIMEMultipart()
    msg["Subject"], msg["From"], msg["To"] = subject, from_addr, to_addr
    msg.attach(MIMEText(body, "plain", "utf-8"))
    if attachment:
        part = MIMEApplication(attachment[1], _subtype="pdf")
        part.add_header("Content-Disposition", "attachment", filename=attachment[0])
        msg.attach(part)
    with smtplib.SMTP(host, int(config.get("port") or 587), timeout=15) as smtp:
        smtp.starttls()
        if config.get("username") and config.get("password"):
            smtp.login(config["username"], config["password"])
        smtp.sendmail(from_addr, [to_addr], msg.as_string())


def pdf_file_name(form_name: str, reference: str | None) -> str:
    stem = re.sub(r"[^\w.-]+", "-", f"{form_name} {reference or ''}".strip()).strip("-") or "submission"
    return f"{stem[:80]}.pdf"


@jobs.handler(RECEIPT_JOB, max_attempts=5)
async def _receipt_job(job: jobs.Job) -> str:
    from app.crud.connectors import first_enabled_connector
    from app.crud.secrets import connector_config

    async with jobs.tenant_session(job.tenant_id) as db:
        submission = (await db.execute(select(FormSubmission).where(FormSubmission.id == uuid.UUID(job.payload["submission_id"])))).scalar_one_or_none()
        if submission is None or submission.is_deleted or submission.status != "submitted":
            return "skipped — the submission is gone"
        form = (
            await db.execute(select(Form).where(Form.tenant_id == job.tenant_id, Form.form_id == submission.form_id, Form.version == submission.form_version))
        ).scalar_one()
        receipt = (form.settings or {}).get("receipt") or {}
        to = str((submission.field_values or {}).get(receipt.get("to_field")) or "").strip()
        if not receipt.get("enabled") or not to:
            return "skipped — no receipt to send"
        connector = await first_enabled_connector(db, job.tenant_id, "email_smtp")
        if connector is None:
            raise jobs.Permanent("not sent — there's no enabled Email (SMTP) connector")
        tenant = await db.get(Tenant, job.tenant_id)
        texts = await answer_texts(db, job.tenant_id, form.field_definitions, submission.field_values)
        when = submission.submitted_at.strftime("%d %b %Y, %H:%M") if submission.submitted_at else ""
        extra = {"form": form.name, "workspace": tenant.name if tenant else "", "submitted_at": when}
        reference = reference_of(form.field_definitions, submission.field_values)
        names = {f.get("name") for f in form.field_definitions}
        subject = fill(receipt.get("subject") or "Your submission: {form}", texts, extra, names)
        message = fill(receipt.get("message") or "Thank you — we've received your submission to “{form}”.", texts, extra, names)
        if reference and reference not in message:
            message += f"\n\nReference: {reference}"
        attachment = None
        if receipt.get("attach_pdf", True):
            attachment = (pdf_file_name(form.name, reference), await submission_pdf(db, job.tenant_id, form, submission))
        config = await connector_config(db, job.tenant_id, connector)
    try:
        await asyncio.to_thread(_send_sync, config, to, subject, message, attachment)
    except (smtplib.SMTPException, OSError, ValueError) as exc:
        raise jobs.Retry(f"the mail server said: {exc}", delay=60 * job.attempt) from None
    return "receipt sent"
