from flask import Flask, jsonify, request
from flask_cors import CORS
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from werkzeug.security import generate_password_hash, check_password_hash
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from functools import wraps
import pyotp
import qrcode
import io
import base64
import os
import datetime
import requests

app = Flask(__name__)
CORS(app)

basedir = os.path.abspath(os.path.dirname(__file__))
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(basedir, 'crm.db')}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-me-in-production")

db = SQLAlchemy(app)
migrate = Migrate(app, db)


class StockNumber(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    number = db.Column(db.String(50), unique=True, nullable=False)
    vendor = db.Column(db.String(50), nullable=False)
    assigned_to = db.Column(db.String(120), nullable=True)
    added_at = db.Column(db.DateTime, server_default=db.func.now())
    assigned_at = db.Column(db.DateTime, nullable=True)

    def to_dict(self):
        return {
            "id": self.id,
            "number": self.number,
            "vendor": self.vendor,
            "assignedTo": self.assigned_to,
            "addedAt": self.added_at.isoformat() + "Z" if self.added_at else None,
            "assignedAt": self.assigned_at.isoformat() + "Z" if self.assigned_at else None,
        }


class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    password_hash = db.Column(db.String(255), nullable=False)
    totp_secret = db.Column(db.String(64), nullable=True)
    totp_enabled = db.Column(db.Boolean, default=False)


class Connector(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    vendor = db.Column(db.String(50), unique=True, nullable=False)
    base_url = db.Column(db.String(255), nullable=True)
    username = db.Column(db.String(120), nullable=True)
    password = db.Column(db.String(255), nullable=True)
    api_key = db.Column(db.String(255), nullable=True)
    path = db.Column(db.String(255), nullable=True)
    field_customer = db.Column(db.String(80), nullable=True)
    field_balance = db.Column(db.String(80), nullable=True)
    field_number = db.Column(db.String(80), nullable=True)
    field_subscription = db.Column(db.String(80), nullable=True)
    last_sync = db.Column(db.DateTime, nullable=True)
    last_error = db.Column(db.String(255), nullable=True)
    auth_type = db.Column(db.String(20), default="key_query")  # key_query | key_header | bearer | basic
    auth_param_name = db.Column(db.String(50), default="key")
    response_wrapper = db.Column(db.String(50), nullable=True)  # e.g. "data" - blank means raw array
    number_fields = db.Column(db.String(255), nullable=True)  # comma-separated fields to concatenate for full number


class VendorBalance(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    vendor = db.Column(db.String(50), nullable=False)
    customer = db.Column(db.String(120), nullable=False)
    number = db.Column(db.String(50), nullable=True)
    balance = db.Column(db.Float, default=0)
    updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=datetime.datetime.utcnow)


class OmegaBalance(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    customer = db.Column(db.String(120), nullable=False)
    balance = db.Column(db.Float, default=0)
    updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=datetime.datetime.utcnow)


class GoldenNumber(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    number = db.Column(db.String(50), nullable=False)
    customer = db.Column(db.String(120), nullable=True)
    source_vendor = db.Column(db.String(50), nullable=True)
    updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=datetime.datetime.utcnow)


class NeonBalance(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    customer = db.Column(db.String(120), nullable=False)
    balance = db.Column(db.Float, default=0)
    subscription = db.Column(db.Float, default=0)
    updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=datetime.datetime.utcnow)


class BriaData(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    customer = db.Column(db.String(120), nullable=False)
    number = db.Column(db.String(50), nullable=True)
    balance = db.Column(db.Float, default=0)
    updated_at = db.Column(db.DateTime, server_default=db.func.now(), onupdate=datetime.datetime.utcnow)


with app.app_context():
    db.create_all()
    if User.query.count() == 0:
        default_user = os.environ.get("ADMIN_USERNAME", "admin")
        default_pass = os.environ.get("ADMIN_PASSWORD", "changeme123")
        db.session.add(User(username=default_user, password_hash=generate_password_hash(default_pass)))
        db.session.commit()
        print(f"Created default admin user '{default_user}' - CHANGE THIS PASSWORD")

serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"])
TOKEN_MAX_AGE = 12 * 3600
PRE_2FA_MAX_AGE = 5 * 60


def generate_token(user_id, scope="full"):
    return serializer.dumps({"user_id": user_id, "scope": scope})


def verify_token(token, required_scope="full"):
    max_age = TOKEN_MAX_AGE if required_scope == "full" else PRE_2FA_MAX_AGE
    try:
        data = serializer.loads(token, max_age=max_age)
        if data.get("scope") != required_scope:
            return None
        return data.get("user_id")
    except (BadSignature, SignatureExpired):
        return None


def login_required(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        auth = request.headers.get("Authorization", "")
        if not auth.startswith("Bearer "):
            return jsonify({"error": "Missing or invalid Authorization header"}), 401
        token = auth.split(" ", 1)[1]
        user_id = verify_token(token, "full")
        if not user_id:
            return jsonify({"error": "Invalid or expired token"}), 401
        request.user_id = user_id
        return f(*args, **kwargs)
    return wrapper


@app.route("/health")
def health():
    return jsonify({"status": "ok", "message": "CRM backend is running"})


@app.route("/auth/login", methods=["POST"])
def login():
    body = request.get_json(force=True) or {}
    username = (body.get("username") or "").strip()
    password = body.get("password") or ""
    user = User.query.filter_by(username=username).first()
    if not user or not check_password_hash(user.password_hash, password):
        return jsonify({"error": "Invalid username or password"}), 401
    if user.totp_enabled:
        pre_token = generate_token(user.id, scope="pre_2fa")
        return jsonify({"require2fa": True, "preToken": pre_token})
    token = generate_token(user.id, scope="full")
    return jsonify({"token": token, "username": user.username, "totpEnabled": False})


@app.route("/auth/2fa/verify", methods=["POST"])
def verify_2fa():
    body = request.get_json(force=True) or {}
    pre_token = body.get("preToken") or ""
    code = str(body.get("code") or "").strip()
    user_id = verify_token(pre_token, "pre_2fa")
    if not user_id:
        return jsonify({"error": "Invalid or expired session - please log in again"}), 401
    user = User.query.get(user_id)
    if not user or not user.totp_secret:
        return jsonify({"error": "2FA not set up for this user"}), 400
    totp = pyotp.TOTP(user.totp_secret)
    if not totp.verify(code, valid_window=1):
        return jsonify({"error": "Invalid code"}), 401
    token = generate_token(user.id, scope="full")
    return jsonify({"token": token, "username": user.username, "totpEnabled": True})


@app.route("/auth/2fa/setup", methods=["POST"])
@login_required
def setup_2fa():
    user = User.query.get(request.user_id)
    secret = pyotp.random_base32()
    user.totp_secret = secret
    db.session.commit()
    uri = pyotp.totp.TOTP(secret).provisioning_uri(name=user.username, issuer_name="GoldenVoIP CRM")
    img = qrcode.make(uri)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    qr_b64 = base64.b64encode(buf.getvalue()).decode()
    return jsonify({"secret": secret, "qrCodeBase64": qr_b64, "provisioningUri": uri})


@app.route("/auth/2fa/enable", methods=["POST"])
@login_required
def enable_2fa():
    body = request.get_json(force=True) or {}
    code = str(body.get("code") or "").strip()
    user = User.query.get(request.user_id)
    if not user.totp_secret:
        return jsonify({"error": "Call /auth/2fa/setup first"}), 400
    totp = pyotp.TOTP(user.totp_secret)
    if not totp.verify(code, valid_window=1):
        return jsonify({"error": "Invalid code"}), 401
    user.totp_enabled = True
    db.session.commit()
    return jsonify({"enabled": True})


@app.route("/auth/2fa/disable", methods=["POST"])
@login_required
def disable_2fa():
    user = User.query.get(request.user_id)
    user.totp_enabled = False
    user.totp_secret = None
    db.session.commit()
    return jsonify({"disabled": True})


@app.route("/auth/2fa/status", methods=["GET"])
@login_required
def status_2fa():
    user = User.query.get(request.user_id)
    return jsonify({"totpEnabled": bool(user.totp_enabled)})


@app.route("/auth/change-password", methods=["POST"])
@login_required
def change_password():
    body = request.get_json(force=True) or {}
    old_password = body.get("oldPassword") or ""
    new_password = body.get("newPassword") or ""
    user = User.query.get(request.user_id)
    if not check_password_hash(user.password_hash, old_password):
        return jsonify({"error": "Current password is incorrect"}), 401
    if len(new_password) < 8:
        return jsonify({"error": "New password must be at least 8 characters"}), 400
    user.password_hash = generate_password_hash(new_password)
    db.session.commit()
    return jsonify({"changed": True})


@app.route("/stock", methods=["GET"])
@login_required
def get_stock():
    rows = StockNumber.query.order_by(StockNumber.added_at.desc()).all()
    return jsonify([r.to_dict() for r in rows])


@app.route("/stock", methods=["POST"])
@login_required
def add_stock():
    body = request.get_json(force=True) or {}
    number = (body.get("number") or "").strip()
    vendor = (body.get("vendor") or "Other").strip()
    if not number:
        return jsonify({"error": "number is required"}), 400
    if StockNumber.query.filter_by(number=number).first():
        return jsonify({"error": "number already in stock"}), 409
    row = StockNumber(number=number, vendor=vendor)
    db.session.add(row)
    db.session.commit()
    return jsonify(row.to_dict()), 201


@app.route("/stock/bulk", methods=["POST"])
@login_required
def add_stock_bulk():
    body = request.get_json(force=True) or {}
    entries = body.get("entries") or []
    added, skipped = 0, 0
    for e in entries:
        number = (e.get("number") or "").strip()
        vendor = (e.get("vendor") or "Other").strip()
        if not number or StockNumber.query.filter_by(number=number).first():
            skipped += 1
            continue
        db.session.add(StockNumber(number=number, vendor=vendor))
        added += 1
    db.session.commit()
    return jsonify({"added": added, "skipped": skipped})


@app.route("/stock/<int:stock_id>/assign", methods=["POST"])
@login_required
def assign_stock(stock_id):
    body = request.get_json(force=True) or {}
    customer = (body.get("customer") or "").strip()
    row = StockNumber.query.get_or_404(stock_id)
    row.assigned_to = customer or None
    row.assigned_at = datetime.datetime.utcnow() if customer else None
    db.session.commit()
    return jsonify(row.to_dict())


@app.route("/stock/<int:stock_id>", methods=["DELETE"])
@login_required
def delete_stock(stock_id):
    row = StockNumber.query.get_or_404(stock_id)
    db.session.delete(row)
    db.session.commit()
    return jsonify({"deleted": True})


@app.route("/connectors", methods=["GET"])
@login_required
def get_connectors():
    rows = Connector.query.all()
    out = {}
    for r in rows:
        out[r.vendor] = {
            "vendor": r.vendor,
            "baseUrl": r.base_url,
            "username": r.username,
            "password": r.password,
            "apiKey": r.api_key,
            "path": r.path,
            "fieldCustomer": r.field_customer,
            "fieldBalance": r.field_balance,
            "fieldNumber": r.field_number,
            "fieldSubscription": r.field_subscription,
            "lastSync": r.last_sync.isoformat() + "Z" if r.last_sync else None,
            "lastError": r.last_error,
        }
    return jsonify(out)


@app.route("/connectors/<vendor>", methods=["POST"])
@login_required
def save_connector(vendor):
    body = request.get_json(force=True) or {}
    row = Connector.query.filter_by(vendor=vendor).first()
    if not row:
        row = Connector(vendor=vendor)
        db.session.add(row)
    row.base_url = body.get("baseUrl")
    row.username = body.get("username")
    row.password = body.get("password")
    row.api_key = body.get("apiKey")
    row.path = body.get("path")
    row.field_customer = body.get("fieldCustomer")
    row.field_balance = body.get("fieldBalance")
    row.field_number = body.get("fieldNumber")
    row.field_subscription = body.get("fieldSubscription")
    db.session.commit()
    return jsonify({"saved": True, "vendor": vendor})


@app.route("/connectors/<vendor>", methods=["DELETE"])
@login_required
def delete_connector(vendor):
    row = Connector.query.filter_by(vendor=vendor).first()
    if not row:
        return jsonify({"error": "Connector not found"}), 404
    db.session.delete(row)
    db.session.commit()
    return jsonify({"deleted": True, "vendor": vendor})


def get_connector_row(vendor):
    return Connector.query.filter_by(vendor=vendor).first()


def build_auth(conn):
    """Returns (extra_query_params: dict, headers: dict) based on connector's auth_type."""
    auth_type = conn.auth_type or "key_query"
    if auth_type == "key_query":
        return {conn.auth_param_name or "key": conn.api_key}, {}
    if auth_type == "key_header":
        return {}, {conn.auth_param_name or "X-Api-Key": conn.api_key}
    if auth_type == "bearer":
        return {}, {"Authorization": f"Bearer {conn.api_key}"}
    if auth_type == "basic":
        import base64 as b64
        token = b64.b64encode(f"{conn.username}:{conn.password}".encode()).decode()
        return {}, {"Authorization": f"Basic {token}"}
    return {}, {}


def extract_rows(payload, wrapper):
    if wrapper:
        return payload.get(wrapper) if isinstance(payload, dict) else None
    if isinstance(payload, list):
        return payload
    if isinstance(payload, dict):
        for key in ("data", "results", "items"):
            if key in payload and isinstance(payload[key], list):
                return payload[key]
    return None


@app.route("/sync/<vendor>", methods=["POST"])
@login_required
def sync_vendor_generic(vendor):
    if vendor == "golden":
        return jsonify({"error": "GoldenVoIP uses its own dedicated sync - use /sync/golden"}), 400

    conn = get_connector_row(vendor)
    if not conn or not conn.base_url:
        return jsonify({"error": f"{vendor} connector not configured"}), 400

    query_params, headers = build_auth(conn)
    url = conn.base_url.rstrip("/") + (conn.path or "")

    try:
        resp = requests.get(url, params=query_params, headers=headers, timeout=15)
        resp.raise_for_status()
    except Exception as e:
        conn.last_error = str(e)
        db.session.commit()
        return jsonify({"error": f"{vendor} request failed: {e}"}), 502

    try:
        payload = resp.json()
    except Exception as e:
        conn.last_error = f"Non-JSON response: {e}"
        db.session.commit()
        return jsonify({
            "error": f"{vendor} returned non-JSON: {e}",
            "raw_status": resp.status_code,
            "raw_body_preview": resp.text[:500],
        }), 502

    rows = extract_rows(payload, conn.response_wrapper)
    if rows is None:
        conn.last_error = "Unexpected response shape - could not find a list of records"
        db.session.commit()
        return jsonify({"error": "Unexpected response shape", "raw": payload}), 502

    field_customer = conn.field_customer or "customer_name"
    field_balance = conn.field_balance or "balance"

    def build_number(r):
        if conn.number_fields:
            parts = [str(r.get(f.strip(), "") or "").strip() for f in conn.number_fields.split(",")]
            return "".join(parts)
        if conn.field_number:
            return str(r.get(conn.field_number, "") or "").strip()
        return None

    VendorBalance.query.filter_by(vendor=vendor).delete()
    count = 0
    for r in rows:
        if not isinstance(r, dict):
            continue
        customer = str(r.get(field_customer, "") or "").strip()
        try:
            balance = float(r.get(field_balance, 0) or 0)
        except (TypeError, ValueError):
            balance = 0
        number = build_number(r)
        if customer:
            db.session.add(VendorBalance(vendor=vendor, customer=customer, number=number, balance=balance))
            count += 1

    conn.last_sync = datetime.datetime.utcnow()
    conn.last_error = None
    db.session.commit()

    return jsonify({"synced": count, "sample_raw_row": rows[0] if rows else None})


@app.route("/vendors/balances", methods=["GET"])
@login_required
def get_vendor_balances():
    rows = VendorBalance.query.all()
    grouped = {}
    for r in rows:
        if r.vendor not in grouped:
            grouped[r.vendor] = {"totalBalance": 0, "customers": set(), "rows": []}
        grouped[r.vendor]["totalBalance"] += r.balance
        grouped[r.vendor]["customers"].add(r.customer)
        grouped[r.vendor]["rows"].append({"customer": r.customer, "number": r.number, "balance": r.balance})

    out = {}
    for vendor, data in grouped.items():
        out[vendor] = {
            "totalBalance": round(data["totalBalance"], 2),
            "customerCount": len(data["customers"]),
            "rows": data["rows"],
        }
    return jsonify(out)


@app.route("/sync/golden", methods=["POST"])
@login_required
def sync_golden():
    conn = get_connector_row("golden")
    if not conn or not conn.base_url or not conn.api_key:
        return jsonify({"error": "GoldenVoIP connector not configured"}), 400

    url = conn.base_url.rstrip("/")
    params_str = conn.path or ""
    full_url = url + params_str
    sep = "&" if "?" in full_url else "?"
    full_url = f"{full_url}{sep}key={conn.api_key}"

    try:
        resp = requests.get(full_url, timeout=15)
        resp.raise_for_status()
    except Exception as e:
        conn.last_error = str(e)
        db.session.commit()
        return jsonify({"error": f"GoldenVoIP request failed: {e}"}), 502

    try:
        payload = resp.json()
    except Exception as e:
        conn.last_error = f"Non-JSON response: {e}"
        db.session.commit()
        return jsonify({
            "error": f"GoldenVoIP returned non-JSON: {e}",
            "raw_status": resp.status_code,
            "raw_body_preview": resp.text[:500],
            "requested_url": full_url.replace(conn.api_key, "REDACTED"),
        }), 502

    rows = payload.get("data") if isinstance(payload, dict) else payload
    if rows is None:
        conn.last_error = "Unexpected response shape - no 'data' field found"
        db.session.commit()
        return jsonify({"error": "Unexpected response shape", "raw": payload}), 502

    field_number = conn.field_number or "number"
    field_customer = conn.field_customer or "tenant"

    def parse_vendor(comment):
        if not comment:
            return "Unknown"
        prefix = str(comment).strip()[:1].upper()
        return {"B": "Bria", "O": "Omega", "D": "DID Logic"}.get(prefix, "Other")

    GoldenNumber.query.delete()
    count = 0
    for r in rows:
        country = str(r.get("di_country") or "").strip()
        area = str(r.get("di_area") or "").strip()
        local = str(r.get(field_number, "") or "").strip()
        number = f"{country}{area}{local}"
        customer = str(r.get(field_customer, "") or "").strip()
        vendor = parse_vendor(r.get("di_admincomment"))
        if number:
            db.session.add(GoldenNumber(number=number, customer=customer, source_vendor=vendor))
            count += 1

    conn.last_sync = datetime.datetime.utcnow()
    conn.last_error = None
    db.session.commit()

    return jsonify({"synced": count, "sample_raw_row": rows[0] if rows else None})


@app.route("/golden/numbers", methods=["GET"])
@login_required
def get_golden_numbers():
    rows = GoldenNumber.query.all()
    return jsonify([{"number": r.number, "customer": r.customer, "vendor": r.source_vendor or "Unknown"} for r in rows])


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=True)
