# retoor <retoor@molodetz.nl>

import json
import re

from datetime import datetime
from typing import Literal, Optional
from urllib.parse import urlsplit, urlparse
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.constants import TOPICS
from devplacepy.rendering import is_single_emoji
from devplacepy.config import (
    DEFAULT_CORRECTION_PROMPT,
    DEFAULT_MODIFIER_PROMPT,
    QUIZ_ANSWER_MAX_CHARS,
    QUIZ_MAX_OPTIONS,
    QUIZ_MAX_QUESTIONS,
    QUIZ_MAX_TIME_LIMIT_SECONDS,
)


def normalize_european_date(value):
    if not value:
        return ""
    text = str(value).strip()
    if not text:
        return ""
    for fmt in ("%d/%m/%Y", "%Y-%m-%d"):
        try:
            return datetime.strptime(text, fmt).strftime("%Y-%m-%d")
        except ValueError:
            continue
    raise ValueError("Date must be in DD/MM/YYYY format")


def normalize_website_url(value):
    if not value:
        return ""
    text = str(value).strip()
    if not text:
        return ""
    if not text.lower().startswith(("http://", "https://")):
        text = f"https://{text}"
    parsed = urlparse(text)
    if parsed.scheme not in ("http", "https") or not parsed.hostname or "." not in parsed.hostname:
        raise ValueError("Website must be a valid http(s) URL")
    return text


def normalize_poll_options(value):
    if value is None:
        return []
    if isinstance(value, str):
        value = [value]
    if not isinstance(value, list):
        return value
    if len(value) == 1 and isinstance(value[0], str):
        single = value[0]
        separator = "\n" if "\n" in single else ("," if "," in single else "")
        if separator:
            return [part.strip() for part in single.split(separator) if part.strip()]
    return value


def _declared_age(birth_date: str) -> int:
    from datetime import date

    from devplacepy.database.moderation import years_between

    normalized = normalize_european_date(birth_date)
    if not normalized:
        raise ValueError("Date of birth is required")
    born = date.fromisoformat(normalized)
    today = date.today()
    if born > today:
        raise ValueError("Date of birth cannot be in the future")
    return years_between(born, today)


class SignupForm(BaseModel):
    username: str = Field(min_length=3, max_length=32)
    email: str = Field(min_length=1, max_length=255)
    password: str = Field(min_length=6, max_length=128)
    confirm_password: str = Field(min_length=1, max_length=128)
    birth_date: str = Field(min_length=1, max_length=20)
    accept_terms: str = Field(default="")

    @property
    def age_band(self) -> str:
        from devplacepy.database.moderation import age_band_for

        return age_band_for(_declared_age(self.birth_date))

    @field_validator("birth_date")
    @classmethod
    def old_enough(cls, value):
        from devplacepy.database import minimum_age

        minimum = minimum_age()
        if _declared_age(value) < minimum:
            raise ValueError(f"You must be at least {minimum} years old to join")
        return value

    @field_validator("accept_terms")
    @classmethod
    def terms_accepted(cls, value):
        if value.strip().lower() not in ("1", "on", "true", "yes"):
            raise ValueError(
                "You must accept the Terms of Service and Community Guidelines"
            )
        return value

    @field_validator("username")
    @classmethod
    def username_chars(cls, value):
        if not value.isascii() or not all(
            c.isalnum() or c in ("-", "_") for c in value
        ):
            raise ValueError(
                "Username can only contain letters, numbers, hyphens, and underscores"
            )
        from devplacepy.services.moderation.filter import classify

        if classify(value).verdict == "block":
            raise ValueError("That username breaks the community guidelines")
        return value

    @field_validator("email")
    @classmethod
    def email_has_at(cls, value):
        if "@" not in value:
            raise ValueError("Valid email is required")
        return value

    @model_validator(mode="after")
    def passwords_match(self):
        if self.password != self.confirm_password:
            raise ValueError("Passwords do not match")
        return self


class LoginForm(BaseModel):
    email: str = Field(min_length=1, max_length=255)
    password: str = Field(min_length=1, max_length=128)
    remember_me: str = ""
    next: str = ""


class ForgotPasswordForm(BaseModel):
    email: str = Field(min_length=1, max_length=255)

    @field_validator("email")
    @classmethod
    def email_has_at(cls, value):
        if "@" not in value:
            raise ValueError("Valid email is required")
        return value


class ResetPasswordForm(BaseModel):
    password: str = Field(min_length=6, max_length=128)
    confirm_password: str = Field(min_length=1, max_length=128)

    @model_validator(mode="after")
    def passwords_match(self):
        if self.password != self.confirm_password:
            raise ValueError("Passwords do not match")
        return self


