"""Atlas Histo — atlas jaringan histologi (Flask + MySQL).

Tiga muka dalam satu app:
  /                    galeri publik (cari + saring kategori)
  /preparat/<id>       halaman preparat: foto besar + anotasi bertitik
  /admin               panel kelola: unggah, sunting, hapus, anotasi

Foto disimpan di ``../assets/uploads/<uuid>.<ext>`` dan dilayani lewat /assets/.
Skema DB: tabel ``gambar`` dan ``anotasi`` (lihat sql/schema.sql); app membuat
tabelnya sendiri kalau belum ada, supaya aman setelah DB dipulihkan.

Konfigurasi lewat env (semua ada nilai bawaannya, aman untuk lokal):
  HIST_DB_HOST/USER/PASS/NAME   koneksi MySQL
  HIST_ADMIN_PASS               kalau diisi, /admin minta sandi
  HIST_SECRET                   kunci sesi; kalau kosong dibuat di .session_key
"""

from __future__ import annotations

import json
import os
import secrets
import uuid
from datetime import datetime
from pathlib import Path

import mysql.connector
from PIL import Image
from flask import (Flask, abort, flash, g, redirect, render_template, request,
                   send_from_directory, session, url_for)
from mysql.connector import Error as MySQLError

BASE_DIR = Path(__file__).resolve().parent
ASSET_DIR = (BASE_DIR.parent / "assets").resolve()
UPLOAD_DIR = ASSET_DIR / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)

EKSTENSI_BOLEH = {"png", "jpg", "jpeg", "webp", "gif"}

from bp_auth import bp as bp_auth  # noqa: E402
from bp_admin import bp as bp_admin  # noqa: E402
from bp_belajar import bp as bp_belajar  # noqa: E402
from bp_ujian import bp as bp_ujian  # noqa: E402
from bp_dosen_ujian import bp as bp_dosen_ujian  # noqa: E402
from keamanan import (boleh_lihat, boleh_sunting, butuh, cek_csrf, csrf_input,  # noqa: E402
                      pengguna_sekarang, sql_lihat_preparat)
from konfigurasi import ADMIN_PASS, BATAS_UNGGAH, DB_CFG, PORT as HIST_PORT  # noqa: E402
from gambar_util import BerkasTidakSah, simpan_unggahan  # noqa: E402
from kalibrasi import KalibrasiTidakSah, bar_disarankan, hitung_um_per_px, label_skala  # noqa: E402
import progres  # noqa: E402
from skema import siapkan_skema  # noqa: E402


def _kunci_sesi() -> str:
    """Kunci sesi dari env, atau berkas .session_key (mode 600)."""
    dari_env = os.environ.get("HIST_SECRET", "").strip()
    if dari_env:
        return dari_env
    berkas = BASE_DIR / ".session_key"
    if berkas.exists():
        return berkas.read_text().strip()
    kunci = secrets.token_urlsafe(32)
    berkas.write_text(kunci)
    try:
        os.chmod(berkas, 0o600)
    except OSError:
        pass
    return kunci


app = Flask(__name__)
app.config.update(
    SECRET_KEY=_kunci_sesi(),
    MAX_CONTENT_LENGTH=BATAS_UNGGAH,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
    TEMPLATES_AUTO_RELOAD=True,
)

def db():
    """Koneksi MySQL per request (disimpan di flask.g)."""
    if "db" not in g:
        g.db = mysql.connector.connect(**DB_CFG)
    return g.db


@app.teardown_appcontext
def _tutup_db(_exc=None):
    koneksi = g.pop("db", None)
    if koneksi is not None:
        try:
            koneksi.close()
        except MySQLError:
            pass


def kueri(sql: str, params: tuple = (), satu: bool = False):
    cur = db().cursor(dictionary=True)
    try:
        cur.execute(sql, params)
        if cur.with_rows:
            hasil = cur.fetchall()
            return (hasil[0] if hasil else None) if satu else hasil
        db().commit()
        return cur.lastrowid
    finally:
        cur.close()


siapkan_skema()

app.register_blueprint(bp_auth)
app.register_blueprint(bp_admin)
app.register_blueprint(bp_belajar)
app.register_blueprint(bp_ujian)
app.register_blueprint(bp_dosen_ujian)


