r"""Pydantic schemas for the Email Templates module (INDL-32).

Security note (SEC-02 / SEC-09 / A03 / A08):
Templates are rendered via plain ``str.replace`` merge-field substitution —
no Jinja2/Handlebars/eval is ever used server-side. To keep that contract
honest, these schemas reject at the API boundary (422) any subject/body that:
  - contains obvious XSS/script vectors (``<script``, ``javascript:``, or
    any HTML event-handler attribute such as ``onerror=``, ``onload=``,
    ``onmouseover=``, ``onfocus=``, etc. via a ``\bon\w+\s*=`` regex)
  - contains Handlebars-style block/partial syntax (``{{#``, ``{{>``) which
    has no meaning here and signals an attempt to probe a template engine
  - contains a ``{{...}}`` placeholder whose inner content is not a bare
    lowercase snake_case identifier (blocks ``{{ 7*7 }}``,
    ``{{ ''.__class__ }}``, ``{{constructor.constructor(...)}}``, etc.)

All of the above checks run against a whitespace-normalized, lowercased
copy of the value (``" ".join(value.lower().split())``) so that inserting
newlines/tabs/extra spaces into a forbidden token (``<scr\nipt>``,
``java\tscript:``) can no longer bypass detection. The stored/returned
value itself is never mutated — only the detection copy is normalized.
"""
from __future__ import annotations

import re
from datetime import datetime
from typing import Literal, Optional
from uuid import UUID

from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator

_FORBIDDEN_SUBSTRINGS = (
    "<script",
    "javascript:",
    "{{#",
    "{{>",
)
# NOTE: onerror=/onload= used to be listed here as literal substrings, but
# that missed every other HTML event-handler attribute (onmouseover=,
# onfocus=, onclick=, ...) and was defeated by injecting whitespace before
# the "=". _EVENT_HANDLER_ATTR_PATTERN below replaces them with a single
# regex that covers all `on<word>=` attributes.

# A legitimate merge field looks like {{purchase_name}} — bare lowercase
# snake_case identifier only. Anything else inside {{ }} is rejected.
_MERGE_FIELD_PATTERN = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
_SAFE_IDENTIFIER = re.compile(r"^[a-z][a-z0-9_]*$")

# Catches any HTML event-handler attribute (onerror=, onload=, onmouseover=,
# onfocus=, onclick=, ...), including a space before "=" (e.g. "onerror =").
_EVENT_HANDLER_ATTR_PATTERN = re.compile(r"\bon\w+\s*=")


def _validate_no_injection(value: str) -> str:
    if value is None:
        return value
    # Detect against normalized+lowered copies only — whitespace/newline
    # splitting must not defeat the substring/regex checks below. The
    # original `value` is still what gets stored/returned; we never mutate
    # it, we only transform the copy used for detection.
    #
    # Two normalized variants are checked:
    #   - `normalized_lower` collapses whitespace runs to a single space
    #     (" ".join(value.lower().split())) — used for the event-handler
    #     regex, where a single space is a meaningful separator (e.g.
    #     "onerror =alert(1)").
    #   - `stripped_lower` removes ALL whitespace entirely — used for the
    #     forbidden-substring checks, so that whitespace injected *inside* a
    #     token (e.g. "<scr\nipt>", "java\tscript:") can no longer split it
    #     apart and evade a plain substring match.
    normalized_lower = " ".join(value.lower().split())
    stripped_lower = re.sub(r"\s+", "", value.lower())
    for token in _FORBIDDEN_SUBSTRINGS:
        if token in normalized_lower or token in stripped_lower:
            raise ValueError(
                f"Value contains a disallowed pattern: {token!r}"
            )
    if _EVENT_HANDLER_ATTR_PATTERN.search(normalized_lower) or _EVENT_HANDLER_ATTR_PATTERN.search(
        stripped_lower
    ):
        raise ValueError(
            "Value contains a disallowed pattern: HTML event-handler attribute"
        )
    for match in _MERGE_FIELD_PATTERN.finditer(value):
        inner = match.group(1).strip()
        if not _SAFE_IDENTIFIER.match(inner):
            raise ValueError(
                "Merge fields must be bare identifiers like {{purchaser_name}}; "
                f"{{{{{match.group(1)}}}}} is not allowed"
            )
    return value


class EmailTemplateCreate(BaseModel):
    name: str = Field(..., max_length=100)
    subject: str = Field(..., max_length=200)
    body: str = Field(..., max_length=5000)
    status: Literal["active", "inactive"] = "active"

    @field_validator("name", "subject", "body")
    @classmethod
    def _validate_content(cls, value: str) -> str:
        return _validate_no_injection(value)


class EmailTemplateUpdate(BaseModel):
    name: Optional[str] = Field(None, max_length=100)
    subject: Optional[str] = Field(None, max_length=200)
    body: Optional[str] = Field(None, max_length=5000)
    status: Optional[Literal["active", "inactive"]] = None

    @field_validator("name", "subject", "body")
    @classmethod
    def _validate_content(cls, value: Optional[str]) -> Optional[str]:
        if value is None:
            return value
        return _validate_no_injection(value)


class EmailTemplatePatch(BaseModel):
    status: Literal["active", "inactive"]


class EmailTemplateTestRequest(BaseModel):
    """Optional override recipient for POST /email-templates/{id}/test.

    Defaults to the requesting user's own email when omitted/empty. Any
    address is accepted here — unlike the earlier SEC-10 restriction that
    pinned this to the requester's own email, the send-test rate limit
    (5/hour per user+template, fail-closed if Redis is unreachable) plus
    audit logging on every send are the accepted abuse controls now that
    an admin can target any address, per explicit product decision.
    """

    to_email: Optional[EmailStr] = None


class EmailTemplateResponse(BaseModel):
    id: UUID
    template_key: Optional[str]
    is_system: bool
    name: str
    description: Optional[str]
    subject: str
    body: str
    status: str
    merge_fields: Optional[list[str]]
    created_at: datetime
    updated_at: datetime

    model_config = ConfigDict(from_attributes=True)
