"""
BizNode Pro — Update Packet Processor (Client Side)
=====================================================
Receives signed PDF update packets from BizNode admin and applies them.

Capabilities:
  - Write new files to disk (templates, configs, scripts)
  - Delete/replace old files
  - Update knowledge base (Qdrant)
  - Run database migrations
  - Create/update scheduled tasks
  - Update bot menus and workflows

Flow:
  1. PDF arrives via email or Telegram
  2. Extract embedded JSON from PDF Subject field
  3. Verify Ed25519 signature against admin public key
  4. Check tier compatibility + version
  5. Route to handler → apply changes to disk/DB/Qdrant
  6. Log result

Security:
  - All packets are Ed25519 signed by admin node
  - Signature verified before ANY file operation
  - Path traversal blocked (no ../ allowed)
  - Files written only within PROJECT_ROOT
  - Blockchain updates require OTP
"""

from __future__ import annotations

import base64
import json
import logging
import os
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

logger = logging.getLogger("updates.processor")

_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(_PROJECT_ROOT))

try:
    import pdfplumber
except ImportError:
    pdfplumber = None

# Admin node's public key — used to verify packet signatures
# This is the BizNode admin's Ed25519 public key (base64url, no padding)
ADMIN_PUBLIC_KEY_FILE = _PROJECT_ROOT / "identity" / "admin_public_key.txt"

# Version tracking
_APPLIED_FILE = _PROJECT_ROOT / "updates" / "applied_versions.json"

# Update types that this client can handle
CLIENT_UPDATE_TYPES = {
    "updatetemplate", "updatemenu", "updateworkflow",
    "updatebiz", "updatekb", "updatetables",
}

# Types that need OTP + Pro tier
BLOCKCHAIN_TYPES = {"updatedzit", "updatebzeusd"}
PRO_TYPES = {"updatedzit", "updatebzeusd"}


# ---------------------------------------------------------------------------
# Version tracking
# ---------------------------------------------------------------------------

def _load_applied() -> dict:
    if _APPLIED_FILE.exists():
        return json.loads(_APPLIED_FILE.read_text(encoding="utf-8"))
    return {}


def _save_applied(data: dict):
    _APPLIED_FILE.parent.mkdir(parents=True, exist_ok=True)
    _APPLIED_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")


def _is_newer(current: str, incoming: str) -> bool:
    def parse(v):
        return [int(p) if p.isdigit() else 0 for p in v.split(".")]
    return parse(incoming) > parse(current)


# ---------------------------------------------------------------------------
# PDF extraction
# ---------------------------------------------------------------------------

def extract_metadata(pdf_path: str) -> Optional[dict]:
    """Extract embedded JSON metadata from PDF update packet."""
    if pdfplumber is None:
        raise RuntimeError("pdfplumber required: pip install pdfplumber")

    try:
        with pdfplumber.open(pdf_path) as pdf:
            subject = pdf.metadata.get("Subject", "")

        if not subject or not subject.startswith("BIZNODE_PACKET:"):
            logger.error("Not a BizNode update packet")
            return None

        encoded = subject[len("BIZNODE_PACKET:"):]
        return json.loads(base64.b64decode(encoded).decode("utf-8"))

    except Exception as exc:
        logger.error("Failed to extract metadata: %s", exc)
        return None


# ---------------------------------------------------------------------------
# Signature verification
# ---------------------------------------------------------------------------

def _get_admin_public_key() -> bytes:
    """Load admin node's public key for signature verification."""
    if ADMIN_PUBLIC_KEY_FILE.exists():
        key_b64 = ADMIN_PUBLIC_KEY_FILE.read_text().strip()
        padding = 4 - len(key_b64) % 4
        if padding != 4:
            key_b64 += "=" * padding
        return base64.urlsafe_b64decode(key_b64)
    raise FileNotFoundError(
        f"Admin public key not found: {ADMIN_PUBLIC_KEY_FILE}\n"
        "Get it from BizNode admin or the Telegram group description."
    )


