#![forbid(unsafe_code)]

//! Command-line entry point for PixelFlow.

use std::ffi::{OsStr, OsString};
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use clap::{CommandFactory, Parser, Subcommand};
use indicatif::{ProgressBar, ProgressStyle};

#[derive(Debug, Parser, PartialEq)]
#[command(
    name = "pixelflow",
    version,
    about = "Render PixelFlow .pf scripts to Y4M"
)]
struct CliArgs {
    #[command(subcommand)]
    command: Option<CliCommand>,

    #[arg(value_name = "SCRIPT")]
    script_path: Option<PathBuf>,

    #[arg(short = 'o', long = "output", value_name = "PATH")]
    output: Option<PathBuf>,

    #[arg(long = "set", value_name = "NAME=VALUE", value_parser = parse_script_parameter)]
    parameters: Vec<pixelflow_script::ScriptParameter>,

    #[arg(long, default_value_t = 0)]
    start: usize,

    #[arg(long)]
    end: Option<usize>,

    #[arg(long, value_parser = parse_worker_threads)]
    threads: Option<usize>,

    #[arg(long)]
    timings: bool,
}

#[derive(Debug, Subcommand, PartialEq)]
enum CliCommand {
    /// Print detailed PixelFlow version and plugin information.
    Version {
        /// Include core, feature, ABI, and loaded plugin details.
        #[arg(long)]
        verbose: bool,
    },
}

