#![warn(missing_docs)]
#![forbid(unsafe_code)]

//! Script integration layer for PixelFlow graph construction.

use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::rc::Rc;
use std::sync::Arc;

use semisafe::slice::get as semisafe_get;

use pixelflow_core::{
    Clip, ClipFormat, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, FilterOptionValue,
    FilterOptions, FilterRegistry, FrameCount, Graph, GraphBuilder, Logger, MetadataKind,
    MetadataSchema, MetadataValue, NodeId, PixelFlowError, Rational, Result, SourceOptionValue,
    SourceRequest,
};
use rhai::{Array, Dynamic, Engine, EvalAltResult, Map, ParseError, Position, Scope};

/// Script value injected from CLI or embedding APIs.
#[derive(Clone, Debug, PartialEq)]
pub enum ScriptValue {
    /// String parameter.
    String(String),
    /// Boolean parameter.
    Bool(bool),
    /// Integer parameter.
    Int(i64),
    /// Floating-point parameter.
    Float(f64),
    /// Rational parameter.
    Rational(Rational),
}

/// One validated `--set name=value` script parameter.
#[derive(Clone, Debug, PartialEq)]
pub struct ScriptParameter {
    name: String,
    value: ScriptValue,
}

impl ScriptParameter {
    /// Parses one `--set name=value` argument.
    pub fn parse_set(argument: &str) -> Result<Self> {
        let Some((name, raw_value)) = argument.split_once('=') else {
            return invalid_parameter("script parameter must use name=value syntax");
        };

        if !is_script_identifier(name) {
            return invalid_parameter(format!("invalid script parameter name '{name}'"));
        }

        Ok(Self {
            name: name.to_owned(),
            value: parse_script_value(raw_value)?,
        })
    }

    /// Returns parameter name.
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Returns parameter value.
    #[must_use]
    pub const fn value(&self) -> &ScriptValue {
        &self.value
    }
}

/// Script engine shell configured with PixelFlow logging hooks.
#[derive(Clone, Default)]
pub struct ScriptEngine {
    logger: Logger,
    filters: FilterRegistry,
    prop_resolver: Option<Arc<dyn ScriptPropResolver>>,
}

/// Resolves one frame metadata value for script-side `prop(...)` calls.
pub trait ScriptPropResolver: Send + Sync {
    /// Renders or otherwise inspects `graph` and returns `key` from `frame_number`.
    fn resolve_prop(
        &self,
        graph: Graph,
        metadata_schema: MetadataSchema,
        frame_number: usize,
        key: &str,
    ) -> Result<MetadataValue>;
}

impl ScriptEngine {
    /// Creates a script engine using a no-op logger.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Creates a script engine using the provided logger.
    #[must_use]
    pub fn with_logger(logger: Logger) -> Self {
        Self::with_logger_and_filter_registry(logger, FilterRegistry::new())
    }

    /// Creates a script engine using the provided filter registry.
    #[must_use]
    pub fn with_filter_registry(filters: FilterRegistry) -> Self {
        Self::with_logger_and_filter_registry(Logger::default(), filters)
    }

    /// Creates a script engine using the provided logger and filter registry.
    #[must_use]
    pub const fn with_logger_and_filter_registry(logger: Logger, filters: FilterRegistry) -> Self {
        Self {
            logger,
            filters,
            prop_resolver: None,
        }
    }

    /// Returns this script engine with replacement filter registry.
    #[must_use]
    pub fn with_filters(mut self, filters: FilterRegistry) -> Self {
        self.filters = filters;
        self
    }

    /// Returns this script engine with a runtime metadata resolver for `prop(...)` calls.
    #[must_use]
    pub fn with_prop_resolver(mut self, resolver: Arc<dyn ScriptPropResolver>) -> Self {
        self.prop_resolver = Some(resolver);
        self
    }

    /// Evaluates script source into a graph handle.
    pub fn evaluate(&self, source: &str, parameters: &[ScriptParameter]) -> Result<ScriptGraph> {
        evaluate_script(
            &self.logger,
            &self.filters,
            self.prop_resolver.clone(),
            source,
            parameters,
        )
    }
}

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct PropCacheKey {
    node_id: NodeId,
    frame_number: usize,
    key: String,
}

#[derive(Clone, Default)]
struct ScriptGraphState {
    builder: GraphBuilder,
    media: Vec<ClipMedia>,
    filters: FilterRegistry,
    prop_resolver: Option<Arc<dyn ScriptPropResolver>>,
    prop_cache: BTreeMap<PropCacheKey, MetadataValue>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
struct ScriptBlob {
    bytes: Arc<[u8]>,
}

impl ScriptBlob {
    fn into_arc_bytes(self) -> Arc<[u8]> {
        self.bytes
    }
}

impl ScriptGraphState {
    fn media_for(&self, clip: Clip) -> Result<ClipMedia> {
        self.media
            .get(clip.node_id().index())
            .cloned()
            .ok_or_else(|| {
                PixelFlowError::new(
                    ErrorCategory::Graph,
                    ErrorCode::new("graph.invalid_clip"),
                    format!("clip references missing node {}", clip.node_id().index()),
                )
            })
    }
}

/// Graph handle returned by script evaluation.
#[derive(Clone, Debug, PartialEq)]
pub struct ScriptGraph {
    graph: Graph,
    metadata_schema: MetadataSchema,
}

impl ScriptGraph {
    /// Returns constructed graph.
    #[must_use]
    pub const fn graph(&self) -> &Graph {
        &self.graph
    }

    /// Returns metadata schema active during script evaluation.
    #[must_use]
    pub const fn metadata_schema(&self) -> &MetadataSchema {
        &self.metadata_schema
    }

    /// Consumes this wrapper and returns constructed graph.
    #[must_use]
    pub fn into_graph(self) -> Graph {
        self.graph
    }

