use crate::token::Span;

pub const DIAGNOSTIC_SCHEMA_NAME: &str = "slovo.diagnostic";
pub const DIAGNOSTIC_SCHEMA_VERSION: u32 = 1;

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum Severity {
    Error,
}

impl Severity {
    fn as_str(self) -> &'static str {
        match self {
            Self::Error => "error",
        }
    }
}

#[derive(Debug, Clone)]
pub struct Diagnostic {
    pub severity: Severity,
    pub code: &'static str,
    pub message: String,
    pub span: Option<Span>,
    pub expected: Option<String>,
    pub found: Option<String>,
    pub hint: Option<String>,
    pub related: Vec<RelatedSpan>,
    pub file: String,
}

#[derive(Debug, Clone)]
pub struct RelatedSpan {
    pub file: String,
    pub label: String,
    pub span: Span,
}

impl Diagnostic {
    pub fn new(file: impl Into<String>, code: &'static str, message: impl Into<String>) -> Self {
        Self {
            severity: Severity::Error,
            file: file.into(),
            code,
            message: message.into(),
            span: None,
            expected: None,
            found: None,
            hint: None,
            related: Vec::new(),
        }
    }

    pub fn with_span(mut self, span: Span) -> Self {
        self.span = Some(span);
        self
    }

    pub fn expected(mut self, expected: impl Into<String>) -> Self {
        self.expected = Some(expected.into());
        self
    }

    pub fn found(mut self, found: impl Into<String>) -> Self {
        self.found = Some(found.into());
        self
    }

    pub fn hint(mut self, hint: impl Into<String>) -> Self {
        self.hint = Some(hint.into());
        self
    }

    pub fn related(mut self, label: impl Into<String>, span: Span) -> Self {
        self.related.push(RelatedSpan {
            file: self.file.clone(),
            label: label.into(),
            span,
        });
        self
    }

    pub fn related_in_file(
        mut self,
        file: impl Into<String>,
        label: impl Into<String>,
        span: Span,
    ) -> Self {
        self.related.push(RelatedSpan {
            file: file.into(),
            label: label.into(),
            span,
        });
        self
    }

    pub fn render_human(&self, source: &str) -> String {
        self.render_human_with_sources(|file| {
            if file == self.file {
                Some(source)
            } else {
                None
            }
        })
    }

    pub fn render_human_with_sources<'a>(
        &self,
        source_for: impl Fn(&str) -> Option<&'a str>,
    ) -> String {
        let mut out = String::new();
        let source = source_for(&self.file).unwrap_or("");

        out.push_str(&format!(
            "{}[{}]: {}\n",
            self.severity.as_str(),
            self.code,
            self.message
        ));

        if let Some(span) = self.span {
            if let Some(range) = SourceRange::new(source, span) {
                out.push_str(&format!(
                    "--> {}:{}:{}-{}:{}\n",
                    self.file,
                    range.start.line,
                    range.start.column,
                    range.end.line,
                    range.end.column
                ));
            }

            let excerpt = source_line_at(source, span.start)
                .or_else(|| source.get(span.start..span.end))
                .unwrap_or("");
            out.push_str(&format!("\nAt:\n  {}\n", excerpt));
        }

        if let Some(expected) = &self.expected {
            out.push_str(&format!("\nExpected:\n  {}\n", expected));
        }

        if let Some(found) = &self.found {
            out.push_str(&format!("\nFound:\n  {}\n", found));
        }

        if let Some(hint) = &self.hint {
            out.push_str(&format!("\nHint:\n  {}\n", hint));
        }

        for related in &self.related {
            out.push_str(&format!("\nRelated:\n  {}\n", related.label));
            let related_source = source_for(&related.file).unwrap_or("");
            if let Some(range) = SourceRange::new(related_source, related.span) {
                out.push_str(&format!(
                    "  --> {}:{}:{}-{}:{}\n",
                    related.file,
                    range.start.line,
                    range.start.column,
                    range.end.line,
                    range.end.column
                ));
            }

            let excerpt = source_line_at(related_source, related.span.start)
                .or_else(|| related_source.get(related.span.start..related.span.end))
                .unwrap_or("");
            out.push_str(&format!("  {}\n", excerpt));
        }

        out
    }

    pub fn render_machine(&self, source: &str) -> String {
        self.render_machine_with_sources(|file| {
            if file == self.file {
                Some(source)
            } else {
                None
            }
        })
    }

    pub fn render_machine_with_sources<'a>(
        &self,
        source_for: impl Fn(&str) -> Option<&'a str>,
    ) -> String {
        let source = source_for(&self.file).unwrap_or("");
        let mut parts = vec![
            format!("  (schema {})", DIAGNOSTIC_SCHEMA_NAME),
            format!("  (version {})", DIAGNOSTIC_SCHEMA_VERSION),
            format!("  (severity {})", self.severity.as_str()),
            format!("  (code {})", self.code),
            format!("  (message {})", render_string(&self.message)),
            format!("  (file {})", render_string(&self.file)),
        ];

        if let Some(span) = self.span {
            parts.push(render_machine_span(
                "  ",
                span,
                SourceRange::new(source, span),
            ));
        }

        if let Some(expected) = &self.expected {
            parts.push(format!("  (expected {})", render_string(expected)));
        }

        if let Some(found) = &self.found {
            parts.push(format!("  (found {})", render_string(found)));
        }

        if let Some(hint) = &self.hint {
            parts.push(format!("  (hint {})", render_string(hint)));
        }

        for related in &self.related {
            let related_source = source_for(&related.file).unwrap_or("");
            parts.push(render_machine_related(
                "  ",
                &related.file,
                related,
                SourceRange::new(related_source, related.span),
            ));
        }

        format!("(diagnostic\n{}\n)", parts.join("\n"))
    }

    pub fn render_json(&self, source: &str) -> String {
        self.render_json_with_sources(|file| {
            if file == self.file {
                Some(source)
            } else {
                None
            }
        })
    }

    pub fn render_json_with_sources<'a>(
        &self,
        source_for: impl Fn(&str) -> Option<&'a str>,
    ) -> String {
        let source = source_for(&self.file).unwrap_or("");
        let file = Some(self.file.as_str());
        render_json_diagnostic(
            self.severity.as_str(),
            self.code,
            &self.message,
            file,
            self.span.map(|span| (span, SourceRange::new(source, span))),
            self.expected.as_deref(),
            self.found.as_deref(),
            self.hint.as_deref(),
            self.related.iter().map(|related| JsonRelated {
                file: &related.file,
                span: related.span,
                range: SourceRange::new(source_for(&related.file).unwrap_or(""), related.span),
                message: Some(&related.label),
            }),
        )
    }
}

