#!/usr/bin/env python3
"""Private YouTube Shorts generator dashboard and worker."""
from __future__ import annotations

import asyncio
import html
import json
import os
import random
import re
import sqlite3
import subprocess
import time
import uuid
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from wsgiref.simple_server import make_server

import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger('youtube_generator')

ROOT = Path('/root/workspace')
PROJECT = ROOT / 'ytshorts'
DATA = ROOT / 'youtube_generator_data'
DB = DATA / 'generator.db'
MEDIA = DATA / 'media'
SOURCES = PROJECT / 'footage' / 'sources'
YTDLP = Path('/root/.agent-reach-venv/bin/yt-dlp')
COOKIES = ROOT / 'shorts_data' / 'youtube-cookies.txt'
DEFAULT_SOURCE = 'https://www.youtube.com/@OrbitalNCG/videos'
VOICE = 'en-US-EmmaNeural'

for path in (DATA, MEDIA, SOURCES):
    path.mkdir(parents=True, exist_ok=True)

STORIES = [
    (
        'The Player Who Knew My Base',
        "Someone joined my Minecraft server and immediately asked why I kept moving my chests. "
        "I had never seen their username before. I ignored them, but five minutes later, a single torch appeared outside my base. "
        "Then another appeared beside the river. The torches formed a trail through the forest and ended at a buried staircase. "
        "At the bottom was a room filled with maps of my builds. Every map had one section circled in red. "
        "I followed the circles and found a hidden tunnel underneath my storage room. "
        "The tunnel opened into a perfect copy of my base, down to the last flower pot. "
        "A sign waited by the door: I BUILT THIS FOR YOU. "
        "Behind me, footsteps echoed. The stranger typed one message: \"You finally found the safe base.\""
    ),
    (
        'The Last Jump Was Not In The Map',
        "I was one jump away from finishing the hardest parkour map on the server when the course changed. "
        "A block appeared behind me, sealing the path back. Then the next platform vanished. "
        "I thought the map was broken, so I checked the timer. It was still counting. "
        "Ahead, a row of invisible blocks stretched over the void. I could not see them, but every few seconds, one flashed for half a second. "
        "I started jumping on the flashes. The first three worked. The fourth nearly sent me into the darkness. "
        "At the final platform, a sign appeared with my username on it. "
        "It said, YOU ARE NOT THE FIRST PLAYER HERE. "
        "The exit opened, but it led back to the beginning. "
        "This time, the timer read zero, and something was standing where the finish line used to be."
    ),
    (
        'The Chest That Refilled Itself',
        "Every night, one chest in my Minecraft base refilled itself with exactly one diamond. "
        "I thought my friends were messing with me, so I locked the room with iron doors and covered the floor in pressure plates. "
        "The next morning, the diamond was there. No plates had moved. No doors had opened. "
        "I placed a camera mod in the corner and waited. At midnight, the torchlight flickered. "
        "A shadow appeared beside the chest, but there was no player attached to it. "
        "The shadow placed a diamond, turned toward the camera, and disappeared through the wall. "
        "I followed the wall outside and found a one-block tunnel leading underground. "
        "At the end was a second base, abandoned except for a sign: "
        "I PROMISED I WOULD PAY YOU BACK. "
        "The username on the sign belonged to the first player who ever joined my server."
    ),
]


def now() -> int:
    return int(time.time())


def db_connect() -> sqlite3.Connection:
    db = sqlite3.connect(DB, timeout=30)
    db.row_factory = sqlite3.Row
    return db


