import os
import subprocess
import cv2
import threading
import uuid
from flask import Flask, render_template, request, redirect, url_for, send_from_directory, jsonify, session, make_response, send_file, Response
from werkzeug.utils import secure_filename
from functools import wraps
from ultralytics import YOLO
import time
import shutil
import requests
from fpdf import FPDF
from io import BytesIO
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
import json
import re
import numpy as np
import tensorflow as tf
from tensorflow import keras

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

app = Flask(__name__)

# Global dictionary untuk menyimpan status background processing jobs
# { job_id: { 'status': 'queued'|'processing'|'done'|'error', 'progress': 0-100, 'message': str, 'result': dict|None, 'error': str|None } }
processing_jobs = {}
processing_jobs_lock = threading.Lock()
app.secret_key = 'keraton_secret_key_123' # In production, use env variable
app.config['UPLOAD_FOLDER'] = os.path.join(BASE_DIR, 'uploads')
app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'gif', 'mp4', 'avi', 'mov'}

# Pastikan folder upload dan instance ada
if not os.path.exists(app.config['UPLOAD_FOLDER']):
    try: os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
    except: pass

instance_dir = os.path.join(BASE_DIR, 'instance')
if not os.path.exists(instance_dir):
    try: os.makedirs(instance_dir, exist_ok=True)
    except: pass

db_path = os.path.join(instance_dir, 'db_arci.db')
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)

# Database Models
class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password = db.Column(db.String(120), nullable=False)
    user_name = db.Column(db.String(120))
    user_role = db.Column(db.String(120))
    user_dept = db.Column(db.String(120))
    conf_threshold = db.Column(db.Float, default=0.70)
    primary_color = db.Column(db.String(20), default='#1342ec')
    analyses = db.relationship('Analysis', backref='author', lazy=True)