def verify_signature(metadata: dict) -> bool:
    """Verify Ed25519 signature against admin public key."""
    try:
        from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

        sig_b64 = metadata.get("signature")
        if not sig_b64:
            logger.error("No signature in packet")
            return False

        # Use admin's known public key (not the one in the packet!)
        raw_pub = _get_admin_public_key()
        pub_key = Ed25519PublicKey.from_public_bytes(raw_pub)

        # Reconstruct signed data
        meta_copy = {k: v for k, v in metadata.items() if k != "signature"}
        meta_bytes = json.dumps(meta_copy, sort_keys=True, ensure_ascii=False).encode("utf-8")

        sig_bytes = base64.b64decode(sig_b64)
        pub_key.verify(sig_bytes, meta_bytes)
        logger.info("Signature verified (admin node: %s)", metadata.get("source_node_id"))
        return True

    except FileNotFoundError as exc:
        logger.error(str(exc))
        return False
    except Exception as exc:
        logger.error("Signature verification failed: %s", exc)
        return False


# ---------------------------------------------------------------------------
# Handlers — each one applies a specific update type
# ---------------------------------------------------------------------------

def _safe_path(base: Path, filename: str) -> Optional[Path]:
    """Resolve filename and ensure it's inside base. Returns None if unsafe."""
    if ".." in filename:
        return None
    resolved = (base / filename).resolve()
    try:
        resolved.relative_to(base.resolve())
        return resolved
    except ValueError:
        return None


def _handle_template(payload: dict, metadata: dict) -> dict:
    """Write/update/delete template files."""
    templates = payload.get("templates", [])
    if not templates:
        return {"success": False, "message": "No templates in payload"}

    tpl_dir = _PROJECT_ROOT / "templates"
    tpl_dir.mkdir(exist_ok=True)
    added = updated = removed = 0
    errors = []

    for tpl in templates:
        action = tpl.get("action", "add")
        filename = tpl.get("filename", "")
        if not filename:
            errors.append("Missing filename")
            continue

        fp = _safe_path(tpl_dir, filename)
        if fp is None:
            errors.append(f"Unsafe path: {filename}")
            continue

        try:
            if action == "remove":
                if fp.exists():
                    fp.unlink()
                    removed += 1
                    logger.info("Removed template: %s", filename)
            else:
                content = tpl.get("content", "")
                if not content:
                    errors.append(f"Empty content: {filename}")
                    continue
                fp.parent.mkdir(parents=True, exist_ok=True)
                existed = fp.exists()
                fp.write_text(content, encoding="utf-8")
                if existed:
                    updated += 1
                else:
                    added += 1
                logger.info("%s template: %s", "Updated" if existed else "Added", filename)
        except Exception as exc:
            errors.append(f"{filename}: {exc}")

    msg = f"Templates: {added} added, {updated} updated, {removed} removed"
    return {"success": not errors or (added + updated + removed) > 0,
            "message": msg, "details": {"errors": errors}}


def _handle_menu(payload: dict, metadata: dict) -> dict:
    """Update menu configuration."""
    menus = payload.get("menus", {})
    if not menus:
        return {"success": False, "message": "No menu data in payload"}

    config_dir = _PROJECT_ROOT / "config"
    config_dir.mkdir(exist_ok=True)
    menu_file = config_dir / "menus.json"

    # Merge with existing
    existing = {}
    if menu_file.exists():
        existing = json.loads(menu_file.read_text(encoding="utf-8"))

    existing.update(menus)
    menu_file.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
    logger.info("Menu config updated: %d entries", len(menus))
    return {"success": True, "message": f"Menu updated: {len(menus)} entries"}


def _handle_workflow(payload: dict, metadata: dict) -> dict:
    """Update workflow definitions."""
    workflows = payload.get("workflows", [])
    if not workflows:
        return {"success": False, "message": "No workflows in payload"}

    wf_dir = _PROJECT_ROOT / "config" / "workflows"
    wf_dir.mkdir(parents=True, exist_ok=True)
    written = 0

    for wf in workflows:
        name = wf.get("name", "")
        if not name:
            continue
        fp = _safe_path(wf_dir, f"{name}.json")
        if fp is None:
            continue
        fp.write_text(json.dumps(wf, indent=2, ensure_ascii=False), encoding="utf-8")
        written += 1
        logger.info("Workflow written: %s", name)

    return {"success": written > 0, "message": f"Workflows: {written} written"}