class PostForm(BaseModel):
    content: str = Field(min_length=10, max_length=125000)
    title: str = Field(default="", max_length=500)
    topic: str = "random"
    project_uid: str = Field(default="", max_length=36)
    attachment_uids: list[str] = []
    poll_question: str = Field(default="", max_length=200)
    poll_options: list[str] = []
    war_faction_a: str = Field(default="", max_length=30)
    war_faction_b: str = Field(default="", max_length=30)

    @field_validator("poll_options")
    @classmethod
    def poll_options_max_length(cls, value):
        if value is None:
            return value
        for opt in value:
            if isinstance(opt, str) and len(opt) > 200:
                raise ValueError("Each poll option must be 200 characters or fewer")
        return value

    @field_validator("topic")
    @classmethod
    def valid_topic(cls, value):
        return value if value in TOPICS else "random"

    @field_validator("poll_options", mode="before")
    @classmethod
    def split_poll_options(cls, value):
        return normalize_poll_options(value)


class WarJoinForm(BaseModel):
    faction: str

    @field_validator("faction")
    @classmethod
    def valid_faction(cls, value):
        if value not in ("a", "b"):
            raise ValueError("Faction must be a or b")
        return value


class PostEditForm(BaseModel):
    content: str = Field(min_length=10, max_length=125000)
    title: str = Field(default="", max_length=500)
    topic: str = "random"
    poll_question: str = Field(default="", max_length=200)
    poll_options: list[str] = []

    @field_validator("poll_options")
    @classmethod
    def poll_options_max_length(cls, value):
        if value is None:
            return value
        for opt in value:
            if isinstance(opt, str) and len(opt) > 200:
                raise ValueError("Each poll option must be 200 characters or fewer")
        return value

    @field_validator("topic")
    @classmethod
    def valid_topic(cls, value):
        return value if value in TOPICS else "random"

    @field_validator("poll_options", mode="before")
    @classmethod
    def split_poll_options(cls, value):
        return normalize_poll_options(value)


class CommentForm(BaseModel):
    content: str = Field(min_length=3, max_length=125000)
    target_uid: str = Field(default="", max_length=36)
    post_uid: str = Field(default="", max_length=36)
    target_type: Literal["post", "project", "news", "issue", "gist", "quiz"] = "post"
    parent_uid: str = Field(default="", max_length=36)
    attachment_uids: list[str] = []

    @model_validator(mode="after")
    def require_target(self):
        if not (self.target_uid or self.post_uid):
            raise ValueError("A target is required")
        return self


class CommentEditForm(BaseModel):
    content: str = Field(min_length=3, max_length=125000)


