"""Key EPS Repair - customer job tracker.

Customers sign in with their job number plus the email or phone number on the job.
Staff manage jobs from /admin. Data lives in SQLite; uploads live on disk.
"""
import hmac
import os
import re
import secrets
import smtplib
import sqlite3
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from functools import wraps
from zoneinfo import ZoneInfo

from flask import (Flask, abort, flash, g, redirect, render_template, request,
                   send_from_directory, session, url_for)
from markupsafe import Markup
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.utils import secure_filename

import code128

BASE_DIR = os.path.dirname(os.path.abspath(__file__))


def _load_brand_file():
    """BRAND=key-tronics loads brands/key-tronics.env. Real environment settings still win over the file."""
    brand = os.environ.get("BRAND", "").strip()
    if not brand:
        return
    if not re.fullmatch(r"[a-z0-9-]{1,40}", brand):
        raise SystemExit("BRAND may only contain lower-case letters, digits and dashes.")
    path = os.path.join(BASE_DIR, "brands", brand + ".env")
    if not os.path.isfile(path):
        raise SystemExit(f"There is no settings file brands/{brand}.env")
    with open(path, encoding="utf-8") as fh:
        for line in fh:
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            value = value.strip()
            if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
                value = value[1:-1]
            os.environ.setdefault(key.strip(), value)


_load_brand_file()
DATA_DIR = os.environ.get("DATA_DIR") or os.path.join(BASE_DIR, "data")
UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
DB_PATH = os.path.join(DATA_DIR, "tracker.db")
os.makedirs(UPLOAD_DIR, exist_ok=True)

DEFAULT_STAGES = ["Received", "Diagnosed", "Repair and testing", "Quality check", "Ready for collection"]
# STAGES can be set per business as five names separated by commas.
STAGES = [name.strip() for name in os.environ.get("STAGES", "").split(",") if name.strip()] or DEFAULT_STAGES
if len(STAGES) != 5:
    raise SystemExit("STAGES must list exactly five stage names separated by commas, for example: "
                     "Received,Diagnosed,Repair or programming,Quality check,Ready for collection")
TZ = ZoneInfo(os.environ.get("TIMEZONE", "Africa/Johannesburg"))
# A second business uses the same code. If COMPANY_NAME is set, its own contact details are required,
# so one business can never show another business's phone number or address by accident.
_CUSTOM_BRAND = bool(os.environ.get("COMPANY_NAME") or os.environ.get("BRAND"))


def _brand_value(name, default):
    value = os.environ.get(name, "").strip()
    if value:
        return value
    if _CUSTOM_BRAND:
        raise SystemExit(f"{name} is not set. Set COMPANY_NAME, COMPANY_PHONE, COMPANY_EMAIL and "
                         f"COMPANY_ADDRESS together for each business.")
    return default


COMPANY = {
    "name": os.environ.get("COMPANY_NAME", "").strip() or ("Key EPS Repair" if not _CUSTOM_BRAND else _brand_value("COMPANY_NAME", "")),
    "phone": _brand_value("COMPANY_PHONE", "078 930 8620"),
    "email": _brand_value("COMPANY_EMAIL", "lillyclosetrading@gmail.com"),
    "address": _brand_value("COMPANY_ADDRESS", "108 Mangold Street, Newton Park, Port Elizabeth 6045"),
    "unit_example": os.environ.get("UNIT_EXAMPLE", "EPS column"),
    "icon": os.environ.get("ICON", "wheel") if os.environ.get("ICON", "wheel") in ("wheel", "key", "none") else "wheel",
    "logo_file": os.environ.get("LOGO_FILE", "").strip(),
}
DEFAULT_CONDITIONS = (
    "[Your repair conditions go here.]\n\n"
    "Sign in to the admin area and open Settings to replace this text with your real conditions."
)
GENERIC_LOGIN_ERROR = ("We could not find a job with those details. Check the job number and the "
                       "email address or phone number you gave us when you booked.")


def _persisted_secret(env_name, filename, nbytes):
    value = os.environ.get(env_name)
    if value:
        return value
    path = os.path.join(DATA_DIR, filename)
    if os.path.exists(path):
        with open(path, encoding="utf-8") as fh:
            return fh.read().strip()
    value = secrets.token_urlsafe(nbytes)
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        fh.write(value)
    if env_name == "ADMIN_PASSWORD":
        print(f"\n*** Admin password generated and saved in {path}\n"
              f"*** Set the ADMIN_PASSWORD environment variable to choose your own.\n")
    return value