#[derive(Clone, Debug, Eq, PartialEq)]
enum OutputTarget {
    Stdout,
    File(PathBuf),
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct TerminalState {
    stderr: bool,
}

impl TerminalState {
    fn current() -> Self {
        Self {
            stderr: std::io::stderr().is_terminal(),
        }
    }
}

const fn should_show_index_progress(terminal: TerminalState) -> bool {
    terminal.stderr
}

const fn should_show_render_progress(target: &OutputTarget, terminal: TerminalState) -> bool {
    terminal.stderr && matches!(target, OutputTarget::File(_))
}

fn main() {
    let args = match parse_args(std::env::args_os()) {
        Ok(args) => args,
        Err(error) => error.exit(),
    };

    if let Err(error) = run(args) {
        eprintln!("{error}");
        std::process::exit(1);
    }
}

fn run(args: CliArgs) -> pixelflow::Result<()> {
    let terminal = TerminalState::current();
    let logger = pixelflow::Logger::default();
    let (core, loaded_plugins) = build_core(args.threads, &logger)?;

    if let Some(CliCommand::Version { verbose }) = args.command {
        print_version(verbose, &loaded_plugins);
        return Ok(());
    }

    let path = args
        .script_path
        .as_deref()
        .expect("clap validation requires script path for render");
    let source = std::fs::read_to_string(path).map_err(|error| {
        pixelflow::PixelFlowError::new(
            pixelflow::ErrorCategory::Io,
            pixelflow::ErrorCode::new("io.read_script"),
            format!("failed to read script '{}': {error}", path.display()),
        )
    })?;
    let script_dir = script_dir(path);
    let prop_resolver = Arc::new(RuntimePropResolver::new(
        script_dir.clone(),
        logger.clone(),
        core.config().worker_threads(),
    ));
    let (graph, metadata_schema) =
        pixelflow_script::ScriptEngine::with_filter_registry(core.registry().clone())
            .with_prop_resolver(prop_resolver)
            .evaluate(&source, &args.parameters)?
            .into_parts();
    let mut index_progress = StderrIndexProgress::new(should_show_index_progress(terminal));
    let indexed = pixelflow_filters::ffms2_source::index_reachable_sources(
        graph,
        &pixelflow_filters::ffms2_source::SourceIndexContext::new(script_dir.as_deref(), logger),
        &mut index_progress,
    )?;
    let (graph, mut executors) = indexed.into_graph_and_executors();
    prepare_graph_for_render(&graph, &metadata_schema, &mut executors)?;

    let options = pixelflow::RenderOptions::new(args.start, args.end);
    let target = output_target(
        args.output
            .expect("clap validation requires output for render"),
    );
    let show_render_progress = should_show_render_progress(&target, terminal);
    match target {
        OutputTarget::Stdout => {
            let stdout = std::io::stdout();
            let stderr = std::io::stderr();
            let mut stdout = stdout.lock();
            let mut stderr = stderr.lock();
            render_y4m(
                &core,
                &graph,
                executors,
                options,
                &mut stdout,
                &mut stderr,
                args.timings,
                show_render_progress,
            )
        }
        OutputTarget::File(path) => {
            let file = std::fs::File::create(&path).map_err(|error| {
                pixelflow::PixelFlowError::new(
                    pixelflow::ErrorCategory::Io,
                    pixelflow::ErrorCode::new("io.write_y4m"),
                    format!("failed to create output '{}': {error}", path.display()),
                )
            })?;
            let stderr = std::io::stderr();
            let mut stderr = stderr.lock();
            render_y4m(
                &core,
                &graph,
                executors,
                options,
                std::io::BufWriter::new(file),
                &mut stderr,
                args.timings,
                show_render_progress,
            )
        }
    }
}

fn build_core(
    worker_threads: Option<usize>,
    logger: &pixelflow::Logger,
) -> pixelflow::Result<(pixelflow::Core, Vec<pixelflow::LoadedPlugin>)> {
    let mut config = pixelflow::CoreConfig::default()
        .with_auto_load_plugins(false)
        .with_logger(logger.clone());
    if let Some(worker_threads) = worker_threads {
        config = config.with_worker_threads(worker_threads);
    }
    let mut core = pixelflow::Core::with_config(config)?;
    pixelflow_filters::register_builtin_filters(core.registry_mut())?;
    let loaded_plugins = pixelflow::load_plugins_from_directories(
        &pixelflow::platform_plugin_directories(),
        core.registry_mut(),
        logger,
    );

    Ok((core, loaded_plugins))
}

fn parse_args<I, S>(args: I) -> Result<CliArgs, clap::Error>
where
    I: IntoIterator<Item = S>,
    S: Into<OsString> + Clone,
{
    let parsed = CliArgs::try_parse_from(args)?;
    validate_cli_args(parsed)
}

fn validate_cli_args(args: CliArgs) -> Result<CliArgs, clap::Error> {
    if args.command.is_none() {
        if args.script_path.is_none() {
            return Err(CliArgs::command().error(
                clap::error::ErrorKind::MissingRequiredArgument,
                "required argument 'SCRIPT' missing for render",
            ));
        }
        if args.output.is_none() {
            return Err(CliArgs::command().error(
                clap::error::ErrorKind::MissingRequiredArgument,
                "required option '-o <PATH>' missing for render",
            ));
        }
    }

    Ok(args)
}

fn parse_script_parameter(value: &str) -> Result<pixelflow_script::ScriptParameter, String> {
    pixelflow_script::ScriptParameter::parse_set(value).map_err(|error| error.to_string())
}

fn parse_worker_threads(value: &str) -> Result<usize, String> {
    let parsed = value
        .parse::<usize>()
        .map_err(|_| "--threads requires a positive integer".to_owned())?;
    if parsed == 0 {
        return Err("--threads requires a positive integer".to_owned());
    }

    Ok(parsed)
}

fn output_target(path: PathBuf) -> OutputTarget {
    if path.as_os_str() == OsStr::new("-") {
        OutputTarget::Stdout
    } else {
        OutputTarget::File(path)
    }
}

fn print_version(verbose: bool, plugins: &[pixelflow::LoadedPlugin]) {
    println!("pixelflow {}", pixelflow::version());
    if !verbose {
        return;
    }

    println!("core {}", pixelflow::version());
    println!("features: cli, script, filters, ffms2-source");
    println!("plugin ABI {}", pixelflow::PIXELFLOW_ABI_VERSION);
    if plugins.is_empty() {
        println!("plugins: none");
    } else {
        println!("plugins:");
        for plugin in plugins {
            println!("  {} (ABI v{})", plugin.name(), plugin.abi_version());
        }
    }
}

struct StderrIndexProgress {
    enabled: bool,
    bar: Option<ProgressBar>,
}

impl StderrIndexProgress {
    const fn new(enabled: bool) -> Self {
        Self { enabled, bar: None }
    }