def init_db() -> None:
    with db_connect() as db:
        db.executescript('''
        CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
        CREATE TABLE IF NOT EXISTS jobs (
          id TEXT PRIMARY KEY, source_url TEXT NOT NULL, story_mode TEXT NOT NULL,
          reddit_text TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, message TEXT NOT NULL DEFAULT '',
          title TEXT NOT NULL DEFAULT '', story TEXT NOT NULL DEFAULT '',
          source_video TEXT NOT NULL DEFAULT '', source_license TEXT NOT NULL DEFAULT '',
          source_credit TEXT NOT NULL DEFAULT '', output_path TEXT NOT NULL DEFAULT '',
          created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
        );
        INSERT OR IGNORE INTO settings(key,value) VALUES ('source_url', 'https://www.youtube.com/@OrbitalNCG/videos');
        INSERT OR IGNORE INTO settings(key,value) VALUES ('story_mode', 'original');
        INSERT OR IGNORE INTO settings(key,value) VALUES ('voice', 'en-US-AvaNeural');
        INSERT OR IGNORE INTO settings(key,value) VALUES ('auto_enabled', '0');
        ''')


def settings() -> dict[str, str]:
    with db_connect() as db:
        return {row['key']: row['value'] for row in db.execute('SELECT key,value FROM settings')}


def json_response(start, body, status='200 OK'):
    data = json.dumps(body, ensure_ascii=False).encode()
    start(status, [('Content-Type', 'application/json; charset=utf-8'), ('Content-Length', str(len(data)))])
    return [data]


def body_json(environ) -> dict:
    length = int(environ.get('CONTENT_LENGTH') or 0)
    raw = environ['wsgi.input'].read(length) if length else b'{}'
    return json.loads(raw or b'{}')


def jobs_list() -> list[dict]:
    with db_connect() as db:
        rows = [dict(row) for row in db.execute('SELECT * FROM jobs ORDER BY created_at DESC LIMIT 50')]
    for row in rows:
        row['download_url'] = f"/api/jobs/{row['id']}/download" if row['output_path'] else ''
    return rows


def valid_source(url: str) -> bool:
    p = urlparse(url.strip())
    return p.scheme in {'http', 'https'} and ('youtube.com' in p.netloc or 'youtu.be' in p.netloc)


def create_job(payload: dict) -> dict:
    source = str(payload.get('source_url') or settings().get('source_url') or DEFAULT_SOURCE).strip()
    mode = str(payload.get('story_mode') or settings().get('story_mode') or 'original')
    if not valid_source(source):
        raise ValueError('Source must be a YouTube URL or channel videos URL.')
    if mode not in {'original', 'reddit_text'}:
        raise ValueError('Story mode must be original or reddit_text.')
    reddit_text = str(payload.get('reddit_text') or '').strip()[:12000]
    if mode == 'reddit_text' and len(reddit_text) < 80:
        raise ValueError('Paste at least 80 characters of Reddit text for Reddit mode.')
    job_id = uuid.uuid4().hex[:12]
    stamp = now()
    with db_connect() as db:
        db.execute('INSERT INTO jobs(id,source_url,story_mode,reddit_text,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
                   (job_id, source, mode, reddit_text, 'queued', stamp, stamp))
        db.commit()
    return {'id': job_id, 'status': 'queued'}


def choose_story(mode: str, reddit_text: str) -> tuple[str, str]:
    if mode == 'reddit_text':
        clean = re.sub(r'\s+', ' ', reddit_text).strip()
        # Keep user-provided story as source material, but frame it as a short narration.
        title = 'The Story Nobody Believed'
        story = ("This story sounds made up, but the ending is impossible to ignore. " + clean)
        return title, story[:18000]
    title, story = random.choice(STORIES)
    return title, story


def run_command(args: list[str], timeout: int = 900) -> subprocess.CompletedProcess:
    return subprocess.run(args, text=True, capture_output=True, timeout=timeout)


def source_metadata(url: str) -> tuple[str, dict]:
    args = [str(YTDLP)]
    if COOKIES.is_file():
        args += ['--cookies', str(COOKIES)]
    args += ['--js-runtimes', 'node', '--remote-components', 'ejs:github', '--no-playlist', '--dump-single-json', url]
    logger.info('Probing source metadata: %s', url)
    result = run_command(args)
    if result.returncode != 0:
        raise RuntimeError(result.stderr[-900:] or 'Could not read YouTube source.')
    data = json.loads(result.stdout)
    license_name = str(data.get('license') or '')
    if 'creative commons' not in license_name.lower():
        raise RuntimeError(f'Source rejected: license is not Creative Commons ({license_name or "missing"}).')
    return str(data.get('id') or uuid.uuid4().hex[:8]), data


