"""Sync API — bidirectional data sync between local & production.

Features:
    - Gzip compression for responses (pull) and request bodies (push)
    - SHA256 checksum for data integrity verification
    - Incremental sync with timestamp tracking
    - Batch UPSERT with ON DUPLICATE KEY UPDATE

Endpoints:
    GET  /api/v1/sync/pull/<table>  — Serve table data (gzipped response + SHA256)
    POST /api/v1/sync/push/<table>  — Receive & UPSERT (accepts gzipped body + SHA256)
    GET  /api/v1/sync/status        — Health check + last sync info
    POST /api/v1/sync/trigger       — Manual sync trigger (admin only)
"""
from __future__ import annotations

import gzip
import hashlib
import hmac
import io
import json
import logging
from datetime import datetime
from decimal import Decimal, InvalidOperation
from functools import wraps

from flask import Blueprint, Response, current_app, jsonify, make_response, request, stream_with_context
from sqlalchemy import text

from app.extensions import db
from app.helpers.auth import admin_required

logger = logging.getLogger(__name__)

api_sync_bp = Blueprint('api_sync', __name__)

# Minimum response size (bytes) to apply gzip compression
GZIP_MIN_SIZE = 1024  # 1KB


# ── Auth decorator ───────────────────────────────────────────────────────

def sync_key_required(f):
    """Require valid X-Sync-Key header (timing-safe comparison)."""
    @wraps(f)
    def decorated(*args, **kwargs):
        if not current_app.config.get('SYNC_ENABLED'):
            return jsonify({'error': 'Sync is disabled'}), 403

        key = request.headers.get('X-Sync-Key', '')
        expected = current_app.config.get('SYNC_API_KEY', '')
        if not expected:
            return jsonify({'error': 'SYNC_API_KEY not configured'}), 500
        if not hmac.compare_digest(key.encode(), expected.encode()):
            return jsonify({'error': 'Invalid sync key'}), 401
        return f(*args, **kwargs)
    return decorated


# ── Compression & Integrity helpers ──────────────────────────────────────

def _gzip_response(data: dict) -> object:
    """Create a gzipped JSON response with SHA256 checksum.

    Returns a Flask Response with:
        - Content-Encoding: gzip
        - X-Content-SHA256: hash of uncompressed JSON
        - X-Original-Size: uncompressed byte count
    """
    json_bytes = json.dumps(data, ensure_ascii=False, separators=(',', ':')).encode('utf-8')

    # Skip compression for small payloads
    if len(json_bytes) < GZIP_MIN_SIZE:
        resp = make_response(json_bytes)
        resp.headers['Content-Type'] = 'application/json'
        resp.headers['X-Content-SHA256'] = hashlib.sha256(json_bytes).hexdigest()
        return resp

    checksum = hashlib.sha256(json_bytes).hexdigest()
    compressed = gzip.compress(json_bytes, compresslevel=6)

    resp = make_response(compressed)
    resp.headers['Content-Type'] = 'application/json'
    resp.headers['Content-Encoding'] = 'gzip'
    resp.headers['X-Content-SHA256'] = checksum
    resp.headers['X-Original-Size'] = str(len(json_bytes))
    resp.headers['Content-Length'] = str(len(compressed))

    return resp


def _decompress_request() -> tuple[dict | None, str | None]:
    """Decompress and verify incoming gzipped JSON request body.

    Returns:
        (parsed_body, error_message)
        On success: (dict, None)
        On error: (None, error_string)
    """
    content_encoding = request.headers.get('Content-Encoding', '')

    if 'gzip' in content_encoding.lower():
        # Client sent gzipped body
        raw_compressed = request.get_data()
        try:
            json_bytes = gzip.decompress(raw_compressed)
        except Exception as e:
            return None, f'Gzip decompression failed: {str(e)[:100]}'

        # Verify SHA256 checksum if provided
        client_hash = request.headers.get('X-Content-SHA256')
        if client_hash:
            server_hash = hashlib.sha256(json_bytes).hexdigest()
            if not hmac.compare_digest(client_hash, server_hash):
                logger.error(
                    '[sync-push] SHA256 mismatch: client=%s server=%s',
                    client_hash[:16], server_hash[:16],
                )
                return None, (
                    f'SHA256 integrity check failed. '
                    f'Expected {client_hash[:16]}..., got {server_hash[:16]}...'
                )

        try:
            body = json.loads(json_bytes)
            return body, None
        except json.JSONDecodeError as e:
            return None, f'Invalid JSON after decompression: {str(e)[:100]}'
    else:
        # Plain JSON body
        body = request.get_json(silent=True) or {}
        return body, None


