"""
Orion Permit Portal — login and sign-up, restricted to an approved list of emails.

How it works
  * Everything on the site (the dashboard and every /api route) requires a signed-in user.
  * Anyone can OPEN the sign-up page, but an account is only created if the email is on
    approved_emails.txt (one address per line; edit it any time — no restart needed).
  * Removing an address from that file also cuts off an existing account at its next request.
  * Passwords are stored hashed (never in plain text). Repeated wrong passwords lock the
    login for a while. Sessions last 14 days.

Files (all live in ORION_AUTH_DIR, which defaults to this folder)
  approved_emails.txt  — the allow-list you maintain
  users.db             — accounts (hashed passwords)
  .secret_key          — signs login cookies (auto-created)
  auth_events.log      — sign-ups, sign-ins, failures, lockouts (no passwords)

Settings (environment variables, all optional)
  ORION_AUTH_DIR        where the files above live (put it OUTSIDE the folder you upload,
                        so re-uploading the site can't overwrite accounts)
  ORION_COOKIE_SECURE   "1" (default) = login cookie only travels over HTTPS; "0" for plain-http testing
  ORION_BEHIND_PROXY    "1" (default) = trust the host's X-Forwarded-* headers; "0" if gunicorn is exposed directly
"""
import hmac
import os
import re
import secrets
import sqlite3
import time
from datetime import datetime, timedelta

from flask import (Blueprint, g, jsonify, make_response, redirect,
                   render_template_string, request, session, url_for)
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.security import check_password_hash, generate_password_hash

from auth_core import (AUTH_DIR, DB_PATH, APPROVED_PATH, SECRET_PATH, EVENT_LOG, MIN_PASSWORD,
                       MAX_PASSWORD, normalize_email, approved_emails, connect, password_problem)


SESSION_DAYS     = 14
LOGIN_EMAIL_MAX  = 5       # wrong passwords per email per 15 minutes
LOGIN_IP_MAX     = 20      # wrong passwords per IP per 15 minutes
SIGNUP_IP_MAX    = 10      # sign-up attempts per IP per hour
OPEN_ENDPOINTS   = {"auth.login", "auth.signup", "auth.logout"}
UNSAFE_METHODS   = {"POST", "PUT", "PATCH", "DELETE"}

bp = Blueprint("auth", __name__)
# Checked against when the email doesn't exist, so "no such user" takes as long as "wrong password"
_DUMMY_HASH = generate_password_hash(secrets.token_hex(16))


# ── storage ──────────────────────────────────────────────────────────────────

def db():
    if "authdb" not in g:
        g.authdb = connect()
    return g.authdb

