pub mod ast;
pub mod attributes;
pub mod mini_template;
pub mod pull;

use std::ops::Range;

use attributes::Timestamps;
use codespan_reporting::diagnostic::{Diagnostic, Label, LabelStyle, Severity};
use tracing::instrument;

use crate::{
    config::Config,
    state::{FileId, Source, TomlError, Treehouse, toml_error_to_diagnostic},
    tree::{
        ast::{Branch, Roots},
        attributes::{Attributes, Content},
        pull::BranchKind,
    },
};

use self::attributes::RootAttributes;

#[derive(Debug, Default, Clone)]
pub struct SemaTree {
    branches: Vec<SemaBranch>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SemaBranchId(usize);

impl SemaTree {
    pub fn add_branch(&mut self, branch: SemaBranch) -> SemaBranchId {
        let id = self.branches.len();
        self.branches.push(branch);
        SemaBranchId(id)
    }

    pub fn branch(&self, id: SemaBranchId) -> &SemaBranch {
        &self.branches[id.0]
    }
}

#[derive(Debug, Clone)]
pub struct SemaRoots {
    pub attributes: RootAttributes,
    pub branches: Vec<SemaBranchId>,
}

impl SemaRoots {
    #[instrument(
        name = "SemaRoots::from_roots",
        skip(treehouse, diagnostics, config, roots)
    )]
    pub fn from_roots(
        treehouse: &mut Treehouse,
        diagnostics: &mut Vec<Diagnostic<FileId>>,
        config: &Config,
        file_id: FileId,
        roots: Roots,
    ) -> Self {
        let mut sema_roots = Self {
            attributes: Self::parse_attributes(treehouse, diagnostics, config, file_id, &roots),
            branches: roots
                .branches
                .into_iter()
                .map(|branch| {
                    SemaBranch::from_branch(treehouse, diagnostics, config, file_id, branch)
                })
                .collect(),
        };

        if sema_roots.attributes.timestamps.is_none() {
            let mut timestamps = None;
            for &branch_id in &sema_roots.branches {
                let branch = treehouse.tree.branch(branch_id);
                if let Some(timestamp) = branch.attributes.timestamp() {
                    let timestamps = timestamps.get_or_insert(Timestamps {
                        created: timestamp,
                        updated: timestamp,
                    });
                    timestamps.created = timestamps.created.min(timestamp);
                    timestamps.updated = timestamps.updated.max(timestamp);
                }
            }
            sema_roots.attributes.timestamps = timestamps;
        }

        sema_roots
    }

    fn parse_attributes(
        treehouse: &mut Treehouse,
        diagnostics: &mut Vec<Diagnostic<FileId>>,
        config: &Config,
        file_id: FileId,
        roots: &Roots,
    ) -> RootAttributes {
        let source = treehouse.source(file_id);

        let mut successfully_parsed = true;
        let mut attributes = if let Some(attributes) = &roots.attributes {
            toml_edit::de::from_str(&source.input()[attributes.data.clone()]).unwrap_or_else(
                |error| {
                    diagnostics.push(toml_error_to_diagnostic(TomlError {
                        message: error.message().to_owned(),
                        span: error.span(),
                        file_id,
                        input_range: attributes.data.clone(),
                    }));
                    successfully_parsed = false;
                    RootAttributes::default()
                },
            )
        } else {
            RootAttributes::default()
        };
        let successfully_parsed = successfully_parsed;

        if successfully_parsed {
            let _attribute_warning_span = roots
                .attributes
                .as_ref()
                .map(|attributes| attributes.percent.clone())
                .unwrap_or(0..1);

            if attributes.title.is_empty() {
                attributes.title = match treehouse.source(file_id) {
                    Source::Tree { tree_path, .. } => tree_path.to_string(),
                    _ => panic!("parse_attributes called for a non-.tree file"),
                }
            }
        }

        if let Some(thumbnail) = &attributes.thumbnail {
            if thumbnail.alt.is_none() {
                diagnostics.push(Diagnostic {
                    severity: Severity::Warning,
                    code: Some("sema".into()),
                    message: "thumbnail without alt text".into(),
                    labels: vec![Label {
                        style: LabelStyle::Primary,
                        file_id,
                        range: roots.attributes.as_ref().unwrap().percent.clone(),
                        message: "".into(),
                    }],
                    notes: vec![
                        "note: alt text is important for people using screen readers".into(),
                        "help: add alt text using the thumbnail.alt key".into(),
                    ],
                })
            }

            if !config.pics.contains_key(&thumbnail.id) {
                diagnostics.push(Diagnostic {
                    severity: Severity::Warning,
                    code: Some("sema".into()),
                    message: format!(
                        "thumbnail picture with id '{}' does not exist",
                        thumbnail.id
                    ),
                    labels: vec![Label {
                        style: LabelStyle::Primary,
                        file_id,
                        range: roots.attributes.as_ref().unwrap().percent.clone(),
                        message: "".into(),
                    }],
                    notes: vec!["note: check your id for typos".into()],
                })
            }
        }

        attributes
    }
}