# ── Other helpers ────────────────────────────────────────────────────────

def _get_registry():
    from app.services.remote_sync.serializers import get_table_registry
    return get_table_registry()


def _parse_value(val, col_type_str: str):
    """Parse incoming JSON value back to Python type for DB insertion."""
    if val is None:
        return None
    if 'Numeric' in col_type_str or 'BigInteger' in col_type_str:
        try:
            return Decimal(str(val))
        except (InvalidOperation, ValueError):
            return val
    if 'DateTime' in col_type_str:
        if isinstance(val, str):
            try:
                return datetime.fromisoformat(val)
            except ValueError:
                return val
    if 'Date' in col_type_str and 'DateTime' not in col_type_str:
        if isinstance(val, str):
            try:
                return datetime.fromisoformat(val).date()
            except ValueError:
                return val
    if 'Boolean' in col_type_str:
        if isinstance(val, str):
            return val.lower() in ('true', '1', 'yes')
        return bool(val)
    return val


def _build_upsert_sql(table_name: str, columns: list[str],
                      upsert_key: list[str], update_cols: list[str],
                      model) -> str:
    """Build MySQL INSERT ... ON DUPLICATE KEY UPDATE statement."""
    cols_str = ', '.join(f'`{c}`' for c in columns)
    vals_str = ', '.join(f':{c}' for c in columns)

    if not update_cols:
        # Insert-only (ignore duplicates)
        return f"INSERT IGNORE INTO `{table_name}` ({cols_str}) VALUES ({vals_str})"

    # Dynamic update_cols for '__all__' sentinel
    if update_cols == '__all__':
        update_cols = [c for c in columns if c not in upsert_key and c != 'id']

    updates = ', '.join(f'`{c}` = VALUES(`{c}`)' for c in update_cols)
    return (
        f"INSERT INTO `{table_name}` ({cols_str}) VALUES ({vals_str}) "
        f"ON DUPLICATE KEY UPDATE {updates}"
    )


# ── Pull endpoint ────────────────────────────────────────────────────────

@api_sync_bp.route('/pull/<table_name>', methods=['GET'])
@sync_key_required
def pull_table(table_name):
    """Serve table data with gzip compression + SHA256 checksum.

    Response includes:
        - Content-Encoding: gzip (if payload > 1KB)
        - X-Content-SHA256: SHA256 of uncompressed JSON
        - X-Original-Size: uncompressed byte count

    Query params:
        since  — ISO datetime, only records newer than this
        offset — Pagination offset (default 0)
        limit  — Page size (default 1000, max 5000)
    """
    registry = _get_registry()
    if table_name not in registry:
        return jsonify({'error': f'Unknown table: {table_name}'}), 400

    cfg = registry[table_name]
    model = cfg['model']
    ts_col = cfg['timestamp_col']
    serialize = cfg['serialize']

    # Parse params
    since_str = request.args.get('since')
    offset = request.args.get('offset', 0, type=int)
    max_limit = current_app.config.get('SYNC_MAX_BATCH_SIZE', 5000)
    limit = min(request.args.get('limit', 1000, type=int), max_limit)

    # Build query
    query = model.query
    if since_str and ts_col:
        try:
            since_dt = datetime.fromisoformat(since_str)
            col = getattr(model, ts_col, None)
            if col is not None:
                query = query.filter(col >= since_dt)
        except ValueError:
            pass

    # Order by timestamp for deterministic pagination
    ts_attr = getattr(model, ts_col, None)
    if ts_attr is not None:
        query = query.order_by(ts_attr.asc())

    total = query.count()
    records = query.offset(offset).limit(limit).all()
    serialized = [serialize(r) for r in records]
    has_more = (offset + limit) < total

    data = {
        'table': table_name,
        'since': since_str,
        'count': len(serialized),
        'total': total,
        'offset': offset,
        'limit': limit,
        'has_more': has_more,
        'next_offset': offset + limit if has_more else None,
        'records': serialized,
    }

    # Return gzipped response with checksum
    return _gzip_response(data)