class Analysis(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    project_title = db.Column(db.String(200))
    original_filename = db.Column(db.String(200))
    result_file = db.Column(db.String(200))
    result_screenshot = db.Column(db.String(200))
    file_type = db.Column(db.String(20)) # 'image' or 'video'
    detections_json = db.Column(db.Text) # Serialized JSON string — unique detections summary
    frame_sequence_json = db.Column(db.Text, nullable=True)
    # Format frame_sequence_json (video only): JSON list of sampled frames.
    # Each frame = list of dicts: {class_id, cx, cy, w, h, conf} (all normalized 0-1)
    # Max FRAME_SAMPLE_STEP frames stored to keep DB size manageable.
    inference_time = db.Column(db.Float)
    timestamp = db.Column(db.DateTime, default=lambda: datetime.now(tz=None))

    @property
    def filename(self):
        return self.project_title

    @property
    def local_timestamp(self):
        return self.timestamp

    @property
    def time(self):
        return self.local_timestamp.strftime("%H:%M") if self.local_timestamp else ""

def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'user' not in session:
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function

# Lazy Loading untuk model YOLO & LSTM agar startup WSGI cepat & ramah memori cPanel
_yolo_model_instance = None
_lstm_model_instance = None

def get_yolo_model():
    global _yolo_model_instance
    if _yolo_model_instance is None:
        print("Lazy loading YOLO model...")
        yolo_model_path = os.path.join(BASE_DIR, 'model', 'YOLOv8_Keraton_FINAL_OK.pt')
        _yolo_model_instance = YOLO(yolo_model_path)
        try:
            _yolo_model_instance.fuse()
        except Exception:
            pass
    return _yolo_model_instance

def get_lstm_model():
    global _lstm_model_instance
    if _lstm_model_instance is None:
        print("Lazy loading LSTM model...")
        try:
            lstm_model_path = os.path.join(BASE_DIR, 'model', 'lstm_model_multiclass.keras')
            _lstm_model_instance = keras.models.load_model(lstm_model_path)
            print("LSTM classification model loaded successfully.")
        except Exception as e:
            print(f"Error loading LSTM model: {e}")
    return _lstm_model_instance

class LazyYOLO:
    def __getattr__(self, name):
        return getattr(get_yolo_model(), name)
    
    def __call__(self, *args, **kwargs):
        return get_yolo_model()(*args, **kwargs)

class LazyLSTM:
    def __getattr__(self, name):
        m = get_lstm_model()
        if m is None:
            raise RuntimeError("LSTM model failed to load.")
        return getattr(m, name)
    
    def __bool__(self):
        return get_lstm_model() is not None

model = LazyYOLO()
lstm_model = LazyLSTM()

# Inisialisasi Database dan Admin User saat app diload (baik via python app.py maupun WSGI)
with app.app_context():
    try:
        db.create_all()
        if not User.query.filter_by(email="admin@keraton.ai").first():
            admin = User(
                email="admin@keraton.ai",
                password="admin123",
                user_name="Admin Keraton",
                user_role="Head of Preservation",
                user_dept="Digital Heritage"
            )
            db.session.add(admin)
            db.session.commit()
            print("Default admin user verified.")
    except Exception as _db_err:
        print(f"Database init warning: {_db_err}")

# Class colors for visualization (BGR format for OpenCV)
CLASS_COLORS = {
    0: {'bgr': (126, 35, 26), 'hex': '#1a237e'},    # Badan_BawahKakiAtap
    1: {'bgr': (32, 94, 27), 'hex': '#1b5e20'},    # Badan_DindingKayuSusunPean
    2: {'bgr': (28, 28, 183), 'hex': '#b71c1c'},   # Badan_IkatPinggang
    3: {'bgr': (140, 20, 74), 'hex': '#4a148c'},   # Badan_Lawang_DenganRamAngin
    4: {'bgr': (155, 87, 1), 'hex': '#01579b'},    # Badan_Longap_TanpaRamAngin
    5: {'bgr': (100, 96, 0), 'hex': '#006064'},    # Badan_TiangKolomDenganKonsol
    6: {'bgr': (64, 77, 0), 'hex': '#004d40'},     # Badan_bawahKanopi
    7: {'bgr': (30, 105, 51), 'hex': '#33691e'},   # Kaki_Beram_Dinding
    8: {'bgr': (23, 119, 130), 'hex': '#827717'},  # Kaki_Genggan
    9: {'bgr': (0, 51, 0), 'hex': '#003300'},      # Kepala_UjungPrabung_AntenaTunjukLangit
    10: {'bgr': (35, 39, 62), 'hex': '#3e2723'},   # Kepala_CucurAtap
    11: {'bgr': (33, 33, 33), 'hex': '#212121'},   # Kepala_Kaki_Atap
    12: {'bgr': (100, 90, 69), 'hex': '#455a64'},  # Kepala_Lisplang
    13: {'bgr': (68, 10, 19), 'hex': '#130a44'},   # Kepala_Tebar_Layar
    14: {'bgr': (12, 54, 191), 'hex': '#bf360c'}   # Kaki_Tangga
}

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        email = request.form.get('email')
        password = request.form.get('password')
        user = User.query.filter_by(email=email, password=password).first()
        if user:
            session['user'] = email
            # Sync session for legacy compatibility if needed
            session['user_name'] = user.user_name
            session['user_role'] = user.user_role
            session['user_dept'] = user.user_dept
            session['conf_threshold'] = user.conf_threshold
            session['primary_color'] = user.primary_color
            return redirect(url_for('dashboard'))
        return render_template('login.html', error="Invalid credentials")
    return render_template('login.html')

@app.route('/logout')
def logout():
    session.clear()
    return redirect(url_for('login'))

def get_current_user():
    if 'user' in session:
        user = User.query.filter_by(email=session['user']).first()
        if user and (user.conf_threshold is None or user.conf_threshold < 0.70):
            user.conf_threshold = 0.70
            try:
                db.session.commit()
            except Exception:
                db.session.rollback()
            session['conf_threshold'] = 0.70
        return user
    return None

def get_history(user_id):
    return Analysis.query.filter_by(user_id=user_id).order_by(Analysis.timestamp.desc()).all()

@app.route('/dashboard')
@login_required
def dashboard():
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    history = get_history(user.id)
    return render_template('dashboard.html', history=history)

@app.route('/results')
@login_required
def results():
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    history = get_history(user.id)
    return render_template('results.html', history=history)

@app.route('/video_view')
@login_required
def video_view():
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    history = get_history(user.id)
    return render_template('video_view.html', history=history)

@app.route('/report')
@login_required
def report():
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    history = get_history(user.id)
    return render_template('report.html', history=history)

@app.route('/comparison')
@login_required
def comparison():
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    history = get_history(user.id)
    return render_template('comparison.html', history=history)

@app.route('/profile')
@login_required
def profile():
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    history = get_history(user.id)
    return render_template('profile.html', user=user, history=history)

@app.route('/settings')
@login_required
def settings():
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    history = get_history(user.id)
    return render_template('settings.html', user=user, history=history)

@app.route('/update_settings', methods=['POST'])
@login_required
def update_settings():
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    data = request.json
    if data and 'conf_threshold' in data:
        user.conf_threshold = float(data['conf_threshold'])
        session['conf_threshold'] = user.conf_threshold
    if data and 'primary_color' in data:
        user.primary_color = data['primary_color']
        session['primary_color'] = user.primary_color
    db.session.commit()
    return jsonify({'status': 'success'})

@app.route('/update_profile', methods=['POST'])
@login_required
def update_profile():
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    data = request.json
    if data and 'user_name' in data:
        user.user_name = data['user_name']
        session['user_name'] = user.user_name
    if data and 'user_role' in data:
        user.user_role = data['user_role']
        session['user_role'] = user.user_role
    if data and 'user_dept' in data:
        user.user_dept = data['user_dept']
        session['user_dept'] = user.user_dept
    db.session.commit()
    return jsonify({'status': 'success'})

@app.route('/analysis/<int:analysis_id>')
@login_required
def view_historical_analysis(analysis_id):
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    analysis = Analysis.query.filter_by(id=analysis_id, user_id=user.id).first_or_404()
    
    raw_dets = json.loads(analysis.detections_json or '[]')
    filtered_dets = [d for d in raw_dets if d.get('confidence', 0) >= 0.70]

    # Sync to session so results/video_view can pick it up
    session['detection_results'] = {
        'original_file': analysis.original_filename,
        'result_file': analysis.result_file,
        'result_screenshot': analysis.result_screenshot,
        'file_type': analysis.file_type,
        'detections': filtered_dets,
        'inference_time': analysis.inference_time
    }
    
    if analysis.file_type == 'video':
        return redirect(url_for('video_view'))
    return redirect(url_for('results'))
@app.route('/delete_analysis/<int:analysis_id>', methods=['DELETE', 'POST'])
@login_required
def delete_analysis(analysis_id):
    try:
        user = get_current_user()
        if user is None:
            return jsonify({'status': 'error', 'message': 'Not authenticated'}), 401
        analysis = Analysis.query.filter_by(id=analysis_id, user_id=user.id).first()
        
        if not analysis:
            return jsonify({'status': 'error', 'message': 'Analysis not found or access denied'}), 404
        
        # Delete files from disk
        if analysis.result_file:
            path = os.path.join(app.config['UPLOAD_FOLDER'], analysis.result_file)
            if os.path.exists(path):
                try: os.remove(path)
                except: pass
        
        if analysis.result_screenshot:
            path = os.path.join(app.config['UPLOAD_FOLDER'], analysis.result_screenshot)
            if os.path.exists(path):
                try: os.remove(path)
                except: pass

        db.session.delete(analysis)
        db.session.commit()
        return jsonify({'status': 'success'})
    except Exception as e:
        db.session.rollback()
        return jsonify({'status': 'error', 'message': str(e)}), 500

@app.route('/classification')
@login_required
def classification():
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    # Fetch recent analyses to show in a selection list
    recent_analyses = Analysis.query.filter_by(user_id=user.id).order_by(Analysis.timestamp.desc()).limit(10).all()
    return render_template('classification.html', history=recent_analyses)

@app.route('/run_secondary_classification/<int:analysis_id>', methods=['POST'])
@login_required
def run_secondary_classification(analysis_id):
    """
    Klasifikasi sekunder menggunakan LSTM.
    Input LSTM: sequence 30 frame tersampling dari video, tiap frame berisi
    hingga 5 deteksi dengan fitur nyata (class_id_norm, cx, cy, w, conf).
    Jika frame_sequence_json tidak ada (analisa gambar / data lama),
    fallback ke metode penghitungan distribusi saja.
    """
    user = get_current_user()
    if user is None:
        return redirect(url_for('login'))
    analysis = Analysis.query.filter_by(id=analysis_id, user_id=user.id).first_or_404()
    
    if not lstm_model:
        return jsonify({'error': 'LSTM model not loaded'}), 500

    try:
        raw_dets = json.loads(analysis.detections_json or '[]')
        detections = [d for d in raw_dets if d.get('confidence', 0) >= 0.70]

        # ── Konstanta arsitektur LSTM ──────────────────────────────────────
        TIMESTEPS = 30   # sesuai input shape model (None, 30, 25)
        N_DETS    = 5    # max deteksi per frame yang dipakai
        FEATURES  = 5    # (class_id_norm, cx, cy, w, conf)
        # ──────────────────────────────────────────────────────────────────

        sequence = np.zeros((1, TIMESTEPS, N_DETS * FEATURES), dtype=np.float32)

        # Nama kelas → ID lookup (dipakai oleh kedua jalur)
        class_name_to_id = {v: k for k, v in model.names.items()}

        raw_sequence = json.loads(analysis.frame_sequence_json or '[]') if analysis.frame_sequence_json else []

        if raw_sequence:
            # ── Jalur UTAMA: gunakan frame_sequence_json (data nyata per-frame) ──
            total_frames = len(raw_sequence)

            # Sampling merata: pilih TIMESTEPS indeks dari seluruh sequence
            sample_indices = [
                min(int(i * total_frames / TIMESTEPS), total_frames - 1)
                for i in range(TIMESTEPS)
            ]

            for t, frame_idx in enumerate(sample_indices):
                frame_dets = raw_sequence[frame_idx]  # list of det dicts
                for i, det in enumerate(frame_dets[:N_DETS]):
                    offset = i * FEATURES
                    sequence[0, t, offset]     = det.get('class_id', 0) / 15.0  # normalized
                    sequence[0, t, offset + 1] = det.get('cx', 0.5)
                    sequence[0, t, offset + 2] = det.get('cy', 0.5)
                    sequence[0, t, offset + 3] = det.get('w', 0.1)
                    sequence[0, t, offset + 4] = det.get('conf', 0.0)

            data_source = 'real_sequence'
        else:
            # ── Jalur FALLBACK: data lama / gambar — repeat unique detections ──
            # (perilaku lama dipertahankan agar backward-compatible)
            for t in range(TIMESTEPS):
                for i, det in enumerate(detections[:N_DETS]):
                    cls_name = det.get('class', '')
                    class_id = class_name_to_id.get(cls_name, 0)
                    offset = i * FEATURES
                    sequence[0, t, offset]     = class_id / 15.0
                    sequence[0, t, offset + 1] = 0.5   # tidak diketahui
                    sequence[0, t, offset + 2] = 0.5
                    sequence[0, t, offset + 3] = 0.2
                    sequence[0, t, offset + 4] = det.get('confidence', 0.8)

            data_source = 'fallback_static'

        # ── Inference LSTM ────────────────────────────────────────────────
        predictions = lstm_model.predict(sequence, verbose=0)[0]

        # 1. Distribusi prediksi LSTM (agregasi per kategori utama)
        # ── 1. Probabilitas Komponen LSTM (Temporal) ─────────────────────────
        raw_lstm = {
            'Badan':  float(np.sum(predictions[0:7])),
            'Kaki':   float(np.sum(predictions[7:9])),
            'Kepala': float(np.sum(predictions[9:14]))
        }
        sum_lstm = sum(raw_lstm.values())
        lstm_probs = (
            {cat: val / sum_lstm for cat, val in raw_lstm.items()}
            if sum_lstm > 0
            else {'Kepala': 0.333, 'Badan': 0.333, 'Kaki': 0.333}
        )

        # ── 2. Probabilitas Komponen YOLOv8 (Spasial) ─────────────────────────
        yolo_conf_sums = {'Kepala': 0.0, 'Badan': 0.0, 'Kaki': 0.0}
        if raw_sequence:
            for frame_dets in raw_sequence:
                for det in frame_dets:
                    conf = det.get('conf', 0)
                    if conf >= 0.70:
                        cls_name = model.names.get(det.get('class_id', -1), '')
                        if cls_name.startswith('Kepala'):   yolo_conf_sums['Kepala'] += conf
                        elif cls_name.startswith('Badan'):  yolo_conf_sums['Badan'] += conf
                        elif cls_name.startswith('Kaki'):   yolo_conf_sums['Kaki'] += conf
        else:
            for det in detections:
                conf = det.get('confidence', 0)
                if conf >= 0.70:
                    cls = det.get('class', '')
                    if cls.startswith('Kepala'):   yolo_conf_sums['Kepala'] += conf
                    elif cls.startswith('Badan'):  yolo_conf_sums['Badan'] += conf
                    elif cls.startswith('Kaki'):   yolo_conf_sums['Kaki'] += conf

        sum_yolo = sum(yolo_conf_sums.values())
        yolo_probs = (
            {cat: val / sum_yolo for cat, val in yolo_conf_sums.items()}
            if sum_yolo > 0
            else {'Kepala': 0.333, 'Badan': 0.333, 'Kaki': 0.333}
        )

        # ── 3. Soft-Voting Hybrid Ensemble (50% YOLO + 50% LSTM) ─────────────
        W_YOLO, W_LSTM = 0.50, 0.50
        hybrid_probs = {
            cat: (W_YOLO * yolo_probs[cat]) + (W_LSTM * lstm_probs[cat])
            for cat in ['Kepala', 'Badan', 'Kaki']
        }
        sum_hybrid = sum(hybrid_probs.values())
        hybrid_probs = (
            {cat: round(val / sum_hybrid, 4) for cat, val in hybrid_probs.items()}
            if sum_hybrid > 0
            else hybrid_probs
        )

        # Kategori & model pemenang versi Soft-Voting Hybrid Ensemble
        winning_category = max(hybrid_probs, key=lambda k: hybrid_probs[k])
        winning_score = hybrid_probs[winning_category]
        winning_model = 'Hybrid Ensemble (YOLOv8 + LSTM)'

        # Top details & best det
        best_yolo_det = max(detections, key=lambda d: d.get('confidence', 0)) if detections else None
        yolo_best_conf = best_yolo_det.get('confidence', 0.0) if best_yolo_det else 0.0
        lstm_max_score = raw_lstm.get(winning_category, 0.0)

        top_indices = np.argsort(predictions)[::-1][:3]
        top_details = [
            {
                'class': model.names.get(int(idx), f'Sub-class {idx}').replace('_', ' ').capitalize(),
                'confidence': float(predictions[idx])
            }
            for idx in top_indices
        ]

        return jsonify({
            'major_category': winning_category,
            'winning_model': winning_model,          # 'Hybrid Ensemble (YOLOv8 + LSTM)'
            'winning_category': winning_category,    # Kategori pemenang gabungan
            'winning_score': round(winning_score, 4),
            'yolo_best': best_yolo_det,              # deteksi YOLO terbaik
            'yolo_best_conf': round(yolo_best_conf, 4),
            'lstm_max_score': round(lstm_max_score, 4),
            'probabilities': hybrid_probs,           # Hasil Soft-Voting Hybrid
            'actual_distribution': yolo_probs,       # Distribusi YOLO
            'lstm_distribution': lstm_probs,         # Distribusi LSTM
            'top_details': top_details,
            'data_source': data_source,
            'frames_used': len(raw_sequence)
        })

    except Exception as e:
        import traceback
        traceback.print_exc()
        return jsonify({'error': str(e)}), 500

def process_video(filepath, filename, conf_threshold=0.70, progress_callback=None):
    """Proses video dengan YOLO detection. progress_callback(pct, msg) dipanggil tiap frame."""
    start_time = time.time()
    base_name = filename.rsplit('.', 1)[0]
    
    # Enforce minimum threshold 0.70 (70%)
    MIN_CONF = 0.70
    effective_conf = max(float(conf_threshold), MIN_CONF)
    
    # Create a unique temp folder for video processing
    save_dir = os.path.join(app.config['UPLOAD_FOLDER'], 'temp_' + base_name)
    if os.path.exists(save_dir):
        shutil.rmtree(save_dir)
    os.makedirs(save_dir, exist_ok=True)
    
    # Initialize video capture
    cap = cv2.VideoCapture(filepath)
    if not cap.isOpened():
        raise ValueError("Could not open video file.")

    fps = cap.get(cv2.CAP_PROP_FPS)
    if fps <= 0:
        fps = 25.0  # fallback fps
    width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
    height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    
    # Gunakan avc1 (H.264) langsung via AVFoundation — tidak perlu re-encode ffmpeg
    raw_filename = 'raw_' + base_name + '.mp4'
    raw_path = os.path.join(app.config['UPLOAD_FOLDER'], raw_filename)
    fourcc = cv2.VideoWriter.fourcc('a', 'v', 'c', '1')
    out = cv2.VideoWriter(raw_path, fourcc, fps, (width, height))
    
    # Unique detections (untuk UI hasil dan PDF report)
    detections = []
    FRAME_SAMPLE_STEP = 3      # simpan 1 dari tiap 3 frame
    frame_sequence = []        # list of list[dict]

    screenshot_saved = False
    screenshot_filename = f"result_{base_name}_screenshot.jpg"
    screenshot_path = os.path.join(app.config['UPLOAD_FOLDER'], screenshot_filename)
    frame_count = 0

    if progress_callback:
        progress_callback(5, f"Membuka video ({width}x{height}, {total_frames} frames)...")

    while cap.isOpened():
        ret, frame = cap.read()
        if not ret: break

        frame_count += 1
        # Predict hanya deteksi yang confidence >= 0.70 (70%)
        results = model.predict(frame, conf=effective_conf, verbose=False)
        frame_detections = []        # untuk UI/report
        frame_dets_for_lstm = []     # untuk LSTM
        all_boxes_this_frame = []

        for r in results:
            for box in r.boxes:
                conf = float(box.conf[0])
                if conf < MIN_CONF:
                    continue  # Strict 70% threshold filter

                cls_id   = int(box.cls[0])
                cls_name = model.names[cls_id]
                color_data = CLASS_COLORS.get(cls_id, {'bgr': (0, 255, 0), 'hex': '#00ff00'})
                color: tuple[int, int, int] = tuple(color_data['bgr'])  # type: ignore[assignment]
                hex_color = color_data['hex']
                b = box.xyxy[0].cpu().numpy().astype(int)
                bxywhn = box.xywhn[0].cpu().numpy()

                all_boxes_this_frame.append({
                    'cls_id': cls_id, 'cls_name': cls_name, 'conf': conf,
                    'color': color, 'hex_color': hex_color,
                    'b': b, 'bxywhn': bxywhn
                })

                frame_detections.append({'class': cls_name, 'confidence': conf, 'color': hex_color})
                existing_det = next((d for d in detections if d['class'] == cls_name), None)
                if existing_det is None:
                    detections.append({'class': cls_name, 'confidence': conf, 'color': hex_color})
                elif conf > existing_det['confidence']:
                    existing_det['confidence'] = conf

                frame_dets_for_lstm.append({
                    'class_id': cls_id,
                    'cx':  round(float(bxywhn[0]), 4),
                    'cy':  round(float(bxywhn[1]), 4),
                    'w':   round(float(bxywhn[2]), 4),
                    'h':   round(float(bxywhn[3]), 4),
                    'conf': round(conf, 4)
                })

        # ── Gambar 1 bbox terbaik per kelas per frame (semua sudah >= 70%) ──
        best_per_class: dict[int, dict] = {}
        for det_info in all_boxes_this_frame:
            cid = det_info['cls_id']
            if cid not in best_per_class or det_info['conf'] > best_per_class[cid]['conf']:
                best_per_class[cid] = det_info

        for det_info in best_per_class.values():
            b, color = det_info['b'], det_info['color']
            cls_name, conf = det_info['cls_name'], det_info['conf']
            cv2.rectangle(frame, (b[0], b[1]), (b[2], b[3]), color, 3)
            label = f"{cls_name} {int(conf * 100)}%"
            (lw, lh), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2)
            cv2.rectangle(frame, (b[0], b[1] - lh - 10), (b[0] + lw, b[1]), (0, 0, 0), -1)
            cv2.putText(frame, label, (b[0], b[1] - 10),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA)

        if not screenshot_saved and frame_detections:
            cv2.imwrite(screenshot_path, frame)
            screenshot_saved = True

        out.write(frame)

        # Simpan frame ke sequence LSTM setiap FRAME_SAMPLE_STEP frame
        # (menyimpan frame kosong juga penting — berarti tidak ada deteksi)
        if frame_count % FRAME_SAMPLE_STEP == 0:
            frame_sequence.append(frame_dets_for_lstm)

        # Progress update setiap 5 frame
        if progress_callback and total_frames > 0 and frame_count % 5 == 0:
            pct = 5 + int((frame_count / total_frames) * 80)
            progress_callback(pct, f"Menganalisa frame {frame_count}/{total_frames}...")

    if not screenshot_saved:
        cap.set(cv2.CAP_PROP_POS_FRAMES, cap.get(cv2.CAP_PROP_FRAME_COUNT) // 2)
        ret, frame = cap.read()
        if ret:
            cv2.imwrite(screenshot_path, frame)

    cap.release()
    out.release()

    if progress_callback:
        progress_callback(88, "Menyimpan output video H.264...")

    # Re-encode dengan ffmpeg (absolute path). Fallback: pakai avc1 langsung.
    filename_out = 'result_' + base_name + '.mp4'
    output_path  = os.path.abspath(os.path.join(app.config['UPLOAD_FOLDER'], filename_out))
    raw_path_abs = os.path.abspath(raw_path)

    try:
        cmd = ['ffmpeg', '-y', '-i', raw_path_abs,
               '-vcodec', 'libx264', '-pix_fmt', 'yuv420p',
               '-profile:v', 'baseline', '-level', '3.0', output_path]
        subprocess.run(cmd, check=True, capture_output=True, text=True)
        print(f"ffmpeg re-encode berhasil: {output_path}")
    except Exception as ffmpeg_err:
        print(f"ffmpeg gagal ({ffmpeg_err}), menggunakan output avc1 / original langsung")
        import shutil as _shutil
        if os.path.exists(raw_path_abs):
            _shutil.copy2(raw_path_abs, output_path)
        elif os.path.exists(filepath):
            _shutil.copy2(filepath, output_path)

    # Ultimate safety check: pastikan output_path benar-benar ada di disk (mencegah 404)
    if not os.path.exists(output_path) and os.path.exists(filepath):
        import shutil as _shutil
        _shutil.copy2(filepath, output_path)

    if os.path.exists(raw_path):
        os.remove(raw_path)

    if progress_callback:
        progress_callback(95, "Menyimpan hasil ke database...")

    inference_time = round(time.time() - start_time, 2)
    print(f"process_video selesai: {frame_count} frames, {len(frame_sequence)} sampled, {len(detections)} unique classes")
    return filename_out, screenshot_filename, detections, inference_time, frame_sequence


def _run_video_job(job_id, filepath, filename, title, user_id, conf_threshold):
    """Background thread function untuk memproses video."""
    def update_job(progress, message, status='processing', result=None, error=None):
        with processing_jobs_lock:
            processing_jobs[job_id].update({
                'status': status,
                'progress': progress,
                'message': message,
                'result': result,
                'error': error
            })

    try:
        update_job(3, 'Memulai analisa video...')

        def progress_callback(pct, msg):
            update_job(pct, msg)

        result_filename, screenshot_filename, detections, inference_time, frame_sequence = process_video(
            filepath, filename, conf_threshold, progress_callback=progress_callback
        )

        update_job(96, 'Menyimpan ke database...')

        # Simpan ke database (gunakan app context)
        with app.app_context():
            analysis = Analysis(
                user_id=user_id,
                project_title=title,
                original_filename=filename,
                result_file=result_filename,
                result_screenshot=screenshot_filename,
                file_type='video',
                detections_json=json.dumps(detections),
                frame_sequence_json=json.dumps(frame_sequence),  # sequence per-frame nyata
                inference_time=inference_time
            )
            db.session.add(analysis)
            db.session.commit()
            analysis_id = analysis.id

        result_data = {
            'original_file': filename,
            'result_file': result_filename,
            'result_screenshot': screenshot_filename,
            'file_type': 'video',
            'detections': detections,
            'inference_time': inference_time,
            'analysis_id': analysis_id
        }
        update_job(100, 'Selesai!', status='done', result=result_data)

    except Exception as e:
        import traceback
        traceback.print_exc()
        update_job(0, f'Error: {str(e)}', status='error', error=str(e))


@app.route('/progress/<job_id>')
@login_required
def job_progress(job_id):
    """SSE endpoint — stream progress updates untuk job video processing."""
    def generate():
        sent_done = False
        while not sent_done:
            with processing_jobs_lock:
                job = processing_jobs.get(job_id)
            
            if not job:
                yield f"data: {json.dumps({'status': 'error', 'error': 'Job not found', 'progress': 0})}\n\n"
                break
            
            # Kirim snapshot state saat ini
            payload = {
                'status': job.get('status', 'queued'),
                'progress': job.get('progress', 0),
                'message': job.get('message', ''),
                'result': job.get('result'),
                'error': job.get('error')
            }
            yield f"data: {json.dumps(payload)}\n\n"
            
            if job.get('status') in ('done', 'error'):
                sent_done = True
                # Bersihkan job dari memory setelah dikirim
                with processing_jobs_lock:
                    processing_jobs.pop(job_id, None)
                break
            
            time.sleep(0.4)  # Poll setiap 400ms
    
    return Response(
        generate(),
        mimetype='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'X-Accel-Buffering': 'no',  # Disable nginx buffering
            'Connection': 'keep-alive'
        }
    )

