#!/usr/bin/env nu

# NCL Index Generator
#
# Scans content directories for per-post *.ncl metadata files and generates
# _index.ncl files at two levels:
#
#   {content_dir}/{type}/{lang}/{category}/_index.ncl  — category index
#   {content_dir}/{type}/{lang}/_index.ncl             — language index (flat array)
#
# Both are valid Nickel arrays exportable via:
#   nickel export _index.ncl --format json
#
# Usage:
#   nu generate-ncl-index.nu [--content-dir PATH] [--type TYPE] [--lang LANG] [--dry-run] [--force] [--verbose]
#
# Environment:
#   SITE_CONTENT_PATH  — overrides --content-dir default

# Files to skip when scanning for post metadata inside category dirs.
const SKIP_FILES = ["_index.ncl"]

# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main [
    --content-dir: string = ""   # Root content directory. Defaults to $SITE_CONTENT_PATH or site/content
    --type: string = ""          # Filter to one content type (e.g. blog, recipes)
    --lang: string = ""          # Filter to one language (e.g. en, es)
    --dry-run                    # Print what would be written without writing files
    --force                      # Overwrite existing _index.ncl even if content is identical
    --validate                   # After generating, check slug/id uniqueness per lang root
    --verbose                    # Print per-file details
] {
    let root = resolve_content_dir $content_dir

    if not ($root | path exists) {
        error make { msg: $"Content directory not found: ($root)" }
    }

    print $"(ansi cyan)NCL Index Generator(ansi reset) — ($root)"
    if $dry_run { print $"(ansi yellow)[dry-run] no files will be written(ansi reset)" }

    let types = discover_types $root $type
    if ($types | is-empty) {
        print "(ansi yellow)No content types found.(ansi reset)"
        return
    }

    mut total_written = 0
    mut total_skipped = 0
    mut total_errors  = 0

    for ct in $types {
        let langs = discover_langs $root $ct $lang
        for language in $langs {
            let result = process_lang $root $ct $language $dry_run $force $verbose
            $total_written = $total_written + $result.written
            $total_skipped = $total_skipped + $result.skipped
            $total_errors  = $total_errors  + $result.errors
        }
    }

    print ""
    print $"(ansi green)Done(ansi reset) — written: ($total_written)  skipped: ($total_skipped)  errors: ($total_errors)"

    if $validate and not $dry_run {
        print ""
        validate_uniqueness $root $type $lang
    }
}

# ---------------------------------------------------------------------------
# Discovery helpers
# ---------------------------------------------------------------------------

def resolve_content_dir [arg: string] {
    if $arg != "" { return $arg }
    $env.SITE_CONTENT_PATH? | default "site/content"
}

def discover_types [root: string, filter: string] {
    let candidates = (ls $root)
        | where type == "dir"
        | get name
        | each { |p| $p | path basename }
        | where { |d| not ($d | str starts-with ".") }

    if $filter != "" {
        $candidates | where { |d| $d == $filter }
    } else {
        $candidates | where { |ct|
            (ls $"($root)/($ct)") | any { |e| $e.type == "dir" }
        }
    }
}

def discover_langs [root: string, ct: string, filter: string] {
    let candidates = (ls $"($root)/($ct)")
        | where type == "dir"
        | get name
        | each { |p| $p | path basename }
        | where { |d| not ($d | str starts-with ".") }

    if $filter != "" {
        $candidates | where { |d| $d == $filter }
    } else {
        $candidates
    }
}

def discover_categories [lang_dir: string] {
    if not ($lang_dir | path exists) { return [] }
    (ls $lang_dir)
        | where type == "dir"
        | get name
        | each { |p| $p | path basename }
        | where { |d| not ($d | str starts-with ".") }
}

def discover_post_ncl [cat_dir: string] {
    if not ($cat_dir | path exists) { return [] }

    # Flat *.ncl files (legacy single-file posts)
    let flat = (ls $cat_dir)
        | where type == "file"
        | where { |e| ($e.name | path parse | get extension) == "ncl" }
        | get name
        | each { |p| $p | path basename }
        | where { |f| not ($SKIP_FILES | any { |s| $s == $f }) }

    # Page-bundle directories: subdirs containing index.ncl
    let bundles = (ls $cat_dir)
        | where type == "dir"
        | get name
        | each { |p| $p | path basename }
        | where { |d| not ($d | str starts-with ".") }
        | where { |d| ($"($cat_dir)/($d)/index.ncl" | path exists) }
        | each { |d| $"($d)/index.ncl" }

    ($flat | append $bundles) | sort
}

# ---------------------------------------------------------------------------
# Per-language processing
# ---------------------------------------------------------------------------