def choose_channel_video(url: str) -> tuple[str, dict]:
    args = [str(YTDLP)]
    if COOKIES.is_file():
        args += ['--cookies', str(COOKIES)]
    args += ['--js-runtimes', 'node', '--remote-components', 'ejs:github', '--flat-playlist', '--dump-single-json', '--playlist-end', '10', url]
    logger.info('Scanning channel source: %s', url)
    result = run_command(args)
    if result.returncode != 0:
        raise RuntimeError(result.stderr[-900:] or 'Could not scan source channel.')
    data = json.loads(result.stdout)
    entries = [x for x in (data.get('entries') or []) if x and x.get('id')]
    if not entries:
        raise RuntimeError('No videos found in source channel.')
    def is_vertical(entry: dict) -> bool:
        title = str(entry.get('title', '')).lower()
        if 'vertical' in title:
            return True
        return False
    vertical = [x for x in entries if is_vertical(x)]
    pool = vertical or entries
    preferred = next((x for x in pool if 'parkour' in str(x.get('title', '')).lower()), pool[0])
    video_url = preferred.get('webpage_url') or f"https://www.youtube.com/watch?v={preferred['id']}"
    return source_metadata(video_url)


def download_source(video_id: str, data: dict) -> tuple[Path, dict]:
    source = SOURCES / f'{video_id}.mp4'
    info = SOURCES / f'{video_id}.info.json'
    if source.is_file() and info.is_file():
        return source, json.loads(info.read_text())
    url = data.get('webpage_url') or f"https://www.youtube.com/watch?v={video_id}"
    args = [str(YTDLP)]
    if COOKIES.is_file():
        args += ['--cookies', str(COOKIES)]
    args += ['--js-runtimes', 'node', '--remote-components', 'ejs:github', '--no-playlist',
             '--merge-output-format', 'mp4', '-f', 'bv*[height>=1080]+ba/bv*+ba',
             '--write-info-json', '-o', str(SOURCES / f'{video_id}.%(ext)s'), url]
    logger.info('Downloading source video: %s', video_id)
    result = run_command(args, timeout=1800)
    if result.returncode != 0 or not source.is_file():
        raise RuntimeError(result.stderr[-1000:] or 'Source download failed.')
    return source, json.loads(info.read_text())


def ass_time(seconds: float) -> str:
    seconds = max(0.0, seconds)
    return f"0:{int(seconds // 60):02d}:{seconds % 60:05.2f}"


def make_ass(words: list[dict], duration: float) -> str:
    header = '''[Script Info]\nScriptType: v4.00+\nPlayResX: 1080\nPlayResY: 1920\nWrapStyle: 1\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,96,&H00FFFFFF,&H000000FF,&H00101010,&H90000000,-1,0,0,0,100,100,1,0,1,7,3,2,50,50,470,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n'''
    lines = []
    for i in range(0, len(words), 3):
        group = words[i:i + 3]
        group_end = words[i + 3]['start'] if i + 3 < len(words) else duration + 0.3
        for j, word in enumerate(group):
            start = word['start'] if j else group[0]['start']
            end = group[j + 1]['start'] if j + 1 < len(group) else group_end
            parts = []
            for k, item in enumerate(group):
                text = re.sub(r"[^\w'\-!?.,]", '', item['word']).upper()
                if k == j:
                    parts.append(f"{{\\c&H0000FFFF&\\fscx128\\fscy128\\bord9\\shad5\\t(0,100,\\fscx100\\fscy100\\bord7)}}{text}{{\\c&H00FFFFFF&\\fscx100\\fscy100\\bord7}}")
                else:
                    parts.append(text)
            lines.append(f"Dialogue: 0,{ass_time(start)},{ass_time(end)},Default,,0,0,0,,{{\\fad(80,40)}}" + ' '.join(parts))
    return header + '\n'.join(lines) + '\n'


