//! Tests for Dockerfile parser.

#[cfg(test)]
#[allow(clippy::module_inception)]
mod tests {
    use super::super::*;

    // --- join_continuation_lines ---

    #[test]
    fn test_join_continuation_simple() {
        let input = "RUN apt-get update && \\\n    apt-get install -y curl";
        let lines = join_continuation_lines(input);
        assert_eq!(lines.len(), 1);
        assert!(lines[0].contains("apt-get update"));
        assert!(lines[0].contains("apt-get install"));
    }

    #[test]
    fn test_join_continuation_no_continuation() {
        let input = "FROM alpine:3.19\nRUN echo hello";
        let lines = join_continuation_lines(input);
        assert_eq!(lines.len(), 2);
    }

    #[test]
    fn test_join_continuation_multiple() {
        let input = "RUN a \\\n    b \\\n    c";
        let lines = join_continuation_lines(input);
        assert_eq!(lines.len(), 1);
        assert!(lines[0].contains('a'));
        assert!(lines[0].contains('b'));
        assert!(lines[0].contains('c'));
    }

    // --- parse_from ---

    #[test]
    fn test_parse_from_simple() {
        let result = parsers::parse_from("alpine:3.19", 1).unwrap();
        assert_eq!(
            result,
            Instruction::From {
                image: "alpine:3.19".to_string(),
                alias: None,
            }
        );
    }

    #[test]
    fn test_parse_from_with_alias() {
        let result = parsers::parse_from("golang:1.21 AS builder", 1).unwrap();
        assert_eq!(
            result,
            Instruction::From {
                image: "golang:1.21".to_string(),
                alias: Some("builder".to_string()),
            }
        );
    }

    #[test]
    fn test_parse_from_empty() {
        assert!(parsers::parse_from("", 1).is_err());
    }

    // --- parse_run ---

    #[test]
    fn test_parse_run_shell() {
        let result = parsers::parse_run("apt-get update && apt-get install -y curl", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Run {
                command: "apt-get update && apt-get install -y curl".to_string(),
                cache_mounts: vec![],
            }
        );
    }

    #[test]
    fn test_parse_run_buildkit_cache_mount() {
        let result =
            parsers::parse_run("--mount=type=cache,target=/root/.cache pnpm install", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Run {
                command: "pnpm install".to_string(),
                cache_mounts: vec![RunCacheMount {
                    raw: "--mount=type=cache,target=/root/.cache".to_string(),
                    target: "/root/.cache".to_string(),
                }],
            }
        );
    }

    #[test]
    fn test_parse_run_unsupported_buildkit_mount_rejected() {
        let err = parsers::parse_run("--mount=type=secret,id=npmrc npm install", 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("only type=cache is supported"));
    }

