"""College KT portal with admin, lead and user logins.

admin: manage users + everything a lead can do
lead : add / edit / delete colleges and their KT
user : search and view colleges and their KT
"""
import datetime
import hashlib
import hmac
import html
import json
import mimetypes
import os
import re
import secrets
import smtplib
import sqlite3
import threading
import time
import urllib.parse
import urllib.request
from email.message import EmailMessage
from email.utils import formataddr
from functools import wraps
from html.parser import HTMLParser
from pathlib import Path

from flask import (Flask, abort, flash, g, redirect, render_template, request,
                   send_from_directory, session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename

DB_PATH = Path(__file__).with_name("colleges.db")
KEY_PATH = Path(__file__).with_name(".secret_key")
GOOGLE_CONFIG_PATH = Path(__file__).with_name("google_oauth.json")
DOMAINS_PATH = Path(__file__).with_name("allowed_domains.json")
SMTP_CONFIG_PATH = Path(__file__).with_name("smtp_config.json")
UPLOAD_DIR = Path(__file__).with_name("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
ALLOWED_UPLOAD_EXT = {
    "pdf", "doc", "docx", "dot", "dotx", "rtf", "odt", "txt", "md", "log", "csv", "tsv",
    "xls", "xlsx", "xlsm", "ods", "ppt", "pptx", "pps", "ppsx", "odp",
    "png", "jpg", "jpeg", "gif", "webp", "zip",
}
MAX_UPLOAD_BYTES = 15 * 1024 * 1024  # 15 MB
APP_NAME = "College KT Portal"
DEFAULT_DOMAINS = ("gmail.com",)
TITLES = ("Dr.", "Mr.", "Ms.", "Mrs.", "Prof.")
KINDS = (
    ("short", "Short answer"), ("paragraph", "Paragraph"), ("multiple_choice", "Multiple choice"),
    ("checkboxes", "Checkboxes"), ("dropdown", "Drop-down"), ("date", "Date"), ("time", "Time"),
)
KIND_LABELS = dict(KINDS)
CHOICE_KINDS = ("multiple_choice", "checkboxes", "dropdown")

BLOCK_TYPES = (
    ("form", "Add Form", "list",
     "A single structured question: short answer, paragraph, multiple choice, checkboxes, drop-down, date or time."),
    ("attachment", "Add Attachment", "paperclip",
     "Upload a file for people to download - PDF, Word, Excel, PowerPoint, image, text, CSV or ZIP."),
    ("embed", "Add Embedded Media", "film",
     "Show a video or another page right on the card."),
    ("link", "Add Link", "link",
     "A button that opens another page in a new tab."),
    ("alert", "Add Alert", "alert",
     "A highlighted callout: Info, Warning or Critical."),
    ("description", "Add Description", "file-text",
     "Plain text - the simplest option."),
    ("reminder", "Add Reminder", "calendar",
     "Track when availability was last updated and when it's due to be checked again."),
    ("faculty", "Add Faculty Specific KT", "user",
     "KT that belongs to one faculty member - pick them from the Faculty List."),
)
BLOCK_META = {v: {"label": l, "icon": ic, "hint": h} for v, l, ic, h in BLOCK_TYPES}


# US-only timezone list (the college portal covers US institutions), sourced from the IANA
# tz database's official "US" entries (zone.tab), grouped by common name.
TIMEZONE_GROUPS = (
    ("Eastern Time", (
        ("America/New_York", "New York (most areas)"),
        ("America/Detroit", "Detroit, Michigan"),
        ("America/Kentucky/Louisville", "Louisville, Kentucky"),
        ("America/Kentucky/Monticello", "Monticello, Kentucky (Wayne County)"),
        ("America/Indiana/Indianapolis", "Indianapolis, Indiana (most areas)"),
        ("America/Indiana/Vincennes", "Vincennes, Indiana"),
        ("America/Indiana/Winamac", "Winamac, Indiana (Pulaski County)"),
        ("America/Indiana/Marengo", "Marengo, Indiana (Crawford County)"),
        ("America/Indiana/Petersburg", "Petersburg, Indiana (Pike County)"),
        ("America/Indiana/Vevay", "Vevay, Indiana (Switzerland County)"),
    )),
    ("Central Time", (
        ("America/Chicago", "Chicago (most areas)"),
        ("America/Indiana/Tell_City", "Tell City, Indiana (Perry County)"),
        ("America/Indiana/Knox", "Knox, Indiana (Starke County)"),
        ("America/Menominee", "Menominee, Michigan"),
        ("America/North_Dakota/Center", "Center, North Dakota (Oliver County)"),
        ("America/North_Dakota/New_Salem", "New Salem, North Dakota (Morton County)"),
        ("America/North_Dakota/Beulah", "Beulah, North Dakota (Mercer County)"),
    )),
    ("Mountain Time", (
        ("America/Denver", "Denver (most areas)"),
        ("America/Boise", "Boise, Idaho / eastern Oregon"),
        ("America/Phoenix", "Phoenix, Arizona (no daylight saving)"),
    )),
    ("Pacific Time", (
        ("America/Los_Angeles", "Los Angeles (most areas)"),
    )),
    ("Alaska Time", (
        ("America/Anchorage", "Anchorage (most areas)"),
        ("America/Juneau", "Juneau"),
        ("America/Sitka", "Sitka"),
        ("America/Metlakatla", "Metlakatla (Annette Island)"),
        ("America/Yakutat", "Yakutat"),
        ("America/Nome", "Nome (western Alaska)"),
        ("America/Adak", "Adak (western Aleutians)"),
    )),
    ("Hawaii Time", (
        ("Pacific/Honolulu", "Honolulu"),
    )),
)
TIMEZONE_SET = frozenset(z for _, zones in TIMEZONE_GROUPS for z, _ in zones)
FACULTY_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
EMAIL_RE = re.compile(r"[a-z0-9._+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)")
ROLES = ("admin", "lead", "user")
MANAGED_ROLES = ("lead", "user")  # roles an admin may create or assign

app = Flask(__name__)


def _icon(name, cls=""):
    """An inline SVG icon from the sprite in base.html, e.g. {{ icon('search') }}."""
    from markupsafe import Markup
    return Markup(f'<svg class="ic {html.escape(cls)}" aria-hidden="true" focusable="false">'
                  f'<use href="#i-{html.escape(name)}"></use></svg>')


app.jinja_env.globals["icon"] = _icon
if not KEY_PATH.exists():
    KEY_PATH.write_text(secrets.token_hex(32))
app.secret_key = KEY_PATH.read_text()


def db():
    if "db" not in g:
        g.db = sqlite3.connect(DB_PATH)
        g.db.row_factory = sqlite3.Row
        g.db.execute("PRAGMA foreign_keys = ON")
    return g.db


@app.teardown_appcontext
def close_db(_):
    conn = g.pop("db", None)
    if conn:
        conn.close()


def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.executescript(
        """
        CREATE TABLE IF NOT EXISTS colleges (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL UNIQUE COLLATE NOCASE,
            url TEXT,
            location TEXT,
            timezone TEXT
        );
        CREATE TABLE IF NOT EXISTS kts (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            college_id INTEGER NOT NULL REFERENCES colleges(id) ON DELETE CASCADE,
            title TEXT NOT NULL DEFAULT '',
            text TEXT NOT NULL,
            kind TEXT NOT NULL DEFAULT 'paragraph',
            options TEXT,
            faculty_id INTEGER REFERENCES faculty(id) ON DELETE SET NULL
        );
        CREATE TABLE IF NOT EXISTS faculty (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL CHECK (title IN ('Dr.', 'Mr.', 'Ms.', 'Mrs.', 'Prof.')),
            first_name TEXT NOT NULL,
            last_name TEXT NOT NULL,
            email TEXT NOT NULL UNIQUE COLLATE NOCASE
        );
        CREATE TABLE IF NOT EXISTS login_otps (
            email TEXT PRIMARY KEY COLLATE NOCASE,
            code_hash TEXT NOT NULL,
            expires_at INTEGER NOT NULL,
            attempts INTEGER NOT NULL DEFAULT 0
        );
        CREATE TABLE IF NOT EXISTS otp_log (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            email TEXT NOT NULL,
            ip TEXT NOT NULL,
            ts INTEGER NOT NULL
        );
        CREATE TABLE IF NOT EXISTS settings (
            key TEXT PRIMARY KEY,
            value TEXT NOT NULL
        );
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT NOT NULL UNIQUE COLLATE NOCASE,
            password_hash TEXT NOT NULL,
            role TEXT NOT NULL CHECK (role IN ('admin', 'lead', 'user')),
            email TEXT
        );
        """
    )
    if "email" not in [r[1] for r in conn.execute("PRAGMA table_info(users)")]:
        conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
    conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS users_email ON users(email COLLATE NOCASE)")
    # migrate the old single-table layout (colleges.kt) into separate KT rows
    cols = [r[1] for r in conn.execute("PRAGMA table_info(colleges)")]
    if "kt" in cols:
        conn.executescript(
            """
            INSERT INTO kts (college_id, text) SELECT id, kt FROM colleges WHERE trim(kt) <> '';
            CREATE TABLE colleges_new (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL UNIQUE COLLATE NOCASE
            );
            INSERT INTO colleges_new (id, name) SELECT id, name FROM colleges;
            DROP TABLE colleges;
            ALTER TABLE colleges_new RENAME TO colleges;
            """
        )
    if "title" not in [r[1] for r in conn.execute("PRAGMA table_info(kts)")]:
        conn.execute("ALTER TABLE kts ADD COLUMN title TEXT NOT NULL DEFAULT ''")
    if "faculty_id" not in [r[1] for r in conn.execute("PRAGMA table_info(kts)")]:
        conn.execute("ALTER TABLE kts ADD COLUMN faculty_id INTEGER REFERENCES faculty(id) ON DELETE SET NULL")
    if "name" not in [r[1] for r in conn.execute("PRAGMA table_info(users)")]:
        conn.execute("ALTER TABLE users ADD COLUMN name TEXT")
    college_cols = [r[1] for r in conn.execute("PRAGMA table_info(colleges)")]
    for col in ("url", "location", "timezone", "logo_path"):
        if col not in college_cols:
            conn.execute(f"ALTER TABLE colleges ADD COLUMN {col} TEXT")
    for table in ("colleges", "faculty", "kts"):  # who added each record (id follows renames, name survives deletion)
        have = [r[1] for r in conn.execute(f"PRAGMA table_info({table})")]
        if "created_by" not in have:
            conn.execute(f"ALTER TABLE {table} ADD COLUMN created_by INTEGER REFERENCES users(id) ON DELETE SET NULL")
        if "created_by_name" not in have:
            conn.execute(f"ALTER TABLE {table} ADD COLUMN created_by_name TEXT")
    kt_cols = [r[1] for r in conn.execute("PRAGMA table_info(kts)")]
    if "kind" not in kt_cols:
        conn.execute("ALTER TABLE kts ADD COLUMN kind TEXT NOT NULL DEFAULT 'paragraph'")
    if "options" not in kt_cols:
        conn.execute("ALTER TABLE kts ADD COLUMN options TEXT")
    kt_cols = [r[1] for r in conn.execute("PRAGMA table_info(kts)")]
    if "block_type" not in kt_cols:
        conn.execute("ALTER TABLE kts ADD COLUMN block_type TEXT NOT NULL DEFAULT 'description'")
        # a KT saved before block types existed: one with a structured question type is now a "form" block
        conn.execute(
            "UPDATE kts SET block_type = 'form' WHERE kind IN "
            "('short', 'date', 'time', 'multiple_choice', 'checkboxes', 'dropdown')"
        )
    for col in ("alert_level", "file_path", "file_name", "file_type"):
        if col not in kt_cols:
            conn.execute(f"ALTER TABLE kts ADD COLUMN {col} TEXT")
    if "file_size" not in kt_cols:
        conn.execute("ALTER TABLE kts ADD COLUMN file_size INTEGER")
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS kt_criteria (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            college_id INTEGER NOT NULL REFERENCES colleges(id) ON DELETE CASCADE,
            title TEXT NOT NULL,
            created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
            created_by_name TEXT
        )
        """
    )
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS issue_concerns (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            college_id INTEGER NOT NULL REFERENCES colleges(id) ON DELETE CASCADE,
            title TEXT NOT NULL,
            description TEXT,
            image_path TEXT,
            created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
            created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
            created_by_name TEXT
        )
        """
    )
    if "pinned" not in [r[1] for r in conn.execute("PRAGMA table_info(kt_criteria)")]:
        conn.execute("ALTER TABLE kt_criteria ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0")
    kt_cols = [r[1] for r in conn.execute("PRAGMA table_info(kts)")]
    if "criteria_id" not in kt_cols:
        conn.execute("ALTER TABLE kts ADD COLUMN criteria_id INTEGER REFERENCES kt_criteria(id) ON DELETE CASCADE")
        # every KT saved before this level existed WAS itself one criteria card with one block inside it:
        # give each of those a matching new kt_criteria row (using its old title), and rename the block
        # to its type's plain label so the title is not shown twice.
        block_label_fallback = {"description": "Description", "form": "Form", "attachment": "Attachment",
                                "embed": "Embedded Media", "link": "Link", "alert": "Alert"}
        old_rows = conn.execute(
            "SELECT id, college_id, title, block_type, created_by, created_by_name FROM kts WHERE criteria_id IS NULL"
        ).fetchall()
        for old_id, college_id, old_title, block_type, created_by, created_by_name in old_rows:
            cur = conn.execute(
                "INSERT INTO kt_criteria (college_id, title, created_by, created_by_name) VALUES (?, ?, ?, ?)",
                (college_id, old_title or "Untitled", created_by, created_by_name),
            )
            new_block_title = block_label_fallback.get(block_type, "Description")
            conn.execute("UPDATE kts SET criteria_id = ?, title = ? WHERE id = ?", (cur.lastrowid, new_block_title, old_id))
        # every KT now belongs to a criteria, not a college directly - retire the old college_id
        # column (it also carried a NOT NULL constraint that ALTER TABLE can't lift on its own)
        conn.executescript(
            """
            CREATE TABLE kts_new (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                criteria_id INTEGER REFERENCES kt_criteria(id) ON DELETE CASCADE,
                title TEXT NOT NULL DEFAULT '',
                text TEXT NOT NULL,
                kind TEXT NOT NULL DEFAULT 'paragraph',
                options TEXT,
                faculty_id INTEGER REFERENCES faculty(id) ON DELETE SET NULL,
                created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
                created_by_name TEXT,
                block_type TEXT NOT NULL DEFAULT 'description',
                alert_level TEXT,
                file_path TEXT,
                file_name TEXT,
                file_type TEXT,
                file_size INTEGER
            );
            INSERT INTO kts_new (id, criteria_id, title, text, kind, options, faculty_id, created_by,
                                 created_by_name, block_type, alert_level, file_path, file_name, file_type, file_size)
            SELECT id, criteria_id, title, text, kind, options, faculty_id, created_by,
                   created_by_name, block_type, alert_level, file_path, file_name, file_type, file_size FROM kts;
            DROP TABLE kts;
            ALTER TABLE kts_new RENAME TO kts;
            """
        )
    kt_cols = [r[1] for r in conn.execute("PRAGMA table_info(kts)")]
    for col in ("description", "label"):
        if col not in kt_cols:
            conn.execute(f"ALTER TABLE kts ADD COLUMN {col} TEXT")
    kt_cols = [r[1] for r in conn.execute("PRAGMA table_info(kts)")]
    if "fields" not in kt_cols:
        conn.execute("ALTER TABLE kts ADD COLUMN fields TEXT")
        # a form block saved before multi-question support: its one label/kind/answer
        # becomes the first (and only) entry of its new fields list
        rows = conn.execute(
            "SELECT id, label, kind, text, options FROM kts WHERE block_type = 'form' AND label IS NOT NULL"
        ).fetchall()
        for row_id, row_label, row_kind, row_text, row_options in rows:
            entry = {"label": row_label, "kind": row_kind}
            if row_kind in ("multiple_choice", "checkboxes", "dropdown") and row_options:
                try:
                    entry.update(json.loads(row_options))
                except ValueError:
                    entry["options"], entry["selected"] = [], []
            else:
                entry["value"] = row_text or ""
            conn.execute("UPDATE kts SET fields = ? WHERE id = ?", (json.dumps([entry]), row_id))
    if not conn.execute("SELECT 1 FROM users").fetchone():
        conn.execute(
            "INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')",
            ("admin", generate_password_hash("admin123")),
        )
        print("Created default admin login -> username: admin  password: admin123 (change it!)")
    conn.commit()
    conn.close()


# ---- auth helpers ---------------------------------------------------------

@app.before_request
def load_user():
    g.user = None
    uid = session.get("uid")
    if uid:
        g.user = db().execute("SELECT * FROM users WHERE id = ?", (uid,)).fetchone()
        if g.user is None:
            session.clear()
    if request.method == "POST":
        token = session.get("csrf")
        if not token or token != request.form.get("csrf"):
            abort(400, "Invalid CSRF token")


@app.context_processor
def inject_csrf():
    if "csrf" not in session:
        session["csrf"] = secrets.token_hex(16)
    return {"csrf_token": session["csrf"], "user": g.get("user")}


def roles_required(*roles):
    def deco(fn):
        @wraps(fn)
        def wrapper(*a, **kw):
            if g.user is None:
                return redirect(url_for("login", next=request.path))
            if g.user["role"] not in roles:
                abort(403)
            return fn(*a, **kw)
        return wrapper
    return deco


def _me():
    """(id, display name) of the signed-in person, stored with every record they add."""
    return g.user["id"], (g.user["name"] or g.user["username"])


def _added_by(table, user):
    """SQL for the adder's current name; falls back to the saved name if that login was deleted."""
    return f"COALESCE(NULLIF({user}.name, ''), {user}.username, {table}.created_by_name)"


_RICH_TAGS = {"b", "strong", "i", "em", "u", "ul", "ol", "li", "br", "div", "p", "span", "blockquote"}


def _clean_rich_style(style):
    """Keep only text-align / margin-left declarations, with safe values - nothing else survives."""
    if not style:
        return None
    kept = []
    for decl in style.split(";"):
        prop, _, val = decl.partition(":")
        prop, val = prop.strip().lower(), val.strip().lower()
        if prop == "text-align" and val in ("left", "right", "center", "justify"):
            kept.append(f"text-align:{val}")
        elif prop == "margin-left":
            m = re.fullmatch(r"(\d{1,3})px", val)
            if m and int(m.group(1)) <= 400:
                kept.append(f"margin-left:{m.group(1)}px")
        elif prop == "margin":
            # shorthand (as Chrome's indent/outdent commands emit): 1-4 values, left is the last
            # when there are 4, otherwise the same as the horizontal value
            nums = re.findall(r"(\d{1,3})px|(0)(?!\w)", val)
            vals = [a or b for a, b in nums]
            if vals and re.fullmatch(r"(?:\d{1,3}px\s*|0\s*){1,4}", val):
                left = vals[-1] if len(vals) == 4 else (vals[1] if len(vals) >= 2 else vals[0])
                if int(left) <= 400:
                    kept.append(f"margin-left:{left}px")
    return "; ".join(kept) if kept else None


class _RichTextSanitizer(HTMLParser):
    """Allowlist HTML sanitizer for the "Description" rich-text fields: only basic formatting
    tags survive (bold/italic/underline/lists/paragraphs/indent), and the only attribute kept
    anywhere is a style consisting solely of text-align and/or margin-left. Everything else -
    scripts, links, images, event handlers, any other tag or attribute - is stripped; disallowed
    tags lose their wrapper but keep their (escaped) text so nothing typed just disappears."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.out = []
        self.open_stack = []

    def handle_starttag(self, tag, attrs):
        tag = tag.lower()
        if tag == "br":
            self.out.append("<br>")
            return
        if tag not in _RICH_TAGS:
            return
        style = _clean_rich_style(dict((k.lower(), v) for k, v in attrs if v is not None).get("style"))
        attr_str = f' style="{html.escape(style, quote=True)}"' if style else ""
        self.out.append(f"<{tag}{attr_str}>")
        self.open_stack.append(tag)

    def handle_startendtag(self, tag, attrs):
        if tag.lower() == "br":
            self.out.append("<br>")

    def handle_endtag(self, tag):
        tag = tag.lower()
        if tag in _RICH_TAGS and tag in self.open_stack:
            while self.open_stack[-1] != tag:
                self.out.append(f"</{self.open_stack.pop()}>")
            self.out.append(f"</{tag}>")
            self.open_stack.pop()

    def handle_data(self, data):
        self.out.append(html.escape(data))

    def close(self):
        super().close()
        while self.open_stack:
            self.out.append(f"</{self.open_stack.pop()}>")


def _sanitize_rich_text(raw, max_len=20000):
    """Reduce a pasted/typed rich-text editor body to a small safe HTML subset."""
    parser = _RichTextSanitizer()
    try:
        parser.feed((raw or "")[: max_len * 2])
        parser.close()
    except Exception:
        return html.escape(raw or "")[:max_len]
    return "".join(parser.out)[:max_len]


def _rich_text_is_blank(sanitized_html):
    """True when sanitized rich-text HTML has no visible text left, only empty tags/whitespace."""
    return not re.sub(r"<[^>]+>", "", sanitized_html or "").strip()


def _normalize_url(raw, empty_msg, invalid_msg, max_len=500):
    """Best-effort https:// URL. Returns (normalized_url_or_None, error_or_None)."""
    raw = (raw or "").strip()
    if not raw:
        return None, empty_msg
    if len(raw) > max_len:
        return None, "That web address is too long."
    candidate = raw if re.match(r"^https?://", raw, re.I) else "https://" + raw
    parsed = urllib.parse.urlparse(candidate)
    host = parsed.hostname or ""
    if parsed.scheme not in ("http", "https") or "." not in host or " " in candidate:
        return None, invalid_msg
    return candidate, None


def _normalize_college_url(raw):
    return _normalize_url(raw, "Enter the college's website address.",
                          "Enter a valid website address, e.g. https://www.college.edu", max_len=300)


US_STATES = (
    "Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut", "Delaware",
    "District of Columbia", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa",
    "Kansas", "Kentucky", "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota",
    "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire", "New Jersey",
    "New Mexico", "New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon",
    "Pennsylvania", "Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah",
    "Vermont", "Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming",
)
US_STATE_SET = frozenset(US_STATES)


def _split_location(location):
    """'City, State' -> (city, state). Best-effort split for redisplaying an existing college in the
    edit form; state is only recognised if it is an exact, known US state name."""
    location = (location or "").strip()
    for state in US_STATES:
        suffix = ", " + state
        if location.endswith(suffix):
            return location[: -len(suffix)].strip(), state
    return location, ""


def _favicon_url(college_url):
    """A small icon for the college, fetched by the browser from its own website - nothing to upload."""
    if not college_url:
        return None
    host = urllib.parse.urlparse(college_url).hostname
    if not host:
        return None
    return "https://www.google.com/s2/favicons?sz=128&domain=" + urllib.parse.quote(host)


def _row_get(row, key):
    try:
        return row[key]
    except (KeyError, IndexError, TypeError):
        return None


def _college_logo_url(c):
    """The logo to show for a college: its uploaded logo if it has one, else the automatic favicon."""
    if _row_get(c, "logo_path"):
        return url_for("college_logo", cid=c["id"], v=c["logo_path"][5:17])
    return _favicon_url(_row_get(c, "url"))


app.jinja_env.globals["favicon_url"] = _favicon_url
app.jinja_env.globals["logo_url"] = _college_logo_url


LOGO_MAX_BYTES = 2 * 1024 * 1024


def _save_image(file_storage, prefix, max_bytes, noun):
    """Store an uploaded image. The type comes from the file's own bytes, never its name.
    Returns (stored_name, None) or (None, error)."""
    data = file_storage.read(max_bytes + 1)
    if not data:
        return None, f"That {noun} file is empty."
    if len(data) > max_bytes:
        return None, f"That {noun} is too big. The limit is {max_bytes // (1024 * 1024)} MB."
    if data.startswith(b"\x89PNG\r\n\x1a\n"):
        ext = "png"
    elif data.startswith(b"\xff\xd8\xff"):
        ext = "jpg"
    elif data[:6] in (b"GIF87a", b"GIF89a"):
        ext = "gif"
    elif data[:4] == b"RIFF" and data[8:12] == b"WEBP":
        ext = "webp"
    else:
        return None, f"The {noun} must be a PNG, JPG, GIF or WebP image."
    stored = prefix + secrets.token_hex(16) + "." + ext
    (UPLOAD_DIR / stored).write_bytes(data)
    return stored, None


def _save_logo(file_storage):
    return _save_image(file_storage, "logo_", LOGO_MAX_BYTES, "logo")


ANY = ROLES
EDITORS = ("admin", "lead")


# ---- email ----------------------------------------------------------------

DEFAULT_SUBJECT = "Welcome to {{app_name}}"
DEFAULT_BODY = """<div style="font-family:Segoe UI,Arial,sans-serif;max-width:560px;margin:0 auto;color:#222">
  <div style="background:#1f3a5f;color:#ffffff;padding:18px 24px;border-radius:8px 8px 0 0">
    <h2 style="margin:0">Welcome to {{app_name}}</h2>
  </div>
  <div style="border:1px solid #d5dbe5;border-top:0;padding:24px;border-radius:0 0 8px 8px">
    <p>Hi {{name}},</p>
    <p>{{added_by}} has given you access to <strong>{{app_name}}</strong> as a <strong>{{role}}</strong>.</p>
    <p>Sign in with your Google account (<strong>{{email}}</strong>). You do not need a separate password.</p>
    <p style="margin:24px 0"><a href="{{login_url}}" style="background:#1f3a5f;color:#ffffff;padding:10px 20px;border-radius:6px;text-decoration:none">Sign in</a></p>
    <p style="color:#666;font-size:13px">If the button does not work, open this link: {{login_url}}</p>
  </div>
</div>
"""
TEMPLATE_VARS = ("app_name", "name", "email", "role", "login_url", "added_by")
_VAR_RE = re.compile(r"\{\{\s*(" + "|".join(TEMPLATE_VARS) + r")\s*\}\}")


class MailError(Exception):
    pass


def get_setting(key, default):
    row = db().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
    return row["value"] if row else default


def set_setting(key, value):
    db().execute("INSERT INTO settings (key, value) VALUES (?, ?) "
                 "ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value))


def fill_template(text, values, escape):
    """Replace {{placeholders}}. Values are HTML-escaped in the body, and forced onto one line in the subject."""
    def sub(m):
        v = str(values.get(m.group(1), ""))
        return html.escape(v) if escape else " ".join(v.split())
    return _VAR_RE.sub(sub, text)


def html_to_text(markup):
    """Plain-text version of the email for clients that do not show HTML."""
    t = re.sub(r"(?is)<(style|script)\b.*?</\1\s*>", "", markup)
    t = re.sub(r'(?is)<a\b[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', r"\2 (\1)", t)
    t = re.sub(r"(?i)<br\s*/?>|</(p|div|h[1-6]|li|tr)\s*>", "\n", t)
    t = html.unescape(re.sub(r"<[^>]+>", "", t))
    return re.sub(r"\n\s*\n\s*\n+", "\n\n", "\n".join(line.strip() for line in t.splitlines())).strip()


def smtp_config():
    """Mail server settings from smtp_config.json or SMTP_* environment variables; None if not set up."""
    cfg = {}
    if SMTP_CONFIG_PATH.exists():
        try:
            cfg = json.loads(SMTP_CONFIG_PATH.read_text(encoding="utf-8"))
        except ValueError:
            cfg = {}
    for key in ("host", "port", "username", "password", "from_email", "from_name", "use_tls", "public_url"):
        if os.environ.get("SMTP_" + key.upper()):
            cfg[key] = os.environ["SMTP_" + key.upper()]
    if not cfg.get("host") or not cfg.get("from_email"):
        return None
    try:
        cfg["port"] = int(cfg.get("port") or 587)
    except ValueError:
        cfg["port"] = 587
    cfg["use_tls"] = str(cfg.get("use_tls", True)).lower() not in ("0", "false", "no")
    return cfg


def send_mail(to, subject, body_html, attachments=(), reply_to=None):
    """attachments: (filename, bytes, mime_type) tuples."""
    cfg = smtp_config()
    if not cfg:
        raise MailError("email sending is not set up yet (see README)")
    msg = EmailMessage()
    msg["Subject"] = " ".join(subject.split())[:200]
    msg["From"] = formataddr((cfg.get("from_name") or "", cfg["from_email"]))
    msg["To"] = to
    if reply_to:
        msg["Reply-To"] = reply_to
    msg.set_content(html_to_text(body_html))
    msg.add_alternative(body_html, subtype="html")
    for filename, data, mime in attachments:
        maintype, _, subtype = (mime or "application/octet-stream").partition("/")
        msg.add_attachment(data, maintype=maintype, subtype=subtype or "octet-stream", filename=filename)
    try:
        server = (smtplib.SMTP_SSL if cfg["port"] == 465 else smtplib.SMTP)(
            cfg["host"], cfg["port"], timeout=60 if attachments else 15)
        with server:
            if cfg["port"] != 465 and cfg["use_tls"]:
                server.starttls()
            if cfg.get("username"):
                server.login(cfg["username"], cfg.get("password", ""))
            server.send_message(msg)
    except smtplib.SMTPAuthenticationError:
        raise MailError("the mail server rejected the login (check the username and password)")
    except (smtplib.SMTPException, OSError) as e:
        raise MailError(f"could not reach the mail server ({e.__class__.__name__})")


def portal_login_url(role=None):
    """The sign-in address: /admin/login for the admin, /login (shared) for Leads and Users."""
    path = "/admin/login" if role == "admin" else "/login"
    base = (smtp_config() or {}).get("public_url")
    return base.rstrip("/") + path if base else request.host_url.rstrip("/") + path


def send_welcome(name, email, role, subject_prefix=""):
    """Email the saved welcome template to a new Lead/User (values are filled in and escaped)."""
    values = {"app_name": APP_NAME, "name": name or email, "email": email, "role": role.capitalize(),
              "login_url": portal_login_url(role), "added_by": _me()[1]}
    subject = fill_template(get_setting("email_subject", DEFAULT_SUBJECT), values, False)
    send_mail(email, subject_prefix + subject, fill_template(get_setting("email_body", DEFAULT_BODY), values, True))


# ---- login ----------------------------------------------------------------

def allowed_domains():
    """Email domains that may sign in. Read on every call, so edits to allowed_domains.json apply at once."""
    try:
        domains = json.loads(DOMAINS_PATH.read_text(encoding="utf-8"))
        return [d.strip().lower() for d in domains if isinstance(d, str) and d.strip()]
    except (OSError, ValueError):
        return list(DEFAULT_DOMAINS)


def valid_email(email):
    """True only for name@domain where domain is exactly one of the allowed domains (no subdomains)."""
    m = EMAIL_RE.fullmatch(email)
    return bool(m) and m.group(1) in allowed_domains()


def google_config():
    cid, secret = os.environ.get("GOOGLE_CLIENT_ID"), os.environ.get("GOOGLE_CLIENT_SECRET")
    if not (cid and secret) and GOOGLE_CONFIG_PATH.exists():
        cfg = json.loads(GOOGLE_CONFIG_PATH.read_text(encoding="utf-8"))
        cid, secret = cfg.get("client_id"), cfg.get("client_secret")
    return (cid, secret) if cid and secret else (None, None)


def google_userinfo(code, redirect_uri):
    """Exchange the authorization code with Google and return the signed-in account's profile."""
    cid, secret = google_config()
    body = urllib.parse.urlencode({
        "code": code, "client_id": cid, "client_secret": secret,
        "redirect_uri": redirect_uri, "grant_type": "authorization_code",
    }).encode()
    with urllib.request.urlopen("https://oauth2.googleapis.com/token", body, timeout=10) as r:
        token = json.load(r)["access_token"]
    req = urllib.request.Request(
        "https://openidconnect.googleapis.com/v1/userinfo", headers={"Authorization": f"Bearer {token}"})
    with urllib.request.urlopen(req, timeout=10) as r:
        return json.load(r)


ROLE_LOGIN = {"admin": "admin_login", "lead": "login", "user": "login"}   # where each role signs in / returns to


@app.route("/login")
def login():
    """Leads and Users sign in here (Google or an emailed code). The admin has a separate address."""
    if g.user:
        return redirect(url_for("dashboard"))
    return render_template("login_member.html", google_ready=google_config()[0] is not None,
                           otp_on=otp_enabled(), mail_ready=smtp_config() is not None)


@app.route("/lead/login")
@app.route("/user/login")
def old_member_login():
    """Earlier per-role addresses (already shared in emails) now forward to the shared page."""
    return redirect(url_for("login"))


@app.route("/admin/login", methods=["GET", "POST"])
def admin_login():
    """The admin signs in with a password."""
    if g.user:
        return redirect(url_for("dashboard"))
    if request.method == "POST":
        row = db().execute(
            "SELECT * FROM users WHERE username = ? AND role = 'admin'", (request.form["username"].strip(),)
        ).fetchone()
        if row and check_password_hash(row["password_hash"], request.form["password"]):
            session.clear()
            session["uid"] = row["id"]
            nxt = request.args.get("next", "")
            return redirect(nxt if nxt.startswith("/") and not nxt.startswith("//") else url_for("dashboard"))
        flash("Invalid admin username or password.", "error")
    return render_template("login_admin.html")


# ---- sign in with an emailed one-time code (Leads and Users) ------------------------------

OTP_TTL = 10 * 60            # a code works for 10 minutes
OTP_MAX_ATTEMPTS = 5         # wrong guesses before the code is thrown away
OTP_COOLDOWN = 60            # seconds between code requests for one email
OTP_PER_EMAIL_HOUR = 5
OTP_PER_IP_HOUR = 20
app.config.setdefault("MAIL_ASYNC", True)   # send in the background so replies take the same time for every address


def otp_enabled():
    return get_setting("otp_enabled", "1") == "1"


def _otp_hash(email, code):
    return hmac.new(app.secret_key.encode(), f"{email}:{code}".encode(), hashlib.sha256).hexdigest()


def _otp_email(code):
    subject = f"Your {APP_NAME} sign-in code"
    body = (
        '<div style="font-family:Segoe UI,Arial,sans-serif;max-width:480px;margin:0 auto;color:#222">'
        f'<h2 style="color:#1f3a5f;margin-bottom:4px">{APP_NAME}</h2>'
        "<p>Use this code to sign in:</p>"
        f'<p style="font-size:34px;letter-spacing:8px;font-weight:700;margin:12px 0;color:#1f3a5f">{code}</p>'
        f"<p>It expires in {OTP_TTL // 60} minutes and can be used once.</p>"
        '<p style="color:#666;font-size:13px">If you did not try to sign in, you can ignore this email.</p>'
        "</div>")
    return subject, body


def _deliver(to, subject, body):
    def job():
        try:
            send_mail(to, subject, body)
        except MailError as e:
            app.logger.warning("Sign-in code email to %s failed: %s", to, e)
    if app.config["MAIL_ASYNC"]:
        threading.Thread(target=job, daemon=True).start()
    else:
        job()


def _request_otp(email):
    """Create and email a code if the address belongs to a Lead or User. Returns 'ok', 'wait' or 'limit'.
    The answer never depends on whether the address is registered."""
    now, ip, d = int(time.time()), request.remote_addr or "?", db()
    d.execute("DELETE FROM otp_log WHERE ts < ?", (now - 3600,))
    d.execute("DELETE FROM login_otps WHERE expires_at < ?", (now,))
    last = d.execute("SELECT max(ts) FROM otp_log WHERE email = ?", (email,)).fetchone()[0]
    if last and now - last < OTP_COOLDOWN:
        return "wait"
    if (d.execute("SELECT count(*) FROM otp_log WHERE email = ?", (email,)).fetchone()[0] >= OTP_PER_EMAIL_HOUR
            or d.execute("SELECT count(*) FROM otp_log WHERE ip = ?", (ip,)).fetchone()[0] >= OTP_PER_IP_HOUR):
        return "limit"
    d.execute("INSERT INTO otp_log (email, ip, ts) VALUES (?, ?, ?)", (email, ip, now))
    person = d.execute("SELECT 1 FROM users WHERE email = ? AND role IN ('lead', 'user')", (email,)).fetchone()
    mail = None
    if person and valid_email(email):
        code = f"{secrets.randbelow(10**6):06d}"       # one active code per address; a new one replaces the old
        d.execute("INSERT INTO login_otps (email, code_hash, expires_at, attempts) VALUES (?, ?, ?, 0) "
                  "ON CONFLICT(email) DO UPDATE SET code_hash = excluded.code_hash, "
                  "expires_at = excluded.expires_at, attempts = 0",
                  (email, _otp_hash(email, code), now + OTP_TTL))
        mail = _otp_email(code)
    d.commit()
    if mail:
        _deliver(email, *mail)
    return "ok"


def _send_code(email):
    """Shared by 'send' and 'resend': request a code and tell the person what happened."""
    if not otp_enabled():
        flash("Sign-in with an email code is turned off.", "error")
        return False
    if smtp_config() is None:
        flash("Email sign-in is not available yet because email sending is not set up. Ask the admin.", "error")
        return False
    status = _request_otp(email)
    if status == "wait":
        flash("A code was just sent. Please wait about a minute before asking for another one.", "error")
        return True      # they already have a code waiting, so go straight to the code page
    if status == "limit":
        flash("Too many code requests. Please try again later.", "error")
        return False
    flash("If that email is registered, a 6-digit code has been sent to it. It expires in "
          f"{OTP_TTL // 60} minutes.", "ok")
    return True


@app.post("/login/otp/send")
def otp_send():
    email = request.form.get("email", "").strip().lower()
    if len(email) > 254 or not FACULTY_EMAIL_RE.fullmatch(email):
        flash("Enter a valid email address.", "error")
        return redirect(url_for("login"))
    if _send_code(email):
        session["otp_email"] = email
        return redirect(url_for("otp_page"))
    return redirect(url_for("login"))


@app.post("/login/otp/resend")
def otp_resend():
    email = session.get("otp_email")
    if not email:
        return redirect(url_for("login"))
    _send_code(email)
    return redirect(url_for("otp_page"))


@app.route("/login/otp")
def otp_page():
    email = session.get("otp_email")
    if not email or not otp_enabled():
        return redirect(url_for("login"))
    return render_template("otp.html", email=email, minutes=OTP_TTL // 60)


@app.route("/login/otp/change")
def otp_change():
    session.pop("otp_email", None)
    return redirect(url_for("login"))


@app.post("/login/otp/verify")
def otp_verify():
    email = session.get("otp_email")
    if not email or not otp_enabled():
        return redirect(url_for("login"))
    code = re.sub(r"\D", "", request.form.get("code", ""))
    d, now = db(), int(time.time())
    row = d.execute("SELECT * FROM login_otps WHERE email = ?", (email,)).fetchone()
    person = d.execute("SELECT * FROM users WHERE email = ? AND role IN ('lead', 'user')", (email,)).fetchone()
    ok = bool(row) and row["expires_at"] >= now and row["attempts"] < OTP_MAX_ATTEMPTS
    if ok and hmac.compare_digest(row["code_hash"], _otp_hash(email, code)) and person and valid_email(email):
        d.execute("DELETE FROM login_otps WHERE email = ?", (email,))     # single use
        d.commit()
        session.clear()
        session["uid"] = person["id"]
        return redirect(url_for("dashboard"))
    if row:
        if row["attempts"] + 1 >= OTP_MAX_ATTEMPTS or row["expires_at"] < now:
            d.execute("DELETE FROM login_otps WHERE email = ?", (email,))
        else:
            d.execute("UPDATE login_otps SET attempts = attempts + 1 WHERE email = ?", (email,))
        d.commit()
    flash("That code is not valid or has expired. Check the code or ask for a new one.", "error")
    return redirect(url_for("otp_page"))


@app.route("/auth/google")
def google_login():
    cid, _ = google_config()
    if not cid:
        flash("Google sign-in is not configured yet. Ask the admin.", "error")
        return redirect(url_for("login"))
    session["oauth_state"] = secrets.token_urlsafe(16)
    params = {
        "client_id": cid, "redirect_uri": url_for("google_callback", _external=True),
        "response_type": "code", "scope": "openid email profile", "state": session["oauth_state"],
        "prompt": "select_account",
    }
    return redirect("https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(params))


@app.route("/auth/google/callback")
def google_callback():
    state = session.pop("oauth_state", None)
    session.pop("login_role", None)      # left over from the earlier per-role pages
    code = request.args.get("code")
    if not state or request.args.get("state") != state or not code:
        flash("Google sign-in was cancelled or failed. Please try again.", "error")
        return redirect(url_for("login"))
    try:
        info = google_userinfo(code, url_for("google_callback", _external=True))
    except Exception:
        flash("Could not complete Google sign-in. Please try again.", "error")
        return redirect(url_for("login"))
    email = str(info.get("email", "")).lower()
    row = db().execute("SELECT * FROM users WHERE email = ? AND role IN ('lead', 'user')", (email,)).fetchone()
    if not info.get("email_verified") or not valid_email(email) or row is None:
        flash("This email has not been given access. Ask the admin to add it.", "error")
        return redirect(url_for("login"))
    if not row["name"] and str(info.get("name", "")).strip():
        db().execute("UPDATE users SET name = ? WHERE id = ?", (str(info["name"]).strip()[:100], row["id"]))
        db().commit()
    session.clear()
    session["uid"] = row["id"]
    return redirect(url_for("dashboard"))


@app.post("/logout")
def logout():
    role = g.user["role"] if g.user else None
    session.clear()
    return redirect(url_for(ROLE_LOGIN.get(role, "login")))


# ---- dashboard --------------------------------------------------------------

@app.route("/")
def index():
    return redirect(url_for("dashboard"))


@app.route("/dashboard")
@roles_required(*ANY)
def dashboard():
    d = db()
    stats = {
        "faculty": d.execute("SELECT count(*) FROM faculty").fetchone()[0],
        "colleges": d.execute("SELECT count(*) FROM colleges").fetchone()[0],
        "leads": d.execute("SELECT count(*) FROM users WHERE role = 'lead'").fetchone()[0],
        "users": d.execute("SELECT count(*) FROM users WHERE role = 'user'").fetchone()[0],
        "kts": d.execute("SELECT count(*) FROM kts").fetchone()[0],
    }
    loaded = d.execute(
        "SELECT count(DISTINCT kc.college_id) FROM kt_criteria kc "
        "WHERE EXISTS (SELECT 1 FROM kts WHERE kts.criteria_id = kc.id)"
    ).fetchone()[0]
    stats["colleges_with_kt"] = loaded
    stats["colleges_without_kt"] = stats["colleges"] - loaded
    rows = d.execute(
        "SELECT c.id, c.name, (SELECT count(*) FROM kts kk JOIN kt_criteria kc2 ON kc2.id = kk.criteria_id "
        "WHERE kc2.college_id = c.id) AS kt_count "
        "FROM colleges c ORDER BY kt_count DESC, c.name LIMIT 8"
    ).fetchall()
    return render_template("dashboard.html", stats=stats, rows=rows)


# ---- search KT ------------------------------------------------------------


def _parse_desc(kind, payload):
    """Validate one 'Add Form' question. Returns (text, options_json, is_blank, error)."""
    if kind not in KIND_LABELS:
        return "", None, False, "choose a valid question type."
    if kind not in CHOICE_KINDS:
        value = str(payload.get("value", "")).strip()
        if kind == "short":
            value = " ".join(value.split())[:500]
        value = value[:10000]
        if not value:
            return "", None, True, "enter the question content."
        try:
            if kind == "date":
                datetime.date.fromisoformat(value)
            elif kind == "time":
                datetime.time.fromisoformat(value)
        except ValueError:
            return value, None, False, f"enter a valid {kind}."
        return value, None, False, None
    raw = payload.get("options")
    raw = raw if isinstance(raw, list) else []
    chosen_raw = payload.get("selected")
    chosen_raw = chosen_raw if isinstance(chosen_raw, list) else []
    options, remap = [], {}
    for i, o in enumerate(raw[:50]):
        o = str(o).strip()[:200]
        if o:
            remap[i] = len(options)
            options.append(o)
    if not options:
        return "", None, True, "add the options."
    if len({o.lower() for o in options}) != len(options):
        return "", None, False, "the options must all be different."
    selected = sorted({remap[i] for i in chosen_raw if type(i) is int and i in remap})
    if kind == "checkboxes":
        if not selected:
            return "", None, False, "tick at least one option."
    else:
        if len(options) < 2:
            return "", None, False, "add at least two options."
        if len(selected) != 1:
            return "", None, False, "select one option."
    text = ", ".join(options[i] for i in selected)
    return text, json.dumps({"options": options, "selected": selected}), False, None


def _human_size(n):
    if not n:
        return None
    size = float(n)
    for unit in ("B", "KB", "MB", "GB"):
        if size < 1024 or unit == "GB":
            return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
        size /= 1024
    return f"{size:.1f} GB"


def _extract_iframe_src(raw):
    """If pasted text is a full <iframe> embed snippet, pull out its src="..." URL."""
    m = re.search(r'<iframe\b[^>]*\bsrc\s*=\s*["\']([^"\']+)["\']', raw, re.I)
    return html.unescape(m.group(1)) if m else None


def _embed_src(url):
    """A YouTube/Vimeo watch link becomes its embeddable player URL; anything else embeds as typed."""
    if not url:
        return None
    m = re.search(r"(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([\w-]{6,})", url)
    if m:
        return f"https://www.youtube.com/embed/{m.group(1)}"
    m = re.search(r"vimeo\.com/(\d+)", url)
    if m:
        return f"https://player.vimeo.com/video/{m.group(1)}"
    return url


def _save_upload(file_storage):
    """Validate and store an uploaded attachment. Returns (info_dict, None) or (None, error)."""
    name = (file_storage.filename or "").strip()
    if not name:
        return None, "Choose a file to upload."
    ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
    if ext not in ALLOWED_UPLOAD_EXT:
        return None, "That file type is not allowed. Allowed: " + ", ".join(sorted(ALLOWED_UPLOAD_EXT)) + "."
    data = file_storage.read()
    if not data:
        return None, "That file is empty."
    if len(data) > MAX_UPLOAD_BYTES:
        return None, f"That file is too big. The limit is {MAX_UPLOAD_BYTES // (1024 * 1024)} MB."
    stored = secrets.token_hex(16) + "." + ext          # never derived from the uploaded filename
    (UPLOAD_DIR / stored).write_bytes(data)
    safe_name = secure_filename(name) or ("file." + ext)
    return {"file_path": stored, "file_name": safe_name[:200], "file_size": len(data), "file_type": ext}, None


def _delete_upload(stored_name):
    """Best-effort cleanup; never lets a missing/locked file break the caller."""
    if not stored_name:
        return
    try:
        (UPLOAD_DIR / stored_name).unlink(missing_ok=True)
    except OSError:
        pass


def _field_view(entry):
    """One 'Add Form' question (label + kind + its answer) formatted for display."""
    kind = entry.get("kind")
    view = {"label": entry.get("label", ""), "kind": kind, "kind_label": KIND_LABELS.get(kind, "Paragraph"),
            "choices": [], "text": ""}
    if kind in CHOICE_KINDS and entry.get("options"):
        chosen = set(entry.get("selected") or [])
        view["choices"] = [(o, i in chosen) for i, o in enumerate(entry["options"])]
        view["text"] = ", ".join(o for i, o in enumerate(entry["options"]) if i in chosen)
    else:
        text = entry.get("value", "") or ""
        if kind == "date":
            try:
                text = datetime.date.fromisoformat(text).strftime("%d %b %Y")
            except (ValueError, TypeError):
                pass
        view["text"] = text
    return view


REMINDER_END_SOON_DAYS = 10     # fewer days than this left until the due date = "end soon"
REMINDER_MESSAGES = {
    "upcoming": "The Availability is Active",
    "due_soon": "The Availability is end soon. Please check COLTE Portal and Update",
    "overdue": "The Availability has been ended. Please check COLTE Portal and Update immediately",
}
app.jinja_env.globals["REMINDER_MESSAGES"] = REMINDER_MESSAGES
app.jinja_env.globals["REMINDER_END_SOON_DAYS"] = REMINDER_END_SOON_DAYS


def _reminder_state(due, today=None):
    """overdue: the due date has passed; due_soon: fewer than 10 days left; otherwise upcoming (Active)."""
    today = today or datetime.date.today()
    if due < today:
        return "overdue"
    return "due_soon" if (due - today).days < REMINDER_END_SOON_DAYS else "upcoming"


def _fmt_date(iso_str):
    try:
        return datetime.date.fromisoformat(iso_str).strftime("%d %b %Y")
    except (ValueError, TypeError):
        return None


def _kt_view(row):
    """A KT row plus what the template needs to show it according to its block type."""
    v = dict(row)
    raw_text = v.get("text")
    v["block_type"] = v.get("block_type") or "description"
    v["kind_label"] = KIND_LABELS.get(v.get("kind"), "Paragraph")
    v["choices"] = []
    if v.get("kind") in CHOICE_KINDS and v.get("options"):
        try:
            data = json.loads(v["options"])
            chosen = set(data.get("selected", []))
            v["choices"] = [(o, i in chosen) for i, o in enumerate(data.get("options", []))]
        except (ValueError, AttributeError):
            pass
    if v.get("kind") == "date":
        try:
            v["text"] = datetime.date.fromisoformat(v["text"]).strftime("%d %b %Y")
        except ValueError:
            pass
    raw_fields = v.get("fields")
    v["fields"] = []
    if v["block_type"] == "form":
        if raw_fields:
            try:
                v["fields"] = [_field_view(e) for e in json.loads(raw_fields)]
            except (ValueError, TypeError):
                v["fields"] = []
        if not v["fields"]:
            opts = None
            if v.get("options"):
                try:
                    opts = json.loads(v["options"])
                except ValueError:
                    opts = None
            v["fields"] = [_field_view({
                "label": v.get("label", ""), "kind": v.get("kind"), "value": v.get("text", ""),
                "options": opts.get("options") if opts else None, "selected": opts.get("selected") if opts else None,
            })]
    # Link: always a button. Embed: an inline player, with the same link as a fallback.
    # A plain description whose whole text is just a URL (saved before block types existed)
    # still gets the "Open link" button too, so nothing already stored changes how it looks.
    v["link"] = None
    if v["block_type"] in ("link", "embed"):
        v["link"] = v.get("text")
    elif v["block_type"] == "description" and v.get("kind") not in CHOICE_KINDS:
        candidate = (raw_text or "").strip()
        if re.fullmatch(r"https?://\S+", candidate):
            v["link"] = candidate
    v["faculty_link"] = (v.get("text") or None) if v["block_type"] == "faculty" else None
    v["embed_src"] = _embed_src(v.get("text")) if v["block_type"] == "embed" else None
    v["file_size_h"] = _human_size(v.get("file_size"))
    v["reminder_updated"] = v["reminder_due"] = v["reminder_state"] = v["reminder_message"] = None
    if v["block_type"] == "reminder" and v.get("options"):
        try:
            dates = json.loads(v["options"])
            due_raw = dates.get("due")
            v["reminder_updated"] = _fmt_date(dates.get("updated"))
            v["reminder_due"] = _fmt_date(due_raw)
            due_date = datetime.date.fromisoformat(due_raw) if due_raw else None
            if due_date:
                v["reminder_state"] = _reminder_state(due_date)
                v["reminder_message"] = REMINDER_MESSAGES[v["reminder_state"]]
        except (ValueError, TypeError):
            pass
    if v["block_type"] == "description" and not v["link"]:
        v["text"] = _sanitize_rich_text(raw_text, max_len=20000)
    if v["block_type"] == "form" and v.get("description"):
        v["description"] = _sanitize_rich_text(v["description"], max_len=4000)
    return v


@app.route("/search")
@roles_required(*ANY)
def search():
    """No query/selection: list of colleges only. Query or ?college=id: matching colleges with their KT."""
    q = request.args.get("q", "").strip()
    cid = request.args.get("college", type=int)
    names = [r["name"] for r in db().execute("SELECT name FROM colleges ORDER BY name")]
    kts = {}
    if cid:
        return redirect(url_for("college_kt_page", cid=cid))  # a single college now has its own page
    elif q:
        colleges = db().execute(
            "SELECT c.*, (SELECT count(*) FROM kt_criteria WHERE college_id = c.id) AS criteria_count "
            "FROM colleges c WHERE c.name LIKE ? ORDER BY c.name", (f"%{q}%",)
        ).fetchall()
        criteria = {c["id"]: _criteria_rows(c["id"]) for c in colleges}
        return render_template("search.html", colleges=colleges, criteria=criteria, q=q, searched=True, names=names)
    else:
        colleges = db().execute(
            "SELECT c.*, (SELECT count(*) FROM kt_criteria WHERE college_id = c.id) AS kt_count, "
            + _added_by("c", "cu") + " AS added_by "
            "FROM colleges c LEFT JOIN users cu ON cu.id = c.created_by ORDER BY c.name"
        ).fetchall()
        return render_template("search.html", colleges=colleges, kts=kts, q=q, searched=False, names=names)


@app.route("/college/<int:cid>/kt")
@roles_required(*ANY)
def college_kt_page(cid):
    """A standalone page (opened in its own tab) listing one college's KT Criteria. Open one to
    see - and add - the KT (Form, Attachment, Embedded Media, Link, Alert, Description) inside it."""
    college = db().execute("SELECT * FROM colleges WHERE id = ?", (cid,)).fetchone()
    if college is None:
        abort(404)
    return _render_college_page(college, open_concerns=True,   # the Issue Concerns popup shows on every load of the page
                                notice={"added": "Issue concern added.", "deleted": "Issue concern deleted."}.get(request.args.get("done")))


CONCERN_MAX_IMAGE = 5 * 1024 * 1024


def _concern_rows(college_id):
    rows = db().execute(
        "SELECT ic.*, " + _added_by("ic", "cu") + " AS added_by FROM issue_concerns ic "
        "LEFT JOIN users cu ON cu.id = ic.created_by WHERE ic.college_id = ? ORDER BY ic.id DESC", (college_id,)
    ).fetchall()
    out = []
    for r in rows:
        d = dict(r)
        d["created_h"] = _fmt_date((r["created_at"] or "")[:10]) or ""
        out.append(d)
    return out


def _render_college_page(college, open_concerns=False, concern_values=None, concern_error=None, notice=None):
    concerns = _concern_rows(college["id"])
    return render_template("college_kt_page.html", college=college, criteria=_criteria_rows(college["id"]),
                           concerns=concerns, open_concerns=open_concerns, concern_values=concern_values or {},
                           concern_error=concern_error, concern_notice=notice, standalone=True)


@app.post("/college/<int:cid>/concerns")
@roles_required(*EDITORS)
def concern_add(cid):
    college = db().execute("SELECT * FROM colleges WHERE id = ?", (cid,)).fetchone()
    if college is None:
        abort(404)
    values = {"title": request.form.get("title", "").strip()[:200],
              "description": request.form.get("description", "").strip()[:3000]}
    error, image = None, None
    if not values["title"]:
        error = "Enter the issue concern title."
    else:
        upload = request.files.get("image")
        if upload is not None and upload.filename:
            image, error = _save_image(upload, "concern_", CONCERN_MAX_IMAGE, "image")
    if error:
        return _render_college_page(college, open_concerns=True, concern_values=values, concern_error=error)
    db().execute("INSERT INTO issue_concerns (college_id, title, description, image_path, created_by, created_by_name) "
                 "VALUES (?, ?, ?, ?, ?, ?)", (cid, values["title"], values["description"] or None, image, *_me()))
    db().commit()
    return redirect(url_for("college_kt_page", cid=cid, concerns=1, done="added"))


@app.post("/concerns/<int:conid>/delete")
@roles_required(*EDITORS)
def concern_delete(conid):
    row = db().execute("SELECT college_id, image_path FROM issue_concerns WHERE id = ?", (conid,)).fetchone()
    if row is None:
        abort(404)
    db().execute("DELETE FROM issue_concerns WHERE id = ?", (conid,))
    db().commit()
    _delete_upload(row["image_path"])
    return redirect(url_for("college_kt_page", cid=row["college_id"], concerns=1, done="deleted"))


@app.route("/concerns/<int:conid>/image")
@roles_required(*ANY)
def concern_image(conid):
    row = db().execute("SELECT image_path FROM issue_concerns WHERE id = ?", (conid,)).fetchone()
    if row is None or not row["image_path"]:
        abort(404)
    resp = send_from_directory(UPLOAD_DIR, row["image_path"], max_age=86400)
    resp.headers["X-Content-Type-Options"] = "nosniff"
    return resp


@app.route("/criteria/new", methods=["GET", "POST"])
@roles_required(*EDITORS)
def criteria_new():
    selected = request.values.get("college", type=int)
    selected_college = db().execute("SELECT * FROM colleges WHERE id = ?", (selected,)).fetchone() if selected else None
    title = request.form.get("title", "").strip() if request.method == "POST" else ""
    if request.method == "POST":
        title = title[:300]
        if not selected_college:
            flash("Please choose a college.", "error")
        elif not title:
            flash("Enter a title.", "error")
        else:
            cur = db().execute(
                "INSERT INTO kt_criteria (college_id, title, created_by, created_by_name) VALUES (?, ?, ?, ?)",
                (selected, title, *_me()),
            )
            db().commit()
            flash(f"KT Criteria \"{title}\" added. You can now add KT inside it.", "ok")
            return redirect(url_for("criteria_page", crid=cur.lastrowid))
    return render_template("criteria_form.html", colleges=_college_rows(), selected=selected,
                           selected_college=selected_college, criteria=None, title=title)


@app.route("/criteria/<int:crid>/edit", methods=["GET", "POST"])
@roles_required(*EDITORS)
def criteria_edit(crid):
    criteria = db().execute(
        "SELECT kc.*, colleges.name AS college FROM kt_criteria kc JOIN colleges ON colleges.id = kc.college_id "
        "WHERE kc.id = ?", (crid,)).fetchone()
    if criteria is None:
        abort(404)
    title = criteria["title"]
    if request.method == "POST":
        title = request.form.get("title", "").strip()[:300]
        if not title:
            flash("Enter a title.", "error")
        else:
            db().execute("UPDATE kt_criteria SET title = ? WHERE id = ?", (title, crid))
            db().commit()
            flash("KT Criteria updated.", "ok")
            return redirect(url_for("criteria_page", crid=crid))
    return render_template("criteria_form.html", colleges=[], selected=criteria["college_id"],
                           selected_college=None, criteria=criteria, title=title)


@app.post("/criteria/<int:crid>/pin")
@roles_required(*EDITORS)
def criteria_pin(crid):
    criteria = db().execute("SELECT college_id, pinned FROM kt_criteria WHERE id = ?", (crid,)).fetchone()
    if criteria is None:
        abort(404)
    db().execute("UPDATE kt_criteria SET pinned = ? WHERE id = ?", (0 if criteria["pinned"] else 1, crid))
    db().commit()
    flash("KT Criteria unpinned." if criteria["pinned"] else "KT Criteria pinned to the top.", "ok")
    return redirect(url_for("college_kt_page", cid=criteria["college_id"]))


@app.post("/criteria/<int:crid>/delete")
@roles_required(*EDITORS)
def criteria_delete(crid):
    criteria = db().execute("SELECT college_id FROM kt_criteria WHERE id = ?", (crid,)).fetchone()
    if criteria is None:
        abort(404)
    for row in db().execute("SELECT file_path FROM kts WHERE criteria_id = ? AND file_path IS NOT NULL", (crid,)):
        _delete_upload(row["file_path"])            # cascade removes the rows; files need cleaning up separately
    db().execute("DELETE FROM kt_criteria WHERE id = ?", (crid,))
    db().commit()
    flash("KT Criteria and its KT deleted.", "ok")
    return redirect(url_for("college_kt_page", cid=criteria["college_id"]))


@app.route("/criteria/<int:crid>")
@roles_required(*ANY)
def criteria_page(crid):
    criteria = db().execute(
        "SELECT kc.*, colleges.id AS college_id, colleges.name AS college_name, colleges.url AS college_url, "
        "colleges.location AS college_location FROM kt_criteria kc "
        "JOIN colleges ON colleges.id = kc.college_id WHERE kc.id = ?", (crid,)).fetchone()
    if criteria is None:
        abort(404)
    blocks = [_kt_view(k) for k in db().execute(
        "SELECT kts.*, f.title AS f_title, f.first_name AS f_first, f.last_name AS f_last, f.email AS f_email, "
        + _added_by("kts", "ku") + " AS added_by "
        "FROM kts LEFT JOIN faculty f ON f.id = kts.faculty_id LEFT JOIN users ku ON ku.id = kts.created_by "
        "WHERE kts.criteria_id = ? ORDER BY kts.id", (crid,)
    )]
    return render_template("criteria_page.html", criteria=criteria, blocks=blocks, standalone=True)


# ---- add / manage colleges ------------------------------------------------

def _college_rows():
    return db().execute(
        "SELECT c.*, " + _added_by("c", "cu") + " AS added_by "
        "FROM colleges c LEFT JOIN users cu ON cu.id = c.created_by ORDER BY c.name"
    ).fetchall()


def _criteria_rows(college_id):
    rows = db().execute(
        "SELECT kc.*, (SELECT count(*) FROM kts WHERE criteria_id = kc.id) AS block_count, "
        + _added_by("kc", "ku") + " AS added_by "
        "FROM kt_criteria kc LEFT JOIN users ku ON ku.id = kc.created_by "
        "WHERE kc.college_id = ? ORDER BY kc.pinned DESC, kc.id", (college_id,)
    ).fetchall()
    # a criteria containing a Reminder gets its card tinted by the most urgent one inside it
    reminders = db().execute(
        "SELECT kts.criteria_id, kts.options FROM kts JOIN kt_criteria kc ON kc.id = kts.criteria_id "
        "WHERE kc.college_id = ? AND kts.block_type = 'reminder'", (college_id,)
    ).fetchall()
    today = datetime.date.today()
    rank = {"overdue": 2, "due_soon": 1, "upcoming": 0}
    status_by_criteria = {}
    for r in reminders:
        try:
            due = datetime.date.fromisoformat(json.loads(r["options"])["due"])
        except (ValueError, TypeError, KeyError):
            continue
        status = _reminder_state(due, today)
        cid = r["criteria_id"]
        if cid not in status_by_criteria or rank[status] > rank[status_by_criteria[cid]]:
            status_by_criteria[cid] = status
    result = []
    for row in rows:
        d = dict(row)
        d["reminder_status"] = status_by_criteria.get(row["id"])
        result.append(d)
    return result


def _college_form():
    """Read and validate the college form. Returns (values, error_or_None); values redisplay the form on error."""
    f = {
        "name": request.form.get("name", "").strip(),
        "url_raw": request.form.get("url", "").strip(),
        "city": request.form.get("city", "").strip()[:100],
        "state": request.form.get("state", "").strip(),
        "timezone": request.form.get("timezone", "").strip(),
    }
    url, url_error = _normalize_college_url(f["url_raw"])
    f["url"] = url or f["url_raw"]  # keep exactly what they typed if it did not validate
    f["location"] = f"{f['city']}, {f['state']}" if f["city"] and f["state"] else (f["city"] or f["state"])
    error = None
    if not f["name"]:
        error = "College name is required."
    elif url_error:
        error = url_error
    elif not f["city"]:
        error = "Enter the city."
    elif f["state"] not in US_STATE_SET:
        error = "Choose a state from the list."
    elif f["timezone"] not in TIMEZONE_SET:
        error = "Choose a timezone from the list."
    return f, error


@app.route("/colleges/new", methods=["GET", "POST"])
@roles_required(*EDITORS)
def college_new():
    values = {}
    if request.method == "POST":
        values, error = _college_form()
        logo_name = None
        if not error:
            upload = request.files.get("logo")
            if upload is not None and upload.filename:
                logo_name, error = _save_logo(upload)
        if error:
            flash(error, "error")
        else:
            try:
                db().execute(
                    "INSERT INTO colleges (name, url, location, timezone, logo_path, created_by, created_by_name) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?)",
                    (values["name"], values["url"], values["location"], values["timezone"], logo_name, *_me()),
                )
                db().commit()
                flash(f"College {values['name']} added. You can now add its KT.", "ok")
                return redirect(url_for("college_new"))
            except sqlite3.IntegrityError:
                _delete_upload(logo_name)
                flash("A college with that name already exists.", "error")
    return render_template("college_new.html", rows=_college_rows(), f=values,
                           timezone_groups=TIMEZONE_GROUPS, us_states=US_STATES)


@app.route("/colleges/<int:cid>/edit", methods=["GET", "POST"])
@roles_required(*EDITORS)
def college_edit(cid):
    row = db().execute("SELECT * FROM colleges WHERE id = ?", (cid,)).fetchone()
    if row is None:
        abort(404)
    city, state = _split_location(row["location"])
    values = {"name": row["name"], "url": row["url"] or "", "url_raw": row["url"] or "",
              "city": city, "state": state, "timezone": row["timezone"] or ""}
    old_logo = row["logo_path"]
    if request.method == "POST":
        values, error = _college_form()
        new_logo = None
        if not error:
            upload = request.files.get("logo")
            if upload is not None and upload.filename:
                new_logo, error = _save_logo(upload)
        if error:
            flash(error, "error")
        else:
            logo_path = old_logo
            if new_logo:
                logo_path = new_logo
            elif request.form.get("remove_logo"):
                logo_path = None
            try:
                db().execute(
                    "UPDATE colleges SET name = ?, url = ?, location = ?, timezone = ?, logo_path = ? WHERE id = ?",
                    (values["name"], values["url"], values["location"], values["timezone"], logo_path, cid),
                )
                db().commit()
                if logo_path != old_logo:
                    _delete_upload(old_logo)        # replaced or removed: drop the old file
                flash("College updated.", "ok")
                return redirect(url_for("college_new"))
            except sqlite3.IntegrityError:
                _delete_upload(new_logo)
                flash("A college with that name already exists.", "error")
    custom_logo = url_for("college_logo", cid=cid, v=old_logo[5:17]) if old_logo else ""
    return render_template("college_edit.html", college=values, cid=cid, custom_logo=custom_logo,
                           timezone_groups=TIMEZONE_GROUPS, us_states=US_STATES)


@app.route("/colleges/<int:cid>/logo")
@roles_required(*ANY)
def college_logo(cid):
    row = db().execute("SELECT logo_path FROM colleges WHERE id = ?", (cid,)).fetchone()
    if row is None or not row["logo_path"]:
        abort(404)
    resp = send_from_directory(UPLOAD_DIR, row["logo_path"], max_age=86400)
    resp.headers["X-Content-Type-Options"] = "nosniff"
    return resp


@app.post("/colleges/<int:cid>/delete")
@roles_required(*EDITORS)
def college_delete(cid):
    row = db().execute("SELECT logo_path FROM colleges WHERE id = ?", (cid,)).fetchone()
    for file_row in db().execute(
        "SELECT kts.file_path FROM kts JOIN kt_criteria kc ON kc.id = kts.criteria_id "
        "WHERE kc.college_id = ? AND kts.file_path IS NOT NULL", (cid,)
    ).fetchall():
        _delete_upload(file_row["file_path"])       # cascade removes the rows; the files need cleaning up separately
    concern_images = [r["image_path"] for r in db().execute(
        "SELECT image_path FROM issue_concerns WHERE college_id = ? AND image_path IS NOT NULL", (cid,))]
    db().execute("DELETE FROM colleges WHERE id = ?", (cid,))
    db().commit()
    for name in concern_images:
        _delete_upload(name)
    if row:
        _delete_upload(row["logo_path"])
    flash("College and its KT deleted.", "ok")
    return redirect(url_for("college_new"))


# ---- add / edit KT --------------------------------------------------------
# Clicking "Add KT Criteria" first shows a picker of six block types (Form, Attachment,
# Embedded Media, Link, Alert, Description). Picking one loads a small form for just that
# type. Editing a KT shows the same fields its own block type already uses - the type itself
# cannot be changed after saving, only its content.

def _faculty_list():
    return db().execute("SELECT id, title, first_name, last_name, email FROM faculty ORDER BY first_name, last_name").fetchall()


def _blank_field():
    return {"label": "", "kind": "paragraph", "value": "", "options": ["", ""], "selected": []}


def _kt_form_values(block_type, existing=None):
    """What the form fields should show: posted values on a failed submission, else the
    existing KT (editing), else blanks (a fresh add)."""
    if request.method == "POST":
        try:
            payload = json.loads(request.form.get("payload", "{}"))
            if not isinstance(payload, dict):
                payload = {}
        except ValueError:
            payload = {}
        posted_fields = []
        field_labels = request.form.getlist("field_label")
        kinds = request.form.getlist("field_kind")
        payloads = request.form.getlist("field_payload")
        for i, label in enumerate(field_labels):
            try:
                p = json.loads(payloads[i]) if i < len(payloads) else {}
                if not isinstance(p, dict):
                    p = {}
            except (ValueError, IndexError):
                p = {}
            posted_fields.append({
                "label": label, "kind": kinds[i] if i < len(kinds) else "paragraph",
                "value": p.get("value", ""),
                "options": p.get("options") or ["", ""], "selected": p.get("selected") or [],
            })
        text_val = request.form.get("kt", "")
        if block_type == "description":
            text_val = _sanitize_rich_text(text_val, max_len=20000)
        return {
            "title": request.form.get("title", "").strip(),
            "text": text_val,
            "url": request.form.get("url", "").strip(),
            "kind": request.form.get("kind") or "paragraph",
            "payload": payload,
            "level": request.form.get("level", ""),
            "description": _sanitize_rich_text(request.form.get("description", ""), max_len=4000),
            "label": request.form.get("label", "").strip(),
            "fields": posted_fields or [_blank_field()],
            "updated": request.form.get("updated", "").strip(),
            "faculty_id": request.form.get("faculty_id", type=int),
            "due": request.form.get("due", "").strip(),
        }
    if existing:
        payload = {"value": existing["text"]}
        if existing["kind"] in CHOICE_KINDS and existing["options"]:
            try:
                payload = json.loads(existing["options"])
            except ValueError:
                pass
        fields = None
        if existing["fields"]:
            try:
                fields = json.loads(existing["fields"])
            except ValueError:
                fields = None
        if not fields:
            fields = [{
                "label": existing["label"] or "", "kind": existing["kind"] or "paragraph",
                "value": payload.get("value", ""), "options": payload.get("options") or ["", ""],
                "selected": payload.get("selected") or [],
            }]
        text_val = existing["text"]
        if block_type == "description":
            text_val = _sanitize_rich_text(text_val, max_len=20000)
        updated_val = due_val = ""
        if block_type == "reminder" and existing["options"]:
            try:
                dates = json.loads(existing["options"])
                updated_val, due_val = dates.get("updated", ""), dates.get("due", "")
            except ValueError:
                pass
        return {
            "title": existing["title"], "text": text_val, "url": existing["text"],
            "kind": existing["kind"] or "paragraph", "payload": payload, "level": existing["alert_level"] or "",
            "description": _sanitize_rich_text(existing["description"] or "", max_len=4000),
            "label": existing["label"] or "", "fields": fields,
            "updated": updated_val, "due": due_val, "faculty_id": existing["faculty_id"],
        }
    return {"title": "", "text": "", "url": "", "kind": "paragraph", "payload": {"value": ""}, "level": "",
            "description": "", "label": "", "fields": [_blank_field()], "updated": "", "due": "",
            "faculty_id": None}


def _faculty_duplicate_error(criteria_id, faculty_id, existing):
    """A faculty may appear on only one KT inside a KT Criteria (Reminder or Faculty Specific KT).
    Keeping an older KT's current faculty is always allowed, so those can still be edited."""
    if existing and existing["faculty_id"] == faculty_id:
        return None
    row = db().execute(
        "SELECT f.title, f.first_name, f.last_name FROM kts JOIN faculty f ON f.id = kts.faculty_id "
        "WHERE kts.criteria_id = ? AND kts.faculty_id = ? AND kts.id != ? LIMIT 1",
        (criteria_id, faculty_id, existing["id"] if existing else 0)).fetchone()
    if row:
        return (f"{row['title']} {row['first_name']} {row['last_name']} already has a KT in this KT Criteria. "
                "Each faculty can only be used once per KT Criteria - choose a different faculty.")
    return None


def _used_faculty_ids(criteria_id, exclude_kt=None):
    return {r[0] for r in db().execute(
        "SELECT faculty_id FROM kts WHERE criteria_id = ? AND faculty_id IS NOT NULL AND id != ?", (criteria_id, exclude_kt or 0))}


def _parse_kt(block_type, existing=None, criteria_id=None):
    """Read and validate the Add/Edit KT form for one block type.
    Returns (fields_ready_for_the_database, error_or_None)."""
    title = request.form.get("title", "").strip()[:300]
    out = {
        "title": title, "text": "", "kind": None, "options": None, "alert_level": None,
        "description": existing["description"] if existing else None,
        "label": existing["label"] if existing else None,
        "fields": existing["fields"] if existing else None,
        "faculty_id": None,
        "file_path": existing["file_path"] if existing else None,
        "file_name": existing["file_name"] if existing else None,
        "file_size": existing["file_size"] if existing else None,
        "file_type": existing["file_type"] if existing else None,
    }
    if not title:
        return None, "Enter a title."

    if block_type == "description":
        text = _sanitize_rich_text(request.form.get("kt", ""), max_len=20000)
        if _rich_text_is_blank(text):
            return None, "Enter the description."
        out.update(text=text, kind="paragraph")

    elif block_type == "form":
        description = _sanitize_rich_text(request.form.get("description", ""), max_len=4000)
        if _rich_text_is_blank(description):
            description = None
        labels = request.form.getlist("field_label")
        kinds = request.form.getlist("field_kind")
        payloads = request.form.getlist("field_payload")
        fields, first_text, first_options = [], "", None
        for i, (raw_label, kind) in enumerate(zip(labels, kinds)):
            label = raw_label.strip()[:300]
            if not label:
                return None, f"Enter the label for question {i + 1}."
            try:
                payload = json.loads(payloads[i]) if i < len(payloads) else {}
                if not isinstance(payload, dict):
                    payload = {}
            except (ValueError, IndexError):
                payload = {}
            text, options_json, _blank, err = _parse_desc(kind, payload)
            if err:
                return None, f"Answer area (question {i + 1}): " + err
            entry = {"label": label, "kind": kind}
            if options_json:
                entry.update(json.loads(options_json))
            else:
                entry["value"] = text
            fields.append(entry)
            if i == 0:
                first_text, first_options = text, options_json
        if not fields:
            return None, "Add at least one question."
        out.update(text=first_text, kind=fields[0]["kind"], options=first_options,
                    label=fields[0]["label"], description=description or None, fields=json.dumps(fields))

    elif block_type in ("link", "embed"):
        raw_url = request.form.get("url", "")
        if block_type == "embed":
            raw_url = _extract_iframe_src(raw_url) or raw_url
        url, err = _normalize_url(raw_url, "Enter a web address.",
                                  "Enter a valid web address, e.g. https://example.com")
        if err:
            return None, err
        out.update(text=url, kind=block_type)

    elif block_type == "alert":
        level = request.form.get("level", "")
        message = request.form.get("kt", "").strip()[:2000]
        if level not in ("info", "warning", "critical"):
            return None, "Choose an alert level."
        if not message:
            return None, "Enter the alert message."
        out.update(text=message, alert_level=level, kind="alert")

    elif block_type == "attachment":
        out["kind"] = "attachment"
        f = request.files.get("file")
        if f is not None and f.filename:
            info, err = _save_upload(f)
            if err:
                return None, err
            if existing and existing["file_path"]:
                _delete_upload(existing["file_path"])            # replacing a file: drop the old one
            out.update(file_path=info["file_path"], file_name=info["file_name"],
                      file_size=info["file_size"], file_type=info["file_type"])
        elif not (existing and existing["file_path"]):
            return None, "Choose a file to upload."
        # else: editing without picking a new file - keep the one already stored

    elif block_type == "reminder":
        updated_raw = request.form.get("updated", "").strip()
        due_raw = request.form.get("due", "").strip()
        try:
            datetime.date.fromisoformat(updated_raw)
        except ValueError:
            return None, "Enter a valid Availability Updated Date."
        try:
            due = datetime.date.fromisoformat(due_raw)
        except ValueError:
            return None, "Enter a valid Availability Due Date."
        if due < datetime.date.fromisoformat(updated_raw):
            return None, "Availability Due Date must be on or after the Availability Updated Date."
        faculty_id = request.form.get("faculty_id", type=int)
        if not faculty_id:
            return None, "Choose a faculty from the list."
        if db().execute("SELECT 1 FROM faculty WHERE id = ?", (faculty_id,)).fetchone() is None:
            return None, "That faculty is no longer in the list. Choose another."
        dup = _faculty_duplicate_error(existing["criteria_id"] if existing else criteria_id, faculty_id, existing)
        if dup:
            return None, dup
        out.update(text=due_raw, kind="reminder", options=json.dumps({"updated": updated_raw, "due": due_raw}),
                   faculty_id=faculty_id)

    elif block_type == "faculty":
        faculty_id = request.form.get("faculty_id", type=int)
        if not faculty_id:
            return None, "Choose a faculty from the list."
        if db().execute("SELECT 1 FROM faculty WHERE id = ?", (faculty_id,)).fetchone() is None:
            return None, "That faculty is no longer in the list. Choose another."
        dup = _faculty_duplicate_error(existing["criteria_id"] if existing else criteria_id, faculty_id, existing)
        if dup:
            return None, dup
        link = ""
        if request.form.get("url", "").strip():
            link, err = _normalize_url(request.form.get("url", ""), "", "Enter a valid web address, e.g. https://example.com")
            if err:
                return None, err
        out.update(kind="faculty", faculty_id=faculty_id, text=link)
        f = request.files.get("file")
        if f is not None and f.filename:
            info, err = _save_upload(f)
            if err:
                return None, err
            if existing and existing["file_path"]:
                _delete_upload(existing["file_path"])            # replacing a file: drop the old one
            out.update(file_path=info["file_path"], file_name=info["file_name"],
                      file_size=info["file_size"], file_type=info["file_type"])
        elif request.form.get("remove_file") and existing and existing["file_path"]:
            _delete_upload(existing["file_path"])
            out.update(file_path=None, file_name=None, file_size=None, file_type=None)
    else:
        return None, "Choose what kind of KT criteria to add."
    return out, None


@app.route("/kt/new", methods=["GET", "POST"])
@roles_required(*EDITORS)
def kt_new():
    selected = request.values.get("criteria", type=int)
    block_type = (request.form.get("block_type") if request.method == "POST" else request.args.get("type", "")).strip()
    if block_type not in BLOCK_META:
        block_type = None
    selected_criteria = db().execute(
        "SELECT kc.*, colleges.name AS college_name FROM kt_criteria kc "
        "JOIN colleges ON colleges.id = kc.college_id WHERE kc.id = ?", (selected,)
    ).fetchone() if selected else None
    values = _kt_form_values(block_type)
    if request.method == "POST" and block_type:
        fields, error = _parse_kt(block_type, criteria_id=selected)
        if not selected_criteria:
            error = error or "Choose a KT Criteria first."
        if error:
            flash(error, "error")
        else:
            db().execute(
                "INSERT INTO kts (criteria_id, title, text, kind, options, alert_level, description, label, fields, "
                "faculty_id, file_path, file_name, file_size, file_type, block_type, created_by, created_by_name) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                (selected, fields["title"], fields["text"], fields["kind"], fields["options"], fields["alert_level"],
                 fields["description"], fields["label"], fields["fields"], fields["faculty_id"],
                 fields["file_path"], fields["file_name"], fields["file_size"], fields["file_type"],
                 block_type, *_me()),
            )
            db().commit()
            flash("KT saved.", "ok")
            return redirect(url_for("criteria_page", crid=selected))
    return render_template("kt_form.html", selected_criteria=selected_criteria,
                           kt=None, values=values, block_type=block_type, block_types=BLOCK_TYPES, block_meta=BLOCK_META, kinds=KINDS,
                           faculty_list=_faculty_list() if block_type in ("faculty", "reminder") else [],
                           used_faculty=_used_faculty_ids(selected_criteria["id"]) if block_type in ("faculty", "reminder") and selected_criteria else set())


@app.route("/kt/<int:kid>/edit", methods=["GET", "POST"])
@roles_required(*EDITORS)
def kt_edit(kid):
    kt = db().execute(
        "SELECT kts.*, kc.title AS criteria_title, colleges.name AS college_name "
        "FROM kts JOIN kt_criteria kc ON kc.id = kts.criteria_id JOIN colleges ON colleges.id = kc.college_id "
        "WHERE kts.id = ?", (kid,)).fetchone()
    if kt is None:
        abort(404)
    block_type = kt["block_type"] or "description"
    values = _kt_form_values(block_type, existing=kt)
    if request.method == "POST":
        fields, error = _parse_kt(block_type, existing=kt)
        if error:
            flash(error, "error")
        else:
            db().execute(
                "UPDATE kts SET title = ?, text = ?, kind = ?, options = ?, alert_level = ?, description = ?, label = ?, fields = ?, "
                "file_path = ?, file_name = ?, file_size = ?, file_type = ?, faculty_id = ? WHERE id = ?",
                (fields["title"], fields["text"], fields["kind"], fields["options"], fields["alert_level"],
                 fields["description"], fields["label"], fields["fields"],
                 fields["file_path"], fields["file_name"], fields["file_size"], fields["file_type"],
                 fields["faculty_id"], kid))
            db().commit()
            flash("KT updated.", "ok")
            return redirect(url_for("criteria_page", crid=kt["criteria_id"]))
    selected_criteria = {"id": kt["criteria_id"], "title": kt["criteria_title"], "college_name": kt["college_name"]}
    return render_template("kt_form.html", selected_criteria=selected_criteria,
                           kt=kt, values=values, block_type=block_type, block_types=BLOCK_TYPES, block_meta=BLOCK_META, kinds=KINDS,
                           faculty_list=_faculty_list() if block_type in ("faculty", "reminder") else [],
                           used_faculty=_used_faculty_ids(selected_criteria["id"], kt["id"] if kt else None) if block_type in ("faculty", "reminder") and selected_criteria else set())


@app.post("/kt/<int:kid>/delete")
@roles_required(*EDITORS)
def kt_delete(kid):
    kt = db().execute("SELECT file_path, criteria_id FROM kts WHERE id = ?", (kid,)).fetchone()
    if kt is None:
        abort(404)
    db().execute("DELETE FROM kts WHERE id = ?", (kid,))
    db().commit()
    if kt["file_path"]:
        _delete_upload(kt["file_path"])
    flash("KT deleted.", "ok")
    return redirect(url_for("criteria_page", crid=kt["criteria_id"]))


@app.route("/kt/<int:kid>/file")
@roles_required(*ANY)
def kt_file(kid):
    kt = db().execute("SELECT * FROM kts WHERE id = ?", (kid,)).fetchone()
    if kt is None or kt["block_type"] not in ("attachment", "faculty") or not kt["file_path"]:
        abort(404)
    return send_from_directory(UPLOAD_DIR, kt["file_path"], download_name=kt["file_name"] or kt["file_path"])


# ---- faculty --------------------------------------------------------------

def _faculty_form():
    """Read and validate the faculty form; returns (values, error message or None)."""
    f = {k: request.form.get(k, "").strip() for k in ("title", "first_name", "last_name", "email")}
    f["email"] = f["email"].lower()
    error = None
    if f["title"] not in TITLES:
        error = "Please choose a title."
    elif not f["first_name"] or not f["last_name"]:
        error = "First name and last name are required."
    elif not FACULTY_EMAIL_RE.fullmatch(f["email"]):
        error = "Enter a valid Mail ID."
    return f, error


@app.route("/faculty/new", methods=["GET", "POST"])
@roles_required(*EDITORS)
def faculty_new():
    values = {}
    if request.method == "POST":
        values, error = _faculty_form()
        if error:
            flash(error, "error")
        else:
            try:
                db().execute(
                    "INSERT INTO faculty (title, first_name, last_name, email, created_by, created_by_name) "
                    "VALUES (?, ?, ?, ?, ?, ?)",
                    (values["title"], values["first_name"], values["last_name"], values["email"], *_me()),
                )
                db().commit()
                flash(f"{values['title']} {values['first_name']} {values['last_name']} added.", "ok")
                return redirect(url_for("faculty_new"))
            except sqlite3.IntegrityError:
                flash("A faculty member with that Mail ID already exists.", "error")
    rows = db().execute(
        "SELECT p.*, " + _added_by("p", "pu") + " AS added_by "
        "FROM faculty p LEFT JOIN users pu ON pu.id = p.created_by ORDER BY p.first_name, p.last_name"
    ).fetchall()
    return render_template("faculty_new.html", rows=rows, titles=TITLES, f=values)


@app.route("/faculty/<int:fid>/edit", methods=["GET", "POST"])
@roles_required(*EDITORS)
def faculty_edit(fid):
    row = db().execute("SELECT * FROM faculty WHERE id = ?", (fid,)).fetchone()
    if row is None:
        abort(404)
    values = dict(row)
    if request.method == "POST":
        values, error = _faculty_form()
        if error:
            flash(error, "error")
        else:
            try:
                db().execute(
                    "UPDATE faculty SET title = ?, first_name = ?, last_name = ?, email = ? WHERE id = ?",
                    (values["title"], values["first_name"], values["last_name"], values["email"], fid),
                )
                db().commit()
                flash("Faculty updated.", "ok")
                return redirect(url_for("faculty_new"))
            except sqlite3.IntegrityError:
                flash("A faculty member with that Mail ID already exists.", "error")
    return render_template("faculty_edit.html", titles=TITLES, f=values, fid=fid)


@app.post("/faculty/<int:fid>/delete")
@roles_required(*EDITORS)
def faculty_delete(fid):
    db().execute("DELETE FROM faculty WHERE id = ?", (fid,))
    db().commit()
    flash("Faculty deleted.", "ok")
    return redirect(url_for("faculty_new"))


# ---- user management (admin) ---------------------------------------------

def _clean_email(value):
    email = value.strip().lower()
    return email if valid_email(email) else None


@app.route("/users", methods=["GET", "POST"])
@roles_required("admin")
def users():
    if request.method == "POST":
        email = _clean_email(request.form["email"])
        role = request.form["role"]
        name = request.form.get("name", "").strip()[:100]
        if role not in MANAGED_ROLES:
            flash("You can only add Lead or User logins.", "error")
        elif not name:
            flash("Enter the person's name.", "error")
        elif not email:
            flash("Enter a valid email from an allowed domain: " + ", ".join(allowed_domains()) + ".", "error")
        else:
            try:
                # no password: these logins sign in with Google only
                db().execute(
                    "INSERT INTO users (username, password_hash, role, email, name) VALUES (?, '', ?, ?, ?)",
                    (email, role, email, name),
                )
                db().commit()
                flash(f"{email} added as {role}. They can now sign in with Google.", "ok")
                if get_setting("email_enabled", "1") == "1":
                    try:
                        send_welcome(name, email, role)
                        flash(f"Welcome email sent to {email}.", "ok")
                    except MailError as e:
                        flash(f"The welcome email was not sent: {e}. Use \"Welcome email\" on that row to resend it.", "error")
            except sqlite3.IntegrityError:
                flash("That email is already added.", "error")
        return redirect(url_for("users"))
    rows = db().execute("SELECT id, username, role, email, name FROM users ORDER BY role, username").fetchall()
    return render_template("users.html", rows=rows, roles=MANAGED_ROLES, google_ready=google_config()[0] is not None,
                           domains=allowed_domains(), mail_ready=smtp_config() is not None,
                           mail_on=get_setting("email_enabled", "1") == "1",
                           signin_url=portal_login_url("lead"))


@app.post("/users/<int:uid>/update")
@roles_required("admin")
def user_update(uid):
    target = db().execute("SELECT * FROM users WHERE id = ?", (uid,)).fetchone()
    if target is None:
        abort(404)
    name = request.form.get("name", "").strip()[:100]
    if target["role"] == "admin":  # the admin has a name and a password, but no role or email to change
        password = request.form.get("password", "")
        if password and len(password) < 6:
            flash("Password must be 6+ characters.", "error")
            return redirect(url_for("users"))
        if name:
            db().execute("UPDATE users SET name = ? WHERE id = ?", (name, uid))
        if password:
            db().execute("UPDATE users SET password_hash = ? WHERE id = ?", (generate_password_hash(password), uid))
        db().commit()
        flash("Admin updated.", "ok")
        return redirect(url_for("users"))
    role = request.form.get("role")
    email = _clean_email(request.form.get("email", ""))
    if role not in MANAGED_ROLES:
        flash("Role must be Lead or User.", "error")
    elif not email:
        flash("Enter a valid email from an allowed domain: " + ", ".join(allowed_domains()) + ".", "error")
    elif not name:
        flash("Enter the person's name.", "error")
    else:
        try:
            db().execute("UPDATE users SET role = ?, email = ?, name = ? WHERE id = ?", (role, email, name, uid))
            db().commit()
            flash("User updated.", "ok")
        except sqlite3.IntegrityError:
            flash("That email is already used by another login.", "error")
    return redirect(url_for("users"))


@app.post("/users/<int:uid>/welcome")
@roles_required("admin")
def user_welcome(uid):
    target = db().execute("SELECT * FROM users WHERE id = ?", (uid,)).fetchone()
    if target is None or target["role"] == "admin" or not target["email"]:
        abort(404)
    try:
        send_welcome(target["name"], target["email"], target["role"])
        flash(f"Welcome email sent to {target['email']}.", "ok")
    except MailError as e:
        flash(f"The welcome email was not sent: {e}.", "error")
    return redirect(url_for("users"))


@app.post("/users/<int:uid>/delete")
@roles_required("admin")
def user_delete(uid):
    target = db().execute("SELECT role FROM users WHERE id = ?", (uid,)).fetchone()
    if target is None:
        abort(404)
    if target["role"] == "admin":
        flash("Admin accounts cannot be deleted here.", "error")
    else:
        db().execute("DELETE FROM users WHERE id = ?", (uid,))
        db().commit()
        flash("User deleted.", "ok")
    return redirect(url_for("users"))


# ---- settings: welcome email template (admin) ----------------------------------------

def _email_context():
    if g.user["role"] != "admin":
        return {}
    cfg = smtp_config()
    return {"subject": get_setting("email_subject", DEFAULT_SUBJECT), "body": get_setting("email_body", DEFAULT_BODY),
            "enabled": get_setting("email_enabled", "1") == "1", "mail_ready": cfg is not None,
            "otp_on": otp_enabled(),
            "mail_from": cfg["from_email"] if cfg else "", "template_vars": TEMPLATE_VARS}


def _template_from_form():
    subject = " ".join(request.form.get("subject", "").split())[:200]
    body = re.sub(r"(?is)<script\b.*?</script\s*>", "", request.form.get("body", "")).strip()  # no scripts in mail
    return subject, body[:50000]


@app.post("/settings/signin")
@roles_required("admin")
def signin_save():
    set_setting("otp_enabled", "1" if request.form.get("otp") == "on" else "0")
    db().commit()
    flash("Sign-in options saved.", "ok")
    return redirect(url_for("settings"))


@app.post("/settings/email")
@roles_required("admin")
def email_save():
    subject, body = _template_from_form()
    if not subject or not body:
        flash("The email needs both a subject and a body.", "error")
    else:
        set_setting("email_subject", subject)
        set_setting("email_body", body)
        set_setting("email_enabled", "1" if request.form.get("enabled") == "on" else "0")
        db().commit()
        flash("Welcome email template saved.", "ok")
    return redirect(url_for("settings"))


@app.post("/settings/email/reset")
@roles_required("admin")
def email_reset():
    db().execute("DELETE FROM settings WHERE key IN ('email_subject', 'email_body')")
    db().commit()
    flash("The welcome email template was reset to the default.", "ok")
    return redirect(url_for("settings"))


@app.post("/settings/email/test")
@roles_required("admin")
def email_test():
    """Send the template as currently typed (saved or not) to an address the admin chooses."""
    to = request.form.get("to", "").strip().lower()
    subject, body = _template_from_form()
    if not FACULTY_EMAIL_RE.fullmatch(to):
        flash("Enter a valid email address to send the test to.", "error")
    elif not subject or not body:
        flash("The email needs both a subject and a body.", "error")
    else:
        values = {"app_name": APP_NAME, "name": "Sample Person", "email": to, "role": "Lead",
                  "login_url": portal_login_url("lead"), "added_by": _me()[1]}
        try:
            send_mail(to, "[Test] " + fill_template(subject, values, False), fill_template(body, values, True))
            flash(f"Test email sent to {to}.", "ok")
        except MailError as e:
            flash(f"The test email was not sent: {e}.", "error")
    return redirect(url_for("settings"))


# ---- support (emailed to the support contact) -----------------------------------------

SUPPORT_EMAIL = "gangadharan@vatechies.com"
SUPPORT_IMAGE_EXT = {"png", "jpg", "jpeg", "jpe", "gif", "webp", "bmp", "tif", "tiff", "heic", "heif", "avif", "svg", "ico"}
SUPPORT_DOC_EXT = {"pdf", "doc", "docx", "dot", "dotx", "rtf", "odt", "txt", "md", "log", "csv", "tsv",
                   "xls", "xlsx", "xlsm", "ods", "ppt", "pptx", "pps", "ppsx", "odp"}
SUPPORT_EXT = SUPPORT_IMAGE_EXT | SUPPORT_DOC_EXT
SUPPORT_MAX_FILES = 10
SUPPORT_MAX_FILE = 10 * 1024 * 1024
SUPPORT_MAX_TOTAL = 15 * 1024 * 1024       # raw bytes; email grows ~1/3 when encoded, and most servers cap near 25 MB
SUPPORT_LIMIT = (5, 600)                    # at most 5 requests per person per 10 minutes
_support_sent = {}


def _support_rate_limited(uid):
    now = time.time()
    recent = [t for t in _support_sent.get(uid, []) if now - t < SUPPORT_LIMIT[1]]
    _support_sent[uid] = recent
    return len(recent) >= SUPPORT_LIMIT[0]


def _read_support_files():
    """Validate the attached files. Returns ([(name, bytes, mime)], error_or_None)."""
    files, total = [], 0
    for f in request.files.getlist("files"):
        if not f or not f.filename:
            continue
        ext = f.filename.rsplit(".", 1)[-1].lower() if "." in f.filename else ""
        if ext not in SUPPORT_EXT:
            return [], f"\"{f.filename}\" is not an image or document type we can accept."
        data = f.read(SUPPORT_MAX_FILE + 1)
        if not data:
            return [], f"\"{f.filename}\" is empty."
        if len(data) > SUPPORT_MAX_FILE:
            return [], f"\"{f.filename}\" is too big. Each file can be up to {SUPPORT_MAX_FILE // (1024 * 1024)} MB."
        total += len(data)
        name = secure_filename(f.filename) or f"attachment.{ext}"
        mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
        files.append((name, data, mime))
    if len(files) > SUPPORT_MAX_FILES:
        return [], f"You can attach up to {SUPPORT_MAX_FILES} files."
    if total > SUPPORT_MAX_TOTAL:
        return [], f"The attachments are too big together. The total limit is {SUPPORT_MAX_TOTAL // (1024 * 1024)} MB."
    return files, None


def _support_email_body(title, message, files):
    who = g.user
    name = who["name"] or who["username"]
    rows = [("From", f"{name} ({who['username']})"), ("Email", who["email"] or "-"), ("Role", who["role"].capitalize()),
            ("Sent", time.strftime("%d %b %Y, %H:%M")), ("Attachments", ", ".join(n for n, _, _ in files) or "None")]
    table = "".join(f'<tr><td style="padding:2px 14px 2px 0;color:#666">{html.escape(k)}</td><td>{html.escape(v)}</td></tr>'
                    for k, v in rows)
    return (f'<div style="font-family:Arial,sans-serif;font-size:14px;color:#222">'
            f'<h2 style="margin:0 0 10px">New support request</h2><table>{table}</table>'
            f'<h3 style="margin:18px 0 6px">{html.escape(title)}</h3>'
            f'<div style="white-space:pre-wrap">{html.escape(message)}</div></div>')


@app.route("/support", methods=["GET", "POST"])
@roles_required(*ANY)
def support():
    values = {"title": "", "message": ""}
    if request.method == "POST":
        values = {"title": request.form.get("title", "").strip()[:200],
                  "message": request.form.get("message", "").strip()[:5000]}
        files, error = _read_support_files()
        if not values["title"]:
            error = "Enter a title."
        elif not values["message"]:
            error = "Enter a message."
        elif not error and _support_rate_limited(g.user["id"]):
            error = "You have sent several requests just now. Please wait a few minutes before sending another."
        if error:
            flash(error, "error")
        else:
            reply_to = (g.user["email"] or "").strip().lower()
            try:
                send_mail(SUPPORT_EMAIL, "[Support] " + values["title"],
                          _support_email_body(values["title"], values["message"], files),
                          attachments=files, reply_to=reply_to if EMAIL_RE.fullmatch(reply_to) else None)
            except MailError as e:
                flash(f"Your request could not be sent: {e}. Please try again (re-select any attachments).", "error")
            else:
                _support_sent.setdefault(g.user["id"], []).append(time.time())
                flash("Thank you - your request has been sent to the support team.", "ok")
                return redirect(url_for("support"))
    accept = ",".join(["image/*"] + ["." + e for e in sorted(SUPPORT_EXT)])
    return render_template("support.html", values=values, accept=accept, max_files=SUPPORT_MAX_FILES,
                           max_file_mb=SUPPORT_MAX_FILE // (1024 * 1024), max_total_mb=SUPPORT_MAX_TOTAL // (1024 * 1024))


# ---- settings (change own password) --------------------------------------------------

@app.route("/settings", methods=["GET", "POST"])
@roles_required(*ANY)
def settings():
    if request.method == "POST" and g.user["role"] != "admin":
        flash("Your account signs in with Google, so there is no password to change.", "error")
    elif request.method == "POST":
        if not check_password_hash(g.user["password_hash"], request.form["current"]):
            flash("Current password is wrong.", "error")
        elif len(request.form["new"]) < 6:
            flash("New password must be 6+ characters.", "error")
        else:
            db().execute(
                "UPDATE users SET password_hash = ? WHERE id = ?",
                (generate_password_hash(request.form["new"]), g.user["id"]),
            )
            db().commit()
            flash("Password changed.", "ok")
            return redirect(url_for("settings"))
    return render_template("settings.html", **_email_context())


import assistant  # noqa: E402  (needs the helpers above)
assistant.register(app, {"db": db, "roles_required": roles_required, "ANY": ANY, "EDITORS": EDITORS, "me": _me,
                         "criteria_rows": _criteria_rows, "kt_view": _kt_view, "fmt_date": _fmt_date,
                         "reminder_state": _reminder_state, "end_soon_days": REMINDER_END_SOON_DAYS})


if __name__ == "__main__":
    init_db()
    app.run(port=5001, debug=False)