SECRET_KEY = _persisted_secret("SECRET_KEY", "secret_key.txt", 48)
ADMIN_PASSWORD = _persisted_secret("ADMIN_PASSWORD", "admin_password.txt", 12)

app = Flask(__name__)
app.config.update(
    SECRET_KEY=SECRET_KEY,
    MAX_CONTENT_LENGTH=60 * 1024 * 1024,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
    SESSION_COOKIE_SECURE=os.environ.get("DEV") != "1",
    PERMANENT_SESSION_LIFETIME=timedelta(hours=6),
)
if os.environ.get("TRUST_PROXY") == "1":
    app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)

SCHEMA = """
CREATE TABLE IF NOT EXISTS jobs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    job_number TEXT NOT NULL UNIQUE,
    customer_name TEXT NOT NULL,
    email TEXT NOT NULL DEFAULT '',
    phone TEXT NOT NULL DEFAULT '',
    phone_norm TEXT NOT NULL DEFAULT '',
    unit TEXT NOT NULL DEFAULT '',
    make_model TEXT NOT NULL DEFAULT '',
    stage INTEGER NOT NULL DEFAULT 0,
    eta TEXT NOT NULL DEFAULT '',
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    conditions_name TEXT NOT NULL DEFAULT '',
    conditions_at TEXT NOT NULL DEFAULT '',
    conditions_text TEXT NOT NULL DEFAULT '',
    report_file TEXT NOT NULL DEFAULT '',
    report_name TEXT NOT NULL DEFAULT '',
    report_visible INTEGER NOT NULL DEFAULT 0,
    pay_amount TEXT NOT NULL DEFAULT '',
    pay_ref TEXT NOT NULL DEFAULT '',
    pay_link TEXT NOT NULL DEFAULT '',
    pay_status TEXT NOT NULL DEFAULT 'none',
    show_pay INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS updates (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
    created_at TEXT NOT NULL,
    title TEXT NOT NULL,
    body TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS photos (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
    filename TEXT NOT NULL,
    caption TEXT NOT NULL DEFAULT '',
    created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS stage_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
    stage INTEGER NOT NULL,
    at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS attempts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ip TEXT NOT NULL,
    subject TEXT NOT NULL,
    ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_attempts ON attempts(ts);
"""


def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.executescript(SCHEMA)
    conn.execute("INSERT OR IGNORE INTO settings(key, value) VALUES('conditions', ?)", (DEFAULT_CONDITIONS,))
    conn.commit()
    conn.close()


init_db()


def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(DB_PATH, timeout=10)
        g.db.row_factory = sqlite3.Row
        g.db.execute("PRAGMA foreign_keys=ON")
    return g.db


@app.teardown_appcontext
def close_db(_exc):
    db = g.pop("db", None)
    if db is not None:
        db.close()


# ---------------------------------------------------------------- helpers

def now_iso():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def _parse(iso):
    try:
        return datetime.fromisoformat(iso).astimezone(TZ)
    except (TypeError, ValueError):
        return None


@app.template_filter("dt")
def fmt_dt(iso):
    d = _parse(iso)
    return d.strftime("%-d %b %Y, %H:%M") if d else ""


@app.template_filter("day")
def fmt_day(iso):
    d = _parse(iso)
    return d.strftime("%-d %b") if d else ""


@app.template_filter("longday")
def fmt_longday(value):
    if not value:
        return ""
    try:
        return datetime.strptime(value, "%Y-%m-%d").strftime("%-d %b %Y")
    except ValueError:
        return value


@app.template_filter("wa_phone")
def wa_phone(value):
    """Phone number in international form for WhatsApp links (South Africa by default)."""
    digits = norm_phone(value)
    if digits.startswith("0"):
        digits = "27" + digits[1:]
    return digits


def norm_phone(value):
    digits = re.sub(r"\D", "", value or "")
    if digits.startswith("27") and len(digits) == 11:
        digits = "0" + digits[2:]
    return digits


