#!/usr/bin/env python3
# mcp_bridge.py — MCP-Bridge für Kameramotor
# Überwacht jobs_mcp/, verarbeitet Jobs via `claude -p` + Magnific-MCP-Tools,
# schreibt Ergebnisse nach results_mcp/, verschiebt fertige Jobs nach done_mcp/.
#
# Starten: NICHT direkt (launchd übernimmt), nur via LaunchD-Plist laden.
# Victor Holland — 2026-06-11

import os
import sys
import json
import time
import logging
import subprocess
import shutil
import re
from pathlib import Path
from datetime import datetime

# ── Pfade ──────────────────────────────────────────────────────────────────

BASE_DIR     = Path(__file__).parent.resolve()
JOBS_DIR     = BASE_DIR / 'jobs_mcp'
RESULTS_DIR  = BASE_DIR / 'results_mcp'
DONE_DIR     = BASE_DIR / 'done_mcp'
LOGS_DIR     = BASE_DIR / 'logs_mcp'

for d in [JOBS_DIR, RESULTS_DIR, DONE_DIR, LOGS_DIR]:
    d.mkdir(parents=True, exist_ok=True)

# ── Logging ────────────────────────────────────────────────────────────────

log_path = LOGS_DIR / 'bridge.log'
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[
        logging.FileHandler(log_path, encoding='utf-8'),
        logging.StreamHandler(sys.stdout),
    ]
)
log = logging.getLogger('mcp_bridge')

# ── Claude-Prompt Builder ──────────────────────────────────────────────────

ALLOWED_IMAGE_TOOLS = (
    'mcp__magnific__account_balance,'
    'mcp__magnific__creations_upload_image,'
    'mcp__magnific__creations_request_upload,'
    'mcp__magnific__creations_finalize_upload,'
    'mcp__magnific__images_generate,'
    'mcp__magnific__images_generate_svg,'
    'mcp__magnific__creations_wait,'
    'mcp__magnific__creations_get'
)
ALLOWED_VIDEO_TOOLS = (
    'mcp__magnific__video_generate,'
    'mcp__magnific__creations_wait,'
    'mcp__magnific__creations_get'
)

