import {createHash} from 'node:crypto';
import {createReadStream, promises as fs} from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {spawn} from 'node:child_process';
import {createRequire} from 'node:module';

const script_dir = path.dirname(fileURLToPath(import.meta.url));
const project_root = path.resolve(script_dir, '..');
const repo_root = path.resolve(project_root, '../../..');
const require_from_e2e = createRequire(path.join(repo_root, 'e2e/package.json'));
const {chromium} = require_from_e2e('playwright');

const plan_path = path.join(project_root, 'edit/edit-plan.json');
const plan = JSON.parse(await fs.readFile(plan_path, 'utf8'));
const {width, height, fps, duration_seconds} = plan.render;
const preview_dir = path.join(project_root, 'out/layout-preview');
const output_path = path.resolve(project_root, plan.output_file);
const mode_arg = process.argv.find(argument => argument.startsWith('--mode='));
const mode = mode_arg ? mode_arg.split('=')[1] : 'preview';

const content_types = {
    '.css': 'text/css; charset=utf-8',
    '.html': 'text/html; charset=utf-8',
    '.js': 'text/javascript; charset=utf-8',
    '.json': 'application/json; charset=utf-8',
    '.mp3': 'audio/mpeg',
    '.svg': 'image/svg+xml',
    '.ttf': 'font/ttf',
};

const run_process = (command, args, options = {}) => new Promise((resolve, reject) => {
    const child = spawn(command, args, {
        cwd: project_root,
        stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
    });
    let stdout = '';
    let stderr = '';
    if (child.stdout) {
        child.stdout.on('data', chunk => {
            stdout += chunk.toString();
        });
    }
    if (child.stderr) {
        child.stderr.on('data', chunk => {
            stderr += chunk.toString();
        });
    }
    child.on('error', reject);
    child.on('close', code => {
        if (code !== 0) {
            reject(new Error(`${command} exited with ${code}\n${stderr}`));
            return;
        }
        resolve({stdout, stderr});
    });
});

const server = http.createServer(async (request, response) => {
    try {
        const url_path = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname);
        const requested_path = url_path === '/' ? '/index.html' : url_path;
        const file_path = path.resolve(project_root, `.${requested_path}`);
        if (!file_path.startsWith(`${project_root}${path.sep}`)) {
            response.writeHead(403);
            response.end('Forbidden');
            return;
        }
        const stat = await fs.stat(file_path);
        if (!stat.isFile()) {
            throw new Error('Not a file');
        }
        response.writeHead(200, {
            'Cache-Control': 'no-store',
            'Content-Type': content_types[path.extname(file_path)] || 'application/octet-stream',
        });
        createReadStream(file_path).pipe(response);
    } catch {
        response.writeHead(404);
        response.end('Not found');
    }
});

await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const base_url = `http://127.0.0.1:${address.port}`;
const browser = await chromium.launch({
    headless: true,
    args: [
        '--disable-gpu',
        '--disable-skia-runtime-opts',
        '--disable-threaded-animation',
        '--disable-threaded-scrolling',
    ],
});
const page = await browser.newPage({
    viewport: {width, height},
    deviceScaleFactor: 1,
});
const browser_messages = [];
page.on('console', message => {
    if (message.type() === 'warning' || message.type() === 'error') {
        browser_messages.push(`${message.type()}: ${message.text()}`);
    }
});
page.on('pageerror', error => {
    browser_messages.push(`pageerror: ${error.message}`);
});

await page.goto(`${base_url}/?render=1`, {waitUntil: 'networkidle'});
await page.waitForFunction(() => window.motion_ready === true);

const render_frame = async time => {
    await page.evaluate(render_time => {
        window.render_at_time(render_time);
    }, time);
    return page.screenshot({
        animations: 'disabled',
        type: 'png',
    });
};

