#!/usr/bin/env python3
"""
CV Document Portal — cv.boostfit.app
Serves Sofiane Ould-Hammou's CV / certifications / diplomas / references
to recruiters with category & type grouping, inline preview, and download.

Run:  gunicorn -w 2 -b 127.0.0.1:5080 server:app   (managed by pm2)
"""
import json
import os
import re
import time
import threading
import zipfile
import io
from pathlib import Path

from flask import Flask, abort, jsonify, render_template, send_file, request

BASE_DIR = Path(__file__).resolve().parent
DOCS_DIR = BASE_DIR
MANIFEST_FILE = BASE_DIR / "documents.json"
PROFILE_FILE = BASE_DIR / "parsed_cv.json"
COUNTER_FILE = BASE_DIR / "downloads.json"
EVENT_FILE = BASE_DIR / "download_log.jsonl"
_counter_lock = threading.Lock()

app = Flask(__name__)

# Extension -> display type (used for badge/icon rendering)
TYPE_META = {
    "pdf": {"label": "PDF", "color": "#b3452f", "icon": "file-pdf"},
    "docx": {"label": "Word", "color": "#3b5b7a", "icon": "file-doc"},
    "doc": {"label": "Word", "color": "#3b5b7a", "icon": "file-doc"},
    "txt": {"label": "Text", "color": "#6d665a", "icon": "file-text"},
}

FILE_TYPE_MAP = {
    "pdf": "application/pdf",
    "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    "doc": "application/msword",
    "txt": "text/plain",
}


def load_manifest():
    with open(MANIFEST_FILE, "r", encoding="utf-8") as fh:
        return json.load(fh)


def load_profile():
    """Merge profile from manifest with parsed_cv.json (which wins per-field)."""
    manifest = load_manifest()
    profile = dict(manifest.get("profile", {}))
    try:
        with open(PROFILE_FILE, "r", encoding="utf-8") as fh:
            parsed = json.load(fh)
        profile["name"] = parsed.get("name", profile.get("name"))
        profile["title"] = parsed.get("title", profile.get("title"))
        profile["location"] = parsed.get("location", profile.get("location"))
        profile["availability"] = parsed.get("availability", profile.get("availability"))
        profile["work_authorization"] = parsed.get("work_authorization", profile.get("work_authorization"))
        profile["email"] = parsed.get("contact_email") or parsed.get("email", profile.get("email"))
        profile["summary"] = parsed.get("professional_summary", profile.get("summary"))
        linkedin = parsed.get("linkedin", "")
        if linkedin:
            linkedin = linkedin.strip()
            if "://" not in linkedin:
                linkedin = "https://www." + linkedin.lstrip("www.").lstrip("/")
            profile["linkedin"] = linkedin
        else:
            profile["linkedin"] = profile.get("linkedin")
        profile["certifications"] = parsed.get("certifications", [])
        profile["skills"] = parsed.get("skills", {})
        profile["languages"] = parsed.get("languages", [])
        profile["experience"] = parsed.get("experience", [])
    except FileNotFoundError:
        pass
    return profile


def human_size(num):
    for unit in ("B", "KB", "MB", "GB"):
        if num < 1024:
            return f"{num:.0f} {unit}" if unit == "B" else f"{num:.1f} {unit}"
        num /= 1024
    return f"{num:.1f} TB"


def page_count(path):
    """Best-effort PDF page count from /Type /Page markers."""
    try:
        data = path.read_bytes()
        if data[:4] != b"%PDF":
            return None
        count = len(re.findall(rb"/Type\s*/Page[^s]", data))
        return count or None
    except OSError:
        return None


def load_documents():
    """Enrich manifest docs with runtime file info: size, pages, real path."""
    manifest = load_manifest()
    cats = {c["id"]: c for c in manifest.get("categories", [])}
    docs = []
    for item in manifest.get("documents", []):
        path = DOCS_DIR / item["file"]
        if not path.is_file():
            continue
        ext = path.suffix.lstrip(".").lower()
        tmeta = TYPE_META.get(ext, {"label": ext.upper(), "color": "#718096", "icon": "file"})
        cat = cats.get(item["category"], {})
        download_name = item.get("download_name") or path.name
        thumb_path = BASE_DIR / "static" / "thumbs" / f"{item['id']}.jpg"
        doc = {
            "id": item["id"],
            "title": item["title"],
            "category": item["category"],
            "category_label": cat.get("label", item["category"]),
            "category_icon": cat.get("icon", "file"),
            "category_color": cat.get("color", "#2563eb"),
            "featured": bool(item.get("featured", False)),
            "type": item.get("type", ext),
            "type_label": tmeta["label"],
            "type_color": tmeta["color"],
            "type_icon": tmeta["icon"],
            "language": item.get("language", ""),
            "date": item.get("date", ""),
            "description": item.get("description", ""),
            "tags": item.get("tags", []),
            "file": item["file"],
            "download_name": download_name,
            "thumb": f"/static/thumbs/{item['id']}.jpg" if thumb_path.is_file() else None,
            "size": human_size(path.stat().st_size),
            "pages": page_count(path),
            "filename": path.name,
            "mtime": time.strftime("%Y-%m-%d", time.localtime(path.stat().st_mtime)),
        }
        docs.append(doc)
    # stable sort: category order in manifest, then title
    order = {cid: i for i, cid in enumerate(cats)}
    docs.sort(key=lambda d: (order.get(d["category"], 99), d["title"].lower()))
    return docs