/// Analyzed branch.
#[derive(Debug, Clone)]
pub struct SemaBranch {
    pub file_id: FileId,

    pub indent_level: usize,
    pub kind: BranchKind,
    pub kind_span: Range<usize>,
    pub content: Range<usize>,

    pub html_id: String,
    pub named_id: String,
    pub attributes: Attributes,
    pub children: Vec<SemaBranchId>,
}

impl SemaBranch {
    pub fn from_branch(
        treehouse: &mut Treehouse,
        diagnostics: &mut Vec<Diagnostic<FileId>>,
        config: &Config,
        file_id: FileId,
        branch: Branch,
    ) -> SemaBranchId {
        let attributes = Self::parse_attributes(treehouse, diagnostics, config, file_id, &branch);

        let named_id = attributes.id.to_owned();
        let html_id = format!("b-{}", attributes.id);

        let redirect_here = attributes.redirect_here.clone();

        let branch = Self {
            file_id,
            indent_level: branch.indent_level,
            kind: branch.kind,
            kind_span: branch.kind_span,
            content: branch.content,
            html_id,
            named_id: named_id.clone(),
            attributes,
            children: branch
                .children
                .into_iter()
                .map(|child| Self::from_branch(treehouse, diagnostics, config, file_id, child))
                .collect(),
        };
        let new_branch_id = treehouse.tree.add_branch(branch);

        if let Some(old_branch_id) = treehouse
            .branches_by_named_id
            .insert(named_id.clone(), new_branch_id)
        {
            let new_branch = treehouse.tree.branch(new_branch_id);
            let old_branch = treehouse.tree.branch(old_branch_id);

            diagnostics.push(
                Diagnostic::warning()
                    .with_code("sema")
                    .with_message(format!("two branches share the same id `{named_id}`"))
                    .with_labels(vec![
                        Label {
                            style: LabelStyle::Primary,
                            file_id,
                            range: new_branch.kind_span.clone(),
                            message: String::new(),
                        },
                        Label {
                            style: LabelStyle::Primary,
                            file_id: old_branch.file_id,
                            range: old_branch.kind_span.clone(),
                            message: String::new(),
                        },
                    ]),
            )
        }

        for source_branch_named_id in redirect_here {
            if let Some(old_branch_id) = treehouse
                .branch_redirects
                .insert(source_branch_named_id.clone(), new_branch_id)
            {
                let new_branch = treehouse.tree.branch(new_branch_id);
                let old_branch = treehouse.tree.branch(old_branch_id);

                diagnostics.push(
                    Diagnostic::warning()
                        .with_code("sema")
                        .with_message(format!(
                            "two branches serve as redirect targets for `{source_branch_named_id}`"
                        ))
                        .with_labels(vec![
                            Label {
                                style: LabelStyle::Primary,
                                file_id,
                                range: new_branch.kind_span.clone(),
                                message: String::new(),
                            },
                            Label {
                                style: LabelStyle::Primary,
                                file_id: old_branch.file_id,
                                range: old_branch.kind_span.clone(),
                                message: String::new(),
                            },
                        ]),
                )
            }
        }

        new_branch_id
    }