    /// Consumes this wrapper and returns constructed graph plus active metadata schema.
    #[must_use]
    pub fn into_parts(self) -> (Graph, MetadataSchema) {
        (self.graph, self.metadata_schema)
    }
}

fn evaluate_script(
    logger: &Logger,
    filters: &FilterRegistry,
    prop_resolver: Option<Arc<dyn ScriptPropResolver>>,
    source: &str,
    parameters: &[ScriptParameter],
) -> Result<ScriptGraph> {
    if source.trim().is_empty() {
        return Err(PixelFlowError::new(
            ErrorCategory::Script,
            ErrorCode::new("script.empty"),
            "script source is empty",
        ));
    }

    let state = Rc::new(RefCell::new(ScriptGraphState {
        filters: filters.clone(),
        prop_resolver,
        ..ScriptGraphState::default()
    }));
    let engine = build_engine(state.clone());
    let mut scope = Scope::new();
    let source = normalize_source(source);
    let source = rewrite_filter_syntax(&source, filters)?;

    if count_output_assignments(&source) > 1 {
        return Err(PixelFlowError::new(
            ErrorCategory::Graph,
            ErrorCode::new("graph.multiple_outputs"),
            "script assigns final output more than once",
        ));
    }

    push_parameters(&mut scope, parameters);
    declare_assigned_variables(&mut scope, &source);

    let ast = engine
        .compile_with_scope(&scope, &source)
        .map_err(|error| parse_error(&error))?;
    engine
        .run_ast_with_scope(&mut scope, &ast)
        .map_err(|error| eval_error(&error))?;

    let output = scope.get_value::<Clip>("output").ok_or_else(|| {
        if scope.contains("output") {
            PixelFlowError::new(
                ErrorCategory::Graph,
                ErrorCode::new("graph.invalid_output"),
                "script final output must be a clip",
            )
        } else {
            PixelFlowError::new(
                ErrorCategory::Graph,
                ErrorCode::new("graph.missing_output"),
                "script does not assign final output",
            )
        }
    })?;

    let (graph, metadata_schema) = {
        let mut state = state.borrow_mut();
        state.builder.set_output(output);
        (
            state.builder.clone().build(),
            state.filters.metadata_schema().clone(),
        )
    };

    logger.log(
        pixelflow_core::LogLevel::Debug,
        "pixelflow_script",
        "script graph constructed",
    );

    Ok(ScriptGraph {
        graph,
        metadata_schema,
    })
}

fn build_engine(state: Rc<RefCell<ScriptGraphState>>) -> Engine {
    let mut engine = Engine::new_raw();
    engine.set_max_operations(50_000);
    engine.set_max_call_levels(32);
    engine.set_max_variables(256);
    engine.set_max_functions(64);
    engine.set_max_modules(0);
    engine.set_max_string_size(1_048_576);
    engine.set_max_array_size(4096);
    engine.set_max_map_size(4096);
    engine.set_strict_variables(true);
    engine.set_fail_on_invalid_map_property(true);

    engine.register_type_with_name::<Clip>("Clip");
    engine.register_type_with_name::<Rational>("Rational");
    engine.register_type_with_name::<ScriptBlob>("Blob");
    engine.register_fn("none", || Dynamic::UNIT);
    engine.register_fn("is_none", |value: Dynamic| value.is_unit());
    engine.register_fn(
        "blob",
        |values: Array| -> std::result::Result<ScriptBlob, Box<EvalAltResult>> {
            script_blob(values).map_err(to_eval_error)
        },
    );

    register_graph_api(&mut engine, state);
    engine
}

fn register_graph_api(engine: &mut Engine, state: Rc<RefCell<ScriptGraphState>>) {
    let register_state = state.clone();
    engine.register_fn(
        "register_prop",
        move |key: &str, kind: &str| -> std::result::Result<(), Box<EvalAltResult>> {
            register_prop(&register_state, key, kind).map_err(to_eval_error)
        },
    );

    let prop_state = state.clone();
    engine.register_fn(
        "prop",
        move |clip: Clip, key: &str| -> std::result::Result<Dynamic, Box<EvalAltResult>> {
            resolve_script_prop(&prop_state, clip, 0, key).map_err(to_eval_error)
        },
    );

    let prop_state = state.clone();
    engine.register_fn(
        "prop",
        move |clip: Clip,
              frame_number: i64,
              key: &str|
              -> std::result::Result<Dynamic, Box<EvalAltResult>> {
            let frame_number = usize::try_from(frame_number).map_err(|_| {
                to_eval_error(invalid_argument_error(
                    "prop frame number must be non-negative".to_owned(),
                ))
            })?;
            resolve_script_prop(&prop_state, clip, frame_number, key).map_err(to_eval_error)
        },
    );

    let source_state = state.clone();
    engine.register_fn(
        "source",
        move |path: &str| -> std::result::Result<Clip, Box<EvalAltResult>> {
            source_from_options(&source_state, path, &Map::new()).map_err(to_eval_error)
        },
    );

    let source_state = state.clone();
    engine.register_fn(
        "source",
        move |path: &str, options: Map| -> std::result::Result<Clip, Box<EvalAltResult>> {
            source_from_options(&source_state, path, &options).map_err(to_eval_error)
        },
    );

    let filter_state = state.clone();
    engine.register_fn(
        "filter",
        move |clip: Clip,
              name: &str,
              options: Map|
              -> std::result::Result<Clip, Box<EvalAltResult>> {
            filter_from_options(&filter_state, clip, name, &options).map_err(to_eval_error)
        },
    );

    let filter_state = state.clone();
    engine.register_fn(
        "filter",
        move |clip: Clip, name: &str| -> std::result::Result<Clip, Box<EvalAltResult>> {
            filter_from_options(&filter_state, clip, name, &Map::new()).map_err(to_eval_error)
        },
    );

    let filter_state = state.clone();
    engine.register_fn(
        "filter",
        move |clips: Array,
              name: &str,
              options: Map|
              -> std::result::Result<Clip, Box<EvalAltResult>> {
            filter_array_from_options(&filter_state, clips, name, &options).map_err(to_eval_error)
        },
    );

    engine.register_fn(
        "filter",
        move |clips: Array, name: &str| -> std::result::Result<Clip, Box<EvalAltResult>> {
            filter_array_from_options(&state, clips, name, &Map::new()).map_err(to_eval_error)
        },
    );
}

fn register_prop(state: &Rc<RefCell<ScriptGraphState>>, key: &str, kind: &str) -> Result<()> {
    state
        .borrow_mut()
        .filters
        .register_metadata_key(key, parse_metadata_kind(kind)?)
}

fn resolve_script_prop(
    state: &Rc<RefCell<ScriptGraphState>>,
    clip: Clip,
    frame_number: usize,
    key: &str,
) -> Result<Dynamic> {
    let cache_key = PropCacheKey {
        node_id: clip.node_id(),
        frame_number,
        key: key.to_owned(),
    };

    let (resolver, graph, metadata_schema) = {
        let state_ref = state.borrow_mut();
        if let Some(value) = state_ref.prop_cache.get(&cache_key).cloned() {
            return Ok(metadata_value_to_dynamic(value));
        }

        if !state_ref.filters.metadata_schema().contains_key(key) {
            return Err(PixelFlowError::new(
                ErrorCategory::Plugin,
                ErrorCode::new("metadata.unregistered_key"),
                format!("metadata key '{key}' is not registered"),
            ));
        }

        let resolver = state_ref.prop_resolver.clone().ok_or_else(|| {
            PixelFlowError::new(
                ErrorCategory::Script,
                ErrorCode::new("script.prop_unavailable"),
                "script prop retrieval requires a runtime resolver",
            )
        })?;

        let mut builder = state_ref.builder.clone();
        builder.set_output(clip);
        (
            resolver,
            builder.build(),
            state_ref.filters.metadata_schema().clone(),
        )
    };

    let value = resolver.resolve_prop(graph, metadata_schema, frame_number, key)?;
    state
        .borrow_mut()
        .prop_cache
        .insert(cache_key, value.clone());
    Ok(metadata_value_to_dynamic(value))
}

fn metadata_value_to_dynamic(value: MetadataValue) -> Dynamic {
    match value {
        MetadataValue::None => Dynamic::UNIT,
        MetadataValue::Bool(value) => Dynamic::from(value),
        MetadataValue::Int(value) => Dynamic::from(value),
        MetadataValue::Float(value) => Dynamic::from(value),
        MetadataValue::String(value) => Dynamic::from(value),
        MetadataValue::Array(values) => {
            Dynamic::from_array(values.into_iter().map(metadata_value_to_dynamic).collect())
        }
        MetadataValue::Rational(value) => Dynamic::from(value),
        MetadataValue::Blob(value) => Dynamic::from(ScriptBlob { bytes: value }),
    }
}

fn script_blob(values: Array) -> Result<ScriptBlob> {
    let mut bytes = Vec::with_capacity(values.len());
    for (index, value) in values.into_iter().enumerate() {
        let Ok(byte) = value.as_int() else {
            return invalid_argument(format!(
                "blob byte at index {index} must be between 0 and 255"
            ));
        };
        let Ok(byte) = u8::try_from(byte) else {
            return invalid_argument(format!(
                "blob byte at index {index} must be between 0 and 255"
            ));
        };
        bytes.push(byte);
    }

    Ok(ScriptBlob {
        bytes: bytes.into(),
    })
}

fn parse_metadata_kind(kind: &str) -> Result<MetadataKind> {
    match kind {
        "bool" => Ok(MetadataKind::Bool),
        "int" => Ok(MetadataKind::Int),
        "float" => Ok(MetadataKind::Float),
        "string" => Ok(MetadataKind::String),
        "array" => Ok(MetadataKind::Array),
        "rational" => Ok(MetadataKind::Rational),
        "blob" => Ok(MetadataKind::Blob),
        _ => invalid_argument(format!(
            "metadata kind '{kind}' must be bool, int, float, string, array, rational, or blob"
        )),
    }
}

fn source_from_options(
    state: &Rc<RefCell<ScriptGraphState>>,
    path: &str,
    options: &Map,
) -> Result<Clip> {
    let mut request = SourceRequest::new(path);
    for (name, value) in options {
        request =
            request.try_with_option(name.as_str(), source_option_value(name.as_str(), value)?)?;
    }

    let media = ClipMedia::new(
        ClipFormat::Fixed(pixelflow_core::resolve_format_alias("yuv420p8")?),
        ClipResolution::Fixed {
            width: 1,
            height: 1,
        },
        FrameCount::Unknown,
        pixelflow_core::FrameRate::Unknown,
    );

    let mut state = state.borrow_mut();
    let clip = state.builder.source_with_request(request, media.clone());
    state.media.push(media);
    Ok(clip)
}

fn source_option_value(name: &str, value: &Dynamic) -> Result<SourceOptionValue> {
    if value.is::<Rational>() {
        return Ok(SourceOptionValue::Rational(value.clone_cast::<Rational>()));
    }

    if let Ok(string) = value.as_immutable_string_ref() {
        if name == "fps" {
            return parse_argument_rational(string.as_str()).map(SourceOptionValue::Rational);
        }
        return Ok(SourceOptionValue::String(string.as_str().to_owned()));
    }

    if let Ok(boolean) = value.as_bool() {
        return Ok(SourceOptionValue::Bool(boolean));
    }

    if let Ok(integer) = value.as_int() {
        return Ok(SourceOptionValue::Int(integer));
    }

    invalid_argument(format!(
        "source option '{name}' must be string, bool, integer, or rational"
    ))
}

fn filter_from_options(
    state: &Rc<RefCell<ScriptGraphState>>,
    clip: Clip,
    name: &str,
    options: &Map,
) -> Result<Clip> {
    filter_clips_from_options(state, &[clip], name, options)
}

fn filter_array_from_options(
    state: &Rc<RefCell<ScriptGraphState>>,
    clips: Array,
    name: &str,
    options: &Map,
) -> Result<Clip> {
    let mut parsed = Vec::with_capacity(clips.len());
    for (index, value) in clips.into_iter().enumerate() {
        if !value.is::<Clip>() {
            return invalid_argument(format!("filter input {index} must be Clip"));
        }
        parsed.push(value.clone_cast::<Clip>());
    }

    filter_clips_from_options(state, &parsed, name, options)
}

fn filter_clips_from_options(
    state: &Rc<RefCell<ScriptGraphState>>,
    clips: &[Clip],
    name: &str,
    options: &Map,
) -> Result<Clip> {
    if !is_filter_name(name) {
        return invalid_argument(format!("invalid filter name '{name}'"));
    }

    let options = filter_options(options)?;
    let plan = {
        let state = state.borrow();
        let input_media: Vec<_> = clips
            .iter()
            .map(|clip| state.media_for(*clip))
            .collect::<Result<_>>()?;
        state.filters.plan_filter(name, &input_media, &options)?
    };

    let (media, compatibility, dependencies, concurrency) = plan.into_parts();
    let mut state = state.borrow_mut();
    let output = state.builder.filter_with_schedule_and_options(
        name,
        clips,
        media.clone(),
        compatibility,
        dependencies,
        concurrency,
        options,
    )?;
    state.media.push(media);
    Ok(output)
}

fn filter_options(options: &Map) -> Result<FilterOptions> {
    let mut converted = FilterOptions::new();
    for (name, value) in options {
        if !is_script_identifier(name.as_str()) {
            return invalid_argument(format!("invalid filter option name '{name}'"));
        }
        converted.insert(name.to_string(), filter_option_value(name.as_str(), value)?);
    }
    Ok(converted)
}

fn filter_option_value(name: &str, value: &Dynamic) -> Result<FilterOptionValue> {
    if value.is_unit() {
        return Ok(FilterOptionValue::None);
    }
    if value.is::<ScriptBlob>() {
        return Ok(FilterOptionValue::Blob(
            value.clone_cast::<ScriptBlob>().into_arc_bytes(),
        ));
    }
    if value.is::<Array>() {
        let values = value.clone_cast::<Array>();
        let mut converted = Vec::with_capacity(values.len());
        for entry in values {
            converted.push(filter_option_value(name, &entry)?);
        }
        return Ok(FilterOptionValue::Array(converted));
    }
    if value.is::<Rational>() {
        return Ok(FilterOptionValue::Rational(value.clone_cast::<Rational>()));
    }
    if let Ok(string) = value.as_immutable_string_ref() {
        return Ok(FilterOptionValue::String(string.as_str().to_owned()));
    }
    if let Ok(boolean) = value.as_bool() {
        return Ok(FilterOptionValue::Bool(boolean));
    }
    if let Ok(integer) = value.as_int() {
        return Ok(FilterOptionValue::Int(integer));
    }
    if let Ok(float) = value.as_float() {
        if float.is_finite() {
            return Ok(FilterOptionValue::Float(float));
        }
        return invalid_argument(format!("filter option '{name}' float must be finite"));
    }

    invalid_argument(format!(
        "filter option '{name}' must be none, string, bool, integer, float, array, rational, or blob"
    ))
}

fn push_parameters(scope: &mut Scope<'_>, parameters: &[ScriptParameter]) {
    for parameter in parameters {
        match parameter.value() {
            ScriptValue::String(value) => {
                scope.push(parameter.name(), value.clone());
            }
            ScriptValue::Bool(value) => {
                scope.push(parameter.name(), *value);
            }
            ScriptValue::Int(value) => {
                scope.push(parameter.name(), *value);
            }
            ScriptValue::Float(value) => {
                scope.push(parameter.name(), *value);
            }
            ScriptValue::Rational(value) => {
                scope.push(parameter.name(), *value);
            }
        }
    }
}

fn declare_assigned_variables(scope: &mut Scope<'_>, source: &str) {
    for line in source.lines() {
        let trimmed = line.trim_start();
        let Some((name, _)) = trimmed.split_once('=') else {
            continue;
        };

        let name = name.trim();
        if is_script_identifier(name) && !scope.contains(name) {
            scope.push_dynamic(name, Dynamic::UNIT);
        }
    }
}

fn normalize_source(source: &str) -> String {
    let mut normalized = String::new();
    let mut nesting = 0usize;

    for line in source.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }

