use std::fmt;

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Command {
    pub line: usize,
    pub raw: String,
    pub command: String,
    pub expected: Vec<String>,
    pub exit_code: ExitCode,
}

#[derive(Debug, Clone, PartialEq)]
pub enum ExitCode {
    /// No explicit exit code — expects 0
    Default,
    /// Expect a specific exit code
    Code(i32),
    /// Expect any non-zero exit code ([*])
    Any,
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Directive {
    Setup { path: String, line: usize },
    Env { key: String, value: String, line: usize },
}

#[derive(Debug, Clone)]
pub struct ShoutFile {
    pub path: String,
    pub commands: Vec<Command>,
    pub directives: Vec<Directive>,
    pub teardown_commands: Vec<Command>,
}

#[derive(Debug)]
pub struct ParseError(pub String);

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

pub fn trim_trailing_empty(lines: &[String]) -> Vec<String> {
    let mut end = lines.len();
    while end > 0 && lines[end - 1].is_empty() {
        end -= 1;
    }
    lines[..end].to_vec()
}

/// Strip trailing `# comment` from a command line, respecting quotes.
fn strip_comment(line: &str) -> &str {
    let mut in_single = false;
    let mut in_double = false;
    for (i, ch) in line.char_indices() {
        match ch {
            '\'' if !in_double => in_single = !in_single,
            '"' if !in_single => in_double = !in_double,
            '#' if !in_single && !in_double => return line[..i].trim_end(),
            _ => {}
        }
    }
    line
}

/// A line like `$# ...` or `$ # ...` — a comment, not a real command.
pub fn is_comment_line(line: &str) -> bool {
    line.starts_with("$#")
        || (line.starts_with("$ ") && strip_comment(&line[2..]).is_empty())
}

fn parse_exit_code(lines: &[String]) -> (Vec<String>, ExitCode) {
    if lines.is_empty() {
        return (vec![], ExitCode::Default);
    }

    let last = &lines[lines.len() - 1];

    // Match [N] or [*]
    if last.starts_with('[') && last.ends_with(']') {
        let inner = &last[1..last.len() - 1];
        if inner == "*" {
            return (lines[..lines.len() - 1].to_vec(), ExitCode::Any);
        }
        if let Ok(code) = inner.parse::<i32>() {
            return (lines[..lines.len() - 1].to_vec(), ExitCode::Code(code));
        }
    }

    (lines.to_vec(), ExitCode::Default)
}

fn parse_env_directive(path: &str, line: &str, line_num: usize) -> Result<(String, String), ParseError> {
    let rest = line[5..].trim();
    match rest.find('=') {
        Some(eq) if eq > 0 => {
            let key = rest[..eq].to_string();
            let value = rest[eq + 1..].to_string();
            Ok((key, value))
        }
        _ => Err(ParseError(format!(
            "{}:{}: malformed @env directive (expected KEY=VALUE): {}",
            path, line_num, line
        ))),
    }
}

fn finalize_command(cmd: &mut Command) {
    let trimmed = trim_trailing_empty(&cmd.expected);
    let (expected, exit_code) = parse_exit_code(&trimmed);
    cmd.expected = trim_trailing_empty(&expected);
    cmd.exit_code = exit_code;
}

pub fn parse_setup(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
    let mut raw_lines: Vec<&str> = content.split('\n').collect();

    // Remove trailing empty line
    if raw_lines.last() == Some(&"") {
        raw_lines.pop();
    }

    let mut commands = Vec::new();
    let mut teardown_commands = Vec::new();
    let mut directives = Vec::new();

    for (i, &line) in raw_lines.iter().enumerate() {
        let line_num = i + 1;

        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        if let Some(rest) = line.strip_prefix("@env ") {
            let _ = rest; // parsed below
            let (key, value) = parse_env_directive(path, line, line_num)?;
            directives.push(Directive::Env { key, value, line: line_num });
        } else if let Some(rest) = line.strip_prefix("@teardown ") {
            if rest.trim().is_empty() {
                return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num)));
            }
            teardown_commands.push(Command {
                line: line_num,
                raw: line.to_string(),
                command: strip_comment(rest).to_string(),
                expected: vec![],
                exit_code: ExitCode::Default,
            });
        } else if line.starts_with("@setup ") {
            return Err(ParseError(format!("{}:{}: @setup not allowed in setup files", path, line_num)));
        } else if line.starts_with('@') {
            return Err(ParseError(format!("{}:{}: unknown directive: {}", path, line_num, line)));
        } else {
            commands.push(Command {
                line: line_num,
                raw: line.to_string(),
                command: strip_comment(line).to_string(),
                expected: vec![],
                exit_code: ExitCode::Default,
            });
        }
    }

    Ok(ShoutFile {
        path: path.to_string(),
        commands,
        directives,
        teardown_commands,
    })
}

pub fn parse(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
    let mut raw_lines: Vec<&str> = content.split('\n').collect();

    // Remove trailing newline
    if raw_lines.last() == Some(&"") {
        raw_lines.pop();
    }

    let mut commands = Vec::new();
    let mut teardown_commands = Vec::new();
    let mut directives = Vec::new();
    let mut current: Option<Command> = None;
    let mut seen_command = false;

    for (i, &line) in raw_lines.iter().enumerate() {
        let line_num = i + 1;

        // Directives (before first command)
        if !seen_command && line.starts_with('@') {
            if let Some(rest) = line.strip_prefix("@setup ") {
                let setup_path = rest.trim();
                if setup_path.is_empty() {
                    return Err(ParseError(format!("{}:{}: @setup requires a file path", path, line_num)));
                }
                directives.push(Directive::Setup { path: setup_path.to_string(), line: line_num });
            } else if let Some(rest) = line.strip_prefix("@teardown ") {
                if rest.trim().is_empty() {
                    return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num)));
                }
                teardown_commands.push(Command {
                    line: line_num,
                    raw: line.to_string(),
                    command: strip_comment(rest).to_string(),
                    expected: vec![],
                    exit_code: ExitCode::Default,
                });
            } else if line.starts_with("@env ") {
                let (key, value) = parse_env_directive(path, line, line_num)?;
                directives.push(Directive::Env { key, value, line: line_num });
            } else {
                return Err(ParseError(format!("{}:{}: unknown directive: {}", path, line_num, line)));
            }
            continue;
        }

        if is_comment_line(line) {
            seen_command = true;
            if let Some(ref mut cmd) = current {
                finalize_command(cmd);
                commands.push(cmd.clone());
                current = None;
            }
        } else if line.starts_with("\\$ ") && current.is_some() {
            // Escaped dollar-space: literal expected output starting with "$ "
            current.as_mut().unwrap().expected.push(line[1..].to_string());
        } else if line.starts_with("$ ") {
            seen_command = true;
            if let Some(ref mut cmd) = current {
                finalize_command(cmd);
                commands.push(cmd.clone());
            }
            current = Some(Command {
                line: line_num,
                raw: line.to_string(),
                command: strip_comment(&line[2..]).to_string(),
                expected: vec![],
                exit_code: ExitCode::Default,
            });
        } else if let Some(ref mut cmd) = current {
            cmd.expected.push(line.to_string());
        }
    }

    if let Some(ref mut cmd) = current {
        finalize_command(cmd);
        commands.push(cmd.clone());
    }

    Ok(ShoutFile {
        path: path.to_string(),
        commands,
        directives,
        teardown_commands,
    })
}