def process_image(filepath, filename, conf_threshold=0.70):
    start_time = time.time()
    base_name = filename.rsplit('.', 1)[0]
    result_filename = 'result_' + base_name + '.jpg'
    result_path = os.path.join(app.config['UPLOAD_FOLDER'], result_filename)
    
    MIN_CONF = 0.70
    effective_conf = max(float(conf_threshold), MIN_CONF)
    
    img = cv2.imread(filepath)
    if img is None:
        print(f"DEBUG process_image: Failed to read image {filepath}")
        if os.path.exists(filepath):
            os.remove(filepath)
        raise ValueError("Invalid image file format or corrupted file.")
        
    results = model(filepath, conf=effective_conf, verbose=False)
    detections = []
    
    for r in results:
        for box in r.boxes:
            conf = float(box.conf[0])
            if conf < MIN_CONF:
                continue  # Filter out detections below 70%

            cls_id = int(box.cls[0])
            cls_name = model.names[cls_id]
            color_data = CLASS_COLORS.get(cls_id, {'bgr': (0, 255, 0), 'hex': '#00ff00'})
            color: tuple[int, int, int] = tuple(color_data['bgr'])  # type: ignore[assignment]
            hex_color = color_data['hex']
            
            # Draw box
            b = box.xyxy[0].cpu().numpy().astype(int)
            cv2.rectangle(img, (b[0], b[1]), (b[2], b[3]), color, 3)
            
            # Label
            label = f"{cls_name} {int(conf * 100)}%"
            (w, h), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2)
            cv2.rectangle(img, (b[0], b[1] - h - 10), (b[0] + w, b[1]), (0, 0, 0), -1)
            cv2.putText(img, label, (b[0], b[1] - 10),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA)
            
            detections.append({
                'class': cls_name,
                'confidence': conf,
                'color': hex_color
            })
            
    cv2.imwrite(result_path, img)
    return result_filename, result_filename, detections, round(time.time() - start_time, 2)

