# attendance_engine.py
from __future__ import annotations

import json
from datetime import datetime, date, time, timedelta
from typing import Optional, Dict, Any, List

from flask import current_app
from sqlalchemy import func

from extensions import db
from models import (
    Employee,
    Attendance,
    Holiday,
    LeaveRequest,
    DesktimeLog,
    BiometricPunch,
    SystemSetting,
    LeaveType,
)

# ---------------------------------------------------------------------
# CONFIG LOADING
# ---------------------------------------------------------------------

_DEFAULT_CONFIG = {
    "default": {
        # source codes in priority order
        "order": ["manual", "biometric", "self", "desktime_hours", "desktime_productive"],
        "min_hours_present": 8.0,
        "min_hours_half_day": 4.0,
    }
}


def _load_attendance_priority_config() -> Dict[str, Any]:
    """
    Load JSON from SystemSetting.key == 'attendance_source_priority'.
    Expected shape:
      {
        "default": {"order":[...], "min_hours_present":8, "min_hours_half_day":4},
        "WFO": {...},
        "WFH": {...},
        "Hybrid": {...}
      }
    """
    try:
        setting = SystemSetting.query.filter_by(key="attendance_source_priority").first()
    except Exception:
        current_app.logger.exception("Could not query SystemSetting for attendance_source_priority")
        return _DEFAULT_CONFIG

    if not setting or not getattr(setting, "json_value", None):
        return _DEFAULT_CONFIG

    try:
        cfg = json.loads(setting.json_value)
        if not isinstance(cfg, dict):
            raise ValueError("attendance_source_priority JSON is not a dict")
        if "default" not in cfg or not isinstance(cfg.get("default"), dict):
            return _DEFAULT_CONFIG
        return cfg
    except Exception:
        current_app.logger.exception("Invalid JSON in attendance_source_priority; using default.")
        return _DEFAULT_CONFIG


def _resolve_mode_config(emp: Employee, cfg: Dict[str, Any]) -> Dict[str, Any]:
    """
    Pick config block based on employee.work_mode if present, else 'default'.
    """
    work_mode = getattr(emp, "work_mode", None)
    if work_mode:
        w = str(work_mode).strip()
        if w in cfg:
            return cfg[w]
        wl = w.lower()
        for k in cfg.keys():
            if str(k).lower() == wl:
                return cfg[k]
    return cfg.get("default", _DEFAULT_CONFIG["default"])


# ---------------------------------------------------------------------
# HELPERS
# ---------------------------------------------------------------------

def _range_for_day(target_date: date):
    start_dt = datetime.combine(target_date, time.min)
    end_dt = datetime.combine(target_date + timedelta(days=1), time.min)
    return start_dt, end_dt


def _to_time(v: Any) -> Optional[time]:
    if not v:
        return None
    if isinstance(v, datetime):
        return v.time()
    if isinstance(v, time):
        return v
    return None


def _parse_ts(value: Any) -> Optional[datetime]:
    if not value:
        return None
    # Desktime commonly uses "YYYY-MM-DD HH:MM:SS"
    try:
        return datetime.strptime(str(value), "%Y-%m-%d %H:%M:%S")
    except Exception:
        return None


def _norm(col):
    """lower(trim(coalesce(col,''))) for SQL comparisons."""
    return func.lower(func.trim(func.coalesce(col, "")))


def _is_usable_candidate(c: Optional[Dict[str, Any]]) -> bool:
    if not c:
        return False
    try:
        hrs = float(c.get("hours") or 0.0)
    except Exception:
        hrs = 0.0
    complete = bool(c.get("complete", True))
    return complete and hrs > 0

_UNPAID_LEAVE_CODES = {"LWP", "LOP", "UL"}


def _apply_hours_based_status(att: Attendance, cfg: dict, target_date: date) -> None:
    """
    Force status/remarks based on hours WHEN check_in & check_out exist.
    Uses target_date because Attendance stores time-only fields.
    """
    # Do not override special days
    if (att.remarks or "").strip().lower() in ("holiday", "weekend"):
        return

        # If leave is already applied (paid/unpaid), never override by hours
    if (att.source or "").strip().lower() == "leave":
        return
    if (att.status or "").strip().lower() in ("paid_leave", "lop", "lwp"):
        return
    if "leave" in ((att.remarks or "").strip().lower()):
        return
    
    if not att.check_in_time or not att.check_out_time:
        return

    dt_in = datetime.combine(target_date, att.check_in_time)
    dt_out = datetime.combine(target_date, att.check_out_time)

    if dt_out <= dt_in:
        return

    hours = (dt_out - dt_in).total_seconds() / 3600.0
    att.working_hours = round(hours, 2)

    min_p = float(cfg.get("min_hours_present", 8.0))
    min_h = float(cfg.get("min_hours_half_day", 4.0))

    if hours >= min_p:
        att.status = "present"
        att.remarks = "Present"
    elif hours >= min_h:
        att.status = "half_day"
        att.remarks = "Half Day"
    else:
        att.status = "lwp"
        att.remarks = "LWP"