def load_secret():
    env = os.environ.get("ORION_SECRET_KEY")
    if env:
        return env
    os.makedirs(AUTH_DIR, exist_ok=True)
    if not os.path.exists(SECRET_PATH):
        fd = os.open(SECRET_PATH, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
        with os.fdopen(fd, "w") as f:
            f.write(secrets.token_hex(32))
    with open(SECRET_PATH) as f:
        return f.read().strip()

def log_event(kind, email="", ip=""):
    clean = lambda s: re.sub(r"[^\w@.+\-:]", "?", str(s))[:80]
    try:
        with open(EVENT_LOG, "a", encoding="utf-8") as f:
            f.write(f"{datetime.now():%Y-%m-%d %H:%M:%S}  {kind:<16} {clean(email):<40} {clean(ip)}\n")
    except OSError:
        pass


# ── approved list ────────────────────────────────────────────────────────────


# ── rate limiting ────────────────────────────────────────────────────────────
def attempts(key, window):
    return db().execute("SELECT COUNT(*) FROM attempts WHERE key=? AND ts>?", (key, time.time() - window)).fetchone()[0]

def add_attempt(key):
    d = db()
    d.execute("INSERT INTO attempts(key, ts) VALUES(?,?)", (key, time.time()))
    if secrets.randbelow(50) == 0:
        d.execute("DELETE FROM attempts WHERE ts<?", (time.time() - 86400,))
    d.commit()

def clear_attempts(key):
    db().execute("DELETE FROM attempts WHERE key=?", (key,)); db().commit()

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


# ── passwords ────────────────────────────────────────────────────────────────

def create_user(email, password):
    """Returns True on success, False if the account already exists."""
    try:
        db().execute("INSERT INTO users(email, pw_hash, created) VALUES(?,?,?)",
                     (email, generate_password_hash(password), datetime.now().isoformat(timespec="seconds")))
        db().commit()
        return True
    except sqlite3.IntegrityError:
        return False


# ── sessions & CSRF ──────────────────────────────────────────────────────────
def csrf_token():
    if "csrf" not in session:
        session["csrf"] = secrets.token_urlsafe(24)
    return session["csrf"]

def csrf_ok():
    return hmac.compare_digest(session.get("csrf", ""), request.form.get("csrf", ""))

def start_session(email, epoch):
    session.clear()
    session.permanent = True
    session["email"], session["epoch"] = email, epoch
    csrf_token()

def current_user():
    email = session.get("email")
    if not email:
        return None
    row = db().execute("SELECT * FROM users WHERE email=?", (email,)).fetchone()
    # signed out if: account deleted, password was reset, or email was taken off the approved list
    if not row or row["session_epoch"] != session.get("epoch") or email not in approved_emails():
        session.clear()
        return None
    return row

def same_site():
    """Blocks state-changing requests that came from another website (CSRF)."""
    site = request.headers.get("Sec-Fetch-Site")          # sent by every modern browser
    if site is not None:
        return site in ("same-origin", "none")
    origin = request.headers.get("Origin")                # fallback for older browsers
    if origin:
        host = origin.split("://", 1)[-1].split("/", 1)[0]
        return host == request.host
    return True                                           # non-browser clients; cookie is SameSite=Lax anyway


# ── pages ────────────────────────────────────────────────────────────────────
PAGE = """<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<meta name="theme-color" content="#0B1220">
<title>{{ title }} — Orion</title>
<style>
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;color:#0F172A;
  background:radial-gradient(900px 520px at 15% -5%,rgba(37,99,235,.38),transparent 62%),radial-gradient(700px 480px at 100% 105%,rgba(14,165,233,.22),transparent 60%),linear-gradient(165deg,#0B1220 0%,#16213A 100%) fixed;
  font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif}
.card{width:100%;max-width:400px;background:#fff;border:1px solid #E2E8F0;border-radius:16px;padding:32px 28px;
  box-shadow:0 2px 4px rgba(0,0,0,.2),0 24px 60px rgba(2,6,23,.55)}
.brand{display:flex;align-items:center;gap:12px;margin-bottom:22px}
.logo{width:40px;height:40px;border-radius:11px;background:linear-gradient(135deg,#2563EB,#0EA5E9);display:grid;place-items:center;color:#fff;font-weight:700;font-size:18px}
.brand b{display:block;font-size:17px;letter-spacing:.2px}.brand span{display:block;font-size:12px;color:#64748B}
h1{margin:0 0 4px;font-size:21px;letter-spacing:-.2px}
p.sub{margin:0 0 20px;color:#64748B;font-size:14px}
label{display:block;font-size:13px;font-weight:600;margin:14px 0 5px}
.field{position:relative}
input{width:100%;padding:11px 12px;border:1px solid #CBD5E1;border-radius:10px;font:inherit;background:#fff;color:inherit}
input:focus{outline:none;border-color:#2563EB;box-shadow:0 0 0 3px rgba(37,99,235,.15)}
.show{position:absolute;right:8px;top:50%;transform:translateY(-50%);border:0;background:none;color:#2563EB;font-weight:600;font-size:12.5px;cursor:pointer;padding:6px}
.field input[type=password],.field input.pw{padding-right:58px}
button.primary{width:100%;margin-top:22px;padding:12px;border:0;border-radius:10px;background:#2563EB;color:#fff;font:inherit;font-weight:650;cursor:pointer}
button.primary:hover{background:#1D4ED8}
.err{margin:0 0 6px;padding:10px 12px;border-radius:10px;background:#FEE2E2;color:#B91C1C;font-size:13.5px}
.hint{font-size:12.5px;color:#64748B;margin-top:5px}
.alt{margin:20px 0 0;text-align:center;font-size:14px;color:#64748B}
a{color:#2563EB;font-weight:600;text-decoration:none}a:hover{text-decoration:underline}
</style></head><body>
<main class="card">
  <div class="brand"><div class="logo">O</div><div><b>Orion</b><span>Permit Intelligence</span></div></div>
  <h1>{{ heading }}</h1><p class="sub">{{ sub }}</p>
  {% if error %}<div class="err" role="alert">{{ error }}</div>{% endif %}
  <form method="post" autocomplete="on">
    <input type="hidden" name="csrf" value="{{ csrf }}">
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="username" required maxlength="254" value="{{ email }}" {{ 'autofocus' if not email }}>
    <label for="password">Password</label>
    <div class="field"><input id="password" name="password" type="password" required maxlength="128" autocomplete="{{ 'new-password' if signup else 'current-password' }}" {{ 'autofocus' if email }}>
      <button type="button" class="show" aria-label="Show password" onclick="tog('password',this)">Show</button></div>
    {% if signup %}
    <div class="hint">At least {{ minpw }} characters.</div>
    <label for="confirm">Confirm password</label>
    <div class="field"><input id="confirm" name="confirm" type="password" required maxlength="128" autocomplete="new-password">
      <button type="button" class="show" aria-label="Show password" onclick="tog('confirm',this)">Show</button></div>
    {% endif %}
    <button class="primary" type="submit">{{ button }}</button>
  </form>
  <p class="alt">{{ alt|safe }}</p>
</main>
<script>function tog(id,b){var i=document.getElementById(id),s=i.type==='password';i.type=s?'text':'password';b.textContent=s?'Hide':'Show'}</script>
</body></html>"""

def page(kind, error="", email="", status=200):
    if kind == "login":
        ctx = dict(title="Sign in", heading="Sign in", sub="Orion team access only.", button="Sign in", signup=False,
                   alt='Need an account? <a href="%s">Sign up</a>' % url_for("auth.signup"))
    else:
        ctx = dict(title="Sign up", heading="Create your account", signup=True, button="Create account",
                   sub="Only approved work emails can sign up.",
                   alt='Already have an account? <a href="%s">Sign in</a>' % url_for("auth.login"))
    resp = make_response(render_template_string(PAGE, error=error, email=email, csrf=csrf_token(), minpw=MIN_PASSWORD, **ctx), status)
    resp.headers["Cache-Control"] = "no-store"
    return resp

GENERIC_SIGNUP_ERROR = ("We couldn't create an account with those details. Use your approved work email, "
                        "or ask whoever manages access to add you.")


# ── routes ───────────────────────────────────────────────────────────────────
@bp.route("/login", methods=["GET", "POST"])
def login():
    if current_user():
        return redirect("/")
    if request.method == "GET":
        return page("login")
    if not csrf_ok():
        return page("login", "Your page expired — please try again.", status=400)
    email, pw, ip = normalize_email(request.form.get("email")), request.form.get("password", ""), client_ip()
    ekey, ikey = f"login-email:{email}", f"login-ip:{ip}"
    if attempts(ekey, 900) >= LOGIN_EMAIL_MAX or attempts(ikey, 900) >= LOGIN_IP_MAX:
        log_event("LOGIN_LOCKED", email, ip)
        return page("login", "Too many attempts. Please wait 15 minutes and try again.", email, 429)
    row = db().execute("SELECT * FROM users WHERE email=?", (email,)).fetchone() if email else None
    password_ok = check_password_hash(row["pw_hash"] if row else _DUMMY_HASH, pw) and row is not None
    if password_ok and email in approved_emails():
        clear_attempts(ekey)
        db().execute("UPDATE users SET last_login=? WHERE email=?", (datetime.now().isoformat(timespec="seconds"), email)); db().commit()
        start_session(email, row["session_epoch"])
        log_event("LOGIN_OK", email, ip)
        return redirect("/")
    add_attempt(ekey); add_attempt(ikey)
    log_event("LOGIN_FAILED", email, ip)
    return page("login", "Incorrect email or password.", email, 401)

@bp.route("/signup", methods=["GET", "POST"])
def signup():
    if current_user():
        return redirect("/")
    if request.method == "GET":
        return page("signup")
    if not csrf_ok():
        return page("signup", "Your page expired — please try again.", status=400)
    email, pw, ip = normalize_email(request.form.get("email")), request.form.get("password", ""), client_ip()
    skey = f"signup-ip:{ip}"
    if attempts(skey, 3600) >= SIGNUP_IP_MAX:
        log_event("SIGNUP_LOCKED", email, ip)
        return page("signup", "Too many sign-up attempts. Please try again later.", email, 429)
    add_attempt(skey)
    if not email:
        return page("signup", "Please enter a valid email address.", status=400)
    problem = password_problem(pw, email)
    if not problem and pw != request.form.get("confirm", ""):
        problem = "The two passwords don't match."
    if problem:
        return page("signup", problem, email, 400)
    if email not in approved_emails():
        log_event("SIGNUP_DENIED", email, ip)
        return page("signup", GENERIC_SIGNUP_ERROR, email, 400)
    if not create_user(email, pw):
        log_event("SIGNUP_EXISTS", email, ip)
        return page("signup", GENERIC_SIGNUP_ERROR + " If you already have an account, sign in instead.", email, 400)
    log_event("SIGNUP_OK", email, ip)
    start_session(email, 0)
    return redirect("/")

@bp.route("/logout", methods=["POST"])
def logout():
    email = session.get("email", "")
    session.clear()
    if email:
        log_event("LOGOUT", email, client_ip())
    return redirect(url_for("auth.login"), 303)

@bp.route("/api/me")
def me():
    return jsonify(email=g.user["email"])


# ── wiring ───────────────────────────────────────────────────────────────────
def init_auth(app):
    """Locks the whole site behind the login. Call once, right after creating the Flask app."""
    app.secret_key = load_secret()
    app.config.update(
        SESSION_COOKIE_NAME="orion_session",
        SESSION_COOKIE_HTTPONLY=True,
        SESSION_COOKIE_SAMESITE="Lax",
        SESSION_COOKIE_SECURE=os.environ.get("ORION_COOKIE_SECURE", "1") != "0",
        PERMANENT_SESSION_LIFETIME=timedelta(days=SESSION_DAYS),
        MAX_CONTENT_LENGTH=2 * 1024 * 1024,
    )
    if os.environ.get("ORION_BEHIND_PROXY", "1") != "0":
        app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
    app.register_blueprint(bp)
    connect().close()                                    # create the tables on startup

    @app.before_request
    def _guard():
        if request.method in UNSAFE_METHODS and not same_site():
            return jsonify(error="cross-site request blocked"), 403
        if request.endpoint in OPEN_ENDPOINTS:
            return None
        user = current_user()
        if user:
            g.user = user
            return None
        if request.path.startswith("/api/"):
            return jsonify(error="login required"), 401
        return redirect(url_for("auth.login"))

    @app.after_request
    def _headers(resp):
        resp.headers.setdefault("X-Content-Type-Options", "nosniff")
        resp.headers.setdefault("X-Frame-Options", "DENY")
        if "no-store" not in (resp.headers.get("Cache-Control") or ""):
            resp.headers["Cache-Control"] = "private, no-cache"    # never cached by shared proxies
        return resp

    @app.teardown_appcontext
    def _close(_exc):
        d = g.pop("authdb", None)
        if d is not None:
            d.close()