# ── geometri anotasi ───────────────────────────────────────────────────────
def urai_titik(teks: str | None) -> list[tuple[float, float]]:
    """JSON [[x,y],…] (persen 0–100) → daftar titik yang sudah dijepit."""
    try:
        data = json.loads(teks or "[]")
    except (TypeError, ValueError):
        return []
    hasil: list[tuple[float, float]] = []
    for p in data if isinstance(data, list) else []:
        if not isinstance(p, (list, tuple)) or len(p) != 2:
            continue
        try:
            x = max(0.0, min(100.0, float(p[0])))
            y = max(0.0, min(100.0, float(p[1])))
        except (TypeError, ValueError):
            continue
        hasil.append((round(x, 1), round(y, 1)))
    return hasil[:200]


def pusat_dan_luas(pts: list[tuple[float, float]]) -> tuple[float, float, float]:
    """Titik berat dan luas poligon (satuan persen²) lewat rumus shoelace."""
    n = len(pts)
    if n < 3:
        if not n:
            return 0.0, 0.0, 0.0
        return (round(sum(p[0] for p in pts) / n, 1),
                round(sum(p[1] for p in pts) / n, 1), 0.0)
    luas2 = cx = cy = 0.0
    for i in range(n):
        x1, y1 = pts[i]
        x2, y2 = pts[(i + 1) % n]
        silang = x1 * y2 - x2 * y1
        luas2 += silang
        cx += (x1 + x2) * silang
        cy += (y1 + y2) * silang
    if abs(luas2) < 1e-9:  # poligon degenerate (titik segaris)
        return (round(sum(p[0] for p in pts) / n, 1),
                round(sum(p[1] for p in pts) / n, 1), 0.0)
    luas = abs(luas2) / 2
    return round(cx / (3 * luas2), 1), round(cy / (3 * luas2), 1), round(luas, 1)


def lengkapi_anotasi(daftar: list[dict]) -> list[dict]:
    """Tambahkan data siap-render: poligon (string SVG), pusat pin, luas %.

    Baris lama (tipe NULL / titik) tetap tampil sebagai titik biasa, jadi data
    sebelum fitur area tidak perlu dimigrasi manual.
    """
    for a in daftar:
        pts = urai_titik(a.get("titik")) if (a.get("tipe") or "titik") == "area" else []
        if len(pts) >= 3:
            cx, cy, luas = pusat_dan_luas(pts)
            a["tipe"] = "area"
            a["poligon"] = " ".join(f"{x},{y}" for x, y in pts)
            a["jumlah_titik"] = len(pts)
            a["luas_pct"] = round(luas / 100, 1)  # 0–100 dari luas foto
            a["px"], a["py"] = cx, cy
        else:
            a["tipe"] = "titik"
            a["poligon"] = None
            a["jumlah_titik"] = 0
            a["luas_pct"] = 0.0
            a["px"] = float(a.get("x_pos") or 0)
            a["py"] = float(a.get("y_pos") or 0)
    return daftar


# ── gerbang akses ──────────────────────────────────────────────────────────
ENDPOINT_BEBAS = {"auth.masuk", "auth.daftar", "auth.pertama", "static", "kesehatan"}


def admin_aktif() -> bool:
    """Panel kelola terbuka untuk dosen dan admin yang sudah masuk."""
    pengguna = pengguna_sekarang()
    return bool(pengguna and pengguna["peran"] in ("admin", "dosen"))


@app.before_request
def _gerbang():
    """Tamu tidak boleh melihat apa pun; POST wajib token CSRF."""
    if request.endpoint in ENDPOINT_BEBAS:
        return None
    pengguna = pengguna_sekarang()
    if not pengguna:
        if request.path.startswith("/api/"):
            return {"ok": False, "error": "butuh login"}, 401
        return redirect(url_for("auth.masuk", lanjut=request.full_path.rstrip("?")))
    if pengguna["ganti_sandi"] and request.endpoint not in ("auth.ganti_sandi", "auth.keluar"):
        return redirect(url_for("auth.ganti_sandi"))
    cek_csrf()
    return None


@app.context_processor
def _konteks():
    return {"admin_aktif": admin_aktif, "ada_sandi": bool(ADMIN_PASS),
            "label_skala": label_skala, "pengguna": pengguna_sekarang(),
            "csrf_input": csrf_input, "boleh_sunting": boleh_sunting}