# ---------------------------------------------------------------------
# SOURCE CANDIDATES
# ---------------------------------------------------------------------

def _candidate_from_biometric(emp: Employee, target_date: date) -> Optional[Dict[str, Any]]:
    """
    Biometric punches from devices/API (exclude self-punch WEB).
    Uses device_code != 'WEB'.
    Direction match is CASE-INSENSITIVE.
    """
    start_dt, end_dt = _range_for_day(target_date)

    q = (
        BiometricPunch.query
        .filter(
            BiometricPunch.employee_id == emp.id,
            BiometricPunch.punch_time >= start_dt,
            BiometricPunch.punch_time < end_dt,
        )
        .filter(_norm(BiometricPunch.device_code) != "web")  # exclude self punches
    )

    first_in = (
        q.filter(_norm(BiometricPunch.direction) == "in")
        .order_by(BiometricPunch.punch_time.asc())
        .first()
    )
    last_out = (
        q.filter(_norm(BiometricPunch.direction) == "out")
        .order_by(BiometricPunch.punch_time.desc())
        .first()
    )

    # Fallback if direction not stored properly: use earliest & latest punch
    if not first_in and not last_out:
        punches = q.order_by(BiometricPunch.punch_time.asc()).all()
        if not punches:
            return None
        check_in = punches[0].punch_time
        check_out = punches[-1].punch_time if len(punches) > 1 else None
    else:
        check_in = first_in.punch_time if first_in else None
        check_out = last_out.punch_time if last_out else None

    if not check_in and not check_out:
        return None

    complete = bool(check_in and check_out and check_out > check_in)
    hours = (check_out - check_in).total_seconds() / 3600.0 if complete else 0.0

    return {
        "source_label": "Biometric",
        "check_in": check_in,
        "check_out": check_out,
        "hours": hours,
        "complete": complete,
    }


def _candidate_from_self_punch(emp: Employee, target_date: date) -> Optional[Dict[str, Any]]:
    """
    Self punch from WEB - COUNT ONLY APPROVED.
    Picks FIRST IN + LAST OUT. Returns what exists (no auto-fill).
    """
    start_dt, end_dt = _range_for_day(target_date)

    punches = (
        BiometricPunch.query
        .filter(
            BiometricPunch.employee_id == emp.id,
            BiometricPunch.punch_time >= start_dt,
            BiometricPunch.punch_time < end_dt,
            _norm(BiometricPunch.device_code) == "web",
            _norm(BiometricPunch.source_name) == "self",
            _norm(BiometricPunch.approval_status) == "approved",
        )
        .order_by(BiometricPunch.punch_time.asc())
        .all()
    )

    if not punches:
        return None

    first_in = next((p for p in punches if (p.direction or "").strip().lower() == "in"), None)
    last_out = next((p for p in reversed(punches) if (p.direction or "").strip().lower() == "out"), None)

    check_in = first_in.punch_time if first_in else None
    check_out = last_out.punch_time if last_out else None

    if not check_in and not check_out:
        return None

    complete = bool(check_in and check_out and check_out > check_in)
    hours = (check_out - check_in).total_seconds() / 3600.0 if complete else 0.0

    return {
        "source_label": "Self Punch (approved)",
        "check_in": check_in,
        "check_out": check_out,
        "hours": hours,
        "complete": complete,
    }


def _get_desktime_log(emp: Employee, target_date: date) -> Optional[Dict[str, Any]]:
    log = DesktimeLog.query.filter_by(employee_id=emp.id, date=target_date).first()
    if not log or not log.raw_data:
        return None
    try:
        return json.loads(log.raw_data)
    except Exception:
        current_app.logger.exception(
            "Failed to parse DesktimeLog.raw_data for emp=%s date=%s", emp.id, target_date
        )
        return None


def _candidate_from_desktime_hours(emp: Employee, target_date: date) -> Optional[Dict[str, Any]]:
    """
    Based on arrived/left span from Desktime.
    """
    data = _get_desktime_log(emp, target_date)
    if not data:
        return None

    arrived = _parse_ts(data.get("arrived"))
    left = _parse_ts(data.get("left"))

    if not arrived or not left or left <= arrived:
        return None

    hours = (left - arrived).total_seconds() / 3600.0
    if hours <= 0:
        return None

    return {
        "source_label": "Desktime (span)",
        "check_in": arrived,
        "check_out": left,
        "hours": hours,
        "complete": True,
    }