const export_previews = async () => {
    await fs.mkdir(preview_dir, {recursive: true});
    const existing = await fs.readdir(preview_dir).catch(() => []);
    await Promise.all(existing.filter(name => name.endsWith('.png') || name.endsWith('.jpg')).map(name => fs.unlink(path.join(preview_dir, name))));

    for (const [index, time] of plan.verification.preview_times_seconds.entries()) {
        const buffer = await render_frame(time);
        const filename = `preview-${String(index).padStart(2, '0')}-${time.toFixed(3)}.png`;
        await fs.writeFile(path.join(preview_dir, filename), buffer);
        process.stdout.write(`preview ${index + 1}/${plan.verification.preview_times_seconds.length}: ${filename}\n`);
    }

    await render_frame(0);
    const first = await render_frame(16);
    await render_frame(28.2);
    const second = await render_frame(16);
    const hash_a = createHash('sha256').update(first).digest('hex');
    const hash_b = createHash('sha256').update(second).digest('hex');
    if (hash_a !== hash_b) {
        await fs.writeFile(path.join(preview_dir, 'determinism-a.png'), first);
        await fs.writeFile(path.join(preview_dir, 'determinism-b.png'), second);
        throw new Error(`Determinism check failed: ${hash_a} != ${hash_b}`);
    }
    process.stdout.write(`determinism: passed (${hash_a.slice(0, 16)})\n`);

    await run_process('ffmpeg', [
        '-hide_banner',
        '-loglevel', 'error',
        '-pattern_type', 'glob',
        '-i', path.join(preview_dir, 'preview-*.png'),
        '-vf', 'scale=320:180,tile=4x4:padding=5:margin=5:color=0x090c1b',
        '-frames:v', '1',
        '-y',
        path.join(preview_dir, 'contact-sheet.jpg'),
    ]);
    process.stdout.write(`contact sheet: ${path.join(preview_dir, 'contact-sheet.jpg')}\n`);
};

const render_video = async () => {
    if (plan.review_status === 'draft' && plan.review_authorization?.skip_intermediate_approval !== true) {
        throw new Error('The edit plan is still draft and has no explicit authorization to skip intermediate approval.');
    }

    await fs.mkdir(path.dirname(output_path), {recursive: true});
    const total_frames = Math.ceil(duration_seconds * fps);
    const ffmpeg_args = [
        '-hide_banner',
        '-loglevel', 'warning',
        '-f', 'image2pipe',
        '-framerate', String(fps),
        '-vcodec', 'png',
        '-i', 'pipe:0',
        '-i', path.join(project_root, 'assets/soundtrack.mp3'),
        '-filter_complex', `[1:a]loudnorm=I=${plan.render.audio_lufs}:TP=${plan.render.audio_true_peak_db}:LRA=7[audio]`,
        '-map', '0:v:0',
        '-map', '[audio]',
        '-t', String(duration_seconds),
        '-c:v', 'libx264',
        '-preset', 'slow',
        '-crf', '15',
        '-pix_fmt', 'yuv420p',
        '-color_primaries', 'bt709',
        '-color_trc', 'bt709',
        '-colorspace', 'bt709',
        '-c:a', 'aac',
        '-b:a', '256k',
        '-ar', '48000',
        '-movflags', '+faststart',
        '-y',
        output_path,
    ];
    const encoder = spawn('ffmpeg', ffmpeg_args, {
        cwd: project_root,
        stdio: ['pipe', 'inherit', 'inherit'],
    });
    const encoder_complete = new Promise((resolve, reject) => {
        encoder.on('error', reject);
        encoder.on('close', code => {
            if (code !== 0) {
                reject(new Error(`ffmpeg exited with ${code}`));
                return;
            }
            resolve();
        });
    });

    for (let frame = 0; frame < total_frames; frame += 1) {
        const time = Math.min(duration_seconds, frame / fps);
        const buffer = await render_frame(time);
        if (!encoder.stdin.write(buffer)) {
            await new Promise(resolve => encoder.stdin.once('drain', resolve));
        }
        if (frame % 120 === 0 || frame === total_frames - 1) {
            process.stdout.write(`render ${frame + 1}/${total_frames} (${time.toFixed(2)}s)\n`);
        }
    }
    encoder.stdin.end();
    await encoder_complete;
    process.stdout.write(`video: ${output_path}\n`);
};

try {
    if (mode === 'preview' || mode === 'all') {
        await export_previews();
    }
    if (mode === 'render' || mode === 'all') {
        await render_video();
    }
    if (browser_messages.length) {
        throw new Error(`Browser warnings/errors:\n${browser_messages.join('\n')}`);
    }
} finally {
    await browser.close();
    await new Promise(resolve => server.close(resolve));
}