# ── Push endpoint ────────────────────────────────────────────────────────

@api_sync_bp.route('/push/<table_name>', methods=['POST'])
@sync_key_required
def push_table(table_name):
    """Receive & UPSERT records from remote.

    Accepts:
        - Content-Encoding: gzip (compressed JSON body)
        - X-Content-SHA256: hash for integrity verification

    JSON body:
        records        — List of record dicts
        sync_timestamp — ISO datetime of when sync was initiated
    """
    registry = _get_registry()
    if table_name not in registry:
        return jsonify({'error': f'Unknown table: {table_name}'}), 400

    # Decompress + verify integrity
    body, error = _decompress_request()
    if error:
        return jsonify({'error': error, 'type': 'integrity_error'}), 422

    cfg = registry[table_name]
    model = cfg['model']
    upsert_key = cfg['upsert_key']
    update_cols = cfg['update_cols']

    records = body.get('records', [])

    if not records:
        return jsonify({'table': table_name, 'upserted': 0, 'message': 'No records'})

    # Get column info from model
    table = model.__table__
    col_names = [c.name for c in table.columns]
    col_types = {c.name: str(c.type) for c in table.columns}

    # Filter records to only include valid columns, skip auto-increment PK for non-PK upserts
    skip_cols = set()
    if 'id' not in upsert_key and any(c.autoincrement for c in table.columns if c.name == 'id'):
        skip_cols.add('id')

    valid_cols = [c for c in col_names if c not in skip_cols]

    # Build UPSERT SQL
    sql = _build_upsert_sql(table_name, valid_cols, upsert_key, update_cols, model)

    upserted = 0
    errors = []

    for rec in records:
        try:
            # Parse values to correct Python types
            params = {}
            for col in valid_cols:
                raw_val = rec.get(col)
                params[col] = _parse_value(raw_val, col_types.get(col, ''))

            db.session.execute(text(sql), params)
            upserted += 1
        except Exception as e:
            key_info = {k: rec.get(k) for k in upsert_key}
            errors.append(f'{key_info}: {str(e)[:100]}')
            logger.warning('[sync-push] %s UPSERT error: %s', table_name, str(e)[:100])

    try:
        db.session.commit()
    except Exception as e:
        db.session.rollback()
        logger.error('[sync-push] %s commit failed: %s', table_name, str(e)[:200])
        return jsonify({
            'table': table_name,
            'upserted': 0,
            'error': f'Commit failed: {str(e)[:200]}',
        }), 500

    result = {
        'table': table_name,
        'received': len(records),
        'upserted': upserted,
        'integrity': 'verified' if request.headers.get('X-Content-SHA256') else 'no_checksum',
    }
    if errors:
        result['errors'] = errors[:10]  # cap error list
        result['error_count'] = len(errors)

    return jsonify(result)


# ── Status endpoint ──────────────────────────────────────────────────────

@api_sync_bp.route('/status', methods=['GET'])
def sync_status():
    """Health check — returns sync config and last sync timestamps."""
    from app.models.settings import AppSettings

    enabled = current_app.config.get('SYNC_ENABLED', False)
    registry = _get_registry() if enabled else {}

    # Gather last sync timestamps
    timestamps = {}
    if enabled:
        for table_name in registry:
            for direction in ('push', 'pull'):
                key = f'sync_last_{direction}_{table_name}'
                val = AppSettings.get(key)
                if val:
                    timestamps[key] = val

    # Table counts
    counts = {}
    if enabled:
        for table_name, cfg in registry.items():
            try:
                counts[table_name] = cfg['model'].query.count()
            except Exception:
                counts[table_name] = -1

    return jsonify({
        'sync_enabled': enabled,
        'tables': list(registry.keys()) if enabled else [],
        'table_counts': counts,
        'last_sync': timestamps,
        'server_time': datetime.utcnow().isoformat(),
        'features': {
            'gzip': True,
            'sha256': True,
            'incremental': True,
        },
    })


