//! Tree browser page.
//!
//! Covers two routes: `/{repo}/commits/{sha}/tree` and
//! `/{repo}/commits/{sha}/tree/{*path}`.  Renders a directory listing for the
//! given tree object, with file entries syntax-highlighted inline and linked to
//! the raw handler.  Sub-directory entries are linked to deeper tree pages.

use std::fmt::Write;
use std::sync::LazyLock;

use askama::Template;
use askama_web::WebTemplate;
use axum::extract::{Path, State};
use axum::response::IntoResponse;
use gix::bstr::ByteSlice;
use serde::Deserialize;
use syntect::html::{ClassStyle, ClassedHTMLGenerator};
use syntect::parsing::SyntaxSet;

use crate::error::{Error, Result};
use crate::git::{self, GitResultExt as _};
use crate::routes::{AppState, Breadcrumb, RepoName};

/// Syntax definitions loaded once and reused for every file view.
static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);

/// Path parameters for the tree page routes.
#[derive(Deserialize)]
pub(super) struct TreePath {
    sha: String,
    #[serde(default)]
    path: Option<String>,
}

/// A single entry in a directory listing.
struct TreeEntry {
    name: String,
    is_dir: bool,
    url: String,
    size: String,
}

/// Formats a byte count as a human-readable string.
#[allow(clippy::cast_precision_loss)]
fn format_size(bytes: u64) -> String {
    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
    let mut size = bytes as f64;
    let mut unit_idx = 0;
    while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
        size /= 1024.0;
        unit_idx += 1;
    }
    if unit_idx == 0 {
        format!("{} {}", bytes, UNITS[unit_idx])
    } else {
        format!("{:.1} {}", size, UNITS[unit_idx])
    }
}

/// Template context for the tree/file page.
#[derive(Template, WebTemplate)]
#[template(path = "tree.html")]
struct TreeTemplate {
    repo: String,
    sha: String,
    path: String,
    breadcrumbs: Vec<Breadcrumb>,
    is_file: bool,
    entries: Vec<TreeEntry>,
    content_html: String,
    raw_url: String,
    parent_url: String,
}