def process_lang [root: string, ct: string, lang: string, dry_run: bool, force: bool, verbose: bool] {
    let lang_dir   = $"($root)/($ct)/($lang)"
    let categories = discover_categories $lang_dir

    if ($categories | is-empty) {
        if $verbose { print $"  ($ct)/($lang): no category subdirectories — skipping" }
        return { written: 0, skipped: 0, errors: 0 }
    }

    print $"  (ansi blue)($ct)/($lang)(ansi reset)"

    mut written       = 0
    mut skipped       = 0
    mut errors        = 0
    mut lang_cat_dirs: list<string> = []

    for cat in $categories {
        let cat_dir = $"($lang_dir)/($cat)"
        let posts   = discover_post_ncl $cat_dir

        if ($posts | is-empty) {
            if $verbose { print $"    ($cat)/: no *.ncl post files — skipping" }
            continue
        }

        # Imports relative to the category _index.ncl: ./slug.ncl
        let cat_imports = $posts | each { |f| $"    import \"./($f)\"" }
        let cat_index   = $"($cat_dir)/_index.ncl"
        let cat_content = build_category_index $ct $lang $cat $cat_imports

        let outcome = write_index $cat_index $cat_content $dry_run $force
        if $outcome == "written"  { $written = $written + 1 }
        if $outcome == "skipped"  { $skipped = $skipped + 1 }
        if $outcome == "error"    { $errors  = $errors  + 1 }

        print $"    ($cat)/: ($posts | length) posts → _index.ncl ($outcome)"

        $lang_cat_dirs = ($lang_cat_dirs | append $cat)
    }

    # Language-level _index.ncl flattens all category arrays.
    if not ($lang_cat_dirs | is-empty) {
        let lang_imports = $lang_cat_dirs | each { |c| $"    import \"./($c)/_index.ncl\"" }
        let lang_index   = $"($lang_dir)/_index.ncl"
        let lang_content = build_lang_index $ct $lang $lang_imports

        let outcome = write_index $lang_index $lang_content $dry_run $force
        if $outcome == "written"  { $written = $written + 1 }
        if $outcome == "skipped"  { $skipped = $skipped + 1 }
        if $outcome == "error"    { $errors  = $errors  + 1 }

        let n_cats = $lang_cat_dirs | length
        print $"    _index.ncl — lang root, ($n_cats) categories → ($outcome)"
    }

    { written: $written, skipped: $skipped, errors: $errors }
}

# ---------------------------------------------------------------------------
# NCL content builders
# ---------------------------------------------------------------------------

def build_category_index [ct: string, lang: string, cat: string, imports: list<string>] {
    let header = build_header $"($ct)/($lang)/($cat)"
    let body   = $imports | str join ",\n"
    $"($header)\n\n[\n($body),\n]\n"
}

def build_lang_index [ct: string, lang: string, cat_imports: list<string>] {
    let header = build_header $"($ct)/($lang)"
    let body   = $cat_imports | str join ",\n"
    # std.array.flatten collapses Array (Array T) -> Array T.
    # Each category _index.ncl is an Array, so this produces one flat list.
    $"($header)\n\nstd.array.flatten [\n($body),\n]\n"
}

def build_header [scope: string] {
    let ts = (date now | format date "%Y-%m-%dT%H:%M:%SZ")
    $"# AUTO-GENERATED — do not edit manually
# Scope:   ($scope)
# Updated: ($ts)
# Regen:   nu scripts/content/generate-ncl-index.nu"
}

# ---------------------------------------------------------------------------
# File I/O
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Uniqueness validation
# ---------------------------------------------------------------------------

# Export each lang-level _index.ncl via nickel and check slug + id uniqueness.
# Duplicates are reported with the conflicting values.
def validate_uniqueness [root: string, type_filter: string, lang_filter: string] {
    print $"(ansi cyan)Validating slug/id uniqueness...(ansi reset)"

    let types = discover_types $root $type_filter
    mut all_ok = true

    for ct in $types {
        let langs = discover_langs $root $ct $lang_filter
        for language in $langs {
            let index_path = $"($root)/($ct)/($language)/_index.ncl"
            if not ($index_path | path exists) { continue }

            let result = validate_lang_index $index_path $ct $language
            if not $result { $all_ok = false }
        }
    }

    if $all_ok {
        print $"(ansi green)All slugs and IDs are unique.(ansi reset)"
    } else {
        print $"(ansi red)Uniqueness errors found — fix duplicates before publishing.(ansi reset)"
    }
}

def validate_lang_index [index_path: string, ct: string, lang: string] {
    let json_result = try {
        nickel export $index_path --format json | from json
    } catch { |e|
        print $"(ansi red)  ($ct)/($lang): nickel export failed — ($e.msg)(ansi reset)"
        return false
    }

    # Nushell converts JSON arrays to tables ("table<...>") or lists.
    let desc = $json_result | describe
    if not (($desc | str starts-with "list") or ($desc | str starts-with "table")) {
        print $"(ansi yellow)  ($ct)/($lang): export is not an array — skipping uniqueness check(ansi reset)"
        return true
    }

    mut ok = true

    # Check slug duplicates
    let slug_dups = find_duplicates $json_result "slug"
    if not ($slug_dups | is-empty) {
        for dup in $slug_dups {
            print $"(ansi red)  ($ct)/($lang): duplicate slug '($dup)'(ansi reset)"
        }
        $ok = false
    }

    # Check id duplicates (only entries that have an explicit id field)
    let with_id = $json_result | where { |e| ($e | columns | any { |c| $c == "id" }) }
    if not ($with_id | is-empty) {
        let id_dups = find_duplicates $with_id "id"
        if not ($id_dups | is-empty) {
            for dup in $id_dups {
                print $"(ansi red)  ($ct)/($lang): duplicate id '($dup)'(ansi reset)"
            }
            $ok = false
        }
    }

    if $ok {
        let n = $json_result | length
        print $"  ($ct)/($lang): ($n) entries — ok"
    }

    $ok
}

# Returns list of values that appear more than once in column `field`.
def find_duplicates [records: list, field: string] {
    $records
        | each { |r| try { $r | get $field } catch { null } }
        | where { |v| $v != null }
        | sort
        | group-by { |v| $v }
        | transpose key entries
        | where { |row| ($row.entries | length) > 1 }
        | get key
}

# ---------------------------------------------------------------------------
# File I/O
# ---------------------------------------------------------------------------

def write_index [path: string, content: string, dry_run: bool, force: bool] {
    if $dry_run { return "written" }

    if (not $force) and ($path | path exists) {
        let existing = open --raw $path
        if $existing == $content { return "skipped" }
    }

    try {
        $content | save --force $path
        "written"
    } catch { |e|
        print $"(ansi red)    Error writing ($path): ($e.msg)(ansi reset)"
        "error"
    }
}