def load_counter():
    try:
        with open(COUNTER_FILE, "r", encoding="utf-8") as fh:
            counter = json.load(fh)
    except (OSError, json.JSONDecodeError):
        return {"_total": 0, "by_doc": {}, "by_day": {}}
    # migrate legacy flat format -> structured
    if "by_doc" not in counter:
        by_doc = {}
        for k, v in counter.items():
            if k.startswith("_") or k in ("by_day",):
                continue
            by_doc[k] = {"count": int(v), "last_ts": None}
        counter = {
            "_total": int(counter.get("_total", 0)),
            "by_doc": by_doc,
            "by_day": counter.get("by_day", {}),
        }
    return counter


def save_counter(counter):
    tmp = COUNTER_FILE.with_suffix(".tmp")
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(counter, fh)
    os.replace(tmp, COUNTER_FILE)


def _append_event(doc_id, ts):
    """Append-only download event log (crash-safe, background analytics)."""
    try:
        with open(EVENT_FILE, "a", encoding="utf-8") as fh:
            fh.write(json.dumps({"doc": doc_id, "ts": ts}) + "\n")
    except OSError:
        pass


def record_download(doc_id):
    """Persist one download event: aggregate counters + event log."""
    ts = time.time()
    day = time.strftime("%Y-%m-%d", time.localtime(ts))
    with _counter_lock:
        counter = load_counter()
        counter["_total"] = counter.get("_total", 0) + 1
        by_doc = counter.setdefault("by_doc", {})
        entry = by_doc.get(doc_id, {"count": 0, "last_ts": None})
        entry["count"] = int(entry.get("count", 0)) + 1
        entry["last_ts"] = ts
        by_doc[doc_id] = entry
        by_day = counter.setdefault("by_day", {})
        by_day[day] = int(by_day.get(day, 0)) + 1
        try:
            save_counter(counter)
        except OSError:
            pass
    _append_event(doc_id, ts)


def doc_by_id(doc_id):
    return next((d for d in load_documents() if d["id"] == doc_id), None)


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------

@app.route("/")
def index():
    return render_template(
        "index.html",
        profile=load_profile(),
        categories=load_manifest().get("categories", []),
        documents=load_documents(),
    )


@app.route("/api/documents")
def api_documents():
    return jsonify(load_documents())


@app.route("/api/profile")
def api_profile():
    return jsonify(load_profile())


@app.route("/download/all")
def download_all():
    """Bundle every document into a single ZIP for recruiters."""
    docs = load_documents()
    if not docs:
        abort(404)
    record_download("_zip")
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        for doc in docs:
            path = DOCS_DIR / doc["file"]
            if path.is_file():
                zf.write(path, arcname=doc["download_name"])
    buf.seek(0)
    safe = "Sofiane-Ould-Hammou-Portfolio.zip"
    response = send_file(
        buf,
        mimetype="application/zip",
        as_attachment=True,
        download_name=safe,
        max_age=0,
    )
    return response


@app.route("/view/<doc_id>")
def view_document(doc_id):
    """Inline preview (browser-native for PDFs)."""
    doc = doc_by_id(doc_id)
    if not doc:
        abort(404)
    path = DOCS_DIR / doc["file"]
    if not path.is_file():
        abort(404)
    mime = FILE_TYPE_MAP.get(doc["type"], "application/octet-stream")
    return send_file(
        path,
        mimetype=mime,
        as_attachment=False,
        download_name=doc["download_name"],
        max_age=0,  # no caching — previews must always get fresh headers
    )


@app.route("/download/<doc_id>")
def download_document(doc_id):
    """Force-download with attachment disposition + download counter."""
    doc = doc_by_id(doc_id)
    if not doc:
        abort(404)
    path = DOCS_DIR / doc["file"]
    if not path.is_file():
        abort(404)

    record_download(doc_id)

    mime = FILE_TYPE_MAP.get(doc["type"], "application/octet-stream")
    response = send_file(
        path,
        mimetype=mime,
        as_attachment=True,
        download_name=doc["download_name"],
        max_age=0,
    )
    # safe filename (RFC 6266)
    safe = re.sub(r'[^\w.\- ]+', '_', doc["download_name"])
    response.headers["Content-Disposition"] = f"attachment; filename=\"{safe}\""
    return response


@app.route("/api/stats")
def api_stats():
    counter = load_counter()
    docs = load_documents()
    by_doc = {}
    for d in docs:
        entry = counter["by_doc"].get(d["id"], {})
        by_doc[d["id"]] = {
            "count": int(entry.get("count", 0)),
            "last_ts": entry.get("last_ts"),
            "title": d["title"],
        }
    by_doc["_zip"] = {
        "count": int(counter["by_doc"].get("_zip", {}).get("count", 0)),
        "last_ts": counter["by_doc"].get("_zip", {}).get("last_ts"),
        "title": "Full portfolio (ZIP)",
    }
    days = []
    now = time.time()
    for i in range(13, -1, -1):
        day = time.strftime("%Y-%m-%d", time.localtime(now - i * 86400))
        days.append({"date": day, "count": int(counter["by_day"].get(day, 0))})
    by_cat = {}
    by_type = {}
    for d in docs:
        by_cat[d["category_label"]] = by_cat.get(d["category_label"], 0) + 1
        by_type[d["type_label"]] = by_type.get(d["type_label"], 0) + 1
    last_ts = max(
        [entry.get("last_ts") or 0 for entry in counter["by_doc"].values()] + [0]
    )
    return jsonify({
        "total_documents": len(docs),
        "total_downloads": int(counter.get("_total", 0)),
        "portfolio_downloads": int(counter["by_doc"].get("_zip", {}).get("count", 0)),
        "by_category": by_cat,
        "by_type": by_type,
        "by_doc": by_doc,
        "by_day": days,
        "last_download": last_ts or None,
    })


@app.errorhandler(404)
def not_found(_):
    return jsonify({"error": "not found"}), 404


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