# ── halaman publik ─────────────────────────────────────────────────────────
@app.route("/")
def index():
    q = (request.args.get("q") or "").strip()
    kategori = (request.args.get("kategori") or "").strip()

    pengguna = pengguna_sekarang()
    syarat_lihat, params_lihat = sql_lihat_preparat(pengguna)
    where, params = [syarat_lihat], list(params_lihat)
    if q:
        where.append("(judul LIKE %s OR deskripsi LIKE %s OR kategori LIKE %s)")
        params += [f"%{q}%"] * 3
    if kategori:
        where.append("kategori = %s")
        params.append(kategori)
    sql = "SELECT * FROM gambar g WHERE " + " AND ".join(where)
    sql += " ORDER BY created_at DESC, id DESC"

    gambar = kueri(sql, tuple(params))
    kategori_semua = [r["kategori"] for r in
                      kueri("SELECT DISTINCT kategori FROM gambar g WHERE " + syarat_lihat +
                            " AND kategori IS NOT NULL AND kategori <> '' ORDER BY kategori",
                            params_lihat)]
    hitung = {r["gambar_id"]: r["c"] for r in
              kueri("SELECT gambar_id, COUNT(*) c FROM anotasi GROUP BY gambar_id")}
    total = kueri("SELECT COUNT(*) c FROM gambar g WHERE " + syarat_lihat,
                  params_lihat, satu=True)["c"]
    total_anotasi = kueri("SELECT COUNT(*) c FROM anotasi", satu=True)["c"]

    return render_template("index.html", gambar=gambar, hitung=hitung,
                           kategori_semua=kategori_semua, q=q,
                           kategori_aktif=kategori, total=total,
                           total_anotasi=total_anotasi)


@app.route("/preparat/<int:gid>")
def preparat(gid: int):
    gbr = kueri("SELECT * FROM gambar WHERE id = %s", (gid,), satu=True)
    if not gbr:
        abort(404)
    if not boleh_lihat(gbr, pengguna_sekarang()):
        abort(404)
    gbr = _pastikan_dimensi(gbr)
    anotasi = lengkapi_anotasi(
        kueri("SELECT * FROM anotasi WHERE gambar_id = %s ORDER BY id", (gid,)))
    tetangga = kueri("SELECT id, judul FROM gambar WHERE id <> %s "
                     "ORDER BY ABS(id - %s), id LIMIT 6", (gid, gid))
    pengguna = pengguna_sekarang()
    dipelajari = False
    if pengguna and pengguna["peran"] == "mahasiswa":
        progres.catat_kunjungan(pengguna["id"], gid)
        dipelajari = progres.sudah_dipelajari(pengguna["id"], gid)
    return render_template("preparat.html", gbr=gbr, anotasi=anotasi,
                           tetangga=tetangga, dipelajari=dipelajari,
                           bisa_tandai=bool(pengguna and pengguna["peran"] == "mahasiswa"))


@app.route("/assets/<path:nama>")
def aset(nama: str):
    """Aset statis. Foto unggahan hanya lewat /foto/<nama> yang memeriksa sesi."""
    if nama.startswith("uploads/"):
        abort(404)
    return send_from_directory(ASSET_DIR, nama, max_age=86400)


@app.get("/foto/<path:nama>")
def foto(nama: str):
    """Sajikan foto preparat hanya untuk pengguna yang berhak melihatnya."""
    pengguna = pengguna_sekarang()
    if not pengguna:
        abort(401)
    baris = kueri("SELECT * FROM gambar WHERE file_gambar = %s OR file_proxy = %s",
                  (nama, nama), satu=True)
    if not baris or not boleh_lihat(baris, pengguna):
        abort(404)
    return send_from_directory(UPLOAD_DIR, nama, max_age=2592000)


@app.route("/health")
def kesehatan():
    try:
        jml = kueri("SELECT COUNT(*) c FROM gambar", satu=True)["c"]
        return {"ok": True, "app": "atlas-histo", "preparat": jml}
    except MySQLError as exc:
        return {"ok": False, "error": str(exc)}, 503


# ── panel kelola ───────────────────────────────────────────────────────────
def _simpan_berkas(berkas) -> dict | None:
    """Simpan unggahan lewat gambar_util; kembalikan info berkas atau None."""
    if not (berkas and (berkas.filename or "").strip()):
        return None
    return simpan_unggahan(berkas, UPLOAD_DIR)


