#!/usr/bin/env python3
"""
sync_agent.py — ClickITnexT Attendance Sync Agent
───────────────────────────────────────────────────
Runs on any office PC on the same LAN as the ZKTeco device.
Pulls punch logs directly from the device and POSTs to your server.

Requirements:
    pip install pyzk requests

Usage:
    python sync_agent.py              # Run once
    python sync_agent.py --daemon     # Run every 5 minutes (loop)

Windows Task Scheduler:
    Program: python
    Arguments: C:\\path\\to\\sync_agent.py
    Trigger: Every 5 minutes
"""

import sys
import json
import time
import logging
import argparse
from datetime import datetime, timedelta
from pathlib import Path

# ── Try importing zk library ─────────────────────────────────
try:
    from zk import ZK, const
    ZK_AVAILABLE = True
except ImportError:
    ZK_AVAILABLE = False
    print("ERROR: pyzk not installed. Run: pip install pyzk requests")
    sys.exit(1)

try:
    import requests
except ImportError:
    print("ERROR: requests not installed. Run: pip install requests")
    sys.exit(1)

# ════════════════════════════════════════════════════════════
# ── CONFIGURATION — Edit these values ───────────────────────
# ════════════════════════════════════════════════════════════
CONFIG = {
    # Your ERP server URL (must be HTTPS in production)
    "server_url": "https://yourdomain.com/soft/mobile/attendance_push.php",

    # API token from devices.php (copy from device card)
    "api_token": "PASTE_YOUR_API_TOKEN_HERE",

    # ZKTeco device settings
    "device_ip":   "192.168.1.100",    # Device LAN IP address
    "device_port": 4370,               # Default ZKTeco port
    "device_password": 0,              # Device password (0 = none)
    "device_timeout":  10,             # Connection timeout (seconds)

    # Sync settings
    "sync_interval_minutes": 5,        # How often to sync (daemon mode)
    "days_back": 3,                    # Fetch records from last N days
    "log_file": "sync_agent.log",      # Log file path (same folder)
}
# ════════════════════════════════════════════════════════════

# Setup logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[
        logging.FileHandler(CONFIG['log_file'], encoding='utf-8'),
        logging.StreamHandler(sys.stdout),
    ]
)
log = logging.getLogger(__name__)

# Last sync state file
STATE_FILE = Path("sync_state.json")


def load_state() -> dict:
    """Load last sync timestamp from state file."""
    if STATE_FILE.exists():
        try:
            with open(STATE_FILE, 'r') as f:
                return json.load(f)
        except Exception:
            pass
    return {"last_sync": None, "last_uid": 0}


def save_state(state: dict):
    """Save sync state to file."""
    with open(STATE_FILE, 'w') as f:
        json.dump(state, f, indent=2, default=str)


def connect_device() -> ZK:
    """Connect to ZKTeco device via TCP/IP."""
    zk = ZK(
        CONFIG['device_ip'],
        port=CONFIG['device_port'],
        timeout=CONFIG['device_timeout'],
        password=CONFIG['device_password'],
        force_udp=False,
        ommit_ping=False
    )
    return zk


def fetch_attendance(conn, since_date: datetime) -> list:
    """Fetch attendance records from device since given date."""
    attendance = conn.get_attendance()
    punches = []

    for record in attendance:
        # record.user_id = employee code
        # record.timestamp = datetime object
        # record.punch = 0=check_in, 1=check_out, 4=OT_in, 5=OT_out, etc.
        if record.timestamp < since_date:
            continue

        punch_type_map = {0:'check_in', 1:'check_out', 2:'break_out', 3:'break_in', 4:'check_in', 5:'check_out'}
        punch_type = punch_type_map.get(record.punch, 'check_in')

        punches.append({
            "emp_code":   str(record.user_id).strip(),
            "datetime":   record.timestamp.strftime('%Y-%m-%d %H:%M:%S'),
            "punch_type": punch_type,
        })

    log.info(f"Fetched {len(punches)} records from device since {since_date.date()}")
    return punches


def post_to_server(punches: list) -> dict:
    """POST punch records to ERP server."""
    if not punches:
        return {"status": "ok", "imported": 0, "skipped": 0, "message": "No new records"}

    headers = {
        "Content-Type":   "application/json",
        "X-API-Token":    CONFIG['api_token'],
        "X-Agent-Version":"1.0",
    }

    payload = {
        "punches": punches,
        "agent_version": "1.0",
        "sent_at": datetime.now().isoformat(),
    }

    try:
        response = requests.post(
            CONFIG['server_url'],
            json=payload,
            headers=headers,
            timeout=30,
        )
        response.raise_for_status()
        return response.json()
    except requests.exceptions.ConnectionError:
        raise Exception(f"Cannot reach server at {CONFIG['server_url']}. Check internet connection.")
    except requests.exceptions.Timeout:
        raise Exception("Server request timed out after 30 seconds.")
    except requests.exceptions.HTTPError as e:
        raise Exception(f"Server returned error {response.status_code}: {response.text[:200]}")
    except json.JSONDecodeError:
        raise Exception(f"Server returned invalid JSON: {response.text[:200]}")