async def synthesize(text: str, out_mp3: Path) -> tuple[list[dict], float]:
    import edge_tts
    communicate = edge_tts.Communicate(text, VOICE, rate='-6%', pitch='-2Hz', boundary='WordBoundary')
    audio = []
    words = []
    async for chunk in communicate.stream():
        if chunk['type'] == 'audio':
            audio.append(chunk['data'])
        elif chunk['type'] == 'WordBoundary':
            words.append({'word': chunk['text'], 'start': chunk['offset'] / 10_000_000, 'end': (chunk['offset'] + chunk['duration']) / 10_000_000})
    if not words:
        raise RuntimeError('Voice service returned no word timings.')
    out_mp3.write_bytes(b''.join(audio))
    duration = words[-1]['end'] + 0.5
    return words, duration


def render(job_id: str, source: Path, title: str, story: str, metadata: dict) -> Path:
    work = MEDIA / job_id
    work.mkdir(parents=True, exist_ok=True)
    audio = work / 'voice.mp3'
    ass = work / 'captions.ass'
    words, duration = asyncio.run(synthesize(story, audio))
    ass.write_text(make_ass(words, duration), encoding='utf-8')
    out = work / 'clip.mp4'
    start_max = max(0.0, float(metadata.get('duration') or 60) - duration - 1)
    offset = min(300.0, start_max)
    ass_path = str(ass).replace('\\', '/').replace(':', '\\:')
    cmd = ['ffmpeg', '-y', '-stream_loop', '-1', '-ss', f'{offset:.2f}', '-i', str(source), '-i', str(audio),
           '-vf', f'ass={ass_path},scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920',
           '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'libx264', '-preset', 'fast', '-crf', '20',
           '-pix_fmt', 'yuv420p', '-r', '60', '-c:a', 'aac', '-b:a', '192k', '-ar', '44100', '-t', f'{duration + 0.5:.2f}',
           '-movflags', '+faststart', str(out)]
    result = run_command(cmd, timeout=1800)
    if result.returncode != 0:
        raise RuntimeError(result.stderr[-1200:] or 'Render failed.')
    return out


def process_job(job: sqlite3.Row) -> None:
    job_id = job['id']
    update_job(job_id, status='processing', message='Selecting licensed source and writing story...')
    try:
        logger.info('Processing job %s from source %s', job_id, job['source_url'])
        if '/@' in job['source_url'] or '/channel/' in job['source_url'] or '/videos' in job['source_url']:
            video_id, listing = choose_channel_video(job['source_url'])
            source, metadata = download_source(video_id, listing)
        else:
            video_id, metadata = source_metadata(job['source_url'])
            source, metadata = download_source(video_id, metadata)
        title, story = choose_story(job['story_mode'], job['reddit_text'])
        update_job(job_id, status='rendering', title=title, story=story, source_video=metadata.get('title', video_id), source_license=metadata.get('license', ''), source_credit=metadata.get('uploader', 'Source creator'), message='Rendering softer voice and animated captions...')
        logger.info('Rendering job %s: %s', job_id, title)
        output = render(job_id, source, title, story, metadata)
        update_job(job_id, status='complete', output_path=str(output), message='Ready. Publishing is disabled.')
        logger.info('Job %s complete: %s', job_id, output)
    except Exception as exc:
        logger.exception('Job %s failed', job_id)
        update_job(job_id, status='error', message=str(exc))


def update_job(job_id: str, **values) -> None:
    values['updated_at'] = now()
    fields = ', '.join(f'{key}=?' for key in values)
    with db_connect() as db:
        db.execute(f'UPDATE jobs SET {fields} WHERE id=?', (*values.values(), job_id))
        db.commit()