def clean_text(value, limit):
    return (value or "").strip()[:limit]


def clean_job_number(value):
    return re.sub(r"\D", "", value or "")[:12]


def matches_verification(job, supplied):
    supplied = (supplied or "").strip()
    if not supplied:
        return False
    if "@" in supplied:
        stored = (job["email"] or "").strip().lower()
        return bool(stored) and hmac.compare_digest(supplied.lower().encode(), stored.encode())
    phone = norm_phone(supplied)
    stored = job["phone_norm"] or ""
    return len(phone) >= 7 and bool(stored) and hmac.compare_digest(phone.encode(), stored.encode())


def client_ip():
    return request.remote_addr or "unknown"


def too_many_attempts(subject):
    cutoff = int(datetime.now().timestamp()) - 15 * 60
    db = get_db()
    by_ip = db.execute("SELECT COUNT(*) FROM attempts WHERE ip=? AND ts>?", (client_ip(), cutoff)).fetchone()[0]
    by_subject = db.execute("SELECT COUNT(*) FROM attempts WHERE subject=? AND ts>?", (subject, cutoff)).fetchone()[0]
    return by_ip >= 10 or by_subject >= 8


def record_failure(subject):
    db = get_db()
    db.execute("INSERT INTO attempts(ip, subject, ts) VALUES(?,?,?)",
               (client_ip(), subject, int(datetime.now().timestamp())))
    db.execute("DELETE FROM attempts WHERE ts < ?", (int(datetime.now().timestamp()) - 86400,))
    db.commit()


def clear_failures(subject):
    db = get_db()
    db.execute("DELETE FROM attempts WHERE subject=? AND ip=?", (subject, client_ip()))
    db.commit()


def get_setting(key, default=""):
    row = get_db().execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
    return row["value"] if row else default


def site_url():
    return (os.environ.get("SITE_URL") or request.url_root).rstrip("/")


def send_email(to, subject, body):
    host = os.environ.get("SMTP_HOST")
    if not host or not to:
        return False
    sender = os.environ.get("SMTP_FROM") or os.environ.get("SMTP_USER") or COMPANY["email"]
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = f'{COMPANY["name"]} <{sender}>'
    msg["To"] = to
    msg.set_content(body)
    try:
        port = int(os.environ.get("SMTP_PORT", "587"))
        smtp = smtplib.SMTP_SSL(host, port, timeout=15) if port == 465 else smtplib.SMTP(host, port, timeout=15)
        with smtp:
            if port != 465:
                smtp.starttls()
            if os.environ.get("SMTP_USER"):
                smtp.login(os.environ["SMTP_USER"], os.environ.get("SMTP_PASSWORD", ""))
            smtp.send_message(msg)
        return True
    except Exception:  # noqa: BLE001 - never let email problems break the page
        app.logger.exception("Email could not be sent")
        return False


def email_footer():
    return (f"\n\nSign in at {site_url()} with your job number and the email address or phone "
            f"number you gave us.\n\n{COMPANY['name']}\n{COMPANY['phone']}")


def save_upload(job_id, storage, kind):
    """Validate and store an uploaded file. Returns (stored_name, original_name) or None."""
    name = storage.filename or ""
    ext = os.path.splitext(name)[1].lower().lstrip(".")
    head = storage.stream.read(16)
    storage.stream.seek(0)
    if kind == "image":
        ok = ((ext in ("jpg", "jpeg") and head[:3] == b"\xff\xd8\xff")
              or (ext == "png" and head[:8] == b"\x89PNG\r\n\x1a\n")
              or (ext == "webp" and head[:4] == b"RIFF" and head[8:12] == b"WEBP"))
    else:
        ok = ext == "pdf" and head[:5] == b"%PDF-"
    if not ok:
        return None
    stored = secrets.token_hex(16) + "." + ("jpg" if ext == "jpeg" else ext)
    folder = os.path.join(UPLOAD_DIR, str(job_id))
    os.makedirs(folder, exist_ok=True)
    storage.save(os.path.join(folder, stored))
    return stored, secure_filename(name)[:120] or stored


def remove_upload(job_id, stored):
    if not stored:
        return
    path = os.path.join(UPLOAD_DIR, str(job_id), stored)
    if os.path.isfile(path):
        os.remove(path)