    #[test]
    fn test_parse_run_exec_form_rejected() {
        let err = parsers::parse_run(r#"["echo", "hello"]"#, 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("RUN exec form is not supported yet"));
    }

    #[test]
    fn test_parse_run_empty() {
        assert!(parsers::parse_run("", 1).is_err());
    }

    // --- parse_copy ---

    #[test]
    fn test_parse_copy_simple() {
        let result = parsers::parse_copy("app.py /workspace/", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Copy {
                src: vec!["app.py".to_string()],
                dst: "/workspace/".to_string(),
                from: None,
                chown: None,
            }
        );
    }

    #[test]
    fn test_parse_copy_multiple_sources() {
        let result = parsers::parse_copy("file1.txt file2.txt /dest/", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Copy {
                src: vec!["file1.txt".to_string(), "file2.txt".to_string()],
                dst: "/dest/".to_string(),
                from: None,
                chown: None,
            }
        );
    }

    #[test]
    fn test_parse_copy_from_stage() {
        let result = parsers::parse_copy("--from=builder /app/bin /usr/local/bin/", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Copy {
                src: vec!["/app/bin".to_string()],
                dst: "/usr/local/bin/".to_string(),
                from: Some("builder".to_string()),
                chown: None,
            }
        );
    }

    #[test]
    fn test_parse_copy_rejects_empty_from_flag() {
        let err = parsers::parse_copy("--from= app.py /workspace/", 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("COPY --from requires a stage name or index"));
    }

    #[test]
    fn test_parse_copy_rejects_duplicate_from_flag() {
        let err = parsers::parse_copy("--from=builder --from=runtime /app /out", 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("COPY specifies --from more than once"));
    }

    #[test]
    fn test_parse_copy_chown() {
        let result = parsers::parse_copy("--chown=1000:1000 app.py /workspace/", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Copy {
                src: vec!["app.py".to_string()],
                dst: "/workspace/".to_string(),
                from: None,
                chown: Some("1000:1000".to_string()),
            }
        );
        // Named user only
        let r2 = parsers::parse_copy("--chown=node app.js /app/", 1).unwrap();
        assert_eq!(
            r2,
            Instruction::Copy {
                src: vec!["app.js".to_string()],
                dst: "/app/".to_string(),
                from: None,
                chown: Some("node".to_string()),
            }
        );
    }

    #[test]
    fn test_parse_copy_rejects_unsupported_flag() {
        // --link is not a supported flag
        let err = parsers::parse_copy("--link app.py /workspace/", 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("COPY flag '--link' is not supported"));
    }

    #[test]
    fn test_parse_copy_json_form_rejected() {
        let err = parsers::parse_copy(r#"["app.py", "/workspace/"]"#, 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("COPY JSON array form is not supported yet"));
    }

    #[test]
    fn test_parse_copy_empty() {
        assert!(parsers::parse_copy("", 1).is_err());
    }

    #[test]
    fn test_parse_copy_single_arg() {
        assert!(parsers::parse_copy("onlysource", 1).is_err());
    }

    // --- parse_env ---

    #[test]
    fn test_parse_env_equals() {
        let result = parsers::parse_env("PATH=/usr/local/bin:/usr/bin", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Env {
                vars: vec![("PATH".to_string(), "/usr/local/bin:/usr/bin".to_string())],
            }
        );
    }

    #[test]
    fn test_parse_env_quoted() {
        let result = parsers::parse_env(r#"MSG="hello world""#, 1).unwrap();
        assert_eq!(
            result,
            Instruction::Env {
                vars: vec![("MSG".to_string(), "hello world".to_string())],
            }
        );
    }

    #[test]
    fn test_parse_env_legacy() {
        let result = parsers::parse_env("MY_VAR my_value", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Env {
                vars: vec![("MY_VAR".to_string(), "my_value".to_string())],
            }
        );
    }

    #[test]
    fn test_parse_env_multi_var() {
        // The form that was broken: several KEY=VALUE pairs on one line.
        let result = parsers::parse_env("GREETING=hello APPDIR=/srv DEBUG=1", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Env {
                vars: vec![
                    ("GREETING".to_string(), "hello".to_string()),
                    ("APPDIR".to_string(), "/srv".to_string()),
                    ("DEBUG".to_string(), "1".to_string()),
                ],
            }
        );
    }

    #[test]
    fn test_parse_env_multi_var_with_quoted_spaces() {
        // A quoted value with spaces must stay one variable, not split the line.
        let result = parsers::parse_env(r#"NAME="John Doe" CITY=NYC"#, 1).unwrap();
        assert_eq!(
            result,
            Instruction::Env {
                vars: vec![
                    ("NAME".to_string(), "John Doe".to_string()),
                    ("CITY".to_string(), "NYC".to_string()),
                ],
            }
        );
    }

    #[test]
    fn test_parse_env_empty() {
        assert!(parsers::parse_env("", 1).is_err());
    }

    // --- parse_entrypoint ---

    #[test]
    fn test_parse_entrypoint_exec() {
        let result = parsers::parse_entrypoint(r#"["/bin/agent", "--listen"]"#, 1).unwrap();
        assert_eq!(
            result,
            Instruction::Entrypoint {
                exec: vec!["/bin/agent".to_string(), "--listen".to_string()],
            }
        );
    }

    #[test]
    fn test_parse_entrypoint_shell() {
        let result = parsers::parse_entrypoint("/bin/agent --listen", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Entrypoint {
                exec: vec![
                    "/bin/sh".to_string(),
                    "-c".to_string(),
                    "/bin/agent --listen".to_string(),
                ],
            }
        );
    }

    #[test]
    fn test_parse_entrypoint_empty() {
        assert!(parsers::parse_entrypoint("", 1).is_err());
    }

    // --- parse_cmd ---

    #[test]
    fn test_parse_cmd_exec() {
        let result = parsers::parse_cmd(r#"["--port", "8080"]"#, 1).unwrap();
        assert_eq!(
            result,
            Instruction::Cmd {
                exec: vec!["--port".to_string(), "8080".to_string()],
            }
        );
    }

    #[test]
    fn test_parse_cmd_shell() {
        let result = parsers::parse_cmd("echo hello", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Cmd {
                exec: vec![
                    "/bin/sh".to_string(),
                    "-c".to_string(),
                    "echo hello".to_string(),
                ],
            }
        );
    }

    #[test]
    fn test_parse_cmd_empty() {
        assert!(parsers::parse_cmd("", 1).is_err());
    }

    // --- parse_expose ---

    #[test]
    fn test_parse_expose() {
        let result = parsers::parse_expose("8080", 1).unwrap();
        // Bare ports are normalized to <port>/tcp, matching Docker.
        assert_eq!(
            result,
            Instruction::Expose {
                ports: vec!["8080/tcp".to_string()],
            }
        );
    }

    #[test]
    fn test_parse_expose_with_proto() {
        let result = parsers::parse_expose("8080/tcp", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Expose {
                ports: vec!["8080/tcp".to_string()],
            }
        );
    }

    #[test]
    fn test_parse_expose_multiple_ports() {
        let result = parsers::parse_expose("80 443 8080/udp", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Expose {
                ports: vec![
                    "80/tcp".to_string(),
                    "443/tcp".to_string(),
                    "8080/udp".to_string(),
                ],
            }
        );
    }

    #[test]
    fn test_parse_expose_empty() {
        assert!(parsers::parse_expose("", 1).is_err());
    }

    // --- parse_label ---

    #[test]
    fn test_parse_label_equals() {
        let result = parsers::parse_label("version=1.0.0", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Label {
                pairs: vec![("version".to_string(), "1.0.0".to_string())],
            }
        );
    }

    #[test]
    fn test_parse_label_quoted() {
        let result = parsers::parse_label(r#"description="My App""#, 1).unwrap();
        assert_eq!(
            result,
            Instruction::Label {
                pairs: vec![("description".to_string(), "My App".to_string())],
            }
        );
    }

    #[test]
    fn test_parse_label_multiple_pairs() {
        let result = parsers::parse_label("a=1 b=2 c=3", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Label {
                pairs: vec![
                    ("a".to_string(), "1".to_string()),
                    ("b".to_string(), "2".to_string()),
                    ("c".to_string(), "3".to_string()),
                ],
            }
        );
    }

    #[test]
    fn test_parse_label_rejects_invalid_kv_token() {
        let err = parsers::parse_label("a=1 invalid-token", 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("invalid LABEL token"));
    }

    #[test]
    fn test_parse_label_empty() {
        assert!(parsers::parse_label("", 1).is_err());
    }

    // --- parse_user ---

    #[test]
    fn test_parse_user() {
        let result = parsers::parse_user("nobody", 1).unwrap();
        assert_eq!(
            result,
            Instruction::User {
                user: "nobody".to_string(),
            }
        );
    }

    #[test]
    fn test_parse_user_with_group() {
        let result = parsers::parse_user("1000:1000", 1).unwrap();
        assert_eq!(
            result,
            Instruction::User {
                user: "1000:1000".to_string(),
            }
        );
    }

    #[test]
    fn test_parse_user_empty() {
        assert!(parsers::parse_user("", 1).is_err());
    }

    // --- parse_arg ---

    #[test]
    fn test_parse_arg_no_default() {
        let result = parsers::parse_arg("VERSION", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Arg {
                name: "VERSION".to_string(),
                default: None,
            }
        );
    }

    #[test]
    fn test_parse_arg_with_default() {
        let result = parsers::parse_arg("VERSION=1.0.0", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Arg {
                name: "VERSION".to_string(),
                default: Some("1.0.0".to_string()),
            }
        );
    }

    #[test]
    fn test_parse_arg_quoted_default() {
        let result = parsers::parse_arg(r#"GREETING="hello world""#, 1).unwrap();
        assert_eq!(
            result,
            Instruction::Arg {
                name: "GREETING".to_string(),
                default: Some("hello world".to_string()),
            }
        );
    }

    #[test]
    fn test_parse_arg_empty() {
        assert!(parsers::parse_arg("", 1).is_err());
    }

    // --- Full Dockerfile parsing ---

    #[test]
    fn test_parse_minimal_dockerfile() {
        let content = "FROM alpine:3.19\nCMD [\"echo\", \"hello\"]";
        let df = Dockerfile::parse(content).unwrap();
        assert_eq!(df.instructions.len(), 2);
        assert!(
            matches!(&df.instructions[0], Instruction::From { image, .. } if image == "alpine:3.19")
        );
    }

    #[test]
    fn test_parse_complex_dockerfile() {
        let content = r#"
# Build stage
FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8080

LABEL version="1.0.0"
LABEL maintainer="team@example.com"

USER nobody

ENTRYPOINT ["python"]
CMD ["app.py"]
"#;
        let df = Dockerfile::parse(content).unwrap();
        assert_eq!(df.instructions.len(), 13);
    }

    #[test]
    fn test_parse_with_continuations() {
        let content = "FROM alpine:3.19\nRUN apk add --no-cache \\\n    curl \\\n    wget";
        let df = Dockerfile::parse(content).unwrap();
        assert_eq!(df.instructions.len(), 2);
        if let Instruction::Run { command, .. } = &df.instructions[1] {
            assert!(command.contains("curl"));
            assert!(command.contains("wget"));
        } else {
            panic!("Expected RUN instruction");
        }
    }

    #[test]
    fn test_parse_empty_dockerfile() {
        let content = "# just a comment\n\n";
        assert!(Dockerfile::parse(content).is_err());
    }

    #[test]
    fn test_parse_no_from() {
        let content = "RUN echo hello";
        assert!(Dockerfile::parse(content).is_err());
    }

    #[test]
    fn test_parse_arg_before_from() {
        let content = "ARG VERSION=3.19\nFROM alpine:${VERSION}";
        let df = Dockerfile::parse(content).unwrap();
        assert_eq!(df.instructions.len(), 2);
        assert!(matches!(&df.instructions[0], Instruction::Arg { .. }));
        assert!(matches!(&df.instructions[1], Instruction::From { .. }));
    }

    #[test]
    fn test_parse_comments_and_blanks() {
        let content = "\n# comment\n\nFROM alpine\n\n# another comment\nRUN echo hi\n\n";
        let df = Dockerfile::parse(content).unwrap();
        assert_eq!(df.instructions.len(), 2);
    }

    // --- unquote ---

    #[test]
    fn test_unquote_double() {
        assert_eq!(utils::unquote(r#""hello world""#), "hello world");
    }

    #[test]
    fn test_unquote_single() {
        assert_eq!(utils::unquote("'hello world'"), "hello world");
    }

    #[test]
    fn test_unquote_none() {
        assert_eq!(utils::unquote("hello"), "hello");
    }

    #[test]
    fn test_unquote_mismatched() {
        assert_eq!(utils::unquote(r#""hello'"#), r#""hello'"#);
    }

    // --- parse_add ---

    #[test]
    fn test_parse_add_simple() {
        let result = parsers::parse_add("app.tar.gz /app/", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Add {
                src: vec!["app.tar.gz".to_string()],
                dst: "/app/".to_string(),
                chown: None,
            }
        );
    }

    #[test]
    fn test_parse_add_chown() {
        let result = parsers::parse_add("--chown=1000:1000 files/ /data/", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Add {
                src: vec!["files/".to_string()],
                dst: "/data/".to_string(),
                chown: Some("1000:1000".to_string()),
            }
        );
    }

    #[test]
    fn test_parse_add_rejects_unsupported_flag() {
        let err = parsers::parse_add("--checksum=sha256:abc file /tmp/", 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("ADD flag '--checksum=sha256:abc' is not supported"));
    }

    #[test]
    fn test_parse_add_json_form_rejected() {
        let err = parsers::parse_add(r#"["file.txt", "/tmp/file.txt"]"#, 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("ADD JSON array form is not supported yet"));
    }

    #[test]
    fn test_parse_add_url() {
        let result = parsers::parse_add("https://example.com/file.tar.gz /tmp/", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Add {
                src: vec!["https://example.com/file.tar.gz".to_string()],
                dst: "/tmp/".to_string(),
                chown: None,
            }
        );
    }

    #[test]
    fn test_parse_add_empty() {
        assert!(parsers::parse_add("", 1).is_err());
    }

    #[test]
    fn test_parse_add_single_arg() {
        assert!(parsers::parse_add("onlysource", 1).is_err());
    }

    #[test]
    fn test_parse_maintainer_accepted_as_label() {
        // MAINTAINER is deprecated but still valid in Docker: the build succeeds
        // and the value is recorded as a `maintainer` label.
        let df = Dockerfile::parse("FROM alpine\nMAINTAINER ops@example.com").unwrap();
        assert!(df.instructions.iter().any(|i| matches!(
            i,
            Instruction::Label { pairs }
                if pairs == &vec![("maintainer".to_string(), "ops@example.com".to_string())]
        )));
    }

    #[test]
    fn test_parse_maintainer_empty_rejected() {
        let err = Dockerfile::parse("FROM alpine\nMAINTAINER")
            .unwrap_err()
            .to_string();
        assert!(err.contains("MAINTAINER requires a value"));
    }

    // --- parse_shell ---

    #[test]
    fn test_parse_shell_bash() {
        let result = parsers::parse_shell(r#"["/bin/bash", "-c"]"#, 1).unwrap();
        assert_eq!(
            result,
            Instruction::Shell {
                exec: vec!["/bin/bash".to_string(), "-c".to_string()],
            }
        );
    }

    #[test]
    fn test_parse_shell_powershell() {
        let result = parsers::parse_shell(r#"["powershell", "-command"]"#, 1).unwrap();
        assert_eq!(
            result,
            Instruction::Shell {
                exec: vec!["powershell".to_string(), "-command".to_string()],
            }
        );
    }

    #[test]
    fn test_parse_shell_empty() {
        assert!(parsers::parse_shell("", 1).is_err());
    }

    #[test]
    fn test_parse_shell_not_json() {
        assert!(parsers::parse_shell("/bin/bash -c", 1).is_err());
    }

    #[test]
    fn test_parse_shell_empty_array() {
        assert!(parsers::parse_shell("[]", 1).is_err());
    }

    // --- parse_stopsignal ---

    #[test]
    fn test_parse_stopsignal_name() {
        let result = parsers::parse_stopsignal("SIGTERM", 1).unwrap();
        assert_eq!(
            result,
            Instruction::StopSignal {
                signal: "SIGTERM".to_string(),
            }
        );
    }

    #[test]
    fn test_parse_stopsignal_number() {
        let result = parsers::parse_stopsignal("9", 1).unwrap();
        assert_eq!(
            result,
            Instruction::StopSignal {
                signal: "9".to_string(),
            }
        );
    }

    #[test]
    fn test_parse_stopsignal_empty() {
        assert!(parsers::parse_stopsignal("", 1).is_err());
    }

    // --- parse_healthcheck ---

    #[test]
    fn test_parse_healthcheck_none() {
        let result = parsers::parse_healthcheck("NONE", 1).unwrap();
        assert_eq!(
            result,
            Instruction::HealthCheck {
                cmd: None,
                interval: None,
                timeout: None,
                retries: None,
                start_period: None,
            }
        );
    }

    #[test]
    fn test_parse_healthcheck_simple_cmd() {
        let result = parsers::parse_healthcheck("CMD curl -f http://localhost/", 1).unwrap();
        assert_eq!(
            result,
            Instruction::HealthCheck {
                cmd: Some(vec![
                    "/bin/sh".to_string(),
                    "-c".to_string(),
                    "curl -f http://localhost/".to_string(),
                ]),
                interval: None,
                timeout: None,
                retries: None,
                start_period: None,
            }
        );
    }

    #[test]
    fn test_parse_healthcheck_with_options() {
        let result = parsers::parse_healthcheck(
            "--interval=10s --timeout=5s --retries=5 --start-period=30s CMD curl -f http://localhost/",
            1,
        )
        .unwrap();
        assert_eq!(
            result,
            Instruction::HealthCheck {
                cmd: Some(vec![
                    "/bin/sh".to_string(),
                    "-c".to_string(),
                    "curl -f http://localhost/".to_string(),
                ]),
                interval: Some(10),
                timeout: Some(5),
                retries: Some(5),
                start_period: Some(30),
            }
        );
    }

    #[test]
    fn test_parse_healthcheck_compound_go_duration() {
        // Docker accepts Go-style compound durations like 1m30s (=90s).
        let result =
            parsers::parse_healthcheck("--interval=1m30s --timeout=2s --retries=3 CMD true", 1)
                .unwrap();
        if let Instruction::HealthCheck {
            interval, timeout, ..
        } = result
        {
            assert_eq!(interval, Some(90));
            assert_eq!(timeout, Some(2));
        } else {
            panic!("expected HealthCheck");
        }
    }

    #[test]
    fn test_parse_healthcheck_json_cmd() {
        let result =
            parsers::parse_healthcheck(r#"CMD ["curl", "-f", "http://localhost/"]"#, 1).unwrap();
        if let Instruction::HealthCheck { cmd, .. } = &result {
            assert_eq!(
                cmd.as_ref().unwrap(),
                &vec![
                    "curl".to_string(),
                    "-f".to_string(),
                    "http://localhost/".to_string()
                ]
            );
        } else {
            panic!("Expected HealthCheck");
        }
    }

    #[test]
    fn test_parse_healthcheck_empty() {
        assert!(parsers::parse_healthcheck("", 1).is_err());
    }

    #[test]
    fn test_parse_healthcheck_no_cmd() {
        assert!(parsers::parse_healthcheck("--interval=10s", 1).is_err());
    }

    #[test]
    fn test_parse_healthcheck_cmd_requires_command() {
        let err = parsers::parse_healthcheck("CMD", 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("HEALTHCHECK CMD requires a command"));
    }

    #[test]
    fn test_parse_healthcheck_invalid_retries() {
        let err = parsers::parse_healthcheck("--retries=bad CMD true", 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("Invalid --retries value"));
    }

    #[test]
    fn test_parse_healthcheck_invalid_duration() {
        let err = parsers::parse_healthcheck("--interval=soon CMD true", 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("Invalid duration"));
    }

    // --- parse_onbuild ---

    #[test]
    fn test_parse_onbuild_run() {
        let result = parsers::parse_onbuild("RUN echo hello", 1).unwrap();
        assert_eq!(
            result,
            Instruction::OnBuild {
                instruction: Box::new(Instruction::Run {
                    command: "echo hello".to_string(),
                    cache_mounts: vec![],
                }),
            }
        );
    }

    #[test]
    fn test_parse_onbuild_copy() {
        let result = parsers::parse_onbuild("COPY . /app", 1).unwrap();
        assert_eq!(
            result,
            Instruction::OnBuild {
                instruction: Box::new(Instruction::Copy {
                    src: vec![".".to_string()],
                    dst: "/app".to_string(),
                    from: None,
                    chown: None,
                }),
            }
        );
    }

    #[test]
    fn test_parse_onbuild_empty() {
        assert!(parsers::parse_onbuild("", 1).is_err());
    }

    #[test]
    fn test_parse_onbuild_onbuild() {
        assert!(parsers::parse_onbuild("ONBUILD RUN echo", 1).is_err());
    }

    #[test]
    fn test_parse_onbuild_from() {
        assert!(parsers::parse_onbuild("FROM alpine", 1).is_err());
    }

    // --- parse_volume ---

    #[test]
    fn test_parse_volume_single() {
        let result = parsers::parse_volume("/data", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Volume {
                paths: vec!["/data".to_string()],
            }
        );
    }

    #[test]
    fn test_parse_volume_multiple() {
        let result = parsers::parse_volume("/data /var/log", 1).unwrap();
        assert_eq!(
            result,
            Instruction::Volume {
                paths: vec!["/data".to_string(), "/var/log".to_string()],
            }
        );
    }

    #[test]
    fn test_parse_volume_json() {
        let result = parsers::parse_volume(r#"["/data", "/var/log"]"#, 1).unwrap();
        assert_eq!(
            result,
            Instruction::Volume {
                paths: vec!["/data".to_string(), "/var/log".to_string()],
            }
        );
    }

    #[test]
    fn test_parse_volume_empty() {
        assert!(parsers::parse_volume("", 1).is_err());
    }

    // --- parse_duration_secs ---

    #[test]
    fn test_parse_duration_seconds() {
        assert_eq!(utils::parse_duration_secs("30s", 1).unwrap(), 30);
    }

    #[test]
    fn test_parse_duration_minutes() {
        assert_eq!(utils::parse_duration_secs("5m", 1).unwrap(), 300);
    }

    #[test]
    fn test_parse_duration_hours() {
        assert_eq!(utils::parse_duration_secs("1h", 1).unwrap(), 3600);
    }

    #[test]
    fn test_parse_duration_plain_number() {
        assert_eq!(utils::parse_duration_secs("30", 1).unwrap(), 30);
    }

    #[test]
    fn test_parse_duration_empty() {
        assert_eq!(utils::parse_duration_secs("", 1).unwrap(), 0);
    }

    #[test]
    fn test_parse_duration_subseconds_round_to_seconds() {
        assert_eq!(utils::parse_duration_secs("1500ms", 1).unwrap(), 2);
        assert_eq!(utils::parse_duration_secs("500ms", 1).unwrap(), 1);
    }

    #[test]
    fn test_parse_duration_rejects_trailing_number() {
        let err = utils::parse_duration_secs("1m30", 1)
            .unwrap_err()
            .to_string();
        assert!(err.contains("Invalid duration"));
    }

    // --- Dockerfile with new instructions ---

    #[test]
    fn test_parse_dockerfile_with_shell() {
        let content = "FROM alpine\nSHELL [\"/bin/bash\", \"-c\"]\nRUN echo hello";
        let df = Dockerfile::parse(content).unwrap();
        assert_eq!(df.instructions.len(), 3);
        assert!(matches!(&df.instructions[1], Instruction::Shell { exec } if exec.len() == 2));
    }

    #[test]
    fn test_parse_dockerfile_with_healthcheck() {
        let content = "FROM alpine\nHEALTHCHECK CMD curl -f http://localhost/";
        let df = Dockerfile::parse(content).unwrap();
        assert_eq!(df.instructions.len(), 2);
        assert!(matches!(
            &df.instructions[1],
            Instruction::HealthCheck { cmd: Some(_), .. }
        ));
    }

    #[test]
    fn test_parse_dockerfile_with_volume() {
        let content = "FROM alpine\nVOLUME /data";
        let df = Dockerfile::parse(content).unwrap();
        assert_eq!(df.instructions.len(), 2);
        assert!(
            matches!(&df.instructions[1], Instruction::Volume { paths } if paths == &["/data".to_string()])
        );
    }

    #[test]
    fn test_parse_dockerfile_with_add() {
        let content = "FROM alpine\nADD app.tar.gz /app/";
        let df = Dockerfile::parse(content).unwrap();
        assert_eq!(df.instructions.len(), 2);
        assert!(matches!(&df.instructions[1], Instruction::Add { .. }));
    }

    #[test]
    fn test_parse_dockerfile_with_stopsignal() {
        let content = "FROM alpine\nSTOPSIGNAL SIGKILL";
        let df = Dockerfile::parse(content).unwrap();
        assert_eq!(df.instructions.len(), 2);
        assert!(
            matches!(&df.instructions[1], Instruction::StopSignal { signal } if signal == "SIGKILL")
        );
    }

    #[test]
    fn test_parse_dockerfile_with_onbuild() {
        let content = "FROM alpine\nONBUILD RUN echo triggered";
        let df = Dockerfile::parse(content).unwrap();
        assert_eq!(df.instructions.len(), 2);
        assert!(matches!(&df.instructions[1], Instruction::OnBuild { .. }));
    }

    #[test]
    fn test_parse_single_instruction_trims_input() {
        let instruction = parse_single_instruction("  RUN echo ready  ").unwrap();
        assert_eq!(
            instruction,
            Instruction::Run {
                command: "echo ready".to_string(),
                cache_mounts: vec![],
            }
        );
    }
}