        if normalized.is_empty() {
            normalized.push_str(trimmed);
            update_nesting(&mut nesting, trimmed);
            continue;
        }

        if trimmed.starts_with('.') || nesting > 0 {
            normalized.push(' ');
            normalized.push_str(trimmed);
        } else {
            if !normalized.ends_with(';') {
                normalized.push(';');
            }
            normalized.push('\n');
            normalized.push_str(trimmed);
        }

        update_nesting(&mut nesting, trimmed);
    }

    if !normalized.is_empty() && !normalized.ends_with(';') {
        normalized.push(';');
    }

    normalized
}

#[derive(Clone, Copy, Debug, Default)]
struct SourceScanState {
    in_string: bool,
    escaped: bool,
    line_comment: bool,
    block_comment_depth: usize,
    nesting: usize,
}

fn rewrite_filter_syntax(source: &str, filters: &FilterRegistry) -> Result<String> {
    let mut known_clip_variables = BTreeSet::new();
    let mut rewritten = String::with_capacity(source.len());
    let mut state = SourceScanState::default();
    let mut index = 0usize;
    let mut statement_start = 0usize;

    while index < source.len() {
        if is_code_position(state)
            && state.nesting == 0
            && *semisafe_get(source.as_bytes(), index) == b';'
        {
            rewritten.push_str(&rewrite_filter_syntax_in_statement(
                slice_range(source, statement_start, index),
                &mut known_clip_variables,
                filters,
            )?);
            rewritten.push(';');
            statement_start = index + 1;
            index += 1;
            continue;
        }

        index = advance_scan_state(source, index, &mut state);
    }

    rewritten.push_str(&rewrite_filter_syntax_in_statement(
        slice_from(source, statement_start),
        &mut known_clip_variables,
        filters,
    )?);
    Ok(rewritten)
}

fn rewrite_filter_syntax_in_statement(
    statement: &str,
    known_clip_variables: &mut BTreeSet<String>,
    filters: &FilterRegistry,
) -> Result<String> {
    let Some((name_start, name_end, rhs_start)) = top_level_assignment_at(statement) else {
        return rewrite_namespaced_filter_functions(statement, filters);
    };

    let name = slice_range(statement, name_start, name_end);
    let rhs = rewrite_namespaced_filter_functions(slice_from(statement, rhs_start), filters)?;

    let Some(rewritten_rhs) = rewrite_clip_chain_rhs(&rhs, known_clip_variables, filters)? else {
        known_clip_variables.remove(name);
        let mut rewritten = String::with_capacity(statement.len());
        rewritten.push_str(slice_range(statement, 0, rhs_start));
        rewritten.push_str(&rhs);
        return Ok(rewritten);
    };

    known_clip_variables.insert(name.to_owned());

    let mut rewritten = String::with_capacity(statement.len());
    rewritten.push_str(slice_range(statement, 0, rhs_start));
    rewritten.push_str(&rewritten_rhs);
    Ok(rewritten)
}

fn top_level_assignment_at(source: &str) -> Option<(usize, usize, usize)> {
    let name_start = skip_trivia(source, 0);
    let name_end = identifier_end_at(source, name_start)?;
    let operator_index = skip_trivia(source, name_end);
    let bytes = source.as_bytes();
    if !starts_with_token(bytes, operator_index, b"=")
        || starts_with_token(bytes, operator_index, b"==")
    {
        return None;
    }

    Some((name_start, name_end, operator_index + 1))
}

fn rewrite_clip_chain_rhs(
    rhs: &str,
    known_clip_variables: &BTreeSet<String>,
    filters: &FilterRegistry,
) -> Result<Option<String>> {
    let Some(mut chain_end) = clip_root_end(rhs, skip_trivia(rhs, 0), known_clip_variables) else {
        return Ok(None);
    };
    let mut rewritten = String::with_capacity(rhs.len());
    let mut segment_start = 0usize;

    loop {
        let method_start = skip_trivia(rhs, chain_end);
        if !starts_with_token(rhs.as_bytes(), method_start, b".") {
            break;
        }

        let Some(method_call) = method_call_at(rhs, method_start) else {
            break;
        };

        if method_call.segments.as_slice() == ["prop"] {
            break;
        }

        if method_call.segments.as_slice() != ["filter"] {
            let filter_name = resolve_method_filter_name(filters, &method_call.segments)?;
            rewritten.push_str(slice_range(rhs, segment_start, method_start));
            rewritten.push_str(".filter(\"");
            rewritten.push_str(filter_name);
            rewritten.push('"');
            if method_call.has_arguments {
                rewritten.push_str(", ");
            }
            segment_start = method_call.after_open_paren;
        }

        chain_end = method_call.end;
    }

    rewritten.push_str(slice_from(rhs, segment_start));
    Ok(Some(rewritten))
}

fn clip_root_end(
    source: &str,
    index: usize,
    known_clip_variables: &BTreeSet<String>,
) -> Option<usize> {
    let name_end = identifier_end_at(source, index)?;
    let name = slice_range(source, index, name_end);
    if name == "source" || name == "filter" {
        return call_expression_end(source, name_end);
    }
    if known_clip_variables.contains(name) {
        return Some(name_end);
    }

    None
}

fn identifier_end_at(source: &str, index: usize) -> Option<usize> {
    let bytes = source.as_bytes();
    let first = *bytes.get(index)?;
    if !(first.is_ascii_alphabetic() || first == b'_') {
        return None;
    }

    let mut name_end = index + 1;
    while let Some(byte) = bytes.get(name_end) {
        if byte.is_ascii_alphanumeric() || *byte == b'_' {
            name_end += 1;
        } else {
            break;
        }
    }

    Some(name_end)
}

fn call_expression_end(source: &str, name_end: usize) -> Option<usize> {
    let open_paren = skip_whitespace(source, name_end);
    if !starts_with_token(source.as_bytes(), open_paren, b"(") {
        return None;
    }

    matching_paren_end(source, open_paren)
}

struct MethodCall<'a> {
    after_open_paren: usize,
    end: usize,
    segments: Vec<&'a str>,
    has_arguments: bool,
}

fn method_call_at(source: &str, index: usize) -> Option<MethodCall<'_>> {
    let call = call_path_at(source, index + 1)?;

    Some(MethodCall {
        after_open_paren: call.after_open_paren,
        end: call.end,
        segments: call.segments,
        has_arguments: call.has_arguments,
    })
}

struct CallPath<'a> {
    after_open_paren: usize,
    close_paren: usize,
    end: usize,
    segments: Vec<&'a str>,
    has_arguments: bool,
}

fn call_path_at(source: &str, start: usize) -> Option<CallPath<'_>> {
    let bytes = source.as_bytes();
    let mut cursor = start;
    let mut segments = Vec::new();

    loop {
        let segment_end = identifier_end_at(source, cursor)?;
        segments.push(slice_range(source, cursor, segment_end));

        let next = skip_whitespace(source, segment_end);
        if starts_with_token(bytes, next, b".") {
            cursor = skip_whitespace(source, next + 1);
            continue;
        }
        if !starts_with_token(bytes, next, b"(") {
            return None;
        }

        let end = matching_paren_end(source, next)?;
        let after_open_paren = next + 1;
        let has_arguments = !starts_with_token(bytes, skip_trivia(source, after_open_paren), b")");
        return Some(CallPath {
            after_open_paren,
            close_paren: end.saturating_sub(1),
            end,
            segments,
            has_arguments,
        });
    }
}

fn namespaced_filter_call_at(source: &str, index: usize) -> Option<CallPath<'_>> {
    if !can_start_namespaced_call(source, index) {
        return None;
    }

    let call = call_path_at(source, index)?;
    match call.segments.as_slice() {
        ["std", _] => Some(call),
        ["plugin", _, rest @ ..] if !rest.is_empty() => Some(call),
        _ => None,
    }
}