def _hapus_berkas(*nama: str | None) -> None:
    """Hapus berkas unggahan (boleh beberapa sekaligus: foto + proxy)."""
    for satu in nama:
        if not satu:
            continue
        jalur = (UPLOAD_DIR / satu).resolve()
        if jalur.is_file() and UPLOAD_DIR in jalur.parents:
            jalur.unlink(missing_ok=True)


def _pastikan_dimensi(gbr: dict) -> dict:
    """Isi lebar_px/tinggi_px dari berkas bila DB belum punya (foto lama).

    Tanpa ini penampil menerima 0 dan jatuh ke fallback yang salah.
    """
    if gbr.get("lebar_px") and gbr.get("tinggi_px"):
        return gbr
    jalur = UPLOAD_DIR / (gbr.get("file_gambar") or "")
    try:
        with Image.open(jalur) as im:
            lebar, tinggi = im.size
    except (OSError, ValueError):
        return gbr
    kueri("UPDATE gambar SET lebar_px=%s, tinggi_px=%s WHERE id=%s",
          (lebar, tinggi, gbr["id"]))
    gbr["lebar_px"], gbr["tinggi_px"] = lebar, tinggi
    return gbr


def _angka_form(nama: str, bawaan: int | None = None) -> int | None:
    teks = (request.form.get(nama) or "").strip()
    if not teks:
        return bawaan
    try:
        return int(float(teks))
    except ValueError:
        return bawaan


@app.route("/admin", methods=["GET", "POST"])
@butuh("admin", "dosen")
def admin():
    if request.method == "POST":
        judul = (request.form.get("judul") or "").strip()
        kategori = (request.form.get("kategori") or "").strip()
        deskripsi = (request.form.get("deskripsi") or "").strip()
        berkas = request.files.get("file")
        if not judul:
            flash("Judul preparat wajib diisi.", "galat")
        elif not berkas or not (berkas.filename or "").strip():
            flash("Pilih berkas foto preparat.", "galat")
        else:
            try:
                info = _simpan_berkas(berkas)
            except BerkasTidakSah as exc:
                flash(str(exc), "galat")
            else:
                kueri(
                    "INSERT INTO gambar (judul, kategori, deskripsi, file_gambar, "
                    "file_proxy, lebar_px, tinggi_px, perbesaran_total, objektif, "
                    "okuler, publik, aktif, dosen_id) "
                    "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1, %s)",
                    (judul, kategori, deskripsi, info["nama"], info["proxy"],
                     info["lebar"], info["tinggi"],
                     _angka_form("perbesaran_total", 40),
                     (request.form.get("objektif") or "4x").strip(),
                     _angka_form("okuler", 10),
                     (1 if "1" in request.form.getlist("publik") else 0)
                     if "publik" in request.form else 1,
                     (pengguna_sekarang() or {}).get("id")))
                flash(f"Preparat “{judul}” tersimpan.", "ok")
                return redirect(url_for("admin"))

    pengguna = pengguna_sekarang() or {}
    if pengguna.get("peran") == "dosen":
        daftar = kueri("SELECT * FROM gambar WHERE dosen_id = %s "
                       "ORDER BY created_at DESC, id DESC", (pengguna["id"],))
    else:
        daftar = kueri("SELECT * FROM gambar ORDER BY created_at DESC, id DESC")
    hitung = {r["gambar_id"]: r["c"] for r in
              kueri("SELECT gambar_id, COUNT(*) c FROM anotasi GROUP BY gambar_id")}
    kategori_semua = [r["kategori"] for r in
                      kueri("SELECT DISTINCT kategori FROM gambar "
                            "WHERE kategori IS NOT NULL AND kategori <> '' "
                            "ORDER BY kategori")]
    return render_template("admin.html", daftar=daftar, hitung=hitung,
                           kategori_semua=kategori_semua)