class ProjectForm(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: str = Field(min_length=1, max_length=5000)
    release_date: str = ""
    demo_date: str = ""
    project_type: Literal["game", "game_asset", "software", "mobile_app", "website"] = (
        "software"
    )
    platforms: str = Field(default="", max_length=500)
    status: str = Field(default="In Development", max_length=100)
    website_url: str = Field(default="", max_length=500)
    repo_url: str = Field(default="", max_length=500)
    cover_attachment_uid: str = Field(default="", max_length=64)
    logo_attachment_uid: str = Field(default="", max_length=64)
    is_private: bool = False
    attachment_uids: list[str] = []

    @field_validator("release_date", "demo_date", mode="before")
    @classmethod
    def normalize_dates(cls, value):
        return normalize_european_date(value)

    @field_validator("website_url", "repo_url")
    @classmethod
    def valid_link_url(cls, value):
        return normalize_website_url(value)


class ProjectEditForm(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: str = Field(min_length=1, max_length=5000)
    release_date: str = ""
    demo_date: str = ""
    project_type: Literal["game", "game_asset", "software", "mobile_app", "website"] = (
        "software"
    )
    platforms: str = Field(default="", max_length=500)
    status: str = Field(default="In Development", max_length=100)
    website_url: str = Field(default="", max_length=500)
    repo_url: str = Field(default="", max_length=500)
    cover_attachment_uid: str = Field(default="", max_length=64)
    logo_attachment_uid: str = Field(default="", max_length=64)

    @field_validator("release_date", "demo_date", mode="before")
    @classmethod
    def normalize_dates(cls, value):
        return normalize_european_date(value)

    @field_validator("website_url", "repo_url")
    @classmethod
    def valid_link_url(cls, value):
        return normalize_website_url(value)


class BackupRunForm(BaseModel):
    target: Literal["database", "uploads", "keys", "full"] = "full"


class BackupScheduleForm(BaseModel):
    name: str = Field(min_length=1, max_length=120)
    target: Literal["database", "uploads", "keys", "full"] = "full"
    kind: Literal["interval", "cron"] = "interval"
    every_seconds: int = Field(default=86400, ge=60)
    cron: str = Field(default="", max_length=120)
    keep_last: int = Field(default=7, ge=0, le=1000)

    @model_validator(mode="after")
    def _validate_schedule(self) -> "BackupScheduleForm":
        if self.kind == "cron" and not self.cron.strip():
            raise ValueError("A cron schedule requires a cron expression")
        return self


class ProjectFlagForm(BaseModel):
    value: bool = False


class ProjectScreenshotsForm(BaseModel):
    attachment_uids: list[str] = []


class CustomizationToggleForm(BaseModel):
    value: bool = False


class AwardGiveForm(BaseModel):
    description: str = Field(min_length=1, max_length=125)


class NotificationPrefForm(BaseModel):
    notification_type: str = Field(min_length=1, max_length=40)
    channel: Literal["in_app", "push", "telegram"]
    value: bool = False


class NotificationDefaultForm(BaseModel):
    notification_type: str = Field(min_length=1, max_length=40)
    channel: Literal["in_app", "push", "telegram"]
    value: bool = False


class AiCorrectionForm(BaseModel):
    enabled: bool = False
    sync: bool = False
    prompt: str = Field(default=DEFAULT_CORRECTION_PROMPT, max_length=20000)


class AiModifierForm(BaseModel):
    enabled: bool = False
    sync: bool = False
    prompt: str = Field(default=DEFAULT_MODIFIER_PROMPT, max_length=20000)


class InteractionsForm(BaseModel):
    enabled: bool = True
    reset: bool = False


class TelegramPairForm(BaseModel):
    action: str = Field(default="request", max_length=16)


class ForkForm(BaseModel):
    title: str = Field(min_length=1, max_length=200)


class PlanningForm(BaseModel):
    numbers: str = Field(default="", max_length=4096)


class UploadUrlForm(BaseModel):
    url: str = Field(min_length=1, max_length=2048)
    filename: Optional[str] = Field(default=None, max_length=255)


class AttachmentRenameForm(BaseModel):
    filename: str = Field(min_length=1, max_length=255)


class ProjectFileWriteForm(BaseModel):
    path: str = Field(min_length=1, max_length=1024)
    content: str = Field(default="", max_length=400000)


class ProjectFileMkdirForm(BaseModel):
    path: str = Field(min_length=1, max_length=1024)


class ProjectFileMoveForm(BaseModel):
    from_path: str = Field(min_length=1, max_length=1024)
    to_path: str = Field(min_length=1, max_length=1024)


class ProjectFileDeleteForm(BaseModel):
    path: str = Field(min_length=1, max_length=1024)


class ProjectFileReplaceLinesForm(BaseModel):
    path: str = Field(min_length=1, max_length=1024)
    start: int = Field(ge=1)
    end: int = Field(ge=0)
    content: str = Field(default="", max_length=400000)


class ProjectFileInsertLinesForm(BaseModel):
    path: str = Field(min_length=1, max_length=1024)
    at: int = Field(ge=1)
    content: str = Field(default="", max_length=400000)


class ProjectFileDeleteLinesForm(BaseModel):
    path: str = Field(min_length=1, max_length=1024)
    start: int = Field(ge=1)
    end: int = Field(ge=1)


class ProjectFileAppendForm(BaseModel):
    path: str = Field(min_length=1, max_length=1024)
    content: str = Field(default="", max_length=400000)


class ContainerInstanceForm(BaseModel):
    name: str = Field(min_length=1, max_length=64)
    boot_command: str = Field(default="", max_length=500)
    boot_language: str = Field(default="none", max_length=10)
    boot_script: str = Field(default="", max_length=100000)
    run_as_uid: str = Field(default="", max_length=36)
    start_on_boot: bool = False
    env: str = Field(default="", max_length=10000)
    cpu_limit: str = Field(default="", max_length=16)
    mem_limit: str = Field(default="", max_length=16)
    ports: str = Field(default="", max_length=500)
    volumes: str = Field(default="", max_length=2000)
    restart_policy: str = Field(default="never", max_length=20)
    autostart: bool = True
    ingress_slug: str = Field(default="", max_length=64)
    ingress_port: Optional[int] = Field(default=None, ge=1, le=65535)

    @field_validator("ingress_port", mode="before")
    @classmethod
    def _blank_ingress_port(cls, value):
        if value is None or (isinstance(value, str) and not value.strip()):
            return None
        return value


class ContainerAdminCreateForm(ContainerInstanceForm):
    project_slug: str = Field(min_length=1, max_length=128)


class ContainerEditForm(BaseModel):
    run_as_uid: str = Field(default="", max_length=36)
    boot_language: str = Field(default="none", max_length=10)
    boot_script: str = Field(default="", max_length=100000)
    boot_command: str = Field(default="", max_length=500)
    restart_policy: str = Field(default="never", max_length=20)
    start_on_boot: bool = False
    cpu_limit: str = Field(default="", max_length=16)
    mem_limit: str = Field(default="", max_length=16)


class ContainerExecForm(BaseModel):
    command: str = Field(min_length=1, max_length=2000)


class ContainerScheduleForm(BaseModel):
    action: str = Field(max_length=10)
    kind: str = Field(max_length=10)
    cron: str = Field(default="", max_length=120)
    run_at: str = Field(default="", max_length=40)
    delay_seconds: Optional[int] = Field(default=None, ge=1)
    every_seconds: Optional[int] = Field(default=None, ge=1)
    max_runs: Optional[int] = Field(default=None, ge=1)


class MessageForm(BaseModel):
    content: str = Field(min_length=0, max_length=2000)
    receiver_uid: str = Field(min_length=1, max_length=36)
    attachment_uids: list[str] = []
    client_id: Optional[str] = Field(default=None, max_length=64)


class ProfileForm(BaseModel):
    bio: str = Field(default="", max_length=500)
    location: str = Field(default="", max_length=200)
    git_link: str = Field(default="", max_length=500)
    website: str = Field(default="", max_length=500)


class GistForm(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: str = Field(default="", max_length=5000)
    source_code: str = Field(min_length=1, max_length=400000)
    language: str = Field(default="plaintext", max_length=50)
    attachment_uids: list[str] = []


class GistEditForm(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: str = Field(default="", max_length=5000)
    source_code: str = Field(min_length=1, max_length=400000)
    language: str = Field(default="plaintext", max_length=50)


class IssueForm(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: str = Field(min_length=1, max_length=5000)
    attachment_uids: list[str] = []


class IssueCommentForm(BaseModel):
    body: str = Field(min_length=1, max_length=5000)
    attachment_uids: list[str] = []


class IssueAttachmentForm(BaseModel):
    attachment_uids: list[str] = []


class IssueStatusForm(BaseModel):
    status: Literal["open", "closed"]


class VoteForm(BaseModel):
    value: int

    @field_validator("value")
    @classmethod
    def valid_value(cls, value):
        if value not in (1, -1):
            raise ValueError("value must be 1 or -1")
        return value


class ReactionForm(BaseModel):
    emoji: str = Field(min_length=1, max_length=16)

    @field_validator("emoji")
    @classmethod
    def valid_emoji(cls, value):
        reaction = (value or "").strip()
        if not is_single_emoji(reaction):
            raise ValueError("Reaction must be a single emoji")
        return reaction


class PollVoteForm(BaseModel):
    option_uid: str = Field(min_length=1, max_length=36)


class SeoRunForm(BaseModel):
    url: str = Field(min_length=3, max_length=2000)
    mode: Literal["url", "sitemap"] = "url"
    max_pages: int = Field(default=10, ge=1, le=50)

    @field_validator("url")
    @classmethod
    def url_scheme(cls, value):
        text = value.strip()
        if not text:
            raise ValueError("A URL is required")
        if "://" in text:
            scheme = text.split("://", 1)[0]
            if scheme not in ("http", "https"):
                raise ValueError(f"Only http and https URLs are allowed; got '{scheme}://'")
        else:
            text = f"https://{text}"
        if not SEO_URL_PATTERN.match(text):
            raise ValueError("URL must be a valid http or https source location")
        return text


SEO_URL_PATTERN = re.compile(r"^https?://[a-zA-Z0-9][\w./:@~^?&#%=;-]*$")

ISSLOP_URL_PATTERN = re.compile(r"^(https?://|git://|ssh://|git@)[\w./:@~^-]+$", re.IGNORECASE)
ISSLOP_SINGLE_SLASH_PATTERN = re.compile(r"^(https?|git|ssh):/(?!/)", re.IGNORECASE)
ISSLOP_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)


class IsslopRunForm(BaseModel):
    url: str = Field(min_length=4, max_length=2048)

    @field_validator("url")
    @classmethod
    def url_scheme(cls, value):
        text = value.strip()
        text = ISSLOP_SINGLE_SLASH_PATTERN.sub(lambda match: f"{match.group(1)}://", text)
        if not ISSLOP_SCHEME_PATTERN.match(text) and not text.startswith("git@"):
            text = f"https://{text}"
        if not ISSLOP_URL_PATTERN.match(text):
            raise ValueError("URL must be an http(s), git or ssh source location")
        return text


DEEPSEARCH_MIN_DEPTH = 1
DEEPSEARCH_MAX_DEPTH = 4
DEEPSEARCH_DEFAULT_DEPTH = 2
DEEPSEARCH_MIN_PAGES = 1
DEEPSEARCH_MAX_PAGES = 30
DEEPSEARCH_DEFAULT_PAGES = 12
DEEPSEARCH_MIN_QUERY = 3
DEEPSEARCH_MAX_QUERY = 500
DEEPSEARCH_MAX_MESSAGE = 2000


class DeepsearchRunForm(BaseModel):
    query: str = Field(min_length=DEEPSEARCH_MIN_QUERY, max_length=DEEPSEARCH_MAX_QUERY)
    depth: int = Field(
        default=DEEPSEARCH_DEFAULT_DEPTH,
        ge=DEEPSEARCH_MIN_DEPTH,
        le=DEEPSEARCH_MAX_DEPTH,
    )
    max_pages: int = Field(
        default=DEEPSEARCH_DEFAULT_PAGES,
        ge=DEEPSEARCH_MIN_PAGES,
        le=DEEPSEARCH_MAX_PAGES,
    )

    @field_validator("query")
    @classmethod
    def query_present(cls, value):
        text = value.strip()
        if not text:
            raise ValueError("A research question is required")
        return text


class DeepsearchChatForm(BaseModel):
    message: str = Field(min_length=1, max_length=DEEPSEARCH_MAX_MESSAGE)

    @field_validator("message")
    @classmethod
    def message_present(cls, value):
        text = value.strip()
        if not text:
            raise ValueError("A message is required")
        return text


class AdminRoleForm(BaseModel):
    role: Literal["member", "admin"]


class AdminPasswordForm(BaseModel):
    password: str = Field(min_length=6, max_length=128)


class AdminSettingsForm(BaseModel):
    site_name: str = Field(default="", max_length=200)
    site_description: str = Field(default="", max_length=500)
    site_tagline: str = Field(default="", max_length=500)
    site_url: str = Field(default="", max_length=300)
    ios_app_url: str = Field(default="", max_length=300)
    max_upload_size_mb: str = Field(default="", max_length=10)
    allowed_file_types: str = Field(default="", max_length=1000)
    max_attachments_per_resource: str = Field(default="", max_length=10)
    rate_limit_per_minute: str = Field(default="", max_length=10)
    rate_limit_window_seconds: str = Field(default="", max_length=10)
    session_max_age_days: str = Field(default="", max_length=10)
    session_remember_days: str = Field(default="", max_length=10)
    registration_open: str = Field(default="", max_length=1)
    maintenance_mode: str = Field(default="", max_length=1)
    maintenance_message: str = Field(default="", max_length=300)
    docs_search_mode: str = Field(default="", max_length=20)
    outbound_proxy_url: str = Field(default="", max_length=500)
    moderation_sla_hours: str = Field(default="", max_length=10)
    moderation_filter_mode: str = Field(default="", max_length=10)
    moderation_minimum_age: str = Field(default="", max_length=3)
    moderation_mature_default_hidden: str = Field(default="", max_length=1)
    account_deletion_grace_hours: str = Field(default="", max_length=10)
    contact_email: str = Field(default="", max_length=200)
    contact_phone: str = Field(default="", max_length=60)
    contact_address: str = Field(default="", max_length=500)
    terms_version: str = Field(default="", max_length=20)
    privacy_version: str = Field(default="", max_length=20)
    guidelines_version: str = Field(default="", max_length=20)
    ai_third_party_provider: str = Field(default="", max_length=120)
    correction_model: str = Field(default="", max_length=120)
    modifier_model: str = Field(default="", max_length=120)
    quiz_grading_model: str = Field(default="", max_length=120)
    extra_head: str = Field(default="", max_length=50000)

    @field_validator("moderation_filter_mode")
    @classmethod
    def validate_filter_mode(cls, value):
        from devplacepy.services.moderation.rules import FILTER_MODES

        if value and value not in FILTER_MODES:
            raise ValueError(f"Filter mode must be one of {', '.join(FILTER_MODES)}")
        return value

    @field_validator("outbound_proxy_url")
    @classmethod
    def validate_outbound_proxy_url(cls, value):
        text = value.strip()
        if not text:
            return text
        parsed = urlsplit(text)
        if parsed.scheme not in ("http", "https", "socks5", "socks5h") or not parsed.hostname:
            raise ValueError("Proxy URL must be http(s):// or socks5(h):// with a host, e.g. http://user:pass@host:port")
        return text


class GamePlantForm(BaseModel):
    slot: int = Field(ge=0, le=64)
    crop: str = Field(min_length=1, max_length=40)


class GameSlotForm(BaseModel):
    slot: int = Field(ge=0, le=64)


class GamePerkForm(BaseModel):
    perk: str = Field(min_length=1, max_length=40)


class GameQuestForm(BaseModel):
    quest: str = Field(min_length=1, max_length=40)
    scope: str = Field(default="daily", min_length=1, max_length=10)


class GameLegacyForm(BaseModel):
    key: str = Field(min_length=1, max_length=40)


class GameInfraForm(BaseModel):
    key: str = Field(min_length=1, max_length=40)


class GameCosmeticForm(BaseModel):
    key: str = Field(min_length=1, max_length=40)


class GameMasteryForm(BaseModel):
    key: str = Field(min_length=1, max_length=40)


class GameEraStartForm(BaseModel):
    name: str = Field(min_length=1, max_length=60)
    duration_days: int = Field(default=28, ge=1, le=180)


QUIZ_KINDS = (
    "single_choice",
    "multiple_choice",
    "true_false",
    "free_text",
    "fill_blank",
    "numeric",
    "ordering",
    "matching",
)

QUIZ_OPTION_KINDS = frozenset(
    {"single_choice", "multiple_choice", "fill_blank", "ordering", "matching"}
)


def normalize_index_list(value):
    parts = normalize_poll_options(value)
    if not isinstance(parts, list):
        return []
    indexes = []
    for part in parts:
        try:
            indexes.append(int(str(part).strip()))
        except (TypeError, ValueError):
            continue
    return indexes


class QuizForm(BaseModel):
    title: str = Field(min_length=3, max_length=200)
    description: str = Field(default="", max_length=5000)
    shuffle_questions: bool = False
    shuffle_options: bool = False
    reveal_answers: bool = False
    allow_review: bool = True
    time_limit_seconds: int = Field(default=0, ge=0, le=QUIZ_MAX_TIME_LIMIT_SECONDS)
    pass_percent: int = Field(default=0, ge=0, le=100)


class QuizQuestionForm(BaseModel):
    kind: Literal[QUIZ_KINDS]
    prompt: str = Field(min_length=1, max_length=2000)
    explanation: str = Field(default="", max_length=2000)
    points: int = Field(default=1, ge=1, le=100)
    media_attachment_uid: str = Field(default="", max_length=36)
    correct_boolean: bool = False
    expected_answer: str = Field(default="", max_length=2000)
    grading_criteria: str = Field(default="", max_length=2000)
    numeric_value: float = 0.0
    numeric_tolerance: float = Field(default=0.0, ge=0.0)
    case_sensitive: bool = False
    options: list[str] = []
    match_values: list[str] = []
    correct_indexes: list[int] = []

    @field_validator("options", "match_values", mode="before")
    @classmethod
    def split_lists(cls, value):
        return normalize_poll_options(value)

    @field_validator("correct_indexes", mode="before")
    @classmethod
    def split_indexes(cls, value):
        return normalize_index_list(value)

    @field_validator("options", "match_values")
    @classmethod
    def bounded_options(cls, value):
        if len(value) > QUIZ_MAX_OPTIONS:
            raise ValueError(f"A question takes at most {QUIZ_MAX_OPTIONS} options")
        for entry in value:
            if len(entry) > 500:
                raise ValueError("Each option must be 500 characters or fewer")
        return value

    @model_validator(mode="after")
    def kind_requirements(self):
        options = [option for option in self.options if option.strip()]
        if self.kind in QUIZ_OPTION_KINDS and not options:
            raise ValueError("This question type needs at least one option")
        if self.kind in ("single_choice", "multiple_choice") and len(options) < 2:
            raise ValueError("Choice questions need at least two options")
        if self.kind == "single_choice" and len(self.correct_indexes) != 1:
            raise ValueError("A single choice question needs exactly one correct option")
        if self.kind == "multiple_choice" and not self.correct_indexes:
            raise ValueError("A multiple choice question needs at least one correct option")
        if self.kind in ("fill_blank", "matching") and len(self.match_values) < len(options):
            raise ValueError("Every option needs an accepted answer")
        if self.kind == "matching" and len(options) < 2:
            raise ValueError("A matching question needs at least two pairs")
        if self.kind == "ordering" and len(options) < 2:
            raise ValueError("An ordering question needs at least two items")
        if self.kind == "free_text" and not (
            self.expected_answer.strip() or self.grading_criteria.strip()
        ):
            raise ValueError("A free text question needs a reference answer or grading criteria")
        return self

    def option_rows(self) -> list[dict]:
        rows = []
        for index, label in enumerate(self.options):
            if not label.strip():
                continue
            match_value = (
                self.match_values[index] if index < len(self.match_values) else ""
            )
            rows.append(
                {
                    "label": label.strip(),
                    "match_value": match_value.strip(),
                    "is_correct": index in set(self.correct_indexes),
                }
            )
        return rows


class QuizReorderForm(BaseModel):
    order: list[str] = []

    @field_validator("order", mode="before")
    @classmethod
    def split_order(cls, value):
        return normalize_poll_options(value)

    @model_validator(mode="after")
    def require_order(self):
        if not self.order:
            raise ValueError("The new question order is required")
        return self


class QuizAnswerForm(BaseModel):
    question_uid: str = Field(min_length=1, max_length=36)
    answer_text: str = Field(default="", max_length=QUIZ_ANSWER_MAX_CHARS)
    option_uids: list[str] = []
    blanks: list[str] = []
    matches: list[str] = []

    @field_validator("option_uids", "blanks", "matches", mode="before")
    @classmethod
    def split_lists(cls, value):
        return normalize_poll_options(value)

    def submission(self) -> dict:
        if self.blanks:
            return {
                "answer_text": json.dumps(self.blanks, ensure_ascii=False),
                "option_uids": self.option_uids,
            }
        if self.matches:
            pairs = dict(zip(self.option_uids, self.matches))
            return {
                "answer_text": json.dumps(pairs, ensure_ascii=False),
                "option_uids": self.option_uids,
            }
        return {"answer_text": self.answer_text, "option_uids": self.option_uids}


class QuizDocumentOption(BaseModel):
    label: str = Field(default="", max_length=500)
    match_value: str = Field(default="", max_length=500)
    is_correct: bool = False


class QuizDocumentQuestion(BaseModel):
    kind: Literal[QUIZ_KINDS]
    prompt: str = Field(min_length=1, max_length=2000)
    explanation: str = Field(default="", max_length=2000)
    points: int = Field(default=1, ge=1, le=100)
    media_attachment_uid: str = Field(default="", max_length=36)
    correct_boolean: bool = False
    expected_answer: str = Field(default="", max_length=2000)
    grading_criteria: str = Field(default="", max_length=2000)
    numeric_value: float = 0.0
    numeric_tolerance: float = Field(default=0.0, ge=0.0)
    case_sensitive: bool = False
    options: list[QuizDocumentOption] = Field(default_factory=list, max_length=QUIZ_MAX_OPTIONS)

    @model_validator(mode="after")
    def kind_requirements(self):
        labelled = [option for option in self.options if option.label.strip()]
        if self.kind in ("single_choice", "multiple_choice") and len(labelled) < 2:
            raise ValueError("Choice questions need at least two options")
        if self.kind == "single_choice" and sum(
            1 for option in labelled if option.is_correct
        ) != 1:
            raise ValueError("A single choice question needs exactly one correct option")
        if self.kind == "multiple_choice" and not any(
            option.is_correct for option in labelled
        ):
            raise ValueError("A multiple choice question needs at least one correct option")
        if self.kind in ("ordering", "matching") and len(labelled) < 2:
            raise ValueError("This question type needs at least two entries")
        if self.kind == "matching" and any(
            not option.match_value.strip() for option in labelled
        ):
            raise ValueError("Every matching pair needs a right-hand value")
        if self.kind == "fill_blank" and any(
            not option.match_value.strip() for option in labelled
        ):
            raise ValueError("Every blank needs an accepted answer")
        if self.kind == "free_text" and not (
            self.expected_answer.strip() or self.grading_criteria.strip()
        ):
            raise ValueError("A free text question needs a reference answer or grading criteria")
        return self


class QuizDocumentSettings(BaseModel):
    shuffle_questions: bool = False
    shuffle_options: bool = False
    reveal_answers: bool = False
    allow_review: bool = True
    time_limit_seconds: int = Field(default=0, ge=0, le=QUIZ_MAX_TIME_LIMIT_SECONDS)
    pass_percent: int = Field(default=0, ge=0, le=100)


class QuizDocument(BaseModel):
    title: str = Field(min_length=3, max_length=200)
    description: str = Field(default="", max_length=5000)
    settings: QuizDocumentSettings = Field(default_factory=QuizDocumentSettings)
    questions: list[QuizDocumentQuestion] = Field(
        default_factory=list, max_length=QUIZ_MAX_QUESTIONS
    )

    @model_validator(mode="after")
    def require_questions(self):
        if not self.questions:
            raise ValueError("A quiz document needs at least one question")
        return self


class QuizImportForm(BaseModel):
    document: QuizDocument

    @field_validator("document", mode="before")
    @classmethod
    def parse_document(cls, value):
        if isinstance(value, str):
            try:
                return json.loads(value)
            except ValueError as exc:
                raise ValueError("document must be valid JSON") from exc
        return value


class TunnelForm(BaseModel):
    label: str = Field(default="", max_length=64)
    container_port: int = Field(default=0, ge=0, le=65535)


class EditorPrefsForm(BaseModel):
    font_size: int = Field(default=0, ge=0, le=48)
    terminal_font_size: int = Field(default=0, ge=0, le=48)
    zoom_level: int = Field(default=-99, ge=-99, le=5)
    theme: str = Field(default="", max_length=32)
    layout: str = Field(default="", max_length=32)
    panel_preset: str = Field(default="", max_length=32)
    window_mode: str = Field(default="", max_length=32)
    window_width: int = Field(default=0, ge=0, le=7680)
    window_height: int = Field(default=0, ge=0, le=4320)
    boot_agent: str = Field(default="", max_length=32)
    boot_shell: int = Field(default=-1, ge=-1, le=1)
    reset: bool = False


class WorkspaceQuotaForm(BaseModel):
    owner_id: str = Field(default="", max_length=36)
    label: str = Field(default="", max_length=64)
    max_workspaces: int = Field(default=0, ge=0, le=100)
    max_tunnels: int = Field(default=0, ge=0, le=100)
    disk_quota_mb: int = Field(default=0, ge=0)
    egress_quota_mb: int = Field(default=0, ge=0)
    idle_stop_minutes: int = Field(default=0, ge=0)
    retention_days: int = Field(default=0, ge=0)
    cpu_millicores: int = Field(default=0, ge=0, le=64000)
    memory_mb: int = Field(default=0, ge=0, le=1048576)


class WorkspaceFlagForm(BaseModel):
    kind: str = Field(default="manual", max_length=40)
    severity: str = Field(default="warn", max_length=16)
    detail: str = Field(default="", max_length=500)


class WorkspaceSuspendForm(BaseModel):
    reason: str = Field(default="", max_length=500)


class ReportForm(BaseModel):
    reason: str = Field(min_length=1, max_length=40)
    detail: str = Field(default="", max_length=2000)

    @field_validator("reason")
    @classmethod
    def known_reason(cls, value):
        from devplacepy.database.moderation import REPORT_REASONS

        if value not in REPORT_REASONS:
            raise ValueError("Unknown report reason")
        return value


class ReportStatusForm(BaseModel):
    status: str = Field(default="acknowledged", max_length=20)

    @field_validator("status")
    @classmethod
    def known_status(cls, value):
        from devplacepy.database.moderation import REPORT_STATUSES

        if value not in REPORT_STATUSES:
            raise ValueError("Unknown report status")
        return value


class ModerationDecisionForm(BaseModel):
    action: str = Field(min_length=1, max_length=30)
    reason: str = Field(default="", max_length=200)
    notes: str = Field(default="", max_length=2000)
    duration_hours: int = Field(default=24, ge=1, le=8760)

    @field_validator("action")
    @classmethod
    def known_action(cls, value):
        from devplacepy.database.moderation import MODERATION_ACTIONS

        if value not in MODERATION_ACTIONS:
            raise ValueError("Unknown moderation action")
        return value


class SuspensionForm(BaseModel):
    reason: str = Field(default="", max_length=200)
    duration_hours: int = Field(default=24, ge=1, le=8760)


class BanForm(BaseModel):
    reason: str = Field(default="", max_length=200)


class ConsentForm(BaseModel):
    kind: str = Field(min_length=1, max_length=40)
    granted: str = Field(default="0", max_length=5)

    @field_validator("kind")
    @classmethod
    def known_kind(cls, value):
        from devplacepy.database.moderation import CONSENT_KINDS

        if value not in CONSENT_KINDS:
            raise ValueError("Unknown consent kind")
        return value


class MaturityForm(BaseModel):
    level: str = Field(default="general", max_length=20)

    @field_validator("level")
    @classmethod
    def known_level(cls, value):
        from devplacepy.database.moderation import MATURITY_LEVELS

        if value not in MATURITY_LEVELS:
            raise ValueError("Unknown maturity level")
        return value


class MaturePreferenceForm(BaseModel):
    mature_opt_in: str = Field(default="0", max_length=5)


class AccountDeleteForm(BaseModel):
    password: str = Field(min_length=1, max_length=128)
    confirm_text: str = Field(default="", max_length=40)