# ── Manual trigger (admin only) ─────────────────────────────────────────

@api_sync_bp.route('/trigger', methods=['POST'])
@admin_required
def trigger_sync():
    """Trigger manual sync from admin UI.

    JSON body:
        direction — 'push', 'pull', or 'both' (default 'both')
        table     — Optional: sync single table only
        full      — If true, ignore last sync timestamps (full re-sync)
    """
    from app.services.remote_sync.sync_orchestrator import SyncOrchestrator

    body = request.get_json(silent=True) or {}
    direction = body.get('direction', 'both')
    table = body.get('table')
    full = body.get('full', False)

    # Validate direction
    if direction not in ('push', 'pull', 'both'):
        return jsonify({'error': f'Invalid direction: {direction}'}), 400

    try:
        orchestrator = SyncOrchestrator()

        # If full sync, clear last sync timestamps first
        if full:
            from app.models.settings import AppSettings
            tables_to_clear = [table] if table else list(orchestrator.registry.keys())
            for t in tables_to_clear:
                for d in ('push', 'pull'):
                    key = f'sync_last_{d}_{t}'
                    AppSettings.set(key, '', category='system')
            db.session.commit()
            logger.info('[sync-trigger] Full sync: cleared timestamps for %d tables',
                        len(tables_to_clear))

        if table:
            result = orchestrator.sync_table(table, direction)
        else:
            result = orchestrator.sync_all(direction)

        result['full_sync'] = full
        return jsonify(result)
    except ValueError as e:
        # Config errors (missing SYNC_REMOTE_URL / SYNC_API_KEY)
        logger.error('[sync-trigger] Config error: %s', str(e))
        return jsonify({'error': str(e), 'type': 'config_error'}), 400
    except Exception as e:
        logger.error('[sync-trigger] Error: %s', str(e))
        return jsonify({'error': str(e), 'type': 'sync_error'}), 500


# ── SSE trigger (admin only, streaming progress) ────────────────────────

def _sse_event(data: dict) -> str:
    """Format a dict as an SSE data line."""
    return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"


@api_sync_bp.route('/trigger-sse', methods=['POST'])
@admin_required
def trigger_sync_sse():
    """Trigger sync with SSE streaming progress.

    Same body as /trigger but returns text/event-stream with per-batch events.
    Events:
        {type: 'start', table, direction, total_records}
        {type: 'batch', table, direction, batch, total_batches,
         records_sent, total_sent, total_records,
         bytes_compressed, bytes_uncompressed, ...}
        {type: 'done', table, direction, status, total_pushed/total_pulled,
         bytes_compressed, bytes_uncompressed, duration_ms}
        {type: 'error', table, direction, error}
        {type: 'finished'}  — end of stream
    """
    from app.services.remote_sync.sync_orchestrator import SyncOrchestrator

    body = request.get_json(silent=True) or {}
    direction = body.get('direction', 'push')
    table = body.get('table')
    full = body.get('full', False)

    if direction not in ('push', 'pull'):
        return jsonify({'error': f'Invalid direction: {direction}'}), 400
    if not table:
        return jsonify({'error': 'table is required for SSE sync'}), 400

    def generate():
        try:
            orchestrator = SyncOrchestrator()
            for event in orchestrator.sync_table_sse(table, direction, full=full):
                yield _sse_event(event)
        except ValueError as e:
            yield _sse_event({'type': 'error', 'table': table or '?',
                              'direction': direction, 'error': str(e)})
        except Exception as e:
            logger.error('[sync-sse] Error: %s', str(e))
            yield _sse_event({'type': 'error', 'table': table or '?',
                              'direction': direction, 'error': str(e)[:200]})
        yield _sse_event({'type': 'finished'})

    return Response(
        stream_with_context(generate()),
        mimetype='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'X-Accel-Buffering': 'no',
        },
    )