def _handle_biz(payload: dict, metadata: dict) -> dict:
    """Update business configuration."""
    config = payload.get("config", {})
    files = payload.get("files", [])

    results = []

    # Update config
    if config:
        config_file = _PROJECT_ROOT / "config" / "business.json"
        config_file.parent.mkdir(exist_ok=True)
        existing = {}
        if config_file.exists():
            existing = json.loads(config_file.read_text(encoding="utf-8"))
        existing.update(config)
        config_file.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
        results.append(f"Config updated: {len(config)} keys")

    # Write files
    for f in files:
        path = f.get("path", "")
        content = f.get("content", "")
        action = f.get("action", "write")

        fp = _safe_path(_PROJECT_ROOT, path)
        if fp is None:
            results.append(f"Unsafe path skipped: {path}")
            continue

        if action == "delete" and fp.exists():
            fp.unlink()
            results.append(f"Deleted: {path}")
        elif content:
            fp.parent.mkdir(parents=True, exist_ok=True)
            fp.write_text(content, encoding="utf-8")
            results.append(f"Written: {path}")

    return {"success": True, "message": "; ".join(results) or "No changes applied"}


def _handle_kb(payload: dict, metadata: dict) -> dict:
    """Update knowledge base articles in Qdrant."""
    articles = payload.get("articles", [])
    if not articles:
        return {"success": False, "message": "No articles in payload"}

    try:
        import requests
        import uuid

        qdrant_url = f"http://localhost:{os.getenv('QDRANT_PORT', '6333')}"
        ollama_url = os.getenv("OLLAMA_URL", "http://localhost:11434")
        embed_model = os.getenv("EMBEDDING_MODEL", "nomic-embed-text")
        collection = "biznode_support"

        def embed(text):
            r = requests.post(f"{ollama_url}/api/embeddings",
                              json={"model": embed_model, "prompt": text}, timeout=30)
            return r.json().get("embedding", [])

        added = removed = 0
        errors = []

        for art in articles:
            action = art.get("action", "add")
            title = art.get("title", "")

            try:
                if action == "remove":
                    art_id = art.get("id")
                    if art_id:
                        requests.post(f"{qdrant_url}/collections/{collection}/points/delete",
                                      json={"points": [art_id]}, timeout=10)
                        removed += 1
                    continue

                content = art.get("content", "")
                tags = art.get("tags", ["support"])
                keywords = art.get("keywords", [])

                vector = embed(f"{title} {content} {' '.join(keywords)}")
                if not vector:
                    errors.append(f"Embed failed: {title}")
                    continue

                point_id = art.get("id") or str(uuid.uuid4())
                requests.put(
                    f"{qdrant_url}/collections/{collection}/points",
                    json={"points": [{
                        "id": point_id, "vector": vector,
                        "payload": {"title": title, "content": content,
                                    "tags": tags, "keywords": keywords,
                                    "source": "update_packet",
                                    "version": metadata.get("version", "")},
                    }]},
                    timeout=15,
                ).raise_for_status()
                added += 1
            except Exception as exc:
                errors.append(f"{title}: {exc}")

        msg = f"KB: {added} added, {removed} removed"
        return {"success": not errors or added + removed > 0,
                "message": msg, "details": {"errors": errors}}

    except Exception as exc:
        return {"success": False, "message": f"KB update failed: {exc}"}


def _handle_tables(payload: dict, metadata: dict) -> dict:
    """Run database migrations."""
    migrations = payload.get("migrations", [])
    if not migrations:
        return {"success": False, "message": "No migrations in payload"}

    try:
        from memory.database import get_db_connection
        conn = get_db_connection()
        cursor = conn.cursor()

        applied = 0
        errors = []

        for mig in migrations:
            sql = mig.get("sql", "")
            name = mig.get("name", "unnamed")
            if not sql:
                continue
            try:
                cursor.execute(sql)
                applied += 1
                logger.info("Migration applied: %s", name)
            except Exception as exc:
                errors.append(f"{name}: {exc}")

        conn.commit()
        msg = f"Migrations: {applied} applied"
        return {"success": not errors or applied > 0,
                "message": msg, "details": {"errors": errors}}

    except Exception as exc:
        return {"success": False, "message": f"Migration failed: {exc}"}