@app.route("/admin/preparat/<int:gid>", methods=["GET", "POST"])
@butuh("admin", "dosen")
def admin_preparat(gid: int):
    gbr = kueri("SELECT * FROM gambar WHERE id = %s", (gid,), satu=True)
    if not gbr or not boleh_sunting(gbr, pengguna_sekarang()):
        abort(404)

    if request.method == "POST":
        aksi = request.form.get("aksi") or "sunting"
        if aksi == "anotasi":
            label = (request.form.get("label") or "").strip()
            ket = (request.form.get("keterangan") or "").strip()
            tipe = (request.form.get("tipe") or "titik").strip().lower()
            if not label:
                flash("Label anotasi wajib diisi.", "galat")
            elif tipe == "area":
                # Poligon disimpan sebagai persen 0–100 → tetap akurat di layar apa pun.
                pts = urai_titik(request.form.get("titik"))
                if len(pts) < 3:
                    flash("Area butuh minimal 3 titik sudut.", "galat")
                else:
                    cx, cy, _luas = pusat_dan_luas(pts)
                    simpul = json.dumps([[x, y] for x, y in pts], separators=(",", ":"))
                    kueri("INSERT INTO anotasi (gambar_id, label, keterangan, x_pos, y_pos, "
                          "tipe, titik) VALUES (%s, %s, %s, %s, %s, 'area', %s)",
                          (gid, label, ket, round(cx), round(cy), simpul))
                    flash(f"Area “{label}” ditambahkan.", "ok")
            else:
                try:
                    x = max(0, min(100, int(float(request.form.get("x_pos") or 0))))
                    y = max(0, min(100, int(float(request.form.get("y_pos") or 0))))
                except ValueError:
                    x = y = 0
                kueri("INSERT INTO anotasi (gambar_id, label, keterangan, x_pos, y_pos, tipe) "
                      "VALUES (%s, %s, %s, %s, %s, 'titik')", (gid, label, ket, x, y))
                flash(f"Titik “{label}” ditambahkan.", "ok")
        else:
            judul = (request.form.get("judul") or "").strip()
            kategori = (request.form.get("kategori") or "").strip()
            deskripsi = (request.form.get("deskripsi") or "").strip()
            berkas = request.files.get("file")
            if not judul:
                flash("Judul tidak boleh kosong.", "galat")
            else:
                try:
                    info = _simpan_berkas(berkas) if berkas and berkas.filename else None
                except BerkasTidakSah as exc:
                    flash(str(exc), "galat")
                else:
                    # Metadata perbesaran selalu bisa disunting, berkas foto tidak wajib baru.
                    kolom = ("judul=%s, kategori=%s, deskripsi=%s, perbesaran_total=%s, "
                             "objektif=%s, okuler=%s, publik=%s")
                    nilai = [judul, kategori, deskripsi,
                             _angka_form("perbesaran_total", gbr.get("perbesaran_total")),
                             (request.form.get("objektif") or gbr.get("objektif") or "4x").strip(),
                             _angka_form("okuler", gbr.get("okuler")),
                             (1 if "1" in request.form.getlist("publik") else 0)
                             if "publik" in request.form else (gbr.get("publik") or 0)]
                    if info:
                        # Kalibrasi lama tidak sah untuk foto baru: kosongkan, minta ulang.
                        kolom += (", file_gambar=%s, file_proxy=%s, lebar_px=%s, tinggi_px=%s, "
                                  "um_per_px=NULL, skala_bar_um=NULL, skala_bar_px=NULL, "
                                  "kalibrasi_pada=NULL")
                        nilai += [info["nama"], info["proxy"], info["lebar"], info["tinggi"]]
                        flash("Foto diganti — kalibrasi skala dikosongkan. "
                              "Silakan kalibrasi ulang.", "galat")
                        kueri(f"UPDATE gambar SET {kolom} WHERE id=%s", tuple(nilai) + (gid,))
                        _hapus_berkas(gbr["file_gambar"], gbr.get("file_proxy"))
                    else:
                        kueri(f"UPDATE gambar SET {kolom} WHERE id=%s", tuple(nilai) + (gid,))
                    flash("Perubahan tersimpan.", "ok")
        return redirect(url_for("admin_preparat", gid=gid))

    anotasi = lengkapi_anotasi(
        kueri("SELECT * FROM anotasi WHERE gambar_id = %s ORDER BY id", (gid,)))
    kategori_semua = [r["kategori"] for r in
                      kueri("SELECT DISTINCT kategori FROM gambar "
                            "WHERE kategori IS NOT NULL AND kategori <> '' "
                            "ORDER BY kategori")]
    return render_template("admin_preparat.html", gbr=gbr, anotasi=anotasi,
                           kategori_semua=kategori_semua)