fn can_start_namespaced_call(source: &str, index: usize) -> bool {
    if index == 0 {
        return true;
    }

    !matches!(
        source.as_bytes().get(index - 1),
        Some(byte) if byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'.'
    )
}

fn rewrite_namespaced_filter_functions(source: &str, filters: &FilterRegistry) -> Result<String> {
    let mut rewritten = String::with_capacity(source.len());
    let mut state = SourceScanState::default();
    let mut index = 0usize;
    let mut segment_start = 0usize;

    while index < source.len() {
        if is_code_position(state)
            && let Some(call) = namespaced_filter_call_at(source, index)
        {
            let filter_name = resolve_function_filter_name(filters, &call.segments)?;
            let Some(first_arg_end) =
                first_argument_end(source, call.after_open_paren, call.close_paren)
            else {
                return invalid_argument(format!(
                    "namespaced filter call '{}' requires an input clip or clip array as first argument",
                    call.segments.join(".")
                ));
            };

            rewritten.push_str(slice_range(source, segment_start, index));
            rewritten.push_str("filter(");
            rewritten.push_str(slice_range(source, call.after_open_paren, first_arg_end).trim());
            rewritten.push_str(", \"");
            rewritten.push_str(filter_name);
            rewritten.push('"');

            let rest = slice_range(source, first_arg_end, call.close_paren).trim_start();
            if let Some(rest) = rest.strip_prefix(',') {
                rewritten.push_str(", ");
                rewritten.push_str(rest.trim_start());
            }
            rewritten.push(')');
            segment_start = call.end;
            index = call.end;
            continue;
        }

        index = advance_scan_state(source, index, &mut state);
    }

    rewritten.push_str(slice_from(source, segment_start));
    Ok(rewritten)
}

fn resolve_function_filter_name<'a>(
    filters: &'a FilterRegistry,
    segments: &[&'a str],
) -> Result<&'a str> {
    match segments {
        ["std", name] => filters.resolve_filter_name_for_plugin_call("std", name),
        ["plugin", plugin, rest @ ..] if !rest.is_empty() => {
            let name = rest.join(".");
            filters.resolve_filter_name_for_plugin_call(plugin, &name)
        }
        _ => invalid_argument(format!(
            "invalid filter namespace '{}'; use std.<filter>(...) or plugin.<namespace>.<filter>(...)",
            segments.join(".")
        )),
    }
}

fn resolve_method_filter_name<'a>(
    filters: &'a FilterRegistry,
    segments: &[&'a str],
) -> Result<&'a str> {
    match segments {
        [name] => Ok(*name),
        ["std", name] => filters.resolve_filter_name_for_plugin_call("std", name),
        ["plugin", plugin, rest @ ..] if !rest.is_empty() => {
            let name = rest.join(".");
            filters.resolve_filter_name_for_plugin_call(plugin, &name)
        }
        _ => invalid_argument(format!(
            "invalid filter method namespace '{}'; use .filter(...), .std.<filter>(...), or .plugin.<namespace>.<filter>(...)",
            segments.join(".")
        )),
    }
}

fn first_argument_end(source: &str, start: usize, close_paren: usize) -> Option<usize> {
    let mut index = skip_trivia(source, start);
    if index >= close_paren {
        return None;
    }

    let mut state = SourceScanState::default();
    while index < close_paren {
        if is_code_position(state)
            && state.nesting == 0
            && *semisafe_get(source.as_bytes(), index) == b','
        {
            return Some(index);
        }

        index = advance_scan_state(source, index, &mut state);
    }

    Some(close_paren)
}

fn matching_paren_end(source: &str, open_paren: usize) -> Option<usize> {
    let mut state = SourceScanState::default();
    let mut index = open_paren;

    while index < source.len() {
        index = advance_scan_state(source, index, &mut state);
        if state.nesting == 0 {
            return Some(index);
        }
    }

    None
}

fn advance_scan_state(source: &str, index: usize, state: &mut SourceScanState) -> usize {
    let bytes = source.as_bytes();
    let current_byte = *semisafe_get(bytes, index);
    let step = next_char_len(source, index);

    if state.line_comment {
        if current_byte == b'\n' {
            state.line_comment = false;
        }
        return index + step;
    }

    if state.block_comment_depth > 0 {
        if starts_with_token(bytes, index, b"/*") {
            state.block_comment_depth += 1;
            return index + 2;
        }
        if starts_with_token(bytes, index, b"*/") {
            state.block_comment_depth -= 1;
            return index + 2;
        }
        return index + step;
    }

    if state.in_string {
        if state.escaped {
            state.escaped = false;
        } else if current_byte == b'\\' {
            state.escaped = true;
        } else if current_byte == b'"' {
            state.in_string = false;
        }
        return index + step;
    }

    if starts_with_token(bytes, index, b"//") {
        state.line_comment = true;
        return index + 2;
    }
    if starts_with_token(bytes, index, b"/*") {
        state.block_comment_depth = 1;
        return index + 2;
    }

    match current_byte {
        b'"' => state.in_string = true,
        b'(' | b'[' | b'{' => state.nesting += 1,
        b')' | b']' | b'}' => state.nesting = state.nesting.saturating_sub(1),
        _ => {}
    }

    index + step
}

fn skip_trivia(source: &str, mut index: usize) -> usize {
    let bytes = source.as_bytes();

    while index < source.len() {
        if starts_with_token(bytes, index, b"//") {
            index += 2;
            while index < source.len() && *semisafe_get(bytes, index) != b'\n' {
                index += next_char_len(source, index);
            }
            continue;
        }

        if starts_with_token(bytes, index, b"/*") {
            let mut depth = 1usize;
            index += 2;
            while index < source.len() && depth > 0 {
                if starts_with_token(bytes, index, b"/*") {
                    depth += 1;
                    index += 2;
                } else if starts_with_token(bytes, index, b"*/") {
                    depth -= 1;
                    index += 2;
                } else {
                    index += next_char_len(source, index);
                }
            }
            continue;
        }

        let ch = slice_from(source, index)
            .chars()
            .next()
            .expect("index should stay on char boundary");
        if ch.is_whitespace() {
            index += ch.len_utf8();
            continue;
        }

        break;
    }

    index
}

fn skip_whitespace(source: &str, mut index: usize) -> usize {
    while index < source.len() {
        let ch = slice_from(source, index)
            .chars()
            .next()
            .expect("index should stay on char boundary");
        if ch.is_whitespace() {
            index += ch.len_utf8();
            continue;
        }

        break;
    }

    index
}

fn next_char_len(source: &str, index: usize) -> usize {
    slice_from(source, index)
        .chars()
        .next()
        .expect("index should stay on char boundary")
        .len_utf8()
}

fn slice_range(source: &str, start: usize, end: usize) -> &str {
    source
        .get(start..end)
        .expect("range should stay on char boundary")
}

fn slice_from(source: &str, index: usize) -> &str {
    source
        .get(index..)
        .expect("index should stay on char boundary")
}

fn starts_with_token(bytes: &[u8], index: usize, token: &[u8]) -> bool {
    bytes
        .get(index..index.saturating_add(token.len()))
        .is_some_and(|slice| slice == token)
}

const fn is_code_position(state: SourceScanState) -> bool {
    !state.in_string && !state.line_comment && state.block_comment_depth == 0
}

fn update_nesting(nesting: &mut usize, line: &str) {
    let mut in_string = false;
    let mut escaped = false;

    for ch in line.chars() {
        if in_string {
            if escaped {
                escaped = false;
                continue;
            }

            match ch {
                '\\' => escaped = true,
                '"' => in_string = false,
                _ => {}
            }
            continue;
        }

        match ch {
            '"' => in_string = true,
            '(' | '[' | '{' => *nesting += 1,
            ')' | ']' | '}' => *nesting = nesting.saturating_sub(1),
            _ => {}
        }
    }
}

fn count_output_assignments(source: &str) -> usize {
    let mut count = 0usize;
    let mut state = SourceScanState::default();
    let mut index = 0usize;
    let mut statement_start = true;

    while index < source.len() {
        if statement_start && is_code_position(state) && state.nesting == 0 {
            index = skip_trivia(source, index);
            if index >= source.len() {
                break;
            }
        }

        if is_code_position(state) {
            let byte = *semisafe_get(source.as_bytes(), index);

            if state.nesting == 0 && byte == b';' {
                statement_start = true;
                index += 1;
                continue;
            }

            if state.nesting == 0 && statement_start {
                if let Some(next_index) = output_assignment_at(source, index) {
                    count += 1;
                    index = next_index;
                }
                statement_start = false;
                continue;
            }
        }

        index = advance_scan_state(source, index, &mut state);
    }

    count
}

fn output_assignment_at(source: &str, index: usize) -> Option<usize> {
    let bytes = source.as_bytes();
    if !starts_with_token(bytes, index, b"output") {
        return None;
    }

    let name_end = index + "output".len();
    if bytes
        .get(name_end)
        .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
    {
        return None;
    }

    let operator_index = skip_trivia(source, name_end);
    if !starts_with_token(bytes, operator_index, b"=")
        || starts_with_token(bytes, operator_index, b"==")
    {
        return None;
    }

    Some(operator_index + 1)
}

fn parse_script_value(raw: &str) -> Result<ScriptValue> {
    if raw == "true" {
        return Ok(ScriptValue::Bool(true));
    }
    if raw == "false" {
        return Ok(ScriptValue::Bool(false));
    }

    if looks_like_rational_literal(raw) {
        return parse_parameter_rational(raw).map(ScriptValue::Rational);
    }

    if let Ok(value) = raw.parse::<i64>() {
        return Ok(ScriptValue::Int(value));
    }

    if raw.contains('.') || raw.contains('e') || raw.contains('E') {
        match raw.parse::<f64>() {
            Ok(value) if value.is_finite() => return Ok(ScriptValue::Float(value)),
            Ok(_) => return invalid_parameter("float parameter must be finite"),
            Err(_) => {}
        }
    }

    Ok(ScriptValue::String(raw.to_owned()))
}