def stage_dates(db, job_id):
    rows = db.execute("SELECT stage, MAX(at) AS at FROM stage_log WHERE job_id=? GROUP BY stage", (job_id,)).fetchall()
    return {r["stage"]: r["at"] for r in rows}


def create_job_number(db):
    row = db.execute("SELECT MAX(CAST(job_number AS INTEGER)) FROM jobs").fetchone()
    return str(max(10000, row[0] or 0) + 1)


# ---------------------------------------------------------------- security

@app.context_processor
def inject_globals():
    if "csrf" not in session:
        session["csrf"] = secrets.token_hex(16)
    return {"company": COMPANY, "stages": STAGES, "csrf_token": session["csrf"]}


@app.before_request
def check_csrf():
    if request.method == "POST":
        sent = request.form.get("csrf_token", "")
        expected = session.get("csrf", "")
        if not expected or not hmac.compare_digest(sent.encode(), expected.encode()):
            abort(400)


@app.after_request
def security_headers(resp):
    resp.headers["X-Content-Type-Options"] = "nosniff"
    resp.headers["X-Frame-Options"] = "DENY"
    resp.headers["Referrer-Policy"] = "same-origin"
    resp.headers["Content-Security-Policy"] = (
        "default-src 'self'; style-src 'self' https://fonts.googleapis.com; "
        "font-src https://fonts.gstatic.com; img-src 'self'; script-src 'self'; "
        "form-action 'self'; frame-ancestors 'none'; base-uri 'self'")
    if request.endpoint in ("customer_job", "admin_job", "admin_home"):
        resp.headers["Cache-Control"] = "no-store"
    return resp


def customer_required(view):
    @wraps(view)
    def wrapper(*args, **kwargs):
        job_id = session.get("job_id")
        if not job_id:
            return redirect(url_for("login"))
        job = get_db().execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
        if job is None:
            session.pop("job_id", None)
            return redirect(url_for("login"))
        g.job = job
        return view(*args, **kwargs)
    return wrapper


def admin_required(view):
    @wraps(view)
    def wrapper(*args, **kwargs):
        if not session.get("admin"):
            return redirect(url_for("admin_login"))
        return view(*args, **kwargs)
    return wrapper


def load_job_or_404(job_id):
    job = get_db().execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
    if job is None:
        abort(404)
    return job


@app.errorhandler(400)
def bad_request(_e):
    return render_template("error.html", title="Please try again",
                           message="That form expired or was not valid. Go back, refresh the page and try again."), 400


@app.errorhandler(404)
def not_found(_e):
    return render_template("error.html", title="Page not found", message="We could not find that page."), 404


@app.errorhandler(413)
def too_large(_e):
    return render_template("error.html", title="File too large",
                           message="That upload is too big. Try fewer or smaller files."), 413


# ---------------------------------------------------------------- customer

@app.get("/")
def login():
    if session.get("job_id"):
        return redirect(url_for("customer_job"))
    return render_template("login.html")


@app.post("/login")
def login_post():
    job_number = clean_job_number(request.form.get("job_number"))
    supplied = request.form.get("verify", "")
    subject = "job:" + (job_number or "none")
    if too_many_attempts(subject):
        return render_template("login.html", error="Too many attempts. Please wait 15 minutes and try again, "
                                                   "or call us.", job_number=job_number), 429
    job = get_db().execute("SELECT * FROM jobs WHERE job_number=?", (job_number,)).fetchone()
    if job is None or not matches_verification(job, supplied):
        record_failure(subject)
        return render_template("login.html", error=GENERIC_LOGIN_ERROR, job_number=job_number), 401
    clear_failures(subject)
    session.clear()
    session["job_id"] = job["id"]
    session["csrf"] = secrets.token_hex(16)
    session.permanent = True
    return redirect(url_for("customer_job"))


@app.post("/logout")
def logout():
    session.clear()
    return redirect(url_for("login"))