@app.route("/admin/preparat/<int:gid>/kalibrasi", methods=["GET", "POST"])
@butuh("admin", "dosen")
def admin_kalibrasi(gid: int):
    """Dosen menarik garis di atas skala bar bawaan foto, lalu mengisi µm-nya."""
    gbr = kueri("SELECT * FROM gambar WHERE id = %s", (gid,), satu=True)
    if not gbr or not boleh_sunting(gbr, pengguna_sekarang()):
        abort(404)

    if request.method == "POST":
        if (request.form.get("aksi") or "") == "hapus":
            kueri("UPDATE gambar SET skala_bar_um=NULL, skala_bar_px=NULL, um_per_px=NULL, "
                  "kalibrasi_pada=NULL WHERE id=%s", (gid,))
            flash("Kalibrasi dihapus; penampil tidak lagi menampilkan skala bar.", "ok")
            return redirect(url_for("admin_kalibrasi", gid=gid))
        um = request.form.get("skala_bar_um")
        px = request.form.get("skala_bar_px")
        try:
            um_px = hitung_um_per_px(um, px)
        except KalibrasiTidakSah as exc:
            flash(str(exc), "galat")
        else:
            kueri("UPDATE gambar SET skala_bar_um=%s, skala_bar_px=%s, um_per_px=%s, "
                  "kalibrasi_pada=NOW() WHERE id=%s",
                  (int(float(um)), int(round(float(px))), um_px, gid))
            flash(f"Kalibrasi tersimpan: {um_px:.2f} µm/px.".replace(".", ","), "ok")
        return redirect(url_for("admin_kalibrasi", gid=gid))

    saran_um, saran_px = (None, None)
    if gbr.get("um_per_px"):
        saran_um, saran_px = bar_disarankan(gbr["um_per_px"])
    return render_template("kalibrasi.html", gbr=gbr, label=label_skala(gbr),
                           saran_um=saran_um, saran_px=saran_px)


@app.get("/api/skala/<int:gid>")
def api_skala(gid: int):
    gbr = kueri("SELECT * FROM gambar WHERE id = %s", (gid,), satu=True)
    if not gbr:
        abort(404)
    return {
        "lebar": gbr.get("lebar_px"),
        "tinggi": gbr.get("tinggi_px"),
        "um_per_px": float(gbr["um_per_px"]) if gbr.get("um_per_px") else None,
        "skala_bar_um": gbr.get("skala_bar_um"),
        "skala_bar_px": gbr.get("skala_bar_px"),
        "proxy": gbr.get("file_proxy"),
        "penuh": gbr.get("file_gambar"),
        "label": label_skala(gbr),
        "perbesaran_total": gbr.get("perbesaran_total"),
    }


@app.post("/admin/anotasi/<int:aid>/hapus")
@butuh("admin", "dosen")
def admin_anotasi_hapus(aid: int):
    baris = kueri("SELECT gambar_id FROM anotasi WHERE id = %s", (aid,), satu=True)
    if not baris:
        abort(404)
    milik = kueri("SELECT * FROM gambar WHERE id = %s", (baris["gambar_id"],), satu=True)
    if not boleh_sunting(milik, pengguna_sekarang()):
        abort(404)
    kueri("DELETE FROM anotasi WHERE id = %s", (aid,))
    flash("Anotasi dihapus.", "ok")
    return redirect(url_for("admin_preparat", gid=baris["gambar_id"]))


@app.post("/admin/preparat/<int:gid>/hapus")
@butuh("admin", "dosen")
def admin_hapus(gid: int):
    gbr = kueri("SELECT * FROM gambar WHERE id = %s", (gid,), satu=True)
    if not gbr or not boleh_sunting(gbr, pengguna_sekarang()):
        abort(404)
    kueri("DELETE FROM anotasi WHERE gambar_id = %s", (gid,))
    kueri("DELETE FROM gambar WHERE id = %s", (gid,))
    _hapus_berkas(gbr["file_gambar"], gbr.get("file_proxy"))
    flash("Preparat dihapus.", "ok")
    return redirect(url_for("admin"))


@app.errorhandler(404)
def _tidak_ada(_e):
    return render_template("404.html"), 404


@app.errorhandler(400)
def _permintaan_salah(e):
    return render_template("404.html", pesan=getattr(e, "description", None)), 400


@app.errorhandler(403)
def _terlarang(_e):
    return render_template("404.html", pesan="Anda tidak punya akses ke halaman ini."), 403


@app.errorhandler(413)
def _terlalu_besar(_e):
    return (render_template("404.html",
                            pesan=f"Berkas melebihi {BATAS_UNGGAH // (1024 * 1024)} MB."),
            413)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=int(os.environ.get("HIST_PORT", "8086")), debug=False)
