//! TypeScript documentation coverage adapter.
//!
//! Checks for:
//! - A leading file-level JSDoc comment (`/** … */`) at the top of every
//!   `.ts` / `.tsx` file.
//! - A JSDoc comment before every exported declaration (`export function`,
//!   `export class`, `export type`, `export interface`, `export const`, etc.).

use std::fs;
use std::path::Path;

use crate::{CheckFailure, CheckResult, LanguageAdapter, relative_key};

/// TypeScript documentation coverage adapter.
pub struct TypeScriptAdapter;

impl TypeScriptAdapter {
    fn check_file(&self, path: &Path) -> Vec<CheckFailure> {
        let content = match fs::read_to_string(path) {
            Ok(c) => c,
            Err(_) => return vec![],
        };
        let lines: Vec<&str> = content.lines().collect();
        let mut failures = Vec::new();

        // File-level JSDoc: first non-empty line must start with "/**"
        if !has_file_level_jsdoc(&content) {
            failures.push(CheckFailure {
                file_path: path.to_path_buf(),
                line: 1,
                item_kind: "file".to_string(),
                item_name: file_stem(path),
            });
        }

        // Exported items missing JSDoc
        for (i, &line) in lines.iter().enumerate() {
            if let Some((kind, name)) = parse_exported_item(line)
                && !has_jsdoc_before(&lines, i)
            {
                failures.push(CheckFailure {
                    file_path: path.to_path_buf(),
                    line: i + 1,
                    item_kind: kind,
                    item_name: name,
                });
            }
        }

        failures
    }

    /// Extract exported item signatures from a TypeScript file as `"kind name"` strings.
    pub(crate) fn extract_items(path: &Path) -> Vec<String> {
        let content = match fs::read_to_string(path) {
            Ok(c) => c,
            Err(_) => return vec![],
        };
        content
            .lines()
            .filter_map(|line| {
                let (kind, name) = parse_exported_item(line)?;
                Some(format!("{kind} {name}"))
            })
            .collect()
    }
}

impl LanguageAdapter for TypeScriptAdapter {
    fn check(&self, files: &[&Path]) -> CheckResult {
        let failures: Vec<CheckFailure> = files.iter().flat_map(|&f| self.check_file(f)).collect();
        if failures.is_empty() {
            CheckResult::Ok
        } else {
            CheckResult::Failures(failures)
        }
    }

    fn update_source_map(
        &self,
        passing_files: &[&Path],
        source_map_path: &Path,
        root: Option<&Path>,
    ) -> Result<(), String> {
        let mut map = crate::read_map(source_map_path)?;
        for &file in passing_files {
            let key = relative_key(file, root);
            let items: Vec<serde_json::Value> = Self::extract_items(file)
                .into_iter()
                .map(serde_json::Value::String)
                .collect();
            map.insert(key, serde_json::Value::Array(items));
        }
        crate::write_map(source_map_path, map)
    }
}

fn file_stem(path: &Path) -> String {
    path.file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("unknown")
        .to_string()
}

/// Return `true` if the file starts with a JSDoc block comment (`/**`).
fn has_file_level_jsdoc(content: &str) -> bool {
    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        return trimmed.starts_with("/**");
    }
    false
}

/// Parse a line as an exported TypeScript declaration.
///
/// Returns `(kind, name)` for supported export forms, `None` otherwise.
fn parse_exported_item(line: &str) -> Option<(String, String)> {
    let trimmed = line.trim();

    // Strip "export default" or "export"
    let rest = if let Some(r) = trimmed.strip_prefix("export default ") {
        r.trim_start()
    } else {
        let r = trimmed.strip_prefix("export ")?;
        r.trim_start()
    };

    // Strip optional "async"
    let rest = if let Some(r) = rest.strip_prefix("async ") {
        r.trim_start()
    } else {
        rest
    };

    let (kind, name_part) = if let Some(r) = rest.strip_prefix("function ") {
        ("function", r.trim_start())
    } else if let Some(r) = rest.strip_prefix("class ") {
        ("class", r.trim_start())
    } else if let Some(r) = rest.strip_prefix("type ") {
        ("type", r.trim_start())
    } else if let Some(r) = rest.strip_prefix("interface ") {
        ("interface", r.trim_start())
    } else if let Some(r) = rest.strip_prefix("const ") {
        ("const", r.trim_start())
    } else if let Some(r) = rest.strip_prefix("let ") {
        ("let", r.trim_start())
    } else {
        let r = rest.strip_prefix("enum ")?;
        ("enum", r.trim_start())
    };

    let name: String = name_part
        .chars()
        .take_while(|&c| c.is_alphanumeric() || c == '_')
        .collect();

    if name.is_empty() {
        // "export default function() {}" — anonymous default export
        return Some((kind.to_string(), "default".to_string()));
    }

    Some((kind.to_string(), name))
}