pub fn render_json_message(
    severity: &str,
    code: &str,
    message: &str,
    hint: Option<&str>,
) -> String {
    render_json_diagnostic(
        severity,
        code,
        message,
        None,
        None,
        None,
        None,
        hint,
        std::iter::empty::<JsonRelated<'_>>(),
    )
}

pub fn render_string(value: &str) -> String {
    let mut out = String::from("\"");

    for ch in value.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            ch if ch.is_control() => out.push_str(&format!("\\u{{{:x}}}", ch as u32)),
            ch => out.push(ch),
        }
    }

    out.push('"');
    out
}

fn render_json_diagnostic<'a>(
    severity: &str,
    code: &str,
    message: &str,
    file: Option<&str>,
    span: Option<(Span, Option<SourceRange>)>,
    expected: Option<&str>,
    found: Option<&str>,
    hint: Option<&str>,
    related: impl Iterator<Item = JsonRelated<'a>>,
) -> String {
    let mut fields = vec![
        format!("\"schema\":{}", render_json_string(DIAGNOSTIC_SCHEMA_NAME)),
        format!("\"version\":{}", DIAGNOSTIC_SCHEMA_VERSION),
        format!("\"severity\":{}", render_json_string(severity)),
        format!("\"code\":{}", render_json_string(code)),
        format!("\"message\":{}", render_json_string(message)),
        format!("\"file\":{}", render_json_nullable_string(file)),
        format!("\"span\":{}", render_json_span(span)),
    ];

    if let Some(expected) = expected {
        fields.push(format!("\"expected\":{}", render_json_string(expected)));
    }

    if let Some(found) = found {
        fields.push(format!("\"found\":{}", render_json_string(found)));
    }

    if let Some(hint) = hint {
        fields.push(format!("\"hint\":{}", render_json_string(hint)));
    }

    let related = related.map(render_json_related).collect::<Vec<_>>();
    if !related.is_empty() {
        fields.push(format!("\"related\":[{}]", related.join(",")));
    }

    format!("{{{}}}", fields.join(","))
}

struct JsonRelated<'a> {
    file: &'a str,
    span: Span,
    range: Option<SourceRange>,
    message: Option<&'a str>,
}

fn render_json_related(related: JsonRelated<'_>) -> String {
    let mut fields = vec![
        format!("\"file\":{}", render_json_string(related.file)),
        format!(
            "\"span\":{}",
            render_json_span(Some((related.span, related.range)))
        ),
    ];

    if let Some(message) = related.message {
        fields.push(format!("\"message\":{}", render_json_string(message)));
    }

    format!("{{{}}}", fields.join(","))
}