def worker_loop() -> None:
    init_db()
    while True:
        with db_connect() as db:
            job = db.execute("SELECT * FROM jobs WHERE status='queued' ORDER BY created_at LIMIT 1").fetchone()
        if job:
            process_job(job)
        else:
            time.sleep(5)


PAGE = '''<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>YouTube Story Generator</title><style>
:root{--bg:#080b12;--card:#111827;--line:#263248;--muted:#92a0b8;--accent:#61f2c2;--hot:#ffdb57}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top,#17243a 0,#080b12 48%);color:#f5f7fb;font:15px Inter,system-ui,sans-serif}main{max-width:1160px;margin:0 auto;padding:34px 20px 70px}h1{font-size:42px;line-height:1.05;margin:0 0 10px;letter-spacing:-1.5px}h1 span{color:var(--accent)}h2{margin:0 0 12px}.sub{color:var(--muted);max-width:760px;line-height:1.6}.grid{display:grid;grid-template-columns:1.05fr .95fr;gap:18px;margin-top:24px}.card{background:rgba(17,24,39,.9);border:1px solid var(--line);border-radius:18px;padding:22px;box-shadow:0 18px 55px #0004}label{display:block;color:#c9d3e5;margin:15px 0 7px}input,select,textarea{width:100%;background:#0a101c;color:#fff;border:1px solid #33435e;border-radius:10px;padding:12px;font:inherit}textarea{min-height:150px;resize:vertical}.row{display:flex;gap:10px;align-items:center}.btn{border:0;border-radius:10px;padding:12px 16px;background:var(--accent);color:#06110f;font-weight:800;cursor:pointer;margin-top:16px}.btn.secondary{background:#263248;color:#fff}.hint,.status{color:var(--muted);font-size:13px;line-height:1.5}.pill{display:inline-block;padding:5px 9px;border-radius:999px;background:#203149;color:#b9c9e5;font-size:12px}.job{border-top:1px solid var(--line);padding:15px 0}.job:first-child{border-top:0}.job h3{margin:0 0 6px}.complete{color:var(--accent)}.error{color:#ff8c8c}.processing{color:var(--hot)}a{color:var(--accent)}video{width:100%;max-height:480px;border-radius:12px;background:#000;margin-top:12px}@media(max-width:800px){.grid{grid-template-columns:1fr}h1{font-size:34px}}
</style></head><body><main><h1><span>Youtube</span> Story Generator</h1><p class="sub">Licensed Minecraft parkour source, softer narration, and high-impact word-pop captions. Original stories run by default. Reddit mode accepts text you provide. Public publishing stays disabled.</p><div class="grid"><section class="card"><h2>Make a clip</h2><label>Parkour source</label><input id="source" value="__SOURCE__"><p class="hint">Use a YouTube channel videos URL or a specific Creative Commons video URL. Source license is checked before download.</p><label>Story mode</label><select id="mode" onchange="toggleReddit()"><option value="original">Original thriller</option><option value="reddit_text">Reddit story text</option></select><div id="redditBox" style="display:none"><label>Reddit text</label><textarea id="redditText" placeholder="Paste story text here. It will be rewritten into narration."></textarea></div><button class="btn" onclick="createJob()">Generate clip</button><p id="notice" class="status"></p></section><section class="card"><h2>Style</h2><p><span class="pill">Soft voice</span> Emma Neural · −6% rate · −2Hz pitch</p><p><span class="pill">Pop captions</span> 3 words · 128% active word · thick outline · bounce + fade</p><p><span class="pill">Format</span> 1080×1920 · 60 fps · H.264/AAC</p><p><span class="pill">Publishing</span> Disabled</p><h2 style="margin-top:26px">System</h2><div id="health" class="status">Loading…</div></section></div><section class="card" style="margin-top:18px"><div class="row"><h2 style="flex:1">Jobs</h2><button class="btn secondary" onclick="load()">Refresh</button></div><div id="jobs">Loading…</div></section></main><script>
function toggleReddit(){redditBox.style.display=mode.value==='reddit_text'?'block':'none'}
async function api(path,opt){let r=await fetch(path,opt);let j=await r.json();if(!r.ok)throw Error(j.error||'Request failed');return j}
async function createJob(){notice.textContent='Queued…';try{let j=await api('/api/jobs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({source_url:source.value,story_mode:mode.value,reddit_text:redditText.value})});notice.textContent='Job '+j.id+' queued. Worker will render it.';load()}catch(e){notice.textContent=e.message}}
async function load(){try{let h=await api('/api/health');health.innerHTML='Dashboard online · Worker '+(h.worker?'online':'offline')+' · Source auth '+(h.cookies?'ready':'missing');let data=await api('/api/jobs');jobs.innerHTML=data.length?data.map(j=>`<div class="job"><h3>${esc(j.title||'Queued story')}</h3><div class="status ${j.status}">${esc(j.status)} · ${esc(j.message||'')}</div>${j.source_video?`<p class="hint">Source: ${esc(j.source_video)}<br>Credit: ${esc(j.source_credit)} · ${esc(j.source_license)}</p>`:''}${j.download_url?`<a href="${j.download_url}">Download clip</a><video controls preload="metadata" src="${j.download_url}"></video>`:''}</div>`).join(''):'<p class="status">No jobs yet.</p>'}catch(e){health.textContent=e.message}}
function esc(x){return String(x).replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}load();setInterval(load,8000)
</script></body></html>'''


