"""Shamuverse Hunter Bot - web app with client logins, admin panel and WhatsApp bot.

Runs on cPanel (Passenger) or any WSGI host. No background threads.
"""
import base64
import hashlib
import hmac
import os
import random
import re
import secrets
import time
import uuid
from datetime import date, datetime, timedelta
from functools import wraps
from urllib.parse import quote
from xml.sax.saxutils import escape

from dotenv import load_dotenv
from flask import (Flask, Response, abort, jsonify, redirect, render_template,
                   request, send_from_directory, session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash

import db
import leads as L
from exporter import export_xlsx

HERE = os.path.dirname(os.path.abspath(__file__))
load_dotenv(os.path.join(HERE, ".env"))
FILES_DIR = os.path.join(HERE, "exports")
os.makedirs(FILES_DIR, exist_ok=True)
db.init_db()


def _secret_key() -> str:
    if os.environ.get("SECRET_KEY"):
        return os.environ["SECRET_KEY"]
    path = os.path.join(db.DATA_DIR, "secret.key")
    if not os.path.exists(path):
        with open(path, "w") as f:
            f.write(secrets.token_hex(32))
    return open(path).read().strip()


app = Flask(__name__)
app.config.update(
    SECRET_KEY=_secret_key(),
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
    PERMANENT_SESSION_LIFETIME=timedelta(days=7),
)
app.teardown_appcontext(db.close_db)

ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
ADMIN_PASS = os.environ.get("ADMIN_PASS", "")
AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN", "")
VALIDATE_TWILIO = os.environ.get("VALIDATE_TWILIO", "1") == "1"


# ----------------------------------------------------------------- helpers
def csrf_token():
    if "csrf" not in session:
        session["csrf"] = secrets.token_hex(16)
    return session["csrf"]


def wa_link(text=""):
    num = re.sub(r"\D", "", db.settings().get("contact_whatsapp", ""))
    return f"https://wa.me/{num}?text={quote(text)}" if num else "#"


app.jinja_env.globals.update(csrf_token=csrf_token, wa_link=wa_link, cfg=lambda: db.settings())


@app.before_request
def csrf_check():
    if request.method == "POST" and request.endpoint != "whatsapp":
        tok = request.form.get("csrf") or request.headers.get("X-CSRF", "")
        if not tok or not hmac.compare_digest(tok, session.get("csrf", "")):
            abort(400)


def today() -> str:
    return date.today().isoformat()


def client_ip() -> str:
    return request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr or "?"


def too_many_fails() -> bool:
    db.run("DELETE FROM login_fails WHERE ts<?", (time.time() - 600,))
    return db.q("SELECT COUNT(*) c FROM login_fails WHERE ip=?", (client_ip(),), one=True)["c"] >= 6


def note_fail():
    db.run("INSERT INTO login_fails(ip,ts) VALUES(?,?)", (client_ip(), time.time()))


def gen_password(n=8) -> str:
    alphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
    return "".join(secrets.choice(alphabet) for _ in range(n))


def client_state(c):
    """(ok, reason) - is this client allowed to hunt right now?"""
    if not c["active"]:
        return False, "Your account is switched OFF. Please contact the admin."
    if c["expires_at"] < today():
        return False, "Your plan has expired. Please renew to continue."
    return True, ""


def used_today(cid: int) -> int:
    return db.q("SELECT COUNT(*) c FROM hunts WHERE client_id=? AND substr(created_at,1,10)=?",
                (cid, today()), one=True)["c"]


def current_client():
    cid = session.get("cid")
    return db.q("SELECT * FROM clients WHERE id=?", (cid,), one=True) if cid else None


def client_required(f):
    @wraps(f)
    def w(*a, **k):
        c = current_client()
        if not c:
            session.pop("cid", None)
            if request.path.startswith("/api/"):
                return jsonify(ok=False, error="Please log in again."), 401
            return redirect(url_for("login"))
        return f(c, *a, **k)
    return w


def admin_required(f):
    @wraps(f)
    def w(*a, **k):
        if not session.get("admin"):
            return redirect(url_for("admin_login"))
        return f(*a, **k)
    return w


class HuntError(Exception):
    pass


def cleanup_old_files(days=7):
    cutoff = time.time() - days * 86400
    for n in os.listdir(FILES_DIR):
        p = os.path.join(FILES_DIR, n)
        if n.endswith(".xlsx") and os.path.getmtime(p) < cutoff:
            try:
                os.remove(p)
            except OSError:
                pass


def perform_hunt(c, what, where, limit, want_emails, source):
    what, where = what.strip()[:80], where.strip()[:80]
    if not what or not where:
        raise HuntError("Enter both a business type and a city.")
    ok, why = client_state(c)
    if not ok:
        raise HuntError(why)
    plan = db.q("SELECT * FROM plans WHERE id=?", (c["plan_id"],), one=True)
    if used_today(c["id"]) >= plan["daily_searches"]:
        raise HuntError(f"Daily limit reached ({plan['daily_searches']} searches on {plan['name']}). Try again tomorrow.")
    if not os.environ.get("GEOAPIFY_API_KEY"):
        raise HuntError("Server is not configured yet (missing Geoapify key).")
    limit = max(1, min(int(limit or 20), plan["max_results"]))
    want_emails = bool(want_emails and plan["emails_allowed"])
    try:
        found = L.dedupe(L.search_places(what, where, limit))
    except RuntimeError as e:
        raise HuntError(str(e))
    except Exception:
        raise HuntError("Could not reach the data provider. Try again in a minute.")
    if not found:
        raise HuntError(f"No results for '{what}' in '{where}'. Try a simpler business word or a bigger city.")
    if want_emails:
        found = L.enrich_emails(found)
    cleanup_old_files()
    fname = uuid.uuid4().hex + ".xlsx"
    export_xlsx(found, os.path.join(FILES_DIR, fname))
    cur = db.run("INSERT INTO hunts(client_id,what,place,count,file,source,created_at) VALUES(?,?,?,?,?,?,?)",
                 (c["id"], what, where, len(found), fname, source, datetime.now().isoformat(timespec="seconds")))
    return dict(id=cur.lastrowid, file=fname, rows=found, what=what, where=where,
                with_phone=sum(1 for x in found if x["phone"]),
                with_email=sum(1 for x in found if x["email"]),
                left_today=plan["daily_searches"] - used_today(c["id"]))


# ------------------------------------------------------------------- public
@app.get("/")
def landing():
    plans = db.q("SELECT * FROM plans ORDER BY sort,id")
    return render_template("landing.html", plans=plans, logged=bool(current_client()))


PREFIX = ["Prime", "Elite", "City", "Royal", "Star", "Alpha", "Smart", "Metro", "Noor", "Green"]
SUFFIX = ["Care", "Hub", "Point", "Center", "House", "Plaza", "Experts", "Zone"]


@app.post("/api/demo")
def api_demo():
    what = request.form.get("what", "").strip()[:40]
    where = request.form.get("where", "").strip()[:40]
    if not what or not where:
        return jsonify(ok=False, error="Enter a business type and a city."), 400
    rnd = random.Random(sum(map(ord, (what + where).lower())))
    rows = []
    for i in range(6):
        pre, suf = rnd.choice(PREFIX), rnd.choice(SUFFIX)
        slug = re.sub(r"[^a-z]", "", f"{pre}{what}".lower()) or "business"
        rows.append({
            "name": f"{pre} {what.title()} {suf}",
            "phone": f"03{rnd.randint(0, 4)}{rnd.randint(0, 9)} ••••{rnd.randint(100, 999)}",
            "email": f"in•••@{slug}.com" if rnd.random() > 0.35 else "",
            "address": where.title(),
        })
    return jsonify(ok=True, rows=rows, total=rnd.randint(18, 60), where=where.title(), what=what)


# ---------------------------------------------------------------- client auth
@app.route("/login", methods=["GET", "POST"])
def login():
    err = ""
    if request.method == "POST":
        if too_many_fails():
            err = "Too many attempts. Wait 10 minutes and try again."
        else:
            u = request.form.get("username", "").strip().lower()
            c = db.q("SELECT * FROM clients WHERE username=?", (u,), one=True)
            if c and check_password_hash(c["password_hash"], request.form.get("password", "")):
                session.clear()
                session["cid"] = c["id"]
                session.permanent = True
                return redirect(url_for("dashboard"))
            note_fail()
            err = "Wrong ID or password."
    return render_template("login.html", err=err)


@app.get("/logout")
def logout():
    session.pop("cid", None)
    return redirect(url_for("landing"))


@app.get("/dashboard")
@client_required
def dashboard(c):
    plan = db.q("SELECT * FROM plans WHERE id=?", (c["plan_id"],), one=True)
    ok, why = client_state(c)
    used = used_today(c["id"])
    hunts = db.q("SELECT * FROM hunts WHERE client_id=? ORDER BY id DESC LIMIT 15", (c["id"],))
    days_left = (date.fromisoformat(c["expires_at"]) - date.today()).days
    return render_template("dashboard.html", c=c, plan=plan, ok=ok, why=why, used=used,
                           left=max(plan["daily_searches"] - used, 0), hunts=hunts, days_left=days_left)


@app.post("/api/hunt")
@client_required
def api_hunt(c):
    try:
        r = perform_hunt(c, request.form.get("what", ""), request.form.get("where", ""),
                         request.form.get("limit", 20), request.form.get("emails") == "1", "web")
    except HuntError as e:
        return jsonify(ok=False, error=str(e)), 400
    return jsonify(ok=True, count=len(r["rows"]), with_phone=r["with_phone"], with_email=r["with_email"],
                   left_today=r["left_today"], rows=r["rows"][:60],
                   download=url_for("download", hid=r["id"]), what=r["what"], where=r["where"])


@app.get("/download/<int:hid>")
def download(hid):
    h = db.q("SELECT * FROM hunts WHERE id=?", (hid,), one=True)
    if not h or not (session.get("admin") or session.get("cid") == h["client_id"]):
        abort(404)
    nice = re.sub(r"[^A-Za-z0-9]+", "-", f"leads-{h['what']}-{h['place']}").strip("-").lower() + ".xlsx"
    return send_from_directory(FILES_DIR, h["file"], as_attachment=True, download_name=nice)


# -------------------------------------------------------------------- admin
@app.route("/admin/login", methods=["GET", "POST"])
def admin_login():
    err = ""
    if request.method == "POST":
        if not ADMIN_PASS:
            err = "ADMIN_PASS is not set on the server."
        elif too_many_fails():
            err = "Too many attempts. Wait 10 minutes."
        elif (hmac.compare_digest(request.form.get("username", ""), ADMIN_USER)
              and hmac.compare_digest(request.form.get("password", ""), ADMIN_PASS)):
            session.clear()
            session["admin"] = True
            session.permanent = True
            return redirect(url_for("admin"))
        else:
            note_fail()
            err = "Wrong admin ID or password."
    return render_template("admin_login.html", err=err)


@app.get("/admin/logout")
def admin_logout():
    session.pop("admin", None)
    return redirect(url_for("admin_login"))


@app.get("/admin")
@admin_required
def admin():
    t = today()
    clients = db.q(
        "SELECT c.*, p.name plan_name, "
        "(SELECT COUNT(*) FROM hunts h WHERE h.client_id=c.id AND substr(h.created_at,1,10)=?) used_today, "
        "(SELECT COUNT(*) FROM hunts h WHERE h.client_id=c.id) total_hunts "
        "FROM clients c JOIN plans p ON p.id=c.plan_id ORDER BY c.id DESC", (t,))
    soon = (date.today() + timedelta(days=7)).isoformat()
    stats = dict(
        total=len(clients),
        active=sum(1 for c in clients if c["active"] and c["expires_at"] >= t),
        expiring=sum(1 for c in clients if c["active"] and t <= c["expires_at"] <= soon),
        hunts_today=db.q("SELECT COUNT(*) c FROM hunts WHERE substr(created_at,1,10)=?", (t,), one=True)["c"],
    )
    recent = db.q("SELECT h.*, c.name cname FROM hunts h JOIN clients c ON c.id=h.client_id ORDER BY h.id DESC LIMIT 12")
    return render_template("admin.html", clients=clients, plans=db.q("SELECT * FROM plans ORDER BY sort,id"),
                           stats=stats, recent=recent, today=t, cred=session.pop("cred", None))


def _date_or(default, s):
    try:
        return date.fromisoformat(s).isoformat()
    except (ValueError, TypeError):
        return default


@app.post("/admin/client/create")
@admin_required
def admin_create():
    name = request.form.get("name", "").strip()[:60] or "Client"
    username = re.sub(r"[^a-z0-9_.-]", "", request.form.get("username", "").strip().lower())
    if not username:
        username = (re.sub(r"[^a-z0-9]", "", name.lower()) or "client")[:12] + str(random.randint(100, 999))
    password = request.form.get("password", "").strip() or gen_password()
    days = max(1, min(int(request.form.get("days") or 30), 3650))
    plan_id = int(request.form.get("plan_id") or 1)
    if db.q("SELECT 1 FROM clients WHERE username=?", (username,), one=True):
        username += str(random.randint(10, 99))
    db.run("INSERT INTO clients(name,username,password_hash,plan_id,active,expires_at,whatsapp,created_at) "
           "VALUES(?,?,?,?,1,?,?,?)",
           (name, username, generate_password_hash(password), plan_id,
            (date.today() + timedelta(days=days)).isoformat(),
            re.sub(r"\D", "", request.form.get("whatsapp", "")), today()))
    session["cred"] = dict(name=name, username=username, password=password, title="Client created")
    return redirect(url_for("admin") + "#clients")


@app.post("/admin/client/<int:cid>/<action>")
@admin_required
def admin_client_action(cid, action):
    c = db.q("SELECT * FROM clients WHERE id=?", (cid,), one=True)
    if not c:
        abort(404)
    if action == "toggle":
        db.run("UPDATE clients SET active=? WHERE id=?", (0 if c["active"] else 1, cid))
    elif action == "extend":
        base = max(date.today(), date.fromisoformat(c["expires_at"]))
        db.run("UPDATE clients SET expires_at=? WHERE id=?", ((base + timedelta(days=30)).isoformat(), cid))
    elif action == "save":
        db.run("UPDATE clients SET name=?, plan_id=?, expires_at=?, whatsapp=? WHERE id=?",
               (request.form.get("name", c["name"]).strip()[:60] or c["name"],
                int(request.form.get("plan_id") or c["plan_id"]),
                _date_or(c["expires_at"], request.form.get("expires_at")),
                re.sub(r"\D", "", request.form.get("whatsapp", "")), cid))
    elif action == "reset":
        pw = gen_password()
        db.run("UPDATE clients SET password_hash=? WHERE id=?", (generate_password_hash(pw), cid))
        session["cred"] = dict(name=c["name"], username=c["username"], password=pw, title="New password")
    elif action == "delete":
        db.run("DELETE FROM hunts WHERE client_id=?", (cid,))
        db.run("DELETE FROM clients WHERE id=?", (cid,))
    else:
        abort(404)
    return redirect(url_for("admin") + "#clients")


@app.post("/admin/plan/<int:pid>")
@admin_required
def admin_plan(pid):
    f = request.form
    db.run("UPDATE plans SET name=?, price=?, daily_searches=?, max_results=?, emails_allowed=?, featured=? WHERE id=?",
           (f.get("name", "Plan").strip()[:30], int(f.get("price") or 0), int(f.get("daily_searches") or 1),
            max(1, min(int(f.get("max_results") or 20), 60)), 1 if f.get("emails_allowed") else 0,
            1 if f.get("featured") else 0, pid))
    return redirect(url_for("admin") + "#plans")


@app.post("/admin/settings")
@admin_required
def admin_settings():
    for k in ("currency", "contact_whatsapp", "tagline"):
        v = request.form.get(k, "").strip()[:140]
        if k == "contact_whatsapp":
            v = re.sub(r"\D", "", v)
        db.run("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)", (k, v))
    return redirect(url_for("admin") + "#settings")


# ----------------------------------------------------------------- WhatsApp
HELP = ("*Shamuverse Hunter Bot*\nSend:\nfind <business> in <city>\n\nOptions: limit 30, emails\n"
        "Example: find dentists in Lahore limit 30 emails")
CMD_RE = re.compile(r"^\s*(?:find|hunt|search)\s+(.+?)\s+in\s+(.+?)\s*$", re.I)


def twiml(body, media=None):
    m = f"<Media>{escape(media)}</Media>" if media else ""
    return Response(f'<?xml version="1.0" encoding="UTF-8"?><Response><Message><Body>{escape(body)}</Body>{m}</Message></Response>',
                    mimetype="text/xml")


def base_url():
    return os.environ.get("BASE_URL", "").rstrip("/") or request.url_root.rstrip("/")


def twilio_ok():
    if not (VALIDATE_TWILIO and AUTH_TOKEN):
        return True
    data = base_url() + "/whatsapp" + "".join(k + v for k, v in sorted(request.form.items()))
    mac = hmac.new(AUTH_TOKEN.encode(), data.encode(), hashlib.sha1).digest()
    return hmac.compare_digest(base64.b64encode(mac).decode(), request.headers.get("X-Twilio-Signature", ""))


@app.post("/whatsapp")
def whatsapp():
    if not twilio_ok():
        abort(403)
    digits = re.sub(r"\D", "", request.form.get("From", ""))
    c = db.q("SELECT * FROM clients WHERE whatsapp=? AND whatsapp!=''", (digits,), one=True)
    if not c:
        return twiml("This number is not registered. Contact the admin to get access.")
    ok, why = client_state(c)
    if not ok:
        return twiml(why)
    m = CMD_RE.match(request.form.get("Body", "").strip())
    if not m:
        return twiml(HELP)
    what, where, limit = m.group(1), m.group(2), 20
    lm = re.search(r"\blimit\s+(\d+)", where, re.I)
    if lm:
        limit, where = int(lm.group(1)), where.replace(lm.group(0), "")
    emails = bool(re.search(r"\bemails?\b", where, re.I))
    where = re.sub(r"\bemails?\b", "", where, flags=re.I).strip(" ,")
    try:
        r = perform_hunt(c, what, where, limit, emails, "whatsapp")
    except HuntError as e:
        return twiml(str(e))
    msg = f"Found {len(r['rows'])} leads for {r['what']} in {r['where']}.\nWith phone: {r['with_phone']}"
    if r["with_email"]:
        msg += f"\nWith email: {r['with_email']}"
    return twiml(msg + f"\nSearches left today: {r['left_today']}", media=f"{base_url()}/files/{r['file']}")


@app.get("/files/<name>")
def files(name):
    if not re.fullmatch(r"[a-f0-9]{32}\.xlsx", name):  # unguessable name, used for WhatsApp media
        abort(404)
    return send_from_directory(FILES_DIR, name, as_attachment=True)