@app.route('/upload', methods=['POST'])
@login_required
def upload_file():
    user = get_current_user()
    if user is None:
        return jsonify({'error': 'Not authenticated'}), 401
    if 'file' not in request.files:
        return jsonify({'error': 'No file part'}), 400
    file = request.files['file']
    if file.filename == '':
        return jsonify({'error': 'No selected file'}), 400
    if file and file.filename and allowed_file(file.filename):
        filename = secure_filename(file.filename)
        filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
        file.save(filepath)
        
        file_ext = filename.rsplit('.', 1)[1].lower()
        is_video = file_ext in {'mp4', 'avi', 'mov'}
        title = request.form.get('title') or filename
        conf_threshold = user.conf_threshold

        if is_video:
            # --- Non-blocking: jalankan di background thread ---
            job_id = str(uuid.uuid4())
            with processing_jobs_lock:
                processing_jobs[job_id] = {
                    'status': 'queued', 'progress': 0,
                    'message': 'Menunggu antrian...', 'result': None, 'error': None
                }
            thread = threading.Thread(
                target=_run_video_job,
                args=(job_id, filepath, filename, title, user.id, conf_threshold),
                daemon=True
            )
            thread.start()
            return jsonify({'job_id': job_id, 'file_type': 'video'})
        else:
            # Gambar tetap synchronous (cepat)
            result_filename, screenshot_filename, detections, inference_time = process_image(filepath, filename, conf_threshold)
            
            analysis = Analysis(
                user_id=user.id,
                project_title=title,
                original_filename=filename,
                result_file=result_filename,
                result_screenshot=screenshot_filename,
                file_type='image',
                detections_json=json.dumps(detections),
                inference_time=inference_time
            )
            db.session.add(analysis)
            db.session.commit()
            
            return jsonify({
                'original_file': filename, 'result_file': result_filename, 'result_screenshot': screenshot_filename,
                'file_type': 'image', 'detections': detections, 'inference_time': inference_time
            })
    return jsonify({'error': 'File type not allowed'}), 400