def run_sync():
    """Run one sync cycle: connect → fetch → post → save state."""
    state = load_state()
    log.info("=" * 50)
    log.info(f"Starting sync — Device: {CONFIG['device_ip']}:{CONFIG['device_port']}")

    # Determine start date for fetching
    if state['last_sync']:
        try:
            since = datetime.fromisoformat(state['last_sync']) - timedelta(minutes=10)  # 10 min overlap
        except ValueError:
            since = datetime.now() - timedelta(days=CONFIG['days_back'])
    else:
        since = datetime.now() - timedelta(days=CONFIG['days_back'])
        log.info(f"First sync — fetching last {CONFIG['days_back']} days")

    log.info(f"Fetching records since: {since}")

    # ── Connect to device ─────────────────────────────────
    conn = None
    try:
        zk   = connect_device()
        conn = zk.connect()
        conn.disable_device()
        log.info(f"Connected to device. Firmware: {conn.get_firmware_version()}")

        # ── Fetch records ─────────────────────────────────
        punches = fetch_attendance(conn, since)

        conn.enable_device()
        conn.disconnect()
        conn = None

    except Exception as e:
        log.error(f"Device connection failed: {e}")
        if conn:
            try:
                conn.enable_device()
                conn.disconnect()
            except Exception:
                pass
        return False

    # ── Post to server ────────────────────────────────────
    if not punches:
        log.info("No new records to sync.")
        save_state({"last_sync": datetime.now().isoformat(), "last_uid": state.get('last_uid', 0)})
        return True

    try:
        result = post_to_server(punches)
        log.info(f"Server response: imported={result.get('imported',0)} skipped={result.get('skipped',0)}")
        save_state({"last_sync": datetime.now().isoformat(), "last_uid": state.get('last_uid', 0)})
        return True
    except Exception as e:
        log.error(f"Failed to post to server: {e}")
        return False


def run_daemon():
    """Run sync in a loop every N minutes."""
    interval = CONFIG['sync_interval_minutes'] * 60
    log.info(f"Starting daemon mode — syncing every {CONFIG['sync_interval_minutes']} minutes")
    while True:
        try:
            run_sync()
        except Exception as e:
            log.error(f"Unexpected error in sync cycle: {e}")
        log.info(f"Next sync in {CONFIG['sync_interval_minutes']} minutes...")
        time.sleep(interval)


def test_connection():
    """Test device connectivity and print device info."""
    log.info(f"Testing connection to {CONFIG['device_ip']}:{CONFIG['device_port']}...")
    try:
        zk   = connect_device()
        conn = zk.connect()
        info = {
            "firmware":  conn.get_firmware_version(),
            "serial_no": conn.get_serialnumber(),
            "platform":  conn.get_platform(),
            "device_name": conn.get_device_name(),
            "users":     len(conn.get_users()),
        }
        conn.disconnect()
        log.info(f"✅ Connection successful!")
        for k, v in info.items():
            log.info(f"   {k:15}: {v}")
        return True
    except Exception as e:
        log.error(f"❌ Connection failed: {e}")
        log.error("   Check: device IP, port 4370, firewall, device network settings")
        return False


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='ClickITnexT Attendance Sync Agent')
    parser.add_argument('--daemon',  action='store_true', help='Run continuously every N minutes')
    parser.add_argument('--test',    action='store_true', help='Test device connection only')
    parser.add_argument('--once',    action='store_true', help='Run sync once and exit (default)')
    args = parser.parse_args()

    # Validate config
    if CONFIG['api_token'] == 'PASTE_YOUR_API_TOKEN_HERE':
        log.error("ERROR: Please set your API token in CONFIG['api_token']")
        sys.exit(1)
    if CONFIG['device_ip'] == '192.168.1.100':
        log.warning("WARNING: Using default device IP. Make sure to set CONFIG['device_ip']")

    if args.test:
        sys.exit(0 if test_connection() else 1)
    elif args.daemon:
        run_daemon()
    else:
        success = run_sync()
        sys.exit(0 if success else 1)