Documentation
use chrono::{DateTime, Local, Locale};
use liwe::model::config::LinkType as ConfigLinkType;
use liwe::model::Key;
use liwe::operations::Changes;
use minijinja::{context, Environment};
use sanitize_filename::sanitize;

use super::{string_to_slug, Action, ActionContext, ActionProvider};
use crate::router::server::extensions::utf16_to_byte_offset;

pub struct LinkAction {
    pub title: String,
    pub identifier: String,
    pub link_type: Option<ConfigLinkType>,
    pub key_template: String,
    pub key_date_format: String,
    pub locale: Locale,
}

impl LinkAction {
    fn format_target_key(
        &self,
        context: &impl ActionContext,
        id: &str,
        _parent_key: &str,
        word: &str,
    ) -> Key {
        let now: DateTime<Local> = context.now().into();
        let formatted = now.format_localized(&self.key_date_format, self.locale).to_string();
        let slug = string_to_slug(word);

        let relative_key = Environment::new()
            .template_from_str(&self.key_template)
            .expect("correct template")
            .render(context! {
                today => formatted,
                id => id.to_string(),
                title => sanitize(word),
                slug => slug,
            })
            .expect("template to work");

        let base_key = Key::name(&relative_key);

        std::iter::successors(Some((base_key.clone(), 1)), |(key, counter)| {
            context.key_exists(key).then(|| {
                let suffixed_name = format!("{}-{}", base_key, counter);
                (Key::name(&suffixed_name), counter + 1)
            })
        })
        .last()
        .map(|(key, _)| key)
        .unwrap_or(base_key)
    }

    fn extract_selected_text(
        line_text: &str,
        start_byte: usize,
        end_byte: usize,
    ) -> Option<(String, usize, usize)> {
        if start_byte < end_byte && end_byte <= line_text.len() {
            let text = line_text[start_byte..end_byte].to_string();
            (!text.trim().is_empty()).then_some((text, start_byte, end_byte))
        } else {
            None
        }
    }

    fn extract_word_at_cursor(
        line_text: &str,
        cursor_byte: usize,
    ) -> Option<(String, usize, usize)> {
        (cursor_byte <= line_text.len()).then(|| {
            let bytes = line_text.as_bytes();

            let start = (0..cursor_byte)
                .rev()
                .take_while(|&i| Self::is_word_char(bytes[i]))
                .last()
                .unwrap_or(cursor_byte);

            let end = (cursor_byte..bytes.len())
                .take_while(|&i| Self::is_word_char(bytes[i]))
                .last()
                .map(|i| i + 1)
                .unwrap_or(cursor_byte);

            (start != end)
                .then(|| line_text[start..end].to_string())
                .filter(|word| !word.trim().is_empty())
                .map(|word| (word, start, end))
        })?
    }

    fn is_word_char(c: u8) -> bool {
        c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || (c >= 128)
    }

    fn replace_word_with_link(
        line_text: &str,
        word: &str,
        start_byte: usize,
        end_byte: usize,
        new_key: &Key,
        link_type: Option<&ConfigLinkType>,
    ) -> String {
        let link_text = match link_type {
            Some(ConfigLinkType::WikiLink) => format!("[[{}]]", new_key),
            Some(ConfigLinkType::Markdown) | None => format!("[{}]({})", word, new_key),
        };

        format!(
            "{}{}{}",
            &line_text[..start_byte],
            link_text,
            &line_text[end_byte..]
        )
    }
}

impl ActionProvider for LinkAction {
    fn identifier(&self) -> String {
        format!("custom.{}", self.identifier)
    }

    fn action(
        &self,
        key: super::Key,
        selection: super::TextRange,
        context: impl ActionContext,
    ) -> Option<Action> {
        (selection.start.line == selection.end.line).then(|| {
            let document = context.get_document_markdown(&key)?;
            let lines: Vec<&str> = document.lines().collect();
            let target_line = selection.start.line as usize;

            let line_text = lines.get(target_line)?;

            let start_byte = utf16_to_byte_offset(line_text, selection.start.character)?;
            let end_byte = utf16_to_byte_offset(line_text, selection.end.character)?;
            let has_selection = start_byte != end_byte;

            if has_selection {
                Self::extract_selected_text(line_text, start_byte, end_byte)?;
            } else {
                Self::extract_word_at_cursor(line_text, start_byte)?;
            }

            Some(Action {
                title: self.title.clone(),
                identifier: self.identifier(),
                key: key.clone(),
                range: selection.clone(),
            })
        })?
    }

    fn changes(
        &self,
        key: super::Key,
        selection: super::TextRange,
        context: impl ActionContext,
    ) -> Option<Changes> {
        (selection.start.line == selection.end.line).then(|| {
            let document = context.get_document_markdown(&key)?;
            let lines: Vec<&str> = document.lines().collect();
            let target_line = selection.start.line as usize;

            let line_text = lines.get(target_line)?;

            let start_byte = utf16_to_byte_offset(line_text, selection.start.character)?;
            let end_byte = utf16_to_byte_offset(line_text, selection.end.character)?;
            let has_selection = start_byte != end_byte;

            let (word, start, end) = if has_selection {
                Self::extract_selected_text(line_text, start_byte, end_byte)?
            } else {
                Self::extract_word_at_cursor(line_text, start_byte)?
            };

            let id = context
                .unique_ids(&key.parent(), 1)
                .first()
                .expect("to have one")
                .to_string();
            let new_key = self.format_target_key(&context, &id, &key.parent(), &word);

            let new_markdown = format!("# {}\n", word);

            let updated_line = Self::replace_word_with_link(
                line_text,
                &word,
                start,
                end,
                &new_key,
                self.link_type.as_ref(),
            );

            let updated_markdown = lines
                .iter()
                .enumerate()
                .map(|(i, &line)| {
                    if i == target_line {
                        updated_line.as_str()
                    } else {
                        line
                    }
                })
                .collect::<Vec<_>>()
                .join("\n")
                + "\n";

            Some(
                Changes::new()
                    .create(new_key, new_markdown)
                    .update(key, updated_markdown),
            )
        })?
    }
}