"""
update_global_threshold.py
==========================
Cron job untuk memperbarui config/thresholds.json secara berkala.

Jadwal: 02:00 AM setiap hari
Syarat eksekusi: Jumlah node baru sejak update terakhir > NEW_NODES_THRESHOLD

Cara menjalankan manual:
    python scripts/update_global_threshold.py

Cara mendaftarkan ke cron (Linux):
    0 2 * * * /path/to/venv/bin/python /path/to/scripts/update_global_threshold.py >> /var/log/threshold_update.log 2>&1
"""

import json
import logging
import os
import sys
from datetime import datetime, timezone, timedelta

import numpy as np
from dotenv import load_dotenv
from neo4j import GraphDatabase

load_dotenv(override=True)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)

# --- Konfigurasi ---
NEO4J_URI = os.getenv("NEO4J_URI", "")
NEO4J_USERNAME = os.getenv("NEO4J_USERNAME", "")
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "")

PILLARS = ["background", "methodology", "gap", "objective", "futurework"]

SECTION_MULTIPLIERS = {
    "background": 1.0,
    "methodology": 1.0,
    "objective": 1.05,
    "gap": 1.15,
    "futurework": 1.05,
}

SAMPLE_SIZE = 500
NEW_NODES_THRESHOLD = 100

CONFIG_PATH = os.path.join(
    os.path.dirname(__file__), "..", "config", "thresholds.json"
)


def load_existing_config() -> dict:
    """
    Baca config/thresholds.json yang ada.

    Returns:
        dict: Isi JSON yang ada, atau dict kosong jika file tidak ditemukan.
    """
    try:
        with open(CONFIG_PATH, "r") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def get_total_node_count(driver) -> int:
    """
    Hitung total node Literature di seluruh database (global, tanpa filter project_id).

    Args:
        driver: Neo4j driver instance.

    Returns:
        int: Jumlah total node Literature.
    """
    with driver.session() as session:
        result = session.run("MATCH (n:Literature) RETURN count(n) AS total")
        return result.single()["total"]


def get_node_count_since_last_update(driver, last_updated_iso: str) -> int:
    """
    Hitung node yang dibuat setelah timestamp last_updated.
    Karena Neo4j Community tidak menyimpan created_at secara otomatis,
    kita bandingkan total node saat ini dengan total yang dicatat di metadata.

    Args:
        driver: Neo4j driver instance.
        last_updated_iso: Timestamp ISO 8601 dari update terakhir.

    Returns:
        int: Estimasi jumlah node baru.
    """
    existing_config = load_existing_config()
    last_total = existing_config.get("_metadata", {}).get("total_nodes", 0)
    current_total = get_total_node_count(driver)
    return max(0, current_total - last_total)


def cosine_similarity(vec1: list, vec2: list) -> float:
    """
    Hitung cosine similarity antara dua vektor.

    Args:
        vec1: Vektor pertama.
        vec2: Vektor kedua.

    Returns:
        float: Nilai cosine similarity antara 0.0 dan 1.0.
    """
    if not vec1 or not vec2 or len(vec1) != len(vec2):
        return 0.0

    a = np.array(vec1)
    b = np.array(vec2)
    norm_a = np.linalg.norm(a)
    norm_b = np.linalg.norm(b)

    if norm_a == 0 or norm_b == 0:
        return 0.0

    return float(np.dot(a, b) / (norm_a * norm_b))


def sample_pairs_global(driver, pillar: str, sample_size: int) -> list[tuple]:
    """
    Ambil pasangan node acak dari seluruh database (tanpa filter project_id).
    Tujuan: menghitung noise floor global untuk threshold yang tidak bias per-workspace.

    Args:
        driver: Neo4j driver instance.
        pillar: Nama pilar (background, methodology, dll).
        sample_size: Jumlah pasangan yang diambil.

    Returns:
        list[tuple]: List of (vec1, vec2) tuples.
    """
    embedding_prop = f"embedding_{pillar}"

    cypher = f"""
    MATCH (n1:Literature), (n2:Literature)
    WHERE elementId(n1) < elementId(n2)
      AND n1.{embedding_prop} IS NOT NULL
      AND n2.{embedding_prop} IS NOT NULL
    WITH n1, n2, rand() AS r
    ORDER BY r
    LIMIT $sample_size
    RETURN n1.{embedding_prop} AS vec1, n2.{embedding_prop} AS vec2
    """

    pairs = []
    with driver.session() as session:
        result = session.run(cypher, sample_size=sample_size)
        for record in result:
            pairs.append((record["vec1"], record["vec2"]))

    return pairs