/// Renders a tree or file at `{sha}:{path}` in the given repository.
#[allow(clippy::too_many_lines)]
pub async fn handler(
    State(state): State<AppState>,
    RepoName(name): RepoName,
    Path(params): Path<TreePath>,
) -> Result<impl IntoResponse> {
    let path = params.path.unwrap_or_default();

    if !path.is_empty() {
        git::validate_path(&path)?;
    }

    let git_repo = git::open_repo(&state.root, &name)?;

    let (object, resolved_path) = if path.is_empty() {
        // When path is empty, resolve the sha to a commit and get its tree.
        let oid = gix::ObjectId::from_hex(params.sha.as_bytes())
            .map_err(|_| Error::BadRequest(format!("invalid sha: {}", params.sha)))?;
        let commit = git_repo
            .find_object(oid)
            .map_err(|_| Error::NotFound(params.sha.clone()))?
            .try_into_commit()
            .map_err(|_| Error::BadRequest(format!("{} is not a commit", params.sha)))?;
        let tree_id = commit.tree_id().corrupt()?;
        let tree = git_repo.find_object(tree_id).corrupt()?;
        (tree, String::new())
    } else {
        let spec = format!("{}:{}", params.sha, path);
        let obj = git_repo
            .rev_parse_single(spec.as_str())
            .map_err(|_| Error::NotFound(spec.clone()))?
            .object()
            .corrupt()?;
        (obj, path)
    };

    let b = crate::config::base_url();
    let breadcrumbs = crate::routes::build_breadcrumbs(b, &name, &params.sha, &resolved_path);
    let raw_url = if resolved_path.is_empty() {
        String::new()
    } else {
        format!("{b}/{name}/commits/{}/raw/{}", params.sha, resolved_path)
    };
    let parent_url = if resolved_path.is_empty() {
        String::new()
    } else {
        let parent_path = resolved_path.rsplit_once('/').map_or("", |(p, _)| p);
        if parent_path.is_empty() {
            format!("{b}/{name}/commits/{}/tree", params.sha)
        } else {
            format!("{b}/{name}/commits/{}/tree/{parent_path}", params.sha)
        }
    };

    match object.kind {
        gix::object::Kind::Tree => {
            let tree = object.into_tree();
            let mut entries: Vec<TreeEntry> = tree
                .iter()
                .filter_map(|entry| {
                    let entry = entry.ok()?;
                    let entry_name = entry.filename().to_str_lossy().into_owned();
                    let is_dir = entry.mode().is_tree() || entry.mode().is_commit();
                    let size = if is_dir {
                        String::new()
                    } else {
                        git_repo
                            .find_object(entry.oid())
                            .ok()
                            .map(|o| format_size(o.data.len() as u64))
                            .unwrap_or_default()
                    };
                    let url = if resolved_path.is_empty() {
                        format!("{b}/{name}/commits/{}/tree/{entry_name}", params.sha)
                    } else {
                        format!(
                            "{b}/{name}/commits/{}/tree/{resolved_path}/{entry_name}",
                            params.sha
                        )
                    };
                    Some(TreeEntry {
                        name: entry_name,
                        is_dir,
                        url,
                        size,
                    })
                })
                .collect();

            // Sort: directories first, then files, alphabetically within each group.
            entries.sort_by(|a, b| {
                if a.is_dir == b.is_dir {
                    a.name.cmp(&b.name)
                } else {
                    b.is_dir.cmp(&a.is_dir)
                }
            });

            Ok(TreeTemplate {
                repo: name,
                sha: params.sha,
                path: resolved_path,
                breadcrumbs,
                is_file: false,
                entries,
                content_html: String::new(),
                raw_url,
                parent_url,
            }
            .into_response())
        }
        gix::object::Kind::Blob => {
            let blob = object.into_blob();
            let data = &blob.data;

            // Binary files: redirect to raw handler.
            if data.contains(&0) {
                return Ok(axum::response::Redirect::to(&raw_url).into_response());
            }

            let content = String::from_utf8_lossy(data).into_owned();

            let extension = resolved_path.rsplit('.').next().unwrap_or("");
            let syntax = SYNTAX_SET
                .find_syntax_by_extension(extension)
                .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());

            let mut generator = ClassedHTMLGenerator::new_with_class_style(
                syntax,
                &SYNTAX_SET,
                ClassStyle::SpacedPrefixed { prefix: "st-" },
            );
            // ClassedHTMLGenerator wraps tokens in `<span class="st-*">` using
            // CSS class mappings instead of inline hex colors, so the
            // highlighting adapts to the app theme via CSS variables.
            let mut hcontent = content;
            if !hcontent.ends_with('\n') {
                hcontent.push('\n');
            }
            for line in hcontent.split_inclusive('\n') {
                generator
                    .parse_html_for_line_which_includes_newline(line)
                    .map_err(|e| Error::BadRequest(e.to_string()))?;
            }
            let inner = generator.finalize();
            // Add line numbers by splitting on preserved newlines.  Segments
            // ending with `\n` are content lines; the final `</span>` closing
            // the root scope is the only segment without a trailing newline.
            let mut numbered = String::new();
            let mut line_num = 1;
            for segment in inner.split_inclusive('\n') {
                if segment.ends_with('\n') {
                    let _ = write!(
                        numbered,
                        "<span class=\"st-linenum\">{line_num}</span>{segment}"
                    );
                    line_num += 1;
                } else {
                    numbered.push_str(segment);
                }
            }
            let content_html = format!("<pre class=\"st-pre\">{numbered}</pre>");

            Ok(TreeTemplate {
                repo: name,
                sha: params.sha,
                path: resolved_path,
                breadcrumbs,
                is_file: true,
                entries: Vec::new(),
                content_html,
                raw_url,
                parent_url,
            }
            .into_response())
        }
        _ => {
            let spec = if resolved_path.is_empty() {
                format!("{}:", params.sha)
            } else {
                format!("{}:{}", params.sha, resolved_path)
            };
            Err(Error::NotFound(spec))
        }
    }
}