class App:
    def __call__(self, environ, start):
        init_db()
        path = environ.get('PATH_INFO', '/')
        method = environ.get('REQUEST_METHOD', 'GET')
        if path == '/api/health':
            return json_response(start, {'ok': True, 'worker': True, 'cookies': COOKIES.is_file()})
        if path == '/api/jobs' and method == 'GET':
            return json_response(start, jobs_list())
        if path.startswith('/api/jobs/') and method == 'GET' and not path.endswith('/download'):
            job_id = path.split('/')[3]
            with db_connect() as db:
                row = db.execute('SELECT * FROM jobs WHERE id=?', (job_id,)).fetchone()
            if not row:
                return json_response(start, {'error': 'Job not found'}, '404 Not Found')
            return json_response(start, dict(row))
        if path == '/api/jobs' and method == 'POST':
            try:
                return json_response(start, create_job(body_json(environ)), '201 Created')
            except Exception as exc:
                return json_response(start, {'error': str(exc)}, '400 Bad Request')
        if path.startswith('/api/jobs/') and path.endswith('/download'):
            job_id = path.split('/')[3]
            with db_connect() as db:
                row = db.execute('SELECT output_path FROM jobs WHERE id=?', (job_id,)).fetchone()
            if not row or not row['output_path'] or not Path(row['output_path']).is_file():
                return json_response(start, {'error': 'Clip not ready'}, '404 Not Found')
            target = Path(row['output_path']).resolve()
            if not target.is_relative_to(MEDIA):
                return json_response(start, {'error': 'Invalid clip path'}, '404 Not Found')
            data = target.read_bytes()
            start('200 OK', [('Content-Type', 'video/mp4'), ('Content-Length', str(len(data))), ('Content-Disposition', f'inline; filename="{target.name}"')])
            return [data]
        if path == '/':
            s = settings()
            page = PAGE.replace('__SOURCE__', html.escape(s.get('source_url', DEFAULT_SOURCE)))
            start('200 OK', [('Content-Type', 'text/html; charset=utf-8')])
            return [page.encode()]
        return json_response(start, {'error': 'not found'}, '404 Not Found')


def serve() -> None:
    init_db()
    print('youtube generator listening on 127.0.0.1:8791', flush=True)
    make_server('127.0.0.1', 8791, App()).serve_forever()


if __name__ == '__main__':
    import sys
    if len(sys.argv) > 1 and sys.argv[1] == 'worker':
        worker_loop()
    else:
        serve()