def build_claude_prompt(job: dict, prompt_text: str) -> tuple[str, str]:
    """Gibt (prompt, allowed_tools) zurück."""
    model     = job.get('model') or job.get('mode') or job.get('slug') or 'imagen-nano-banana-2-flash'
    job_type  = job.get('type', 'image').lower()
    num_imgs  = job.get('num_images', 1)
    resolution = job.get('resolution', '4k')
    ratio     = job.get('ratio', '1:1')
    source_url = job.get('source_image_url') or job.get('image_url') or ''
    source_path = job.get('image') or job.get('input') or ''

    if job_type == 'video':
        start_frame = job.get('start_frame') or job.get('start_image', '')
        end_frame   = job.get('end_frame') or job.get('end_image', '')
        duration    = job.get('duration', 5)
        sound       = job.get('withSoundEffects', job.get('with_sound_effects', True))
        kf_block = ''
        if start_frame:
            kf_block += f'\n- keyframes[0]: url="{start_frame}", type="image"'
        if end_frame:
            kf_block += f'\n- keyframes[1]: url="{end_frame}", type="image"'

        spec = {
            'type': 'video',
            'model': model,
            'prompt': prompt_text,
            'duration': duration,
            'resolution': resolution,
            'withSoundEffects': bool(sound),
        }
        if start_frame:
            spec['keyframe_start_url'] = start_frame
        if end_frame:
            spec['keyframe_end_url'] = end_frame
        prompt = (
            f'You are a deterministic MCP tool runner, not a creative agent.\n'
            f'No interpretation. No alternatives. No questions. No prompt rewriting. No model changes.\n'
            f'Never ask the user anything. If a tool call fails, retry it once; if it still fails, '
            f'return ONLY {{"error": "<short reason>", "type": "video"}} and stop.\n'
            f'Execute this fixed JSON spec exactly:\n'
            f'{json.dumps(spec, ensure_ascii=False)}\n\n'
            f'Fixed sequence:\n'
            f'1. Call mcp__magnific__video_generate with exactly model, prompt, duration, resolution and '
            f'withSoundEffects from the JSON spec.'
            + (' Pass keyframes[0].url=keyframe_start_url, keyframes[0].type="image".' if start_frame else '')
            + (' Pass keyframes[1].url=keyframe_end_url, keyframes[1].type="image".' if end_frame else '')
            + f'\n2. Call mcp__magnific__creations_wait on the returned creation identifier until terminal.\n'
            f'3. Call mcp__magnific__creations_get if needed to obtain the final downloadable video URL.\n'
            f'4. Return ONLY this JSON object, no markdown, no prose:\n'
            f'{{"url": "<download_url>", "identifier": "<id>", "type": "video"}}'
        )
        allowed = ALLOWED_VIDEO_TOOLS

    else:  # image (default)
        if not source_url and not source_path:
            raise ValueError('image job requires source_image_url or image')
        spec = {
            'type': 'image',
            'model': model,
            'prompt': prompt_text,
            'num_images': num_imgs,
            'resolution': resolution,
            'aspectRatio': ratio,
            'source_image_url': source_url,
            'source_image_path': source_path,
        }
        prompt = (
            f'You are a deterministic MCP tool runner, not a creative agent.\n'
            f'No interpretation. No alternatives. No questions. No prompt rewriting. No model changes.\n'
            f'Execute this fixed JSON spec exactly:\n'
            f'{json.dumps(spec, ensure_ascii=False)}\n\n'
            f'Fixed sequence:\n'
            f'1. Call mcp__magnific__account_balance.\n'
            f'2. If source_image_url is non-empty, call mcp__magnific__creations_upload_image with exactly that URL.\n'
            f'3. Call mcp__magnific__images_generate with exactly model, prompt, num_images, resolution and aspectRatio from the JSON spec, using the uploaded source image creation identifier as the only reference.\n'
            f'4. Call mcp__magnific__creations_wait for each returned creation identifier until terminal.\n'
            f'5. Call mcp__magnific__creations_get if needed to obtain final downloadable image URL(s).\n'
            f'6. Call mcp__magnific__account_balance.\n'
            f'7. Return ONLY this JSON object, no markdown, no prose:\n'
            f'{{"urls": ["<url1>", ...], "identifiers": ["<id1>", ...], "type": "image"}}'
        )
        allowed = ALLOWED_IMAGE_TOOLS

    return prompt, allowed


# ── Result-Parser ──────────────────────────────────────────────────────────

def extract_json_from_output(text: str) -> dict | None:
    """Versucht ein JSON-Objekt aus dem Claude-Output zu extrahieren."""
    # 1) Direkt JSON parsen wenn der gesamte Output JSON ist
    try:
        return json.loads(text.strip())
    except Exception:
        pass

    # 2) JSON aus --output-format json: result.result Feld
    try:
        outer = json.loads(text.strip())
        if 'result' in outer:
            inner = outer['result']
            if isinstance(inner, dict):
                return inner
            return json.loads(inner)
    except Exception:
        pass

    # 3) Erstes {...} aus dem Text fischen
    match = re.search(r'\{[^{}]*"(?:url|urls|identifier|identifiers|error)[^{}]*\}', text, re.DOTALL)
    if match:
        try:
            return json.loads(match.group(0))
        except Exception:
            pass

    return None


# ── Job-Verarbeitung ───────────────────────────────────────────────────────