fn parse_parameter_rational(raw: &str) -> Result<Rational> {
    parse_rational(raw, invalid_parameter_error)
}

fn parse_argument_rational(raw: &str) -> Result<Rational> {
    parse_rational(raw, invalid_argument_error)
}

fn parse_rational(raw: &str, make_error: fn(String) -> PixelFlowError) -> Result<Rational> {
    let Some((numerator, denominator)) = raw.split_once('/') else {
        return Err(make_error(
            "rational must use numerator/denominator syntax".to_owned(),
        ));
    };
    let numerator = numerator
        .parse::<i64>()
        .map_err(|_| make_error("invalid rational numerator".to_owned()))?;
    let denominator = denominator
        .parse::<i64>()
        .map_err(|_| make_error("invalid rational denominator".to_owned()))?;

    if denominator == 0 {
        return Err(make_error(
            "rational denominator must not be zero".to_owned(),
        ));
    }

    Ok(Rational {
        numerator,
        denominator,
    })
}

fn looks_like_rational_literal(raw: &str) -> bool {
    let Some((numerator, denominator)) = raw.split_once('/') else {
        return false;
    };

    numerator.parse::<i64>().is_ok() && denominator.parse::<i64>().is_ok()
}

fn is_script_identifier(name: &str) -> bool {
    let mut bytes = name.bytes();

    matches!(bytes.next(), Some(first) if first.is_ascii_alphabetic() || first == b'_')
        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
}

fn is_filter_name(name: &str) -> bool {
    !name.is_empty() && name.split('.').all(is_script_identifier)
}

fn parse_error(error: &ParseError) -> PixelFlowError {
    PixelFlowError::new(
        ErrorCategory::Script,
        ErrorCode::new("script.parse"),
        format!("{}: {error}", format_position(error.position())),
    )
}

fn eval_error(error: &EvalAltResult) -> PixelFlowError {
    if let rhai::EvalAltResult::ErrorSystem(_, inner) = error.unwrap_inner()
        && let Some(error) = inner.downcast_ref::<PixelFlowError>()
    {
        return PixelFlowError::new(error.category(), error.code(), error.message());
    }

    PixelFlowError::new(
        ErrorCategory::Script,
        ErrorCode::new("script.eval"),
        format!("{}: {error}", format_position(error.position())),
    )
}

fn format_position(position: Position) -> String {
    if position.is_none() {
        "unknown source position".to_owned()
    } else {
        format!(
            "line {}, position {}",
            position.line().unwrap_or(0),
            position.position().unwrap_or(0)
        )
    }
}

#[expect(
    clippy::unnecessary_box_returns,
    reason = "Rhai host functions return Box<EvalAltResult>"
)]
fn to_eval_error(error: PixelFlowError) -> Box<EvalAltResult> {
    Box::new(EvalAltResult::ErrorSystem(
        error.to_string(),
        Box::new(error),
    ))
}

fn invalid_parameter<T>(message: impl Into<String>) -> Result<T> {
    Err(invalid_parameter_error(message))
}

fn invalid_parameter_error(message: impl Into<String>) -> PixelFlowError {
    PixelFlowError::new(
        ErrorCategory::Script,
        ErrorCode::new("script.invalid_parameter"),
        message,
    )
}

fn invalid_argument<T>(message: impl Into<String>) -> Result<T> {
    Err(invalid_argument_error(message))
}

fn invalid_argument_error(message: impl Into<String>) -> PixelFlowError {
    PixelFlowError::new(
        ErrorCategory::Script,
        ErrorCode::new("script.invalid_argument"),
        message,
    )
}

#[cfg(test)]
mod tests {
    #![expect(clippy::indexing_slicing, reason = "allow in tests")]

    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::{Arc, Mutex};

    use pixelflow_core::{
        ClipFormat, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, FilterChangeSet,
        FilterCompatibility, FilterDescriptor, FilterOptionValue, FilterPlan, FilterPlanRequest,
        FilterRegistry, FrameCount, FrameRate, Graph, LogLevel, LogRecord, LogSink, Logger,
        Metadata, MetadataKind, MetadataValue, NodeKind, PixelFlowError, Rational,
    };

    use super::{ScriptEngine, ScriptParameter, ScriptValue};

    #[derive(Default)]
    struct FakePropResolver {
        calls: AtomicUsize,
    }

    impl super::ScriptPropResolver for FakePropResolver {
        fn resolve_prop(
            &self,
            graph: Graph,
            _metadata_schema: super::MetadataSchema,
            frame_number: usize,
            key: &str,
        ) -> pixelflow_core::Result<MetadataValue> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            assert_eq!(graph.outputs().len(), 1);
            assert_eq!(frame_number, 3);