    #[cfg(test)]
    const fn has_active_bar_for_tests(&self) -> bool {
        self.bar.is_some()
    }
}

impl pixelflow_filters::ffms2_source::IndexProgressSink for StderrIndexProgress {
    fn begin_source(&mut self, path: &Path) {
        if !self.enabled {
            return;
        }

        let bar = ProgressBar::new(0);
        bar.set_style(index_progress_style());
        bar.set_prefix("indexing");
        bar.set_message(path.display().to_string());
        bar.tick();
        self.bar = Some(bar);
    }

    fn progress(&mut self, path: &Path, current: usize, total: usize) {
        if !self.enabled {
            return;
        }

        if self.bar.is_none() {
            self.begin_source(path);
        }

        if let Some(bar) = &self.bar {
            bar.set_length(progress_units(total));
            bar.set_position(progress_units(current));
        }
    }

    fn cache_hit(&mut self, path: &Path) {
        if let Some(bar) = &self.bar {
            bar.println(format!("index cache hit {}", path.display()));
        }
    }

    fn finish_source(&mut self, path: &Path) {
        let Some(bar) = self.bar.take() else {
            return;
        };

        bar.println(format!("indexed {}", path.display()));
        bar.finish_and_clear();
    }
}

fn index_progress_style() -> ProgressStyle {
    ProgressStyle::with_template("{prefix:.bold} [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
        .unwrap_or_else(|_| ProgressStyle::default_bar())
        .progress_chars("=> ")
}

fn progress_units(value: usize) -> u64 {
    u64::try_from(value).unwrap_or(u64::MAX)
}

fn script_dir(path: &Path) -> Option<PathBuf> {
    path.parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .map(Path::to_path_buf)
}

fn output_media(
    graph: &pixelflow::Graph,
) -> pixelflow::Result<(
    pixelflow::FormatDescriptor,
    usize,
    usize,
    usize,
    pixelflow::Rational,
)> {
    let output = graph.outputs().first().ok_or_else(|| {
        pixelflow::PixelFlowError::new(
            pixelflow::ErrorCategory::Graph,
            pixelflow::ErrorCode::new("graph.missing_output"),
            "graph does not have a final output",
        )
    })?;
    let node = graph.node(output.node_id()).ok_or_else(|| {
        pixelflow::PixelFlowError::new(
            pixelflow::ErrorCategory::Graph,
            pixelflow::ErrorCode::new("graph.invalid_clip"),
            "final output node is missing",
        )
    })?;
    let pixelflow::ClipFormat::Fixed(format) = node.media().format() else {
        return Err(unsupported_y4m("final output has variable format"));
    };
    let pixelflow::ClipResolution::Fixed { width, height } = node.media().resolution() else {
        return Err(unsupported_y4m("final output has variable resolution"));
    };
    let pixelflow::FrameCount::Finite(frame_count) = node.media().frame_count() else {
        return Err(unsupported_y4m("final output has unknown frame count"));
    };
    let pixelflow::FrameRate::Cfr(frame_rate) = node.media().frame_rate() else {
        return Err(unsupported_y4m("final output has unknown frame rate"));
    };
    if !pixelflow::is_y4m_compatible_format(format) {
        return Err(unsupported_y4m(format!(
            "final output format '{}' is not Y4M-compatible; convert explicitly to integer YUV or Gray",
            format.name()
        )));
    }

    Ok((format.clone(), *width, *height, frame_count, frame_rate))
}

fn unsupported_y4m(message: impl Into<String>) -> pixelflow::PixelFlowError {
    pixelflow::PixelFlowError::new(
        pixelflow::ErrorCategory::Format,
        pixelflow::ErrorCode::new("format.unsupported_y4m"),
        message,
    )
}

fn render_y4m<W: Write, E: Write>(
    core: &pixelflow::Core,
    graph: &pixelflow::Graph,
    executors: pixelflow::RenderExecutorMap,
    options: pixelflow::RenderOptions,
    output: W,
    diagnostics: &mut E,
    timings: bool,
    show_progress: bool,
) -> pixelflow::Result<()> {
    let (format, width, height, frame_count, frame_rate) = output_media(graph)?;
    let range = options.validate(frame_count)?;
    let mut render = core.render_ordered(graph.clone(), executors, options)?;
    let mut writer = pixelflow::Y4mWriter::new(output, format, width, height, frame_rate)?;
    let started = std::time::Instant::now();
    let total = range.len();
    let progress = render_progress_bar(total, show_progress);

    for (index, frame) in render.by_ref().enumerate() {
        let frame = frame?;
        writer.write_frame(&frame)?;
        update_render_progress(&progress, index + 1, total, started.elapsed());
    }

    progress.finish_and_clear();

    if timings {
        write_timing_report(diagnostics, graph, &render.timing_report())?;
    }

    let mut output = writer.into_inner();
    output.flush().map_err(|error| write_y4m_error(&error))?;
    Ok(())
}

fn render_progress_bar(total: usize, enabled: bool) -> ProgressBar {
    if !enabled {
        return ProgressBar::hidden();
    }

    let bar = ProgressBar::new(progress_units(total));
    bar.set_style(render_progress_style());
    bar.set_prefix("rendering");
    bar
}

fn update_render_progress(
    bar: &ProgressBar,
    rendered: usize,
    total: usize,
    elapsed: std::time::Duration,
) {
    let seconds = elapsed.as_secs_f64().max(0.001);
    let fps = rendered as f64 / seconds;
    let remaining = total.saturating_sub(rendered);
    let eta = if fps > 0.0 {
        remaining as f64 / fps
    } else {
        0.0
    };

    bar.set_message(format!("{fps:.2} fps, ETA {eta:.1}s"));
    bar.set_position(progress_units(rendered));
}

fn render_progress_style() -> ProgressStyle {
    ProgressStyle::with_template("{prefix:.bold} [{wide_bar:.cyan/blue}] {pos}/{len} frames {msg}")
        .unwrap_or_else(|_| ProgressStyle::default_bar())
        .progress_chars("=> ")
}

fn write_timing_report<W: Write>(
    writer: &mut W,
    graph: &pixelflow::Graph,
    report: &pixelflow::TimingReport,
) -> pixelflow::Result<()> {
    writeln!(writer, "timings:").map_err(|error| stderr_error(&error))?;
    for (node_id, timing) in report.iter() {
        let Some(node) = graph.node(node_id) else {
            continue;
        };
        let pixelflow::NodeKind::Filter { name, .. } = node.kind() else {
            continue;
        };
        writeln!(
            writer,
            "  {name}#{}: {} frames, {:.3} ms total",
            node_id.index(),
            timing.frames(),
            timing.total().as_secs_f64() * 1000.0
        )
        .map_err(|error| stderr_error(&error))?;
    }
    Ok(())
}

fn write_y4m_error(error: &std::io::Error) -> pixelflow::PixelFlowError {
    pixelflow::PixelFlowError::new(
        pixelflow::ErrorCategory::Io,
        pixelflow::ErrorCode::new("io.write_y4m"),
        format!("failed to write Y4M output: {error}"),
    )
}

fn stderr_error(error: &std::io::Error) -> pixelflow::PixelFlowError {
    pixelflow::PixelFlowError::new(
        pixelflow::ErrorCategory::Io,
        pixelflow::ErrorCode::new("io.write_stderr"),
        format!("failed to write diagnostic output: {error}"),
    )
}

fn ensure_reachable_executors(
    graph: &pixelflow::Graph,
    executors: &pixelflow::RenderExecutorMap,
) -> pixelflow::Result<()> {
    for node_id in graph.validation_plan()?.reachable_nodes() {
        if !executors.contains(*node_id) {
            return Err(pixelflow::PixelFlowError::new(
                pixelflow::ErrorCategory::Core,
                pixelflow::ErrorCode::new("render.missing_executor"),
                format!("render executor for node {} is missing", node_id.index()),
            ));
        }
    }
    Ok(())
}

fn prepare_graph_for_render(
    graph: &pixelflow::Graph,
    metadata_schema: &pixelflow::MetadataSchema,
    executors: &mut pixelflow::RenderExecutorMap,
) -> pixelflow::Result<()> {
    graph.validate()?;
    pixelflow_filters::build_builtin_executors(graph, metadata_schema, executors)?;
    ensure_reachable_executors(graph, executors)
}

fn resolve_prop_from_prepared_graph(
    graph: pixelflow::Graph,
    mut executors: pixelflow::RenderExecutorMap,
    metadata_schema: &pixelflow::MetadataSchema,
    worker_threads: usize,
    frame_number: usize,
    key: &str,
) -> pixelflow::Result<pixelflow::MetadataValue> {
    prepare_graph_for_render(&graph, metadata_schema, &mut executors)?;

    let end = frame_number.checked_add(1).ok_or_else(|| {
        pixelflow::PixelFlowError::new(
            pixelflow::ErrorCategory::Script,
            pixelflow::ErrorCode::new("script.invalid_argument"),
            "prop frame number is too large",
        )
    })?;
    let options = pixelflow::RenderOptions::new(frame_number, Some(end));
    let mut render = pixelflow::RenderEngine::new(pixelflow::WorkerPoolConfig::new(worker_threads))
        .render_ordered(graph, executors, options)?;

    let frame = render.next().ok_or_else(|| {
        pixelflow::PixelFlowError::new(
            pixelflow::ErrorCategory::Core,
            pixelflow::ErrorCode::new("render.frame_out_of_range"),
            "prop frame number is outside clip frame range",
        )
    })??;

    Ok(frame
        .metadata()
        .get(key)
        .cloned()
        .unwrap_or(pixelflow::MetadataValue::None))
}

#[derive(Clone)]
struct RuntimePropResolver {
    script_dir: Option<PathBuf>,
    logger: pixelflow::Logger,
    worker_threads: usize,
}

impl RuntimePropResolver {
    const fn new(
        script_dir: Option<PathBuf>,
        logger: pixelflow::Logger,
        worker_threads: usize,
    ) -> Self {
        Self {
            script_dir,
            logger,
            worker_threads,
        }
    }
}

impl pixelflow_script::ScriptPropResolver for RuntimePropResolver {
    fn resolve_prop(
        &self,
        graph: pixelflow::Graph,
        metadata_schema: pixelflow::MetadataSchema,
        frame_number: usize,
        key: &str,
    ) -> pixelflow::Result<pixelflow::MetadataValue> {
        let mut progress = pixelflow_filters::ffms2_source::NoopIndexProgressSink;
        let indexed = pixelflow_filters::ffms2_source::index_reachable_sources(
            graph,
            &pixelflow_filters::ffms2_source::SourceIndexContext::new(
                self.script_dir.as_deref(),
                self.logger.clone(),
            ),
            &mut progress,
        )?;
        let (graph, executors) = indexed.into_graph_and_executors();
        resolve_prop_from_prepared_graph(
            graph,
            executors,
            &metadata_schema,
            self.worker_threads,
            frame_number,
            key,
        )
    }
}

#[cfg(test)]
mod tests {
    use std::path::Path;
    use std::sync::Arc;

    use clap::error::ErrorKind;
    use pixelflow_filters::ffms2_source::IndexProgressSink;

    use super::{
        CliCommand, OutputTarget, TerminalState, output_media, parse_args, render_progress_bar,
        render_y4m, script_dir, should_show_index_progress, should_show_render_progress,
        update_render_progress,
    };

    struct SyntheticGrayExecutor;

    impl pixelflow::FrameExecutor for SyntheticGrayExecutor {
        fn prepare(
            &self,
            request: pixelflow::FrameRequest<'_>,
        ) -> pixelflow::Result<pixelflow::Frame> {
            let format = pixelflow::resolve_format_alias("gray8")?;
            let mut builder = pixelflow::FrameBuilder::new(
                format,
                2,
                1,
                &pixelflow::MetadataSchema::core(),
                pixelflow::AllocatorConfig::default(),
            )?;
            let mut plane = builder.plane_mut::<u8>(0)?;
            plane.row_mut(0).expect("row exists").copy_from_slice(&[
                u8::try_from(request.frame_number()).expect("test frame fits u8"),
                9,
            ]);
            Ok(builder.finish())
        }
    }

    struct MetadataSourceExecutor {
        value: pixelflow::MetadataValue,
    }

    impl pixelflow::FrameExecutor for MetadataSourceExecutor {
        fn prepare(
            &self,
            _request: pixelflow::FrameRequest<'_>,
        ) -> pixelflow::Result<pixelflow::Frame> {
            let format = pixelflow::resolve_format_alias("gray8")?;
            let frame = pixelflow::FrameBuilder::new(
                format,
                1,
                1,
                &pixelflow::MetadataSchema::core(),
                pixelflow::AllocatorConfig::default(),
            )?
            .finish();
            let mut metadata = frame.metadata().clone();
            metadata.set(
                &pixelflow::MetadataSchema::core(),
                "core:matrix",
                self.value.clone(),
            )?;
            Ok(frame.with_metadata(metadata))
        }
    }

    #[test]
    fn parse_args_collects_render_options() {
        let args = parse_args([
            "pixelflow",
            "script.pf",
            "-o",
            "-",
            "--start",
            "2",
            "--end",
            "5",
            "--threads",
            "3",
            "--timings",
            "--set",
            "width=1280",
        ])
        .expect("args should parse");

        assert_eq!(args.script_path, Some("script.pf".into()));
        assert_eq!(args.output, Some("-".into()));
        assert_eq!(args.start, 2);
        assert_eq!(args.end, Some(5));
        assert_eq!(args.threads, Some(3));
        assert!(args.timings);
        assert_eq!(args.parameters.len(), 1);
    }

    #[test]
    fn parse_args_rejects_missing_output_for_render() {
        let error = parse_args(["pixelflow", "script.pf"]).expect_err("output is required");

        assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument);
        assert!(error.to_string().contains("-o"));
    }

    #[test]
    fn parse_args_rejects_zero_threads() {
        let error = parse_args(["pixelflow", "script.pf", "-o", "-", "--threads", "0"])
            .expect_err("zero threads should fail");

        assert_eq!(error.kind(), ErrorKind::ValueValidation);
    }

    #[test]
    fn parse_args_supports_verbose_version_subcommand() {
        let args = parse_args(["pixelflow", "version", "--verbose"])
            .expect("verbose version command should parse");

        assert!(matches!(
            args.command,
            Some(CliCommand::Version { verbose: true })
        ));
        assert_eq!(args.script_path, None);
        assert_eq!(args.output, None);
    }

    #[test]
    fn parse_args_uses_clap_help_and_short_version_actions() {
        let help = parse_args(["pixelflow", "--help"]).expect_err("help exits early");
        let version = parse_args(["pixelflow", "--version"]).expect_err("version exits early");

        assert_eq!(help.kind(), ErrorKind::DisplayHelp);
        assert_eq!(version.kind(), ErrorKind::DisplayVersion);
    }

    #[test]
    fn script_dir_uses_parent_directory() {
        assert_eq!(
            script_dir(Path::new("/tmp/script.pf")),
            Some(Path::new("/tmp").to_path_buf())
        );
    }

    #[test]
    fn disabled_index_progress_does_not_create_active_bar() {
        let mut sink = super::StderrIndexProgress::new(false);

        sink.begin_source(Path::new("input.mkv"));
        sink.progress(Path::new("input.mkv"), 5, 10);
        sink.cache_hit(Path::new("input.mkv"));
        sink.finish_source(Path::new("input.mkv"));

        assert!(!sink.has_active_bar_for_tests());
    }

    #[test]
    fn index_progress_may_show_when_stdout_is_piped() {
        let terminal = TerminalState { stderr: true };

        assert!(should_show_index_progress(terminal));
    }

    #[test]
    fn progress_is_hidden_when_stderr_is_not_interactive() {
        let terminal = TerminalState { stderr: false };

        assert!(!should_show_index_progress(terminal));
        assert!(!should_show_render_progress(
            &OutputTarget::File("out.y4m".into()),
            terminal
        ));
    }

    #[test]
    fn render_progress_is_hidden_for_stdout_output() {
        let terminal = TerminalState { stderr: true };

        assert!(!should_show_render_progress(
            &OutputTarget::Stdout,
            terminal
        ));
    }

    #[test]
    fn render_progress_shows_for_file_output_on_interactive_stderr() {
        let terminal = TerminalState { stderr: true };

        assert!(should_show_render_progress(
            &OutputTarget::File("out.y4m".into()),
            terminal
        ));
    }

    #[test]
    fn render_progress_helpers_track_position_and_message_when_enabled() {
        let progress = render_progress_bar(4, true);

        update_render_progress(&progress, 2, 4, std::time::Duration::from_secs(1));

        assert_eq!(progress.length(), Some(4));
        assert_eq!(progress.position(), 2);
        assert_eq!(progress.prefix(), "rendering");
        assert!(progress.message().contains("2.00 fps"));
        assert!(progress.message().contains("ETA 1.0s"));
    }

    #[test]
    fn render_y4m_writes_only_y4m_to_output_and_progress_to_stderr() {
        let format = pixelflow::resolve_format_alias("gray8").expect("gray8 format should resolve");
        let media = pixelflow::ClipMedia::fixed(
            format,
            2,
            1,
            2,
            pixelflow::Rational {
                numerator: 24,
                denominator: 1,
            },
        );
        let mut builder = pixelflow::GraphBuilder::new();
        let source = builder.source("synthetic", media);
        builder.set_output(source);
        let graph = builder.build();
        let mut executors = pixelflow::RenderExecutorMap::new();
        executors.insert(source.node_id(), Arc::new(SyntheticGrayExecutor));
        let core = pixelflow::Core::with_config(
            pixelflow::CoreConfig::default()
                .with_auto_load_plugins(false)
                .with_worker_threads(1),
        )
        .expect("core should construct");
        let mut y4m = Vec::new();
        let mut stderr = Vec::new();

        render_y4m(
            &core,
            &graph,
            executors,
            pixelflow::RenderOptions::new(0, None),
            &mut y4m,
            &mut stderr,
            false,
            false,
        )
        .expect("render should succeed");

        assert!(y4m.starts_with(b"YUV4MPEG2"));
        assert!(
            y4m.windows(b"FRAME\n".len())
                .any(|window| window == b"FRAME\n")
        );
        assert!(stderr.is_empty());
    }

    #[test]
    fn render_y4m_writes_timings_when_progress_is_hidden() {
        let format = pixelflow::resolve_format_alias("gray8").expect("gray8 format should resolve");
        let media = pixelflow::ClipMedia::fixed(
            format,
            2,
            1,
            1,
            pixelflow::Rational {
                numerator: 24,
                denominator: 1,
            },
        );
        let mut builder = pixelflow::GraphBuilder::new();
        let source = builder.source("synthetic", media);
        builder.set_output(source);
        let graph = builder.build();
        let mut executors = pixelflow::RenderExecutorMap::new();
        executors.insert(source.node_id(), Arc::new(SyntheticGrayExecutor));
        let core = pixelflow::Core::with_config(
            pixelflow::CoreConfig::default()
                .with_auto_load_plugins(false)
                .with_worker_threads(1),
        )
        .expect("core should construct");
        let mut y4m = Vec::new();
        let mut stderr = Vec::new();

        render_y4m(
            &core,
            &graph,
            executors,
            pixelflow::RenderOptions::new(0, None),
            &mut y4m,
            &mut stderr,
            true,
            false,
        )
        .expect("render should succeed");

        assert!(
            String::from_utf8(stderr)
                .expect("stderr should be UTF-8")
                .contains("timings:")
        );
    }

    #[test]
    fn output_media_rejects_non_y4m_compatible_final_format_before_render() {
        let format = pixelflow::resolve_format_alias("rgbp8").expect("rgbp8 format should resolve");
        let media = pixelflow::ClipMedia::fixed(
            format,
            2,
            2,
            1,
            pixelflow::Rational {
                numerator: 24,
                denominator: 1,
            },
        );
        let mut builder = pixelflow::GraphBuilder::new();
        let source = builder.source("synthetic", media);
        builder.set_output(source);

        let error = output_media(&builder.build()).expect_err("RGB output should fail");

        assert_eq!(error.code().as_str(), "format.unsupported_y4m");
        assert!(error.message().contains("convert explicitly"));
    }

    #[test]
    fn render_y4m_uses_zero_based_half_open_range() {
        let format = pixelflow::resolve_format_alias("gray8").expect("gray8 format should resolve");
        let media = pixelflow::ClipMedia::fixed(
            format,
            2,
            1,
            4,
            pixelflow::Rational {
                numerator: 1,
                denominator: 1,
            },
        );
        let mut builder = pixelflow::GraphBuilder::new();
        let source = builder.source("synthetic", media);
        builder.set_output(source);
        let graph = builder.build();
        let mut executors = pixelflow::RenderExecutorMap::new();
        executors.insert(source.node_id(), Arc::new(SyntheticGrayExecutor));
        let core = pixelflow::Core::with_config(
            pixelflow::CoreConfig::default()
                .with_auto_load_plugins(false)
                .with_worker_threads(1),
        )
        .expect("core should construct");
        let mut y4m = Vec::new();
        let mut stderr = Vec::new();

        render_y4m(
            &core,
            &graph,
            executors,
            pixelflow::RenderOptions::new(1, Some(3)),
            &mut y4m,
            &mut stderr,
            false,
            false,
        )
        .expect("render should succeed");

        assert!(y4m.ends_with(b"FRAME\n\x01\tFRAME\n\x02\t"));
    }

    #[test]
    fn core_config_uses_threads_flag() {
        let args = parse_args(["pixelflow", "script.pf", "-o", "-", "--threads", "2"])
            .expect("args should parse");
        let config = pixelflow::CoreConfig::default()
            .with_auto_load_plugins(false)
            .with_worker_threads(args.threads.expect("threads should parse"));
        let core = pixelflow::Core::with_config(config).expect("core should build");

        assert_eq!(core.config().worker_threads(), 2);
        assert_eq!(core.render_engine().worker_threads(), 2);
    }

    #[test]
    fn resolve_prop_from_prepared_graph_renders_one_frame_metadata() {
        let format = pixelflow::resolve_format_alias("gray8").expect("gray8 resolves");
        let media = pixelflow::ClipMedia::fixed(
            format,
            1,
            1,
            4,
            pixelflow::Rational {
                numerator: 24,
                denominator: 1,
            },
        );
        let mut builder = pixelflow::GraphBuilder::new();
        let clip = builder.source("synthetic", media);
        builder.set_output(clip);
        let graph = builder.build();

        let mut executors = pixelflow::RenderExecutorMap::new();
        executors.insert(
            clip.node_id(),
            Arc::new(MetadataSourceExecutor {
                value: pixelflow::MetadataValue::String("bt709".to_owned()),
            }),
        );

        let got = super::resolve_prop_from_prepared_graph(
            graph,
            executors,
            &pixelflow::MetadataSchema::core(),
            1,
            2,
            "core:matrix",
        )
        .expect("prop should render");

        assert_eq!(got, pixelflow::MetadataValue::String("bt709".to_owned()));
    }
}