def resolve_direct_url(url):
    """
    Transforms cloud storage sharing links (Google Drive, Dropbox) into direct download links.
    """
    # Google Drive transformation
    if 'drive.google.com' in url:
        # Match pattern like /file/d/[ID]/view or ?id=[ID]
        gd_match = re.search(r'/file/d/([a-zA-Z0-9_-]+)', url) or re.search(r'id=([a-zA-Z0-9_-]+)', url)
        if gd_match:
            file_id = gd_match.group(1)
            return f"https://drive.google.com/uc?export=download&id={file_id}"
    
    # Dropbox transformation
    if 'dropbox.com' in url:
        # Change dl=0 to dl=1 for direct download
        if 'dl=0' in url:
            return url.replace('dl=0', 'dl=1')
        elif 'dl=1' not in url:
            # Append if no dl param
            separator = '&' if '?' in url else '?'
            return f"{url}{separator}dl=1"
            
    return url

@app.route('/upload_url', methods=['POST'])
@login_required
def upload_url():
    user = get_current_user()
    if user is None:
        return jsonify({'error': 'Not authenticated'}), 401
    
    # Try different ways of getting the data based on Content-Type
    if request.is_json:
        json_data = request.json or {}
        url = json_data.get('url')
        title = json_data.get('title')
    else:
        url = request.form.get('url')
        title = request.form.get('title')
        
    print(f"DEBUG /upload_url: Received URL={url}, Title={title}, Content-Type={request.content_type}")
    
    if not url:
        return jsonify({'error': 'No URL provided'}), 400
    
    # Transform URL for cloud storage
    direct_url = resolve_direct_url(url)
    
    try:
        # User a proper User-Agent to avoid being blocked by some servers
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
        }
        
        print(f"DEBUG /upload_url: Requesting URL {direct_url}")
        with open("/tmp/url_upload_debug.log", "a") as logf:
            logf.write(f"\\n--- URL Upload Attempt at {datetime.now()} ---\\n")
            logf.write(f"URL: {direct_url}\\n")
            
        # Increase timeout for large files and stream the response
        try:
            response = requests.get(direct_url, stream=True, timeout=30, headers=headers)
            with open("/tmp/url_upload_debug.log", "a") as logf:
                logf.write(f"Response Status: {response.status_code}\\n")
        except Exception as req_e:
            with open("/tmp/url_upload_debug.log", "a") as logf:
                logf.write(f"Request Exception: {str(req_e)}\\n")
            raise req_e

        if response.status_code != 200:
            return jsonify({'error': f'Could not download file. Server returned status: {response.status_code}'}), 400
        
        # 1. Try to get filename from Content-Disposition header
        filename = None
        cd = response.headers.get('content-disposition')
        if cd:
            filenames = re.findall('filename=(.+)', cd)
            if filenames:
                filename = filenames[0].strip('" ')
        
        # 2. Fallback to extracting from URL
        if not filename:
            filename = url.split('/')[-1].split('?')[0]
            
        # 3. Clean filename and add prefix
        if not filename or '.' not in filename:
            content_type = response.headers.get('content-type', '')
            if 'video' in content_type: ext = '.mp4'
            elif 'image' in content_type: ext = '.jpg'
            else: ext = '.bin'
            filename = f"download_{int(time.time())}{ext}"
        
        filename = secure_filename(f"url_{int(time.time())}_{filename}")
        filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
        
        # Chunked download to handle large files
        with open(filepath, 'wb') as f:
            for chunk in response.iter_content(chunk_size=1024*1024): # 1MB chunks
                if chunk:
                    f.write(chunk)
                    
        if not allowed_file(filename):
            print(f"DEBUG /upload_url: File not allowed: {filename}")
            if os.path.exists(filepath): os.remove(filepath)
            return jsonify({'error': 'Content type not allowed (must be JPG, PNG, or MP4)'}), 400
            
        file_ext = filename.rsplit('.', 1)[1].lower()
        is_video = file_ext in {'mp4', 'avi', 'mov'}
        final_title = title or filename
        
        print(f"DEBUG /upload_url: Saved file {filepath}, video={is_video}")
        
        conf_threshold = user.conf_threshold
        if is_video:
            print(f"DEBUG /upload_url: Processing video {filename} in background")
            # --- Non-blocking: jalankan di background thread ---
            job_id = str(uuid.uuid4())
            with processing_jobs_lock:
                processing_jobs[job_id] = {
                    'status': 'queued', 'progress': 0,
                    'message': 'Mengunduh video selesai, memulai analisa...', 'result': None, 'error': None
                }
            thread = threading.Thread(
                target=_run_video_job,
                args=(job_id, filepath, filename, final_title, user.id, conf_threshold),
                daemon=True
            )
            thread.start()
            return jsonify({'job_id': job_id, 'file_type': 'video'})
        else:
            result_filename, screenshot_filename, detections, inference_time = process_image(filepath, filename, conf_threshold)
            
            analysis = Analysis(
                user_id=user.id,
                project_title=final_title,
                original_filename=filename,
                result_file=result_filename,
                result_screenshot=screenshot_filename,
                file_type='image',
                detections_json=json.dumps(detections),
                inference_time=inference_time
            )
            db.session.add(analysis)
            db.session.commit()
            
            return jsonify({
                'original_file': filename, 'result_file': result_filename, 'result_screenshot': screenshot_filename,
                'file_type': 'image', 'detections': detections, 'inference_time': inference_time
            })
            
    except Exception as e:
        import traceback
        traceback.print_exc()
        print(f"DEBUG /upload_url: Exception occurred - {str(e)}")
        return jsonify({'error': f'Failed to process URL: {str(e)}'}), 500