/// Return `true` if a JSDoc comment appears before the item at `item_idx`.
///
/// Scans backward, skipping blank lines and decorator lines (`@…`). Returns
/// `true` if the first substantive line ends with `*/` (closing a JSDoc block)
/// or starts with `/**` (single-line JSDoc).
fn has_jsdoc_before(lines: &[&str], item_idx: usize) -> bool {
    let mut i = item_idx;
    while i > 0 {
        i -= 1;
        let line = lines[i].trim();
        if line.is_empty() {
            // A blank line breaks the JSDoc–item adjacency: stop searching.
            return false;
        }
        if line.starts_with('@') {
            // Decorator — keep scanning upward
            continue;
        }
        return line.ends_with("*/") || line.starts_with("/**");
    }
    false
}

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

    fn write_ts(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
        let path = dir.join(name);
        std::fs::write(&path, content).unwrap();
        path
    }

    #[test]
    fn check_fully_documented_file_returns_ok() {
        let tmp = TempDir::new().unwrap();
        let path = write_ts(
            tmp.path(),
            "app.ts",
            "/**\n * File doc.\n */\n\n/** Does something. */\nexport function hello(): void {}\n",
        );
        let adapter = TypeScriptAdapter;
        assert_eq!(adapter.check(&[&path]), CheckResult::Ok);
    }

    #[test]
    fn check_detects_missing_file_jsdoc() {
        let tmp = TempDir::new().unwrap();
        let path = write_ts(
            tmp.path(),
            "app.ts",
            "/** Does something. */\nexport function hello(): void {}\n",
        );
        // First non-empty line IS "/**", so this file passes the file-level check.
        // Use a file that starts with code instead.
        let path2 = write_ts(
            tmp.path(),
            "app2.ts",
            "import { foo } from './foo';\n/** A function. */\nexport function hello(): void {}\n",
        );
        let adapter = TypeScriptAdapter;
        let result = adapter.check(&[&path2]);
        assert!(
            matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")),
            "expected file failure, got {result:?}"
        );
        // The first file (starts with /**) should pass the file-level check
        let result2 = adapter.check(&[&path]);
        // It may still fail on the export if there's no separate export doc,
        // but the file-level check itself should pass (first line is /**)
        assert!(
            !matches!(&result2, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")),
            "file starting with /** should not have file-level failure"
        );
    }

    #[test]
    fn check_detects_missing_export_jsdoc_with_correct_fields() {
        let tmp = TempDir::new().unwrap();
        let path = write_ts(
            tmp.path(),
            "app.ts",
            "/**\n * File doc.\n */\n\nexport function undocumented(): void {}\n",
        );
        let adapter = TypeScriptAdapter;
        let result = adapter.check(&[&path]);
        if let CheckResult::Failures(failures) = result {
            let f = failures.iter().find(|f| f.item_kind == "function").unwrap();
            assert_eq!(f.item_name, "undocumented");
            assert_eq!(f.file_path, path);
        } else {
            panic!("expected failures");
        }
    }

    #[test]
    fn parse_exported_item_recognises_various_kinds() {
        assert_eq!(
            parse_exported_item("export function foo()"),
            Some(("function".into(), "foo".into()))
        );
        assert_eq!(
            parse_exported_item("export async function bar()"),
            Some(("function".into(), "bar".into()))
        );
        assert_eq!(
            parse_exported_item("export class Baz"),
            Some(("class".into(), "Baz".into()))
        );
        assert_eq!(
            parse_exported_item("export type Qux = string;"),
            Some(("type".into(), "Qux".into()))
        );
        assert_eq!(
            parse_exported_item("export interface IFoo"),
            Some(("interface".into(), "IFoo".into()))
        );
        assert_eq!(
            parse_exported_item("export const MY_CONST = 1;"),
            Some(("const".into(), "MY_CONST".into()))
        );
        assert_eq!(parse_exported_item("function notExported()"), None);
        assert_eq!(parse_exported_item("const x = 1;"), None);
    }
}