            match key {
                "core:matrix" => Ok(MetadataValue::String("bt709".to_owned())),
                "core:frame_number" => Ok(MetadataValue::Int(3)),
                "core:duration" => Ok(MetadataValue::Rational(Rational {
                    numerator: 1001,
                    denominator: 30000,
                })),
                "core:source_path" => Ok(MetadataValue::None),
                _ => Err(PixelFlowError::new(
                    ErrorCategory::Core,
                    ErrorCode::new("metadata.unregistered_key"),
                    format!("metadata key '{key}' is not registered"),
                )),
            }
        }
    }

    fn fake_filter_registry() -> FilterRegistry {
        fn passthrough(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            Ok(FilterPlan::new(
                request.input_media()[0].clone(),
                FilterCompatibility::Preserve,
            ))
        }

        fn resize_like(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            let width = match request.options().get("width") {
                Some(FilterOptionValue::Int(width)) => {
                    usize::try_from(*width).expect("test width fits")
                }
                _ => panic!("test planner expected integer width"),
            };
            let height = match request.options().get("height") {
                Some(FilterOptionValue::Int(height)) => {
                    usize::try_from(*height).expect("test height fits")
                }
                _ => panic!("test planner expected integer height"),
            };
            let input = &request.input_media()[0];
            let media = ClipMedia::new(
                input.format().clone(),
                ClipResolution::Fixed { width, height },
                input.frame_count(),
                input.frame_rate(),
            );
            Ok(FilterPlan::new(
                media,
                FilterCompatibility::AllowChanges(FilterChangeSet {
                    format: false,
                    resolution: true,
                    frame_count: false,
                    frame_rate: false,
                }),
            ))
        }

        fn set_prop(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            assert_eq!(
                request.options().get("key"),
                Some(&FilterOptionValue::String("acme/filter:enabled".to_owned(),))
            );
            assert_eq!(
                request.options().get("value"),
                Some(&FilterOptionValue::Bool(true))
            );
            assert_eq!(
                request.metadata_schema().kind("acme/filter:enabled"),
                Some(MetadataKind::Bool)
            );

            Ok(FilterPlan::new(
                request.input_media()[0].clone(),
                FilterCompatibility::Preserve,
            ))
        }

        fn set_prop_array(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            assert_eq!(
                request.options().get("key"),
                Some(&FilterOptionValue::String("acme/filter:ratios".to_owned(),))
            );
            assert_eq!(
                request.options().get("value"),
                Some(&FilterOptionValue::Array(vec![
                    FilterOptionValue::Int(1),
                    FilterOptionValue::String("x".to_owned()),
                    FilterOptionValue::None,
                ]))
            );
            assert_eq!(
                request.metadata_schema().kind("acme/filter:ratios"),
                Some(MetadataKind::Array)
            );

            Ok(FilterPlan::new(
                request.input_media()[0].clone(),
                FilterCompatibility::Preserve,
            ))
        }

        fn set_prop_blob(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            assert_eq!(
                request.options().get("key"),
                Some(&FilterOptionValue::String("acme/filter:payload".to_owned(),))
            );
            assert_eq!(
                request.options().get("value"),
                Some(&FilterOptionValue::Blob(vec![0_u8, 127, 255].into()))
            );
            assert_eq!(
                request.metadata_schema().kind("acme/filter:payload"),
                Some(MetadataKind::Blob)
            );

            Ok(FilterPlan::new(
                request.input_media()[0].clone(),
                FilterCompatibility::Preserve,
            ))
        }

        fn set_prop_checked(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            let Some(FilterOptionValue::String(key)) = request.options().get("key") else {
                panic!("test planner expected string key");
            };
            let Some(FilterOptionValue::Bool(value)) = request.options().get("value") else {
                panic!("test planner expected bool value");
            };

            let mut metadata = Metadata::new(request.metadata_schema());
            metadata.set(request.metadata_schema(), key, MetadataValue::Bool(*value))?;

            Ok(FilterPlan::new(
                request.input_media()[0].clone(),
                FilterCompatibility::Preserve,
            ))
        }

        fn expect_bool(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            assert_eq!(
                request.options().get("value"),
                Some(&FilterOptionValue::Bool(true))
            );

            Ok(FilterPlan::new(
                request.input_media()[0].clone(),
                FilterCompatibility::Preserve,
            ))
        }

        fn expect_rational(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            assert_eq!(
                request.options().get("value"),
                Some(&FilterOptionValue::Rational(Rational {
                    numerator: 1001,
                    denominator: 30000,
                }))
            );

            Ok(FilterPlan::new(
                request.input_media()[0].clone(),
                FilterCompatibility::Preserve,
            ))
        }

        let mut registry = FilterRegistry::new();
        registry
            .register_filter_planner(
                FilterDescriptor::new("custom_filter", "test", "test"),
                passthrough,
            )
            .expect("custom filter registers");
        registry
            .register_filter_planner(
                FilterDescriptor::new("resize_like", "test", "test"),
                resize_like,
            )
            .expect("resize-like filter registers");
        registry
            .register_filter_planner(FilterDescriptor::new("set_prop", "test", "test"), set_prop)
            .expect("set-prop filter registers");
        registry
            .register_filter_planner(
                FilterDescriptor::new("set_prop_array", "test", "test"),
                set_prop_array,
            )
            .expect("set-prop-array filter registers");
        registry
            .register_filter_planner(
                FilterDescriptor::new("set_prop_blob", "test", "test"),
                set_prop_blob,
            )
            .expect("set-prop-blob filter registers");
        registry
            .register_filter_planner(
                FilterDescriptor::new("set_prop_checked", "test", "test"),
                set_prop_checked,
            )
            .expect("set-prop-checked filter registers");
        registry
            .register_filter_planner(
                FilterDescriptor::new("expect_bool", "test", "test"),
                expect_bool,
            )
            .expect("expect-bool filter registers");
        registry
            .register_filter_planner(
                FilterDescriptor::new("expect_rational", "test", "test"),
                expect_rational,
            )
            .expect("expect-rational filter registers");
        registry
    }

    fn script_engine_with_fake_filters() -> ScriptEngine {
        ScriptEngine::with_filter_registry(fake_filter_registry())
    }

    fn namespaced_filter_registry() -> FilterRegistry {
        fn passthrough(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            Ok(FilterPlan::new(
                request.input_media()[0].clone(),
                FilterCompatibility::Preserve,
            ))
        }

        fn resize_like(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            let width = match request.options().get("width") {
                Some(FilterOptionValue::Int(width)) => usize::try_from(*width).expect("width fits"),
                _ => panic!("test planner expected integer width"),
            };
            let height = match request.options().get("height") {
                Some(FilterOptionValue::Int(height)) => {
                    usize::try_from(*height).expect("height fits")
                }
                _ => panic!("test planner expected integer height"),
            };
            let input = &request.input_media()[0];
            Ok(FilterPlan::new(
                ClipMedia::new(
                    input.format().clone(),
                    ClipResolution::Fixed { width, height },
                    input.frame_count(),
                    input.frame_rate(),
                ),
                FilterCompatibility::AllowChanges(FilterChangeSet {
                    format: false,
                    resolution: true,
                    frame_count: false,
                    frame_rate: false,
                }),
            ))
        }

        let mut registry = FilterRegistry::new();
        registry
            .register_filter(FilterDescriptor::new("acme.blur", "acme", "blur"))
            .expect("third-party descriptor-only filter registers");
        registry
            .register_filter_planner(
                FilterDescriptor::new("merge_planes", "pixelflow", "std"),
                passthrough,
            )
            .expect("std merge-like filter registers");
        registry
            .register_filter_planner(
                FilterDescriptor::new("resize", "pixelflow", "std"),
                resize_like,
            )
            .expect("std resize-like filter registers");
        registry
    }

    #[derive(Debug, Default)]
    struct CaptureSink {
        records: Mutex<Vec<LogRecord>>,
    }

    impl LogSink for CaptureSink {
        fn log(&self, record: &LogRecord) {
            self.records
                .lock()
                .expect("capture sink lock should succeed")
                .push(record.clone());
        }
    }

    #[test]
    fn set_argument_parser_coerces_supported_values() {
        assert_eq!(
            ScriptParameter::parse_set("enabled=true")
                .expect("bool parameter should parse")
                .value(),
            &ScriptValue::Bool(true)
        );
        assert_eq!(
            ScriptParameter::parse_set("count=42")
                .expect("int parameter should parse")
                .value(),
            &ScriptValue::Int(42)
        );
        assert_eq!(
            ScriptParameter::parse_set("scale=1.25")
                .expect("float parameter should parse")
                .value(),
            &ScriptValue::Float(1.25)
        );
        assert_eq!(
            ScriptParameter::parse_set("rate=30000/1001")
                .expect("rational parameter should parse")
                .value(),
            &ScriptValue::Rational(Rational {
                numerator: 30000,
                denominator: 1001,
            })
        );
        assert_eq!(
            ScriptParameter::parse_set("path=input.mkv")
                .expect("string parameter should parse")
                .value(),
            &ScriptValue::String("input.mkv".to_owned())
        );
        assert_eq!(
            ScriptParameter::parse_set("path=/tmp/input.mkv")
                .expect("slash-containing string parameter should parse")
                .value(),
            &ScriptValue::String("/tmp/input.mkv".to_owned())
        );
    }

    #[test]
    fn set_argument_parser_rejects_invalid_names_and_rationals() {
        let bad_name =
            ScriptParameter::parse_set("9bad=value").expect_err("invalid name should fail");
        assert_eq!(bad_name.category(), ErrorCategory::Script);
        assert_eq!(bad_name.code(), ErrorCode::new("script.invalid_parameter"));

        let bad_rate =
            ScriptParameter::parse_set("rate=1/0").expect_err("zero denominator should fail");
        assert_eq!(bad_rate.category(), ErrorCategory::Script);
        assert_eq!(bad_rate.code(), ErrorCode::new("script.invalid_parameter"));
    }

    #[test]
    fn evaluate_rejects_empty_source() {
        let error = ScriptEngine::new()
            .evaluate("  \n", &[])
            .expect_err("empty script should fail");

        assert_eq!(error.category(), ErrorCategory::Script);
        assert_eq!(error.code(), ErrorCode::new("script.empty"));
    }

    #[test]
    fn evaluate_exposes_none_helpers() {
        let graph = ScriptEngine::new()
            .evaluate(
                "flag = is_none(none())\noutput = source(\"input.mkv\")",
                &[],
            )
            .expect("script should evaluate");

        assert_eq!(graph.graph().outputs().len(), 1);
    }

    #[test]
    fn sandbox_rejects_file_process_and_network_like_apis() {
        let engine = ScriptEngine::new();

        for script in [
            "output = read_file(\"secret.txt\")",
            "output = write_file(\"x\", \"y\")",
            "output = command(\"echo\")",
            "import \"other.pf\" as other; output = source(\"input.mkv\")",
        ] {
            let error = engine
                .evaluate(script, &[])
                .expect_err("unregistered API should fail");
            assert_eq!(error.category(), ErrorCategory::Script);
        }
    }

    #[test]
    fn syntax_errors_include_source_position() {
        let error = ScriptEngine::new()
            .evaluate("output = source(\"input.mkv\"", &[])
            .expect_err("invalid syntax should fail");

        assert_eq!(error.category(), ErrorCategory::Script);
        assert_eq!(error.code(), ErrorCode::new("script.parse"));
        assert!(error.message().contains("line"));
    }

    #[test]
    fn method_chain_dispatches_identifier_filters_through_registry() {
        let script = r#"
            output = source("input.mkv")
                .custom_filter(#{ enabled: true })
                .resize_like(#{ width: 320, height: 180 })
        "#;

        let graph = script_engine_with_fake_filters()
            .evaluate(script, &[])
            .expect("generic registered filters should evaluate")
            .into_graph();

        assert_eq!(graph.nodes().len(), 3);
        let output = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists");
        let NodeKind::Filter { name, .. } = output.kind() else {
            panic!("output should be filter node");
        };
        assert_eq!(name, "resize_like");
        assert!(matches!(
            output.media().resolution(),
            ClipResolution::Fixed {
                width: 320,
                height: 180
            }
        ));
    }

    #[test]
    fn array_filter_dispatches_multi_input_filters_through_registry() {
        fn merge_like(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            assert_eq!(request.input_media().len(), 3);
            Ok(FilterPlan::new(
                ClipMedia::new(
                    ClipFormat::Fixed(pixelflow_core::resolve_format_alias("yuv420p8")?),
                    ClipResolution::Fixed {
                        width: 1,
                        height: 1,
                    },
                    FrameCount::Unknown,
                    FrameRate::Unknown,
                ),
                FilterCompatibility::Custom,
            ))
        }

        let mut registry = FilterRegistry::new();
        registry
            .register_filter_planner(
                FilterDescriptor::new("merge_like", "acme", "filters"),
                merge_like,
            )
            .expect("filter registers");

        let graph = ScriptEngine::with_filter_registry(registry)
            .evaluate(
                r#"
                    y = source("input.mkv")
                    u = source("input.mkv")
                    v = source("input.mkv")
                    output = filter([y, u, v], "merge_like", #{ format: "yuv420p8" })
                "#,
                &[],
            )
            .expect("multi-input filter should evaluate")
            .into_graph();

        let output = graph
            .node(graph.outputs()[0].node_id())
            .expect("output exists");
        let NodeKind::Filter {
            name,
            inputs,
            compatibility,
            ..
        } = output.kind()
        else {
            panic!("output should be filter node");
        };
        assert_eq!(name, "merge_like");
        assert_eq!(inputs.len(), 3);
        assert_eq!(*compatibility, FilterCompatibility::Custom);
    }

    #[test]
    fn array_filter_rejects_non_clip_entries() {
        let error = script_engine_with_fake_filters()
            .evaluate(
                r#"
                    y = source("input.mkv")
                    output = filter([y, 7], "custom_filter")
                "#,
                &[],
            )
            .expect_err("non-clip array entry should fail");

        assert_eq!(error.category(), ErrorCategory::Script);
        assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
        assert!(error.message().contains("filter input 1 must be Clip"));
    }

    #[test]
    fn string_named_filter_dispatch_supports_non_identifier_names() {
        fn passthrough(request: FilterPlanRequest<'_>) -> pixelflow_core::Result<FilterPlan> {
            Ok(FilterPlan::new(
                request.input_media()[0].clone(),
                FilterCompatibility::Preserve,
            ))
        }

        let mut registry = FilterRegistry::new();
        registry
            .register_filter_planner(
                FilterDescriptor::new("acme.blur", "acme", "blur"),
                passthrough,
            )
            .expect("filter registers");

        let graph = ScriptEngine::with_filter_registry(registry)
            .evaluate(
                r#"output = filter(source("input.mkv"), "acme.blur", #{ radius: 2 })"#,
                &[],
            )
            .expect("string-named filter should evaluate")
            .into_graph();

        let NodeKind::Filter { name, .. } = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists")
            .kind()
        else {
            panic!("expected filter node");
        };
        assert_eq!(name, "acme.blur");
    }

    #[test]
    fn register_prop_registers_metadata_key_for_filter_planner() {
        let graph = script_engine_with_fake_filters()
            .evaluate(
                r#"
                    register_prop("acme/filter:enabled", "bool")
                    output = source("input.mkv").set_prop(#{ key: "acme/filter:enabled", value: true })
                "#,
                &[],
            )
            .expect("registered metadata key should reach filter planner")
            .into_graph();

        let NodeKind::Filter { name, .. } = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists")
            .kind()
        else {
            panic!("expected filter node");
        };
        assert_eq!(name, "set_prop");
    }

    #[test]
    fn filter_options_convert_rhai_arrays_to_metadata_arrays() {
        let graph = script_engine_with_fake_filters()
            .evaluate(
                r#"
                    register_prop("acme/filter:ratios", "array")
                    output = source("input.mkv").set_prop_array(#{ key: "acme/filter:ratios", value: [1, "x", none()] })
                "#,
                &[],
            )
            .expect("array metadata value should reach filter planner")
            .into_graph();

        let NodeKind::Filter { name, .. } = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists")
            .kind()
        else {
            panic!("expected filter node");
        };
        assert_eq!(name, "set_prop_array");
    }

    #[test]
    fn script_filter_call_preserves_options_for_runtime_executor() {
        let graph = script_engine_with_fake_filters()
            .evaluate(
                r#"
                    clip = source("input.mkv")
                    output = clip.resize_like(#{ width: 320, height: 180 })
                "#,
                &[],
            )
            .expect("script should evaluate")
            .into_graph();

        let output = graph
            .node(graph.outputs()[0].node_id())
            .expect("output exists");
        let options = output.filter_options().expect("output is filter");

        assert_eq!(
            options.get("width"),
            Some(&pixelflow_core::FilterOptionValue::Int(320))
        );
        assert_eq!(
            options.get("height"),
            Some(&pixelflow_core::FilterOptionValue::Int(180))
        );
    }

    #[test]
    fn filter_options_convert_blob_helper_to_metadata_blobs() {
        let graph = script_engine_with_fake_filters()
            .evaluate(
                r#"
                    register_prop("acme/filter:payload", "blob")
                    output = source("input.mkv").set_prop_blob(#{ key: "acme/filter:payload", value: blob([0, 127, 255]) })
                "#,
                &[],
            )
            .expect("blob metadata value should reach filter planner")
            .into_graph();

        let NodeKind::Filter { name, .. } = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists")
            .kind()
        else {
            panic!("expected filter node");
        };
        assert_eq!(name, "set_prop_blob");
    }

    #[test]
    fn blob_helper_rejects_bytes_outside_u8_range() {
        let error = script_engine_with_fake_filters()
            .evaluate(
                r#"
                    register_prop("acme/filter:payload", "blob")
                    output = source("input.mkv").set_prop_blob(#{ key: "acme/filter:payload", value: blob([256]) })
                "#,
                &[],
            )
            .expect_err("out-of-range blob byte should fail");

        assert_eq!(error.category(), ErrorCategory::Script);
        assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
        assert_eq!(
            error.message(),
            "blob byte at index 0 must be between 0 and 255"
        );
    }

    #[test]
    fn unsupported_filter_option_error_lists_array_and_blob_types() {
        let error = script_engine_with_fake_filters()
            .evaluate(
                r#"output = source("input.mkv").custom_filter(#{ payload: #{ nested: true } })"#,
                &[],
            )
            .expect_err("nested map option should fail");

        assert_eq!(error.category(), ErrorCategory::Script);
        assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
        assert_eq!(
            error.message(),
            "filter option 'payload' must be none, string, bool, integer, float, array, rational, or blob"
        );
    }

    #[test]
    fn unregistered_prop_key_reports_structured_failure() {
        let error = script_engine_with_fake_filters()
            .evaluate(
                r#"output = source("input.mkv").set_prop_checked(#{ key: "acme/filter:enabled", value: true })"#,
                &[],
            )
            .expect_err("unregistered metadata key should fail");

        assert_eq!(error.category(), ErrorCategory::Plugin);
        assert_eq!(error.code(), ErrorCode::new("metadata.unregistered_key"));
        assert_eq!(
            error.message(),
            "metadata key 'acme/filter:enabled' is not registered"
        );
    }

    #[test]
    fn registered_prop_key_allows_metadata_validated_property_flow() {
        let graph = script_engine_with_fake_filters()
            .evaluate(
                r#"
                    register_prop("acme/filter:enabled", "bool")
                    output = source("input.mkv").set_prop_checked(#{ key: "acme/filter:enabled", value: true })
                "#,
                &[],
            )
            .expect("registered metadata key should allow property flow")
            .into_graph();

        let NodeKind::Filter { name, .. } = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists")
            .kind()
        else {
            panic!("expected filter node");
        };
        assert_eq!(name, "set_prop_checked");
    }

    #[test]
    fn prop_without_resolver_reports_structured_error() {
        let error = ScriptEngine::new()
            .evaluate(
                r#"
                    clip = source("input.mkv")
                    value = prop(clip, 0, "core:matrix")
                    output = clip
                "#,
                &[],
            )
            .expect_err("prop calls require a runtime resolver");

        assert_eq!(error.category(), ErrorCategory::Script);
        assert_eq!(error.code(), ErrorCode::new("script.prop_unavailable"));
    }

    #[test]
    fn prop_converts_metadata_values_to_rhai_values() {
        let resolver = Arc::new(FakePropResolver::default());
        let graph = script_engine_with_fake_filters()
            .with_prop_resolver(resolver)
            .evaluate(
                r#"
                    clip = source("input.mkv")
                    matrix = prop(clip, 3, "core:matrix")
                    frame = clip.prop(3, "core:frame_number")
                    duration = prop(clip, 3, "core:duration")
                    missing = clip.prop(3, "core:source_path")
                    checked = clip.expect_bool(#{ value: matrix == "bt709" && frame == 3 && is_none(missing) })
                    rated = checked.expect_rational(#{ value: duration })
                    output = rated.resize_like(#{ width: frame * 100 + 20, height: 180 })
                "#,
                &[],
            )
            .expect("prop values should be usable from script")
            .into_graph();

        let NodeKind::Filter { name, .. } = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists")
            .kind()
        else {
            panic!("expected filter output when prop branch matches");
        };
        assert_eq!(name, "resize_like");
    }

    #[test]
    fn prop_rejects_negative_frame_numbers() {
        let resolver = Arc::new(FakePropResolver::default());
        let error = ScriptEngine::new()
            .with_prop_resolver(resolver)
            .evaluate(
                r#"
                    clip = source("input.mkv")
                    value = prop(clip, -1, "core:matrix")
                    output = clip
                "#,
                &[],
            )
            .expect_err("negative frame should fail");

        assert_eq!(error.category(), ErrorCategory::Script);
        assert_eq!(error.code(), ErrorCode::new("script.invalid_argument"));
        assert_eq!(error.message(), "prop frame number must be non-negative");
    }

    #[test]
    fn repeated_prop_requests_are_cached_per_script_evaluation() {
        let resolver = Arc::new(FakePropResolver::default());
        let counter = Arc::clone(&resolver);

        script_engine_with_fake_filters()
            .with_prop_resolver(resolver)
            .evaluate(
                r#"
                    clip = source("input.mkv")
                    a = prop(clip, 3, "core:matrix")
                    b = clip.prop(3, "core:matrix")
                    output = clip
                "#,
                &[],
            )
            .expect("duplicate prop calls should evaluate");

        assert_eq!(counter.calls.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn unregistered_filter_reports_graph_diagnostic() {
        let error = ScriptEngine::new()
            .evaluate(r#"output = source("input.mkv").missing(#{})"#, &[])
            .expect_err("missing filter should fail");

        assert_eq!(error.category(), ErrorCategory::Graph);
        assert_eq!(error.code(), ErrorCode::new("graph.unknown_filter"));
    }

    #[test]
    fn plugin_namespace_function_dispatches_descriptor_only_filter() {
        let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
            .evaluate(
                r#"output = plugin.acme.blur(source("input.mkv"), #{ radius: 2 })"#,
                &[],
            )
            .expect("plugin namespace function should evaluate")
            .into_graph();

        let NodeKind::Filter {
            name,
            compatibility,
            ..
        } = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists")
            .kind()
        else {
            panic!("expected filter node");
        };
        assert_eq!(name, "acme.blur");
        assert_eq!(*compatibility, FilterCompatibility::Custom);
    }

    #[test]
    fn plugin_namespace_method_dispatches_descriptor_only_filter() {
        let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
            .evaluate(
                r#"output = source("input.mkv").plugin.acme.blur(#{ radius: 2 })"#,
                &[],
            )
            .expect("plugin namespace method should evaluate")
            .into_graph();

        let NodeKind::Filter { name, .. } = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists")
            .kind()
        else {
            panic!("expected filter node");
        };
        assert_eq!(name, "acme.blur");
    }

    #[test]
    fn std_namespace_function_dispatches_std_filter() {
        let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
            .evaluate(
                r#"
                    y = source("input.mkv")
                    u = source("input.mkv")
                    v = source("input.mkv")
                    output = std.merge_planes([y, u, v], #{ format: "yuv420p8" })
                "#,
                &[],
            )
            .expect("std namespace function should evaluate")
            .into_graph();

        let NodeKind::Filter { name, inputs, .. } = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists")
            .kind()
        else {
            panic!("expected filter node");
        };
        assert_eq!(name, "merge_planes");
        assert_eq!(inputs.len(), 3);
    }

    #[test]
    fn plugin_std_namespace_function_dispatches_std_filter() {
        let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
            .evaluate(
                r#"
                    y = source("input.mkv")
                    u = source("input.mkv")
                    v = source("input.mkv")
                    output = plugin.std.merge_planes([y, u, v])
                "#,
                &[],
            )
            .expect("plugin.std namespace function should evaluate")
            .into_graph();

        let NodeKind::Filter { name, inputs, .. } = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists")
            .kind()
        else {
            panic!("expected filter node");
        };
        assert_eq!(name, "merge_planes");
        assert_eq!(inputs.len(), 3);
    }

    #[test]
    fn std_namespace_method_dispatches_std_filter() {
        let graph = ScriptEngine::with_filter_registry(namespaced_filter_registry())
            .evaluate(
                r#"output = source("input.mkv").std.resize(#{ width: 320, height: 180 })"#,
                &[],
            )
            .expect("std namespace method should evaluate")
            .into_graph();

        let output = graph
            .node(graph.outputs()[0].node_id())
            .expect("output node exists");
        let NodeKind::Filter { name, .. } = output.kind() else {
            panic!("expected filter node");
        };
        assert_eq!(name, "resize");
        assert!(matches!(
            output.media().resolution(),
            ClipResolution::Fixed {
                width: 320,
                height: 180,
            }
        ));
    }

    #[test]
    fn unknown_plugin_namespace_reports_structured_graph_error() {
        let error = ScriptEngine::with_filter_registry(namespaced_filter_registry())
            .evaluate(
                r#"output = plugin.missing.blur(source("input.mkv"), #{ radius: 2 })"#,
                &[],
            )
            .expect_err("unknown namespace should fail");

        assert_eq!(error.category(), ErrorCategory::Graph);
        assert_eq!(error.code(), ErrorCode::new("graph.unknown_filter"));
    }

    #[test]
    fn std_namespace_function_rewrites_even_when_statement_result_is_unused() {
        ScriptEngine::with_filter_registry(namespaced_filter_registry())
            .evaluate(
                r#"
                    std.resize(source("input.mkv"), #{ width: 320, height: 180 })
                    output = source("input.mkv")
                "#,
                &[],
            )
            .expect("unused namespaced statement should still parse and evaluate");
    }

    #[test]
    fn filter_method_rewrite_preserves_strings_and_explicit_filter_calls() {
        assert_eq!(
            super::rewrite_filter_syntax(
                r#"output = source("a.b").sample_filter(#{ width: 1, height: 1 })"#,
                &fake_filter_registry(),
            )
            .expect("rewrite should succeed"),
            r#"output = source("a.b").filter("sample_filter", #{ width: 1, height: 1 })"#
        );
        assert_eq!(
            super::rewrite_filter_syntax(
                r#"output = clip.filter("acme.blur", #{ radius: 2 })"#,
                &fake_filter_registry(),
            )
            .expect("rewrite should succeed"),
            r#"output = clip.filter("acme.blur", #{ radius: 2 })"#
        );
    }

    #[test]
    fn filter_method_rewrite_ignores_comments_and_supports_spacing_and_no_arg_calls() {
        let source = super::normalize_source(concat!(
            "// clip.sample_filter()\n",
            "/* clip.resize_like(#{ width: 1, height: 1 }) */\n",
            "text = \"clip.no_args_filter()\";\n",
            "clip = source(\"input.mkv\")\n",
            "output = clip.resize_like   (#{ width: 320, height: 180 })\n",
            "done = clip.no_args_filter   ()\n",
            "kept = clip.filter(\"acme.blur\", #{ radius: 2 })\n",
        ));

        let expected = super::normalize_source(concat!(
            "// clip.sample_filter()\n",
            "/* clip.resize_like(#{ width: 1, height: 1 }) */\n",
            "text = \"clip.no_args_filter()\";\n",
            "clip = source(\"input.mkv\")\n",
            "output = clip.filter(\"resize_like\", #{ width: 320, height: 180 })\n",
            "done = clip.filter(\"no_args_filter\")\n",
            "kept = clip.filter(\"acme.blur\", #{ radius: 2 })\n",
        ));

        assert_eq!(
            super::rewrite_filter_syntax(&source, &fake_filter_registry())
                .expect("rewrite should succeed"),
            expected
        );
    }

    #[test]
    fn filter_method_rewrite_only_targets_clip_producing_rhs_chains() {
        let source = super::normalize_source(
            r#"
                clip = source("input.mkv")
                text = "abc".len()
                output = clip.resize_like(#{ width: "xy".len(), height: 180 })
            "#,
        );

        let expected = concat!(
            "clip = source(\"input.mkv\");\n",
            "text = \"abc\".len();\n",
            "output = clip.filter(\"resize_like\", #{ width: \"xy\".len(), height: 180 });",
        );

        assert_eq!(
            super::rewrite_filter_syntax(&source, &fake_filter_registry())
                .expect("rewrite should succeed"),
            expected
        );
    }

    #[test]
    fn logger_and_filter_registry_constructor_preserves_custom_logger() {
        let sink = Arc::new(CaptureSink::default());
        let engine = ScriptEngine::with_logger_and_filter_registry(
            Logger::new(sink.clone()),
            fake_filter_registry(),
        );

        engine
            .evaluate(
                r#"output = source("input.mkv").custom_filter(#{ enabled: true })"#,
                &[],
            )
            .expect("script should evaluate with combined logger and registry");

        let records = sink
            .records
            .lock()
            .expect("capture sink lock should succeed");
        assert!(records.iter().any(|record| {
            record.level() == LogLevel::Debug
                && record.target() == "pixelflow_script"
                && record.message() == "script graph constructed"
        }));
    }

    #[test]
    fn count_output_assignments_ignores_strings_comments_comparisons_and_nesting() {
        let source = concat!(
            "output = source(\"real.mkv\");\n",
            "text = \"output = fake\";\n",
            "// output = fake;\n",
            "/* output = fake; */\n",
            "output == none();\n",
            "check = output == none();\n",
            "other = output != none();\n",
            "compare = output >= 0;\n",
            "limit = output <= 10;\n",
            "if cond { output = source(\"nested.mkv\"); }\n",
            "config = #{ nested: \"output = fake\" };\n",
            "text = \"skip; output = fake\";\n",
            "/* skip; output = fake; */\n",
        );

        assert_eq!(super::count_output_assignments(source), 1);
    }

    #[test]
    fn source_options_are_preserved_for_ffms2_indexing() {
        let graph = ScriptEngine::new()
            .evaluate(
                r#"output = source("input.mkv", #{ cache: "cache.pfidx", fps: "30000/1001", vfr: "normalize", format: "yuv420p10" })"#,
                &[],
            )
            .expect("script should evaluate")
            .into_graph();
        let node = graph
            .node(graph.outputs()[0].node_id())
            .expect("source node exists");

        let pixelflow_core::NodeKind::Source { request, .. } = node.kind() else {
            panic!("expected source node");
        };

        assert_eq!(request.path(), "input.mkv");
        assert_eq!(
            request.options().get("cache"),
            Some(&pixelflow_core::SourceOptionValue::String(
                "cache.pfidx".to_owned(),
            ))
        );
        assert_eq!(
            request.options().get("fps"),
            Some(&pixelflow_core::SourceOptionValue::Rational(Rational {
                numerator: 30_000,
                denominator: 1_001,
            }))
        );
    }

    #[test]
    fn source_graph_validation_plan_works_before_indexing() {
        let graph = ScriptEngine::new()
            .evaluate(
                "unused = source(\"unused.mkv\")\noutput = source(\"used.mkv\")",
                &[],
            )
            .expect("script should evaluate")
            .into_graph();

        let plan = graph
            .validation_plan()
            .expect("reachability works before media validation");

        assert_eq!(plan.reachable_sources().len(), 1);
        let node = graph
            .node(plan.reachable_sources()[0])
            .expect("source exists");
        let pixelflow_core::NodeKind::Source { request, .. } = node.kind() else {
            panic!("expected source");
        };
        assert_eq!(request.path(), "used.mkv");
    }

    #[test]
    fn multiline_option_blocks_build_valid_graph() {
        let script = r#"
            output = source(
                "input.mkv",
                #{
                    width: 640,
                    height: 360,
                    frames: 2
                }
            )
        "#;

        let graph = ScriptEngine::new()
            .evaluate(script, &[])
            .expect("multiline options should evaluate");

        graph
            .graph()
            .validation_plan()
            .expect("multiline source graph should have validation plan");
    }

    #[test]
    fn missing_output_reports_graph_diagnostic() {
        let error = ScriptEngine::new()
            .evaluate("clip = source(\"input.mkv\")", &[])
            .expect_err("missing output should fail");

        assert_eq!(error.category(), ErrorCategory::Graph);
        assert_eq!(error.code(), ErrorCode::new("graph.missing_output"));
        assert_eq!(error.message(), "script does not assign final output");
    }

    #[test]
    fn output_must_be_clip() {
        let error = ScriptEngine::new()
            .evaluate("output = 42", &[])
            .expect_err("non-clip output should fail");

        assert_eq!(error.category(), ErrorCategory::Graph);
        assert_eq!(error.code(), ErrorCode::new("graph.invalid_output"));
    }

    #[test]
    fn duplicate_output_assignments_are_rejected() {
        let error = ScriptEngine::new()
            .evaluate(
                "output = source(\"first.mkv\")\noutput = source(\"second.mkv\")",
                &[],
            )
            .expect_err("duplicate output assignment should fail");

        assert_eq!(error.category(), ErrorCategory::Graph);
        assert_eq!(error.code(), ErrorCode::new("graph.multiple_outputs"));
    }

    #[test]
    fn set_parameters_are_available_in_script_scope() {
        let params = [
            ScriptParameter::parse_set("width=640").expect("width should parse"),
            ScriptParameter::parse_set("height=360").expect("height should parse"),
            ScriptParameter::parse_set("frames=12").expect("frames should parse"),
        ];
        let script =
            "output = source(\"input.mkv\", #{ width: width, height: height, frames: frames })";

        let graph = ScriptEngine::new()
            .evaluate(script, &params)
            .expect("params should inject");
        graph
            .graph()
            .validation_plan()
            .expect("parameterized graph should have validation plan");
    }
}