@app.route('/download_report', methods=['POST'])
@login_required
def download_report():
    data = request.json
    if not data:
        return jsonify({'error': 'No data provided'}), 400
    
    try:
        pdf = FPDF()
        pdf.add_page()
        
        # Header
        pdf.set_font("Helvetica", "B", 24)
        pdf.set_text_color(19, 66, 236) # Primary color
        pdf.cell(0, 20, "Keraton AI - Architectural Analysis", ln=True, align="C")
        
        pdf.set_font("Helvetica", "B", 12)
        pdf.set_text_color(100, 116, 139) # slate-500
        pdf.cell(0, 10, f"Analysis Report: {data.get('original_file', 'Unknown')}", ln=True, align="C")
        pdf.ln(10)
        
        # Image
        result_file = data.get('result_screenshot') or data.get('result_file')
        if result_file:
            img_path = os.path.join(app.config['UPLOAD_FOLDER'], result_file)
            is_image = result_file.lower().endswith(('.png', '.jpg', '.jpeg'))
            if os.path.exists(img_path) and is_image:
                try:
                    # Fit image to page dimensions while keeping aspect ratio
                    img = cv2.imread(img_path)
                    if img is not None:
                        h_px, w_px, _ = img.shape
                        aspect = h_px / w_px
                        max_w = 190
                        max_h = pdf.h - pdf.get_y() - 15  # 15mm bottom margin
                        
                        w_fit = max_w
                        h_fit = w_fit * aspect
                        
                        if h_fit > max_h:
                            h_fit = max_h
                            w_fit = h_fit / aspect
                            
                        pdf.image(img_path, x=10, w=w_fit, h=h_fit)
                        pdf.ln(5)
                except Exception as img_err:
                    print(f"DEBUG: pdf.image error: {img_err}")
        
        pdf.add_page()
        # Details Table
        pdf.set_font("Helvetica", "B", 14)
        pdf.set_text_color(30, 41, 59) # slate-800
        pdf.cell(0, 15, "Structural Detections", ln=True)
        
        # Table Header
        pdf.set_font("Helvetica", "B", 10)
        pdf.set_fill_color(241, 245, 249) # slate-100
        pdf.cell(100, 10, "Structural Element", 1, 0, "C", True)
        pdf.cell(90, 10, "Confidence Level (%)", 1, 1, "C", True)
        
        # Table Rows
        pdf.set_font("Helvetica", "", 10)
        detections = data.get('detections', [])
        if not detections:
            pdf.cell(190, 10, "No structural elements detected.", 1, 1, "C")
        else:
            for det in detections:
                cls_name = det.get('class', 'Unknown').replace('_', ' ').capitalize()
                conf = f"{float(det.get('confidence', 0)) * 100:.1f}%"
                pdf.cell(100, 10, cls_name, 1, 0, "L")
                pdf.cell(90, 10, conf, 1, 1, "C")
        
        # Footer
        pdf.ln(10)
        pdf.set_font("Helvetica", "I", 8)
        pdf.set_text_color(148, 163, 184) # slate-400
        pdf.multi_cell(0, 5, "This report was automatically generated by Keraton AI Computer Vision Engine v1.0. Analysis is based on modern Neural Vision processing of traditional architectural markers.", align="C")
        
        temp_path = os.path.join(app.config['UPLOAD_FOLDER'], f"temp_report_{int(time.time())}.pdf")
        pdf.output(temp_path)
        with open(temp_path, 'rb') as f:
            pdf_bytes = f.read()
        try: os.remove(temp_path)
        except: pass
        
        return send_file(
            BytesIO(pdf_bytes),
            mimetype='application/pdf',
            as_attachment=True,
            download_name=f"Keraton_Report_{int(time.time())}.pdf"
        )
    except Exception as e:
        import traceback
        traceback.print_exc()
        return jsonify({'error': str(e)}), 500