def _candidate_from_desktime_productive(emp: Employee, target_date: date) -> Optional[Dict[str, Any]]:
    """
    Based on productiveTime seconds from Desktime.
    """
    data = _get_desktime_log(emp, target_date)
    if not data:
        return None

    prod_sec = data.get("productiveTime") or 0
    try:
        prod_sec = float(prod_sec)
    except Exception:
        prod_sec = 0

    if prod_sec <= 0:
        return None

    hours = prod_sec / 3600.0
    arrived = _parse_ts(data.get("arrived"))
    left = _parse_ts(data.get("left"))

    return {
        "source_label": "Desktime (productive)",
        "check_in": arrived,
        "check_out": left,
        "hours": hours,
        "complete": True,
    }


# ---------------------------------------------------------------------
# MAIN ENGINE
# ---------------------------------------------------------------------

def recalc_attendance_for_day(emp: Employee, target_date: date) -> Attendance:
    """
    Choose best source for a given employee+date based on config.
    RULES:
      - If Manual: do not override.
      - If Bulk: keep Bulk marker, but ALWAYS correct status/remarks based on hours if IN/OUT exist.
      - Else: pick first usable candidate in order.
      - Holiday/Leave overrides remarks.
      - Present/Half Day/LWP based on hours thresholds.
    """
    cfg_all = _load_attendance_priority_config()
    mode_cfg = _resolve_mode_config(emp, cfg_all)

    order: List[str] = mode_cfg.get("order") or _DEFAULT_CONFIG["default"]["order"]
    min_present = float(mode_cfg.get("min_hours_present", 8.0))
    min_half = float(mode_cfg.get("min_hours_half_day", 4.0))

    att: Optional[Attendance] = Attendance.query.filter_by(
        employee_id=emp.id, date=target_date
    ).first()

    # Manual override
    if att and (att.source or "").strip().lower() == "manual":
        return att

    is_bulk = bool(att and (att.source or "").strip().lower() == "bulk")

    # Pick candidate
    candidate = None
    for src_code in order:
        src = (src_code or "").strip().lower()
        if src == "manual":
            continue
        elif src == "biometric":
            candidate = _candidate_from_biometric(emp, target_date)
        elif src == "self":
            candidate = _candidate_from_self_punch(emp, target_date)
        elif src == "desktime_hours":
            candidate = _candidate_from_desktime_hours(emp, target_date)
        elif src == "desktime_productive":
            candidate = _candidate_from_desktime_productive(emp, target_date)
        else:
            candidate = None

        if _is_usable_candidate(candidate):
            break
        candidate = None

    selected_source_label = "System"
    check_in = None
    check_out = None
    hours = 0.0

    if candidate:
        selected_source_label = candidate.get("source_label") or "System"
        check_in = candidate.get("check_in")
        check_out = candidate.get("check_out")
        try:
            hours = float(candidate.get("hours") or 0.0)
        except Exception:
            hours = 0.0

    # Holiday / Leave override
    holiday = Holiday.query.filter_by(date=target_date).first()

    leave = (
            LeaveRequest.query.filter_by(employee_id=emp.id, status="final_approved")
            .filter(LeaveRequest.start_date <= target_date, LeaveRequest.end_date >= target_date)
            .first()
        )

    leave_type = LeaveType.query.get(leave.leave_type_id) if leave else None
    leave_code = ((getattr(leave_type, "code", "") or "").strip().upper()) if leave_type else ""

    if holiday:
        remarks = "Holiday"
        hours = 0.0
        status = "holiday"
    elif leave:
        # LWP/LOP/UL should NOT be treated as a paid working day
        if leave_code in _UNPAID_LEAVE_CODES:
            remarks = "LWP (Leave Applied)"
            hours = 0.0
            status = "lwp"
        else:
            remarks = "Leave"
            status = "paid_leave"
    else:
        if hours >= min_present:
            remarks = "Present"
            status = "present"
        elif hours >= min_half:
            remarks = "Half Day"
            status = "half_day"
        else:
            remarks = "LWP"
            status = "lwp"

    # Create row if missing
    if not att:
        att = Attendance(employee_id=emp.id, date=target_date)
        db.session.add(att)

    # Fill timings + hours (if available)
    att.check_in_time = _to_time(check_in)
    att.check_out_time = _to_time(check_out)
    att.working_hours = round(hours, 2) if hours is not None else None

    # Set engine decision first
    att.remarks = remarks
    att.status = status

    # Source marker
    if is_bulk:
        # keep bulk but record where time came from
        if selected_source_label and selected_source_label != "System":
            att.note = (att.note or "")
            if "TimeFrom:" not in att.note:
                att.note = (att.note + " | " if att.note else "") + f"TimeFrom:{selected_source_label}"
        att.source = "Bulk"
    else:
        att.source = selected_source_label

    # ✅ FINAL OVERRIDE: if IN/OUT exist, always correct Present/Half/LWP by hours
    _apply_hours_based_status(att, mode_cfg, target_date)

    db.session.commit()
    return att