def process_job(job_file: Path) -> None:
    job_id = job_file.stem
    log.info(f'[{job_id}] Verarbeite Job: {job_file.name}')

    try:
        job = json.loads(job_file.read_text(encoding='utf-8'))
    except Exception as e:
        log.error(f'[{job_id}] Ungültiges JSON: {e}')
        _move_failed(job_file, job_id, str(e))
        return

    # Prompt lesen
    prompt_text = job.get('prompt')
    if not prompt_text:
        prompt_file = job.get('prompt_file')
        if prompt_file and os.path.exists(prompt_file):
            try:
                prompt_text = Path(prompt_file).read_text(encoding='utf-8').strip()[:9900]
            except Exception as e:
                log.error(f'[{job_id}] prompt_file lesen fehlgeschlagen: {e}')
                _move_failed(job_file, job_id, str(e))
                return

    if not prompt_text:
        log.error(f'[{job_id}] Kein Prompt gefunden')
        _move_failed(job_file, job_id, 'Kein Prompt')
        return

    claude_prompt, allowed_tools = build_claude_prompt(job, prompt_text)

    log.info(f'[{job_id}] Rufe claude auf (type={job.get("type","image")}, model={job.get("model","seedream-4-5")})')

    try:
        result = subprocess.run(
            [
                'claude', '-p', claude_prompt,
                '--model', 'claude-haiku-4-5-20251001',
                '--allowedTools', allowed_tools,
                '--output-format', 'json',
                '--max-turns', '12',
            ],
            capture_output=True,
            text=True,
            timeout=900,
            env={**os.environ, 'TERM': 'xterm-256color'},
        )
    except subprocess.TimeoutExpired:
        log.error(f'[{job_id}] Claude-Aufruf Timeout (900s)')
        _move_failed(job_file, job_id, 'claude timeout')
        return
    except FileNotFoundError:
        log.error(f'[{job_id}] `claude` nicht im PATH gefunden')
        _move_failed(job_file, job_id, 'claude not found')
        return

    if result.returncode != 0:
        log.error(f'[{job_id}] Claude Exit {result.returncode}: {result.stderr[:400]}')
        _move_failed(job_file, job_id, f'claude exit {result.returncode}: {result.stderr[:200]}')
        return

    combined = result.stdout + '\n' + result.stderr
    parsed = extract_json_from_output(result.stdout)

    if not parsed:
        log.error(f'[{job_id}] Konnte kein JSON aus Claude-Output extrahieren:\n{combined[:600]}')
        _move_failed(job_file, job_id, 'no json in output')
        return

    if parsed.get('error'):
        log.error(f'[{job_id}] MCP-Postbote meldete Fehler: {parsed["error"]}')
        _move_failed(job_file, job_id, f'mcp error: {parsed["error"]}')
        return

    urls = parsed.get('urls') or ([parsed.get('url')] if parsed.get('url') else [])
    if not urls:
        log.error(f'[{job_id}] MCP-Ergebnis ohne URL(s)')
        _move_failed(job_file, job_id, 'no urls in parsed output')
        return

    # Ergebnis in results_mcp/<job_id>.json schreiben
    result_payload = {
        'job_id':      job_id,
        'done_at':     datetime.utcnow().isoformat() + 'Z',
        'job':         job,
        **parsed,
    }
    result_file = RESULTS_DIR / f'{job_id}.json'
    result_file.write_text(json.dumps(result_payload, ensure_ascii=False, indent=2), encoding='utf-8')
    log.info(f'[{job_id}] Ergebnis gespeichert: {result_file.name}')

    # Job in done_mcp/ verschieben
    done_target = DONE_DIR / job_file.name
    shutil.move(str(job_file), str(done_target))
    log.info(f'[{job_id}] Job fertig → done_mcp/')


def _move_failed(job_file: Path, job_id: str, reason: str) -> None:
    failed_dir = BASE_DIR / 'failed_mcp'
    failed_dir.mkdir(exist_ok=True)
    error_payload = {
        'job_id':   job_id,
        'failed_at': datetime.utcnow().isoformat() + 'Z',
        'reason':   reason,
    }
    (failed_dir / f'{job_id}_error.json').write_text(
        json.dumps(error_payload, ensure_ascii=False, indent=2), encoding='utf-8'
    )
    try:
        shutil.move(str(job_file), str(failed_dir / job_file.name))
    except Exception:
        pass
    log.error(f'[{job_id}] Job fehlgeschlagen: {reason}')


# ── Haupt-Loop ─────────────────────────────────────────────────────────────

def main() -> None:
    log.info('MCP-Bridge gestartet. Überwache jobs_mcp/ ...')
    processing: set[str] = set()

    while True:
        try:
            job_files = sorted(JOBS_DIR.glob('*.json'))
            for jf in job_files:
                if jf.stem in processing:
                    continue
                processing.add(jf.stem)
                try:
                    process_job(jf)
                except Exception as e:
                    log.exception(f'[{jf.stem}] Unbehandelter Fehler: {e}')
                    _move_failed(jf, jf.stem, str(e))
                finally:
                    processing.discard(jf.stem)
        except Exception as e:
            log.exception(f'Fehler im Haupt-Loop: {e}')

        time.sleep(2)


if __name__ == '__main__':
    main()