@app.route('/download_comparison_report', methods=['POST'])
@login_required
def download_comparison_report():
    data = request.json
    if not data or 'source_a' not in data or 'source_b' not in data:
        return jsonify({'error': 'Missing source data for comparison report'}), 400
    
    try:
        pdf = FPDF()
        
        # Helper to add a source section
        def add_source_section(source_data, source_label):
            pdf.add_page()
            # Section Header
            pdf.set_font("Helvetica", "B", 20)
            pdf.set_text_color(19, 66, 236) if source_label == "Source A" else pdf.set_text_color(16, 185, 129)
            pdf.cell(0, 15, f"Keraton AI - {source_label} Analysis", ln=True, align="C")
            
            pdf.set_font("Helvetica", "B", 12)
            pdf.set_text_color(100, 116, 139) # slate-500
            pdf.cell(0, 10, f"Original File: {source_data.get('original_file', 'Unknown')}", ln=True, align="C")
            pdf.ln(5)
            
            # Evidence Image
            result_file = source_data.get('result_screenshot') or source_data.get('result_file')
            if result_file:
                img_path = os.path.join(app.config['UPLOAD_FOLDER'], result_file)
                is_image = result_file.lower().endswith(('.png', '.jpg', '.jpeg'))
                if os.path.exists(img_path) and is_image:
                    try:
                        # Fit image to page dimensions while keeping aspect ratio
                        img = cv2.imread(img_path)
                        if img is not None:
                            h_px, w_px, _ = img.shape
                            aspect = h_px / w_px
                            max_w = 190
                            max_h = pdf.h - pdf.get_y() - 15  # 15mm bottom margin
                            
                            w_fit = max_w
                            h_fit = w_fit * aspect
                            
                            if h_fit > max_h:
                                h_fit = max_h
                                w_fit = h_fit / aspect
                                
                            pdf.image(img_path, x=10, w=w_fit, h=h_fit)
                            pdf.ln(5)
                    except Exception as img_err:
                        print(f"DEBUG: Joint Report image error: {img_err}")
            
            pdf.add_page()
            # Detections Table
            pdf.set_font("Helvetica", "B", 14)
            pdf.set_text_color(30, 41, 59) # slate-800
            pdf.cell(0, 12, f"Detected Structural Elements ({source_label})", ln=True)
            
            pdf.set_font("Helvetica", "B", 10)
            pdf.set_fill_color(241, 245, 249) # slate-100
            pdf.cell(100, 10, "Component", 1, 0, "C", True)
            pdf.cell(90, 10, "Confidence (%)", 1, 1, "C", True)
            
            pdf.set_font("Helvetica", "", 10)
            detections = source_data.get('detections', [])
            if not detections:
                pdf.cell(190, 10, "No elements detected.", 1, 1, "C")
            else:
                # Use a dict to show unique best detections if many frames overlaps
                # Actually for video we usually pass a summarized list from the frontend
                for det in detections[:20]: # Limit to top 20 for PDF space
                    cls_name = det.get('class', 'Unknown').replace('_', ' ').capitalize()
                    conf = f"{float(det.get('confidence', 0)) * 100:.1f}%"
                    pdf.cell(100, 10, cls_name, 1, 0, "L")
                    pdf.cell(90, 10, conf, 1, 1, "C")
            
            # Section Footer
            pdf.ln(10)
            pdf.set_font("Helvetica", "I", 8)
            pdf.set_text_color(148, 163, 184) # slate-400
            pdf.multi_cell(0, 5, f"Comparison analysis generated by Keraton AI. Source variance insight provided in comparative summary.", align="C")

        add_source_section(data['source_a'], "Source A")
        add_source_section(data['source_b'], "Source B")
        
        temp_path = os.path.join(app.config['UPLOAD_FOLDER'], f"temp_comp_report_{int(time.time())}.pdf")
        pdf.output(temp_path)
        with open(temp_path, 'rb') as f:
            pdf_bytes = f.read()
        try: os.remove(temp_path)
        except: pass
        
        return send_file(
            BytesIO(pdf_bytes),
            mimetype='application/pdf',
            as_attachment=True,
            download_name=f"Keraton_Comparison_Report_{int(time.time())}.pdf"
        )
    except Exception as e:
        import traceback
        traceback.print_exc()
        return jsonify({'error': str(e)}), 500