def _handle_files(payload: dict, metadata: dict) -> dict:
    """
    Generic file operations handler.
    Used by updatebiz and any payload with a 'files' key.

    Payload:
    {
        "files": [
            {"path": "relative/path.py", "content": "...", "action": "write"},
            {"path": "old/file.py", "action": "delete"},
            {"path": "config/settings.json", "content": "{...}", "action": "write"}
        ]
    }
    """
    files = payload.get("files", [])
    if not files:
        return {"success": False, "message": "No files in payload"}

    written = deleted = 0
    errors = []

    for f in files:
        path = f.get("path", "")
        action = f.get("action", "write")

        fp = _safe_path(_PROJECT_ROOT, path)
        if fp is None:
            errors.append(f"Unsafe path: {path}")
            continue

        try:
            if action == "delete":
                if fp.exists():
                    if fp.is_dir():
                        shutil.rmtree(fp)
                    else:
                        fp.unlink()
                    deleted += 1
                    logger.info("Deleted: %s", path)
            elif action == "write":
                content = f.get("content", "")
                if not content:
                    errors.append(f"Empty content: {path}")
                    continue
                fp.parent.mkdir(parents=True, exist_ok=True)
                # Support binary content (base64 encoded)
                if f.get("encoding") == "base64":
                    fp.write_bytes(base64.b64decode(content))
                else:
                    fp.write_text(content, encoding="utf-8")
                written += 1
                logger.info("Written: %s", path)
        except Exception as exc:
            errors.append(f"{path}: {exc}")

    msg = f"Files: {written} written, {deleted} deleted"
    return {"success": not errors or (written + deleted) > 0,
            "message": msg, "details": {"errors": errors}}


def _handle_schedule(payload: dict, metadata: dict) -> dict:
    """
    Create/update/delete Windows scheduled tasks or cron-like jobs.

    Payload:
    {
        "tasks": [
            {
                "action": "create",
                "name": "BizNodeKBSync",
                "command": "python scripts/seed_support_kb.py",
                "schedule": "daily",
                "time": "17:00",
                "description": "Daily KB sync"
            },
            {
                "action": "delete",
                "name": "OldTask"
            }
        ]
    }
    """
    tasks = payload.get("tasks", [])
    if not tasks:
        return {"success": False, "message": "No tasks in payload"}

    import subprocess
    created = deleted = 0
    errors = []

    for task in tasks:
        action = task.get("action", "create")
        name = task.get("name", "")
        if not name:
            errors.append("Missing task name")
            continue

        # Sanitize name
        if not name.replace("_", "").replace("-", "").isalnum():
            errors.append(f"Invalid task name: {name}")
            continue

        try:
            if action == "delete":
                if sys.platform == "win32":
                    subprocess.run(
                        ["schtasks", "/Delete", "/TN", name, "/F"],
                        capture_output=True, timeout=10,
                    )
                else:
                    # Remove crontab entry
                    subprocess.run(
                        f'crontab -l 2>/dev/null | grep -v "# {name}" | crontab -',
                        shell=True, capture_output=True, timeout=10,
                    )
                deleted += 1
                logger.info("Deleted scheduled task: %s", name)
                continue

            command = task.get("command", "")
            schedule = task.get("schedule", "daily")  # daily, weekly, hourly
            time_str = task.get("time", "17:00")

            if not command:
                errors.append(f"Missing command for task {name}")
                continue

            if sys.platform == "win32":
                # Windows Task Scheduler
                freq_map = {"daily": "DAILY", "weekly": "WEEKLY", "hourly": "HOURLY"}
                freq = freq_map.get(schedule, "DAILY")

                full_cmd = f'cd /d "{_PROJECT_ROOT}" && {command}'

                schtask_args = [
                    "schtasks", "/Create", "/F",
                    "/TN", name,
                    "/TR", full_cmd,
                    "/SC", freq,
                ]
                if freq != "HOURLY":
                    schtask_args.extend(["/ST", time_str])

                r = subprocess.run(schtask_args, capture_output=True, text=True, timeout=15)
                if r.returncode == 0:
                    created += 1
                    logger.info("Created scheduled task: %s (%s at %s)", name, schedule, time_str)
                else:
                    errors.append(f"{name}: {r.stderr.strip()}")
            else:
                # Linux cron
                cron_map = {
                    "daily": f"{time_str.split(':')[1]} {time_str.split(':')[0]} * * *",
                    "weekly": f"{time_str.split(':')[1]} {time_str.split(':')[0]} * * 0",
                    "hourly": "0 * * * *",
                }
                cron_time = cron_map.get(schedule, f"0 17 * * *")
                cron_line = f'{cron_time} cd "{_PROJECT_ROOT}" && {command} # {name}'

                subprocess.run(
                    f'(crontab -l 2>/dev/null | grep -v "# {name}"; echo "{cron_line}") | crontab -',
                    shell=True, capture_output=True, timeout=10,
                )
                created += 1
                logger.info("Created cron job: %s", name)

        except Exception as exc:
            errors.append(f"{name}: {exc}")

    msg = f"Schedules: {created} created, {deleted} deleted"
    return {"success": not errors or (created + deleted) > 0,
            "message": msg, "details": {"errors": errors}}