def calculate_threshold(driver, pillar: str) -> dict | None:
    """
    Hitung threshold Z-Score untuk satu pilar.

    Args:
        driver: Neo4j driver instance.
        pillar: Nama pilar.

    Returns:
        dict: Hasil kalkulasi, atau None jika tidak ada pasangan valid.

    Raises:
        ValueError: Jika sample_size menghasilkan distribusi yang tidak valid.
    """
    pairs = sample_pairs_global(driver, pillar, SAMPLE_SIZE)

    if not pairs:
        logger.warning("No valid pairs found for pillar", extra={"pillar": pillar})
        return None

    similarities = [cosine_similarity(v1, v2) for v1, v2 in pairs]
    mean = float(np.mean(similarities))
    stddev = float(np.std(similarities))
    z_score = mean + (3 * stddev)
    multiplier = SECTION_MULTIPLIERS[pillar]
    final_threshold = round(z_score * multiplier, 4)

    logger.info(
        "Threshold calculated",
        extra={
            "pillar": pillar,
            "mean": round(mean, 4),
            "stddev": round(stddev, 4),
            "z_score": round(z_score, 4),
            "multiplier": multiplier,
            "final_threshold": final_threshold,
        },
    )

    return {
        "pillar": pillar,
        "sample_count": len(pairs),
        "mean": round(mean, 4),
        "stddev": round(stddev, 4),
        "z_score": round(z_score, 4),
        "multiplier": multiplier,
        "final_threshold": final_threshold,
    }


def write_thresholds(threshold_results: dict, total_nodes: int) -> None:
    """
    Tulis threshold baru ke config/thresholds.json.

    Args:
        threshold_results: Dict hasil kalkulasi per pilar.
        total_nodes: Total node saat ini untuk dicatat di metadata.

    Raises:
        OSError: Jika file tidak dapat ditulis.
    """
    now = datetime.now(tz=timezone(timedelta(hours=7))).isoformat()

    output = {
        pillar: threshold_results[pillar]["final_threshold"]
        for pillar in PILLARS
        if pillar in threshold_results
    }
    output["_metadata"] = {
        "last_updated": now,
        "calibration_source": "Z-Score (Mean + 3*StdDev) x Section_Multiplier",
        "sample_size": SAMPLE_SIZE,
        "total_nodes": total_nodes,
        "multipliers": SECTION_MULTIPLIERS,
    }

    os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)

    with open(CONFIG_PATH, "w") as f:
        json.dump(output, f, indent=2)

    logger.info("Thresholds written to file", extra={"path": CONFIG_PATH})


def main() -> None:
    """Entry point untuk cron job update threshold."""
    logger.info("Starting global threshold update job")

    if not all([NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD]):
        logger.error("Neo4j credentials incomplete. Aborting.")
        sys.exit(1)

    driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USERNAME, NEO4J_PASSWORD))

    try:
        existing_config = load_existing_config()
        last_updated = existing_config.get("_metadata", {}).get("last_updated", "never")

        new_node_count = get_node_count_since_last_update(driver, last_updated)
        total_nodes = get_total_node_count(driver)

        logger.info(
            "Pre-flight check",
            extra={
                "total_nodes": total_nodes,
                "new_nodes_since_last_update": new_node_count,
                "required_minimum": NEW_NODES_THRESHOLD,
                "last_updated": last_updated,
            },
        )

        if new_node_count < NEW_NODES_THRESHOLD:
            logger.info(
                "Condition not met. Maintaining last state.",
                extra={
                    "new_nodes": new_node_count,
                    "threshold": NEW_NODES_THRESHOLD,
                },
            )
            sys.exit(0)

        logger.info(
            "Condition met. Proceeding with recalibration.",
            extra={"new_nodes": new_node_count},
        )

        results = {}
        for pillar in PILLARS:
            result = calculate_threshold(driver, pillar)
            if result:
                results[pillar] = result

        if len(results) < len(PILLARS):
            logger.error(
                "Incomplete results. Aborting write to prevent partial config.",
                extra={"computed": list(results.keys()), "expected": PILLARS},
            )
            sys.exit(1)

        write_thresholds(results, total_nodes)

        logger.info(
            "Global threshold update completed successfully",
            extra={"thresholds": {p: results[p]["final_threshold"] for p in PILLARS}},
        )

    finally:
        driver.close()
        logger.info("Neo4j connection closed.")


if __name__ == "__main__":
    main()