@app.get("/job")
@customer_required
def customer_job():
    db = get_db()
    job = g.job
    dates = stage_dates(db, job["id"])
    tracker = []
    for index, name in enumerate(STAGES):
        if index < job["stage"] or (index == job["stage"] and index == len(STAGES) - 1):
            state = "done"
        elif index == job["stage"]:
            state = "current"
        else:
            state = "next"
        tracker.append({"name": name, "state": state, "date": dates.get(index, "")})
    updates = db.execute("SELECT * FROM updates WHERE job_id=? ORDER BY id DESC", (job["id"],)).fetchall()
    photos = db.execute("SELECT * FROM photos WHERE job_id=? ORDER BY id", (job["id"],)).fetchall()
    return render_template("job.html", job=job, tracker=tracker, updates=updates, photos=photos,
                           conditions_text=get_setting("conditions", DEFAULT_CONDITIONS),
                           confirmed=bool(job["conditions_at"]))


@app.post("/job/conditions")
@customer_required
def customer_conditions():
    job = g.job
    if job["conditions_at"]:
        return redirect(url_for("customer_job"))
    name = clean_text(request.form.get("signed_name"), 120)
    if not request.form.get("agree") or len(name) < 2:
        flash("Please tick the box and type your full name to confirm.", "error")
        return redirect(url_for("customer_job"))
    db = get_db()
    db.execute("UPDATE jobs SET conditions_name=?, conditions_at=?, conditions_text=?, updated_at=? WHERE id=?",
               (name, now_iso(), get_setting("conditions", DEFAULT_CONDITIONS), now_iso(), job["id"]))
    db.commit()
    flash("Thank you. Your confirmation has been saved.", "ok")
    if job["email"]:
        send_email(job["email"], f"Conditions confirmed for job {job['job_number']}",
                   f"Hello {job['customer_name']},\n\nWe have recorded that {name} accepted our repair "
                   f"conditions for job {job['job_number']}.\n\nThe conditions you accepted:\n\n"
                   f"{get_setting('conditions', DEFAULT_CONDITIONS)}{email_footer()}")
    return redirect(url_for("customer_job"))


@app.get("/job/photo/<int:photo_id>")
@customer_required
def customer_photo(photo_id):
    photo = get_db().execute("SELECT * FROM photos WHERE id=? AND job_id=?", (photo_id, g.job["id"])).fetchone()
    if photo is None:
        abort(404)
    return send_from_directory(os.path.join(UPLOAD_DIR, str(g.job["id"])), photo["filename"])


@app.get("/job/report")
@customer_required
def customer_report():
    job = g.job
    if not job["report_file"] or not job["report_visible"]:
        abort(404)
    return send_from_directory(os.path.join(UPLOAD_DIR, str(job["id"])), job["report_file"],
                               as_attachment=True, download_name=f"test-report-{job['job_number']}.pdf")


# ---------------------------------------------------------------- admin

@app.get("/admin/login")
def admin_login():
    if session.get("admin"):
        return redirect(url_for("admin_home"))
    return render_template("admin_login.html")


@app.post("/admin/login")
def admin_login_post():
    if too_many_attempts("admin"):
        return render_template("admin_login.html", error="Too many attempts. Wait 15 minutes and try again."), 429
    supplied = request.form.get("password", "")
    if not hmac.compare_digest(supplied.encode(), ADMIN_PASSWORD.encode()):
        record_failure("admin")
        return render_template("admin_login.html", error="That password is not correct."), 401
    clear_failures("admin")
    session.clear()
    session["admin"] = True
    session["csrf"] = secrets.token_hex(16)
    session.permanent = True
    return redirect(url_for("admin_home"))


@app.post("/admin/logout")
def admin_logout():
    session.clear()
    return redirect(url_for("admin_login"))


@app.get("/admin")
@admin_required
def admin_home():
    query = clean_text(request.args.get("q"), 80)
    sql = "SELECT * FROM jobs"
    params = []
    if query:
        like = f"%{query}%"
        sql += " WHERE job_number LIKE ? OR customer_name LIKE ? OR unit LIKE ? OR make_model LIKE ?"
        params = [like] * 4
    sql += " ORDER BY updated_at DESC"
    jobs = get_db().execute(sql, params).fetchall()
    return render_template("admin_list.html", jobs=jobs, q=query)