fn render_json_span(span: Option<(Span, Option<SourceRange>)>) -> String {
    let Some((span, range)) = span else {
        return "null".to_string();
    };

    let mut fields = vec![
        format!("\"byte_start\":{}", span.start),
        format!("\"byte_end\":{}", span.end),
    ];

    if let Some(range) = range {
        fields.push(format!("\"line_start\":{}", range.start.line));
        fields.push(format!("\"column_start\":{}", range.start.column));
        fields.push(format!("\"line_end\":{}", range.end.line));
        fields.push(format!("\"column_end\":{}", range.end.column));
    }

    format!("{{{}}}", fields.join(","))
}

fn render_json_nullable_string(value: Option<&str>) -> String {
    value
        .map(render_json_string)
        .unwrap_or_else(|| "null".to_string())
}

fn render_json_string(value: &str) -> String {
    let mut out = String::from("\"");

    for ch in value.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{08}' => out.push_str("\\b"),
            '\u{0c}' => out.push_str("\\f"),
            ch if ch <= '\u{1f}' => out.push_str(&format!("\\u{:04x}", ch as u32)),
            ch => out.push(ch),
        }
    }

    out.push('"');
    out
}

fn render_machine_span(indent: &str, span: Span, range: Option<SourceRange>) -> String {
    let mut out = format!(
        "{indent}(span\n{indent}  (bytes {} {})",
        span.start, span.end
    );

    if let Some(range) = range {
        out.push_str(&format!(
            "\n{indent}  (range {} {} {} {})",
            range.start.line, range.start.column, range.end.line, range.end.column
        ));
    }

    out.push_str(&format!("\n{indent})"));
    out
}

fn render_machine_related(
    indent: &str,
    file: &str,
    related: &RelatedSpan,
    range: Option<SourceRange>,
) -> String {
    let span_indent = format!("{indent}    ");
    let mut span_parts = vec![
        format!("{span_indent}(file {})", render_string(file)),
        format!(
            "{span_indent}(bytes {} {})",
            related.span.start, related.span.end
        ),
    ];

    if let Some(range) = range {
        span_parts.push(format!(
            "{span_indent}(range {} {} {} {})",
            range.start.line, range.start.column, range.end.line, range.end.column
        ));
    }

    span_parts.push(format!(
        "{span_indent}(message {})",
        render_string(&related.label)
    ));

    format!(
        "{indent}(related\n{indent}  (span\n{}\n{indent}  )\n{indent})",
        span_parts.join("\n")
    )
}

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
struct SourceRange {
    start: SourcePosition,
    end: SourcePosition,
}

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
struct SourcePosition {
    line: usize,
    column: usize,
}

impl SourceRange {
    fn new(source: &str, span: Span) -> Option<Self> {
        if span.start > span.end || span.end > source.len() {
            return None;
        }

        Some(Self {
            start: source_position(source, span.start),
            end: source_position(source, span.end),
        })
    }
}

fn source_position(source: &str, offset: usize) -> SourcePosition {
    let mut line = 1;
    let mut column = 1;

    for byte in source.as_bytes().iter().take(offset) {
        if *byte == b'\n' {
            line += 1;
            column = 1;
        } else {
            column += 1;
        }
    }

    SourcePosition { line, column }
}

fn source_line_at(source: &str, offset: usize) -> Option<&str> {
    if offset > source.len() {
        return None;
    }

    let start = source[..offset].rfind('\n').map_or(0, |index| index + 1);
    let end = source[offset..]
        .find('\n')
        .map_or(source.len(), |index| offset + index);

    source.get(start..end)
}

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

    #[test]
    fn source_range_uses_one_based_line_and_column_numbers() {
        let source = "first\n  second\nthird";

        let range = SourceRange::new(source, Span::new(8, 14)).unwrap();

        assert_eq!(
            range,
            SourceRange {
                start: SourcePosition { line: 2, column: 3 },
                end: SourcePosition { line: 2, column: 9 },
            }
        );
    }

    #[test]
    fn source_range_columns_are_utf8_byte_columns() {
        let source = "pre ž value";
        let start = source.find("value").unwrap();
        let end = start + "value".len();

        let range = SourceRange::new(source, Span::new(start, end)).unwrap();

        assert_eq!(
            range,
            SourceRange {
                start: SourcePosition { line: 1, column: 8 },
                end: SourcePosition {
                    line: 1,
                    column: 13
                },
            }
        );
    }
}