# Handler routing
HANDLERS = {
    "updatetemplate": _handle_template,
    "updatemenu":     _handle_menu,
    "updateworkflow": _handle_workflow,
    "updatebiz":      _handle_biz,
    "updatekb":       _handle_kb,
    "updatetables":   _handle_tables,
}


# ---------------------------------------------------------------------------
# Main processor
# ---------------------------------------------------------------------------

class ProcessResult:
    def __init__(self, success: bool, message: str, update_type: str = "",
                 version: str = "", details: Any = None):
        self.success = success
        self.message = message
        self.update_type = update_type
        self.version = version
        self.details = details


def process_packet(
    pdf_path: str,
    node_tier: str = "pro",
    otp_verified: bool = False,
    force: bool = False,
) -> ProcessResult:
    """
    Process a BizNode update packet on this Pro node.

    1. Extract JSON from PDF
    2. Verify Ed25519 signature
    3. Check tier + version
    4. Route to handler
    5. Apply file/DB/KB changes
    6. Log result
    """
    # Extract
    metadata = extract_metadata(pdf_path)
    if metadata is None:
        return ProcessResult(False, "Failed to extract metadata from PDF")

    update_type = metadata.get("update_type", "unknown")
    version = metadata.get("version", "0.0.0")
    tier = metadata.get("tier", "all")

    # Verify signature
    if not verify_signature(metadata):
        return ProcessResult(False, "REJECTED: Invalid signature", update_type, version)

    # Tier check
    if tier == "pro" and node_tier != "pro":
        return ProcessResult(False, "This update requires Pro tier", update_type, version)

    # Target check
    target = metadata.get("target_node")
    if target:
        try:
            from services.mcp_service import get_node_id
            if target != get_node_id():
                return ProcessResult(False, "Update targeted to different node",
                                     update_type, version)
        except ImportError:
            logger.warning("Cannot verify target node — mcp_service not available")

    # Version check
    if not force:
        applied = _load_applied()
        last = applied.get(update_type, "0.0.0")
        if not _is_newer(last, version):
            return ProcessResult(
                False,
                f"Version {version} already applied (current: {last})",
                update_type, version)

    # OTP for blockchain
    if update_type in BLOCKCHAIN_TYPES and not otp_verified:
        return ProcessResult(
            False,
            f"Blockchain update requires OTP verification",
            update_type, version,
            {"requires_otp": True})

    # Route to handler
    payload = metadata.get("payload", {})

    # Check for generic file operations in any payload
    if "files" in payload:
        file_result = _handle_files(payload, metadata)
        logger.info("File operations: %s", file_result["message"])

    # Check for schedule operations in any payload
    if "tasks" in payload:
        sched_result = _handle_schedule(payload, metadata)
        logger.info("Schedule operations: %s", sched_result["message"])

    # Run type-specific handler
    handler = HANDLERS.get(update_type)
    if handler is None and "files" not in payload and "tasks" not in payload:
        return ProcessResult(False, f"No handler for {update_type}", update_type, version)

    result = {"success": True, "message": "Applied"}
    if handler:
        result = handler(payload, metadata)

    if result.get("success"):
        applied = _load_applied()
        applied[update_type] = version
        applied[f"{update_type}_applied_at"] = datetime.now(timezone.utc).isoformat()
        _save_applied(applied)

    return ProcessResult(
        result.get("success", False),
        result.get("message", "Unknown"),
        update_type, version,
        result.get("details"))


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")

    if len(sys.argv) < 2:
        print("Usage: python packet_processor.py <packet.pdf> [--force]")
        sys.exit(1)

    pdf = sys.argv[1]
    force = "--force" in sys.argv

    r = process_packet(pdf, force=force)
    status = "SUCCESS" if r.success else "FAILED"
    print(f"\n{status}: {r.message}")
    print(f"Type: {r.update_type}, Version: {r.version}")
    if r.details:
        print(f"Details: {json.dumps(r.details, indent=2)}")