@app.post("/admin/jobs/new")
@admin_required
def admin_new_job():
    name = clean_text(request.form.get("customer_name"), 120)
    email = clean_text(request.form.get("email"), 160).lower()
    phone = clean_text(request.form.get("phone"), 40)
    if not name or not (email or norm_phone(phone)):
        flash("Enter the customer's name and at least an email address or a phone number.", "error")
        return redirect(url_for("admin_home"))
    db = get_db()
    stamp = now_iso()
    cur = db.execute(
        "INSERT INTO jobs(job_number, customer_name, email, phone, phone_norm, unit, make_model, eta, "
        "created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
        (create_job_number(db), name, email, phone, norm_phone(phone),
         clean_text(request.form.get("unit"), 120), clean_text(request.form.get("make_model"), 120),
         clean_text(request.form.get("eta"), 10), stamp, stamp))
    job_id = cur.lastrowid
    db.execute("INSERT INTO stage_log(job_id, stage, at) VALUES(?,?,?)", (job_id, 0, stamp))
    db.execute("INSERT INTO updates(job_id, created_at, title, body) VALUES(?,?,?,?)",
               (job_id, stamp, "Unit received", "Your unit arrived at our workshop and has been booked in."))
    db.commit()
    return redirect(url_for("admin_job", job_id=job_id))


@app.get("/admin/jobs/<int:job_id>")
@admin_required
def admin_job(job_id):
    db = get_db()
    job = load_job_or_404(job_id)
    updates = db.execute("SELECT * FROM updates WHERE job_id=? ORDER BY id DESC", (job_id,)).fetchall()
    photos = db.execute("SELECT * FROM photos WHERE job_id=? ORDER BY id", (job_id,)).fetchall()
    return render_template("admin_job.html", job=job, updates=updates, photos=photos)


@app.post("/admin/jobs/<int:job_id>/details")
@admin_required
def admin_details(job_id):
    load_job_or_404(job_id)
    name = clean_text(request.form.get("customer_name"), 120)
    email = clean_text(request.form.get("email"), 160).lower()
    phone = clean_text(request.form.get("phone"), 40)
    if not name or not (email or norm_phone(phone)):
        flash("Enter the customer's name and at least an email address or a phone number.", "error")
        return redirect(url_for("admin_job", job_id=job_id))
    db = get_db()
    db.execute("UPDATE jobs SET customer_name=?, email=?, phone=?, phone_norm=?, unit=?, make_model=?, "
               "updated_at=? WHERE id=?",
               (name, email, phone, norm_phone(phone), clean_text(request.form.get("unit"), 120),
                clean_text(request.form.get("make_model"), 120), now_iso(), job_id))
    db.commit()
    flash("Job details saved.", "ok")
    return redirect(url_for("admin_job", job_id=job_id))


@app.post("/admin/jobs/<int:job_id>/progress")
@admin_required
def admin_progress(job_id):
    job = load_job_or_404(job_id)
    db = get_db()
    try:
        stage = int(request.form.get("stage", job["stage"]))
    except ValueError:
        stage = job["stage"]
    stage = min(max(stage, 0), len(STAGES) - 1)
    eta = clean_text(request.form.get("eta"), 10)
    if eta and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", eta):
        eta = job["eta"]
    title = clean_text(request.form.get("title"), 120)
    body = clean_text(request.form.get("body"), 2000)
    stamp = now_iso()
    if stage != job["stage"]:
        db.execute("INSERT INTO stage_log(job_id, stage, at) VALUES(?,?,?)", (job_id, stage, stamp))
        if not title:
            title = STAGES[stage]
    db.execute("UPDATE jobs SET stage=?, eta=?, updated_at=? WHERE id=?", (stage, eta, stamp, job_id))
    if title or body:
        db.execute("INSERT INTO updates(job_id, created_at, title, body) VALUES(?,?,?,?)",
                   (job_id, stamp, title or "Update", body))
    db.commit()
    msg = "Saved."
    if request.form.get("notify") and (title or body or stage != job["stage"]):
        sent = send_email(job["email"], f"Update on job {job['job_number']}: {STAGES[stage]}",
                          f"Hello {job['customer_name']},\n\nThere is an update on job {job['job_number']}.\n\n"
                          f"Stage: {STAGES[stage]}\n{title}\n{body}{email_footer()}")
        msg += " Customer emailed." if sent else " The email was not sent (email is not set up or the customer has no email)."
    flash(msg, "ok")
    return redirect(url_for("admin_job", job_id=job_id))