@app.route('/download_classification_report', methods=['POST'])
@login_required
def download_classification_report():
    data = request.json
    if not data or 'probabilities' not in data:
        return jsonify({'error': 'Missing classification data'}), 400
    
    try:
        pdf = FPDF()
        pdf.add_page()
        
        # Header
        pdf.set_font("Helvetica", "B", 24)
        pdf.set_text_color(19, 66, 236) # Primary blue
        pdf.cell(0, 20, "Keraton AI - Structural Characterization", ln=True, align="C")
        
        pdf.set_font("Helvetica", "B", 12)
        pdf.set_text_color(100, 116, 139) # slate-500
        pdf.cell(0, 10, f"Neural Analysis Report: {data.get('project_title', 'Unknown')}", ln=True, align="C")
        pdf.ln(5)
        
        # Evidence Image
        result_file = data.get('result_file')
        if result_file:
            img_path = os.path.join(app.config['UPLOAD_FOLDER'], result_file)
            if os.path.exists(img_path):
                pdf.image(img_path, x=70, w=70) # Smaller centered image
                pdf.ln(5)
        
        # Major Category Card
        pdf.set_fill_color(241, 245, 249)
        pdf.set_font("Helvetica", "B", 16)
        pdf.set_text_color(30, 41, 59)
        pdf.cell(0, 15, f"Dominant Category: {data.get('major_category', '---')}", 1, 1, "C", True)
        pdf.ln(10)
        
        # Two Columns for Distributions
        start_y = pdf.get_y()
        
        # Left: Neural Predictions
        pdf.set_font("Helvetica", "B", 12)
        pdf.cell(95, 10, "Neural Prediction (LSTM)", ln=0)
        # Right: Actual Breakdown
        pdf.cell(95, 10, "Analysis Breakdown (Counts)", ln=1)
        
        pdf.set_font("Helvetica", "", 10)
        
        # Neural Probabilities
        probs = data.get('probabilities', {})
        actuals = data.get('actual_distribution', {})
        
        y_pos = pdf.get_y()
        for cat in ['Kepala', 'Badan', 'Kaki']:
            # Neural column
            pdf.set_xy(10, y_pos)
            p_val = probs.get(cat, 0)
            pdf.cell(45, 8, f"{cat}:", 0)
            pdf.set_font("Helvetica", "B", 10)
            pdf.cell(50, 8, f"{p_val*100:.1f}%", 0)
            pdf.set_font("Helvetica", "", 10)
            
            # Actual column
            pdf.set_xy(105, y_pos)
            a_val = actuals.get(cat, 0)
            pdf.cell(45, 8, f"{cat}:", 0)
            pdf.set_font("Helvetica", "B", 10)
            pdf.cell(50, 8, f"{a_val*100:.1f}%", 0)
            pdf.set_font("Helvetica", "", 10)
            
            y_pos += 8
            
        pdf.set_y(y_pos + 10)
        
        pdf.add_page()
        # Details Table
        pdf.set_font("Helvetica", "B", 14)
        pdf.set_text_color(30, 41, 59)
        pdf.cell(0, 12, "Key Structural Activations", ln=True)
        
        pdf.set_font("Helvetica", "B", 10)
        pdf.set_fill_color(241, 245, 249)
        pdf.cell(100, 10, "Structural Component", 1, 0, "C", True)
        pdf.cell(90, 10, "Activation Strength (%)", 1, 1, "C", True)
        
        pdf.set_font("Helvetica", "", 10)
        top_details = data.get('top_details', [])
        if not top_details:
            pdf.cell(190, 10, "No specific activations recorded.", 1, 1, "C")
        else:
            for det in top_details:
                pdf.cell(100, 10, det.get('class', 'Unknown'), 1, 0, "L")
                pdf.cell(90, 10, f"{float(det.get('confidence', 0))*100:.1f}%", 1, 1, "C")
        
        # Footer
        pdf.ln(10)
        pdf.set_font("Helvetica", "I", 8)
        pdf.set_text_color(148, 163, 184)
        pdf.multi_cell(0, 5, "Structural characterization report generated by Keraton AI Neural Analytics engine. Confidence scores represent semantic correlation with historical architectural datasets.", align="C")
        
        temp_path = os.path.join(app.config['UPLOAD_FOLDER'], f"temp_class_report_{int(time.time())}.pdf")
        pdf.output(temp_path)
        with open(temp_path, 'rb') as f:
            pdf_bytes = f.read()
        try: os.remove(temp_path)
        except: pass
        
        return send_file(
            BytesIO(pdf_bytes),
            mimetype='application/pdf',
            as_attachment=True,
            download_name=f"Classification_Report_{int(time.time())}.pdf"
        )
        
    except Exception as e:
        import traceback
        traceback.print_exc()
        return jsonify({'error': str(e)}), 500

@app.route('/uploads/<filename>')
def uploaded_file(filename):
    return send_from_directory(app.config['UPLOAD_FOLDER'], filename)

if __name__ == '__main__':
    if not os.path.exists(app.config['UPLOAD_FOLDER']):
        os.makedirs(app.config['UPLOAD_FOLDER'])
    
    with app.app_context():
        db.create_all()
        # Seed default user if not exists
        if not User.query.filter_by(email="admin@keraton.ai").first():
            admin = User(
                email="admin@keraton.ai", 
                password="admin123", # For demo purposes, plaintext is used as requested
                user_name="Admin Keraton",
                user_role="Head of Preservation",
                user_dept="Digital Heritage"
            )
            db.session.add(admin)
            db.session.commit()
            print("Default admin user created.")

    app.run(debug=True, port=5001, threaded=True, use_reloader=False)