    fn parse_attributes(
        treehouse: &mut Treehouse,
        diagnostics: &mut Vec<Diagnostic<FileId>>,
        config: &Config,
        file_id: FileId,
        branch: &Branch,
    ) -> Attributes {
        let source = treehouse.source(file_id);

        let mut successfully_parsed = true;
        let mut attributes = if let Some(attributes) = &branch.attributes {
            toml_edit::de::from_str(&source.input()[attributes.data.clone()]).unwrap_or_else(
                |error| {
                    diagnostics.push(toml_error_to_diagnostic(TomlError {
                        message: error.message().to_owned(),
                        span: error.span(),
                        file_id,
                        input_range: attributes.data.clone(),
                    }));
                    successfully_parsed = false;
                    Attributes::default()
                },
            )
        } else {
            Attributes::default()
        };
        let successfully_parsed = successfully_parsed;

        // Only check for attribute validity if the attributes were parsed successfully.
        if successfully_parsed {
            let attribute_warning_span = branch
                .attributes
                .as_ref()
                .map(|attributes| attributes.percent.clone())
                .unwrap_or(branch.kind_span.clone());

            // Check that every block has an ID.
            if attributes.id.is_empty() {
                attributes.id = format!("treehouse-missingno-{}", treehouse.next_missingno());
                diagnostics.push(Diagnostic {
                    severity: Severity::Warning,
                    code: Some("attr".into()),
                    message: "branch does not have an `id` attribute".into(),
                    labels: vec![Label {
                        style: LabelStyle::Primary,
                        file_id,
                        range: attribute_warning_span.clone(),
                        message: String::new(),
                    }],
                    notes: vec![
                        format!(
                            "note: a generated id `{}` will be used, but this id is unstable and will not persist across generations",
                            attributes.id
                        ),
                        format!("help: run `treehouse fix {}` to add missing ids to branches", treehouse.path(file_id)),
                    ],
                });
            }

            // Check that link-type blocks are `+`-type to facilitate lazy loading.
            if let Content::Link(_) = &attributes.content
                && branch.kind == BranchKind::Expanded
            {
                diagnostics.push(Diagnostic {
                    severity: Severity::Warning,
                    code: Some("attr".into()),
                    message: "`content.link` branch is expanded by default".into(),
                    labels: vec![Label {
                        style: LabelStyle::Primary,
                        file_id,
                        range: branch.kind_span.clone(),
                        message: String::new(),
                    }],
                    notes: vec![
                        "note: `content.link` branches should normally be collapsed to allow for lazy loading".into(),
                    ],
                });
            }

            // Resolve content.links.
            if let Content::Link(tree_path) = &attributes.content {
                if let Some(file_id) = treehouse.files_by_tree_path.get(tree_path) {
                    attributes.content = Content::ResolvedLink(*file_id);
                } else {
                    diagnostics.push(Diagnostic {
                        severity: Severity::Error,
                        code: Some("attr".into()),
                        message: format!("linked tree `{tree_path}` does not exist"),
                        labels: vec![Label {
                            style: LabelStyle::Primary,
                            file_id,
                            range: attribute_warning_span.clone(),
                            message: "".into(),
                        }],
                        notes: vec![],
                    })
                }
            }

            // Check that each tag belongs to the allowed set.
            for tag in &attributes.tags {
                if !config.feed.tags.contains(tag) {
                    diagnostics.push(Diagnostic {
                        severity: Severity::Warning,
                        code: Some("attr".into()),
                        message: format!("tag `{tag}` is not within the set of allowed tags"),
                        labels: vec![Label {
                            style: LabelStyle::Primary,
                            file_id,
                            range: attribute_warning_span.clone(),
                            message: "".into(),
                        }],
                        notes: vec![
                            "note: tag should be one from the set defined in `feed.tags` in treehouse.toml".into(),
                        ],
                    })
                }
            }
        }
        attributes
    }
}

#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum ParseErrorKind {
    #[error("branch kind (`+` or `-`) expected")]
    BranchKindExpected,

    #[error("root branches must not be indented")]
    RootIndentLevel,

    #[error("at least {expected} spaces of indentation were expected, but got {got}")]
    InconsistentIndentation { got: usize, expected: usize },

    #[error("unterminated code block")]
    UnterminatedCodeBlock,
}

#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{range:?}: {kind}")]
pub struct ParseError {
    pub kind: ParseErrorKind,
    pub range: Range<usize>,
}

impl ParseErrorKind {
    pub fn at(self, range: Range<usize>) -> ParseError {
        ParseError { kind: self, range }
    }
}