@app.post("/admin/jobs/<int:job_id>/photos")
@admin_required
def admin_photos(job_id):
    load_job_or_404(job_id)
    db = get_db()
    saved = rejected = 0
    caption = clean_text(request.form.get("caption"), 120)
    for storage in request.files.getlist("photos"):
        if not storage or not storage.filename:
            continue
        result = save_upload(job_id, storage, "image")
        if result is None:
            rejected += 1
            continue
        db.execute("INSERT INTO photos(job_id, filename, caption, created_at) VALUES(?,?,?,?)",
                   (job_id, result[0], caption, now_iso()))
        saved += 1
    db.commit()
    if saved:
        flash(f"{saved} photo(s) added.", "ok")
    if rejected:
        flash(f"{rejected} file(s) skipped. Photos must be JPG, PNG or WebP.", "error")
    if not saved and not rejected:
        flash("Choose at least one photo.", "error")
    return redirect(url_for("admin_job", job_id=job_id))


@app.post("/admin/jobs/<int:job_id>/photos/<int:photo_id>/delete")
@admin_required
def admin_photo_delete(job_id, photo_id):
    db = get_db()
    photo = db.execute("SELECT * FROM photos WHERE id=? AND job_id=?", (photo_id, job_id)).fetchone()
    if photo is None:
        abort(404)
    remove_upload(job_id, photo["filename"])
    db.execute("DELETE FROM photos WHERE id=?", (photo_id,))
    db.commit()
    flash("Photo removed.", "ok")
    return redirect(url_for("admin_job", job_id=job_id))


@app.get("/admin/jobs/<int:job_id>/photo/<int:photo_id>")
@admin_required
def admin_photo(job_id, photo_id):
    photo = get_db().execute("SELECT * FROM photos WHERE id=? AND job_id=?", (photo_id, job_id)).fetchone()
    if photo is None:
        abort(404)
    return send_from_directory(os.path.join(UPLOAD_DIR, str(job_id)), photo["filename"])


@app.post("/admin/jobs/<int:job_id>/report")
@admin_required
def admin_report(job_id):
    job = load_job_or_404(job_id)
    storage = request.files.get("report")
    if not storage or not storage.filename:
        flash("Choose a PDF file first.", "error")
        return redirect(url_for("admin_job", job_id=job_id))
    result = save_upload(job_id, storage, "pdf")
    if result is None:
        flash("That file is not a PDF.", "error")
        return redirect(url_for("admin_job", job_id=job_id))
    remove_upload(job_id, job["report_file"])
    db = get_db()
    db.execute("UPDATE jobs SET report_file=?, report_name=?, report_visible=0, updated_at=? WHERE id=?",
               (result[0], result[1], now_iso(), job_id))
    db.commit()
    flash("Test report saved. The customer cannot see it until you tick 'Show this report to the customer'.", "ok")
    return redirect(url_for("admin_job", job_id=job_id))


@app.post("/admin/jobs/<int:job_id>/report/visibility")
@admin_required
def admin_report_visibility(job_id):
    job = load_job_or_404(job_id)
    if not job["report_file"]:
        flash("Upload a report first.", "error")
        return redirect(url_for("admin_job", job_id=job_id))
    visible = 1 if request.form.get("visible") else 0
    db = get_db()
    db.execute("UPDATE jobs SET report_visible=?, updated_at=? WHERE id=?", (visible, now_iso(), job_id))
    db.commit()
    flash("The customer can now download the report." if visible else "The report is hidden from the customer.", "ok")
    return redirect(url_for("admin_job", job_id=job_id))


@app.post("/admin/jobs/<int:job_id>/report/delete")
@admin_required
def admin_report_delete(job_id):
    job = load_job_or_404(job_id)
    remove_upload(job_id, job["report_file"])
    db = get_db()
    db.execute("UPDATE jobs SET report_file='', report_name='', report_visible=0, updated_at=? WHERE id=?",
               (now_iso(), job_id))
    db.commit()
    flash("Test report removed.", "ok")
    return redirect(url_for("admin_job", job_id=job_id))


@app.get("/admin/jobs/<int:job_id>/report")
@admin_required
def admin_report_download(job_id):
    job = load_job_or_404(job_id)
    if not job["report_file"]:
        abort(404)
    return send_from_directory(os.path.join(UPLOAD_DIR, str(job_id)), job["report_file"],
                               as_attachment=True, download_name=f"test-report-{job['job_number']}.pdf")


@app.get("/admin/jobs/<int:job_id>/label")
@admin_required
def admin_label(job_id):
    job = load_job_or_404(job_id)
    barcode = code128.svg(job["job_number"], module=2, height=70, label=f"Barcode for job {job['job_number']}")
    return render_template("admin_label.html", job=job, barcode=Markup(barcode), site=site_url())


@app.get("/admin/scan")
@admin_required
def admin_scan():
    """Open a job from a scanned (or typed) job number. Scanners type the number and press Enter."""
    raw = request.args.get("code", "")
    code = clean_job_number(raw)
    job = get_db().execute("SELECT id FROM jobs WHERE job_number=?", (code,)).fetchone() if code else None
    if job is None:
        flash(f"No job found for \"{clean_text(raw, 40)}\".", "error")
        return redirect(url_for("admin_home"))
    return redirect(url_for("admin_job", job_id=job["id"]))


@app.post("/admin/jobs/<int:job_id>/payment")
@admin_required
def admin_payment(job_id):
    job = load_job_or_404(job_id)
    link = clean_text(request.form.get("pay_link"), 500)
    if link and not link.lower().startswith("https://"):
        flash("The payment link must start with https://", "error")
        return redirect(url_for("admin_job", job_id=job_id))
    status = request.form.get("pay_status", job["pay_status"])
    if status not in ("none", "sent", "paid"):
        status = job["pay_status"]
    if link and status == "none":
        status = "sent"
    db = get_db()
    db.execute("UPDATE jobs SET pay_amount=?, pay_ref=?, pay_link=?, pay_status=?, show_pay=?, updated_at=? "
               "WHERE id=?",
               (clean_text(request.form.get("pay_amount"), 20), clean_text(request.form.get("pay_ref"), 80),
                link, status, 1 if request.form.get("show_pay") else 0, now_iso(), job_id))
    db.commit()
    msg = "Payment details saved."
    if request.form.get("send_email") and link:
        amount = clean_text(request.form.get("pay_amount"), 20)
        sent = send_email(job["email"], f"Payment for job {job['job_number']}",
                          f"Hello {job['customer_name']},\n\nYour job {job['job_number']} is complete."
                          f"{' The amount due is R ' + amount + '.' if amount else ''}\n\n"
                          f"You can pay securely online here:\n{link}{email_footer()}")
        msg += " Payment link emailed." if sent else " The email was not sent (email is not set up or the customer has no email)."
    flash(msg, "ok")
    return redirect(url_for("admin_job", job_id=job_id))


@app.post("/admin/jobs/<int:job_id>/remind")
@admin_required
def admin_remind(job_id):
    job = load_job_or_404(job_id)
    sent = send_email(job["email"], f"Please confirm our conditions for job {job['job_number']}",
                      f"Hello {job['customer_name']},\n\nBefore we carry on with job {job['job_number']}, "
                      f"please read and confirm our repair conditions on our website.{email_footer()}")
    flash("Reminder emailed." if sent else "The email was not sent (email is not set up or the customer has no email).",
          "ok" if sent else "error")
    return redirect(url_for("admin_job", job_id=job_id))


@app.route("/admin/settings", methods=["GET", "POST"])
@admin_required
def admin_settings():
    db = get_db()
    if request.method == "POST":
        text = (request.form.get("conditions") or "").strip()[:20000]
        if not text:
            flash("The conditions cannot be empty.", "error")
        else:
            db.execute("INSERT INTO settings(key, value) VALUES('conditions', ?) "
                       "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (text,))
            db.commit()
            flash("Conditions saved. Customers who already confirmed keep the version they accepted.", "ok")
        return redirect(url_for("admin_settings"))
    return render_template("admin_settings.html", conditions=get_setting("conditions", DEFAULT_CONDITIONS))


@app.get("/healthz")
def healthz():
    return "ok"


if __name__ == "__main__":
    app.run(debug=os.environ.get("DEV") == "1", port=int(os.environ.get("PORT", "5000")))
