#!/usr/bin/env nu

# Markdown → NCL Metadata Generator
#
# Reads YAML frontmatter from existing *.md content files and generates
# a co-located *.ncl metadata file for each one.
#
# Generated files use make_post / make_recipe from the project schema.
# Only fields present in PostMetadata / RecipeMetadata are emitted;
# display-only frontmatter (css_class, category_description, etc.) is dropped.
#
# Usage:
#   nu md-to-ncl.nu [--content-dir PATH] [--schema-dir PATH]
#                   [--type TYPE] [--lang LANG]
#                   [--dry-run] [--force] [--verbose]
#                   [--fill-translations]   # cross-lang stem matching
#
# Environment:
#   SITE_CONTENT_PATH  — overrides --content-dir default

# Fields emitted for blog posts (PostMetadata).
const POST_FIELDS = [
    "id", "title", "slug", "subtitle", "excerpt", "content_file",
    "author", "date", "updated_at", "published", "featured", "draft",
    "category", "tags", "read_time", "sort_order", "image_url", "translations",
]

# Extra fields emitted for recipes (RecipeMetadata).
const RECIPE_EXTRA_FIELDS = ["difficulty", "duration", "prerequisites", "tools"]

# Content types that use RecipeMetadata.
const RECIPE_TYPES = ["recipes"]

# Fields to silently drop — frontmatter-only, not in NCL schema.
const DROP_FIELDS = [
    "css_class", "category_description", "category_published",
]

# Default author value (matches schema default — omit from NCL if equal).
const DEFAULT_AUTHOR = "Jesús Pérez Lorenzo"

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

def main [
    --content-dir: string = ""   # Content root. Default: $SITE_CONTENT_PATH or site/content
    --schema-dir:  string = ""   # Schema dir.   Default: rustelo/nickel/content/metadata
    --type: string = ""          # Filter to one content type
    --lang: string = ""          # Filter to one language
    --dry-run                    # Show what would be written without writing
    --force                      # Overwrite existing *.ncl files
    --validate-contracts         # After generating, run nickel export on every post *.ncl
    --fill-translations          # Auto-populate translations field via cross-lang stem matching
    --verbose                    # Print per-field details
] {
    let root       = resolve_content_dir $content_dir
    let schema_abs = resolve_schema_dir $schema_dir

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

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

    # Build translation map before generation pass (scans all langs unconditionally).
    let trans_map = if $fill_translations {
        print $"(ansi cyan)Building translation map...(ansi reset)"
        let m = build_translation_map $root
        print $"  ($m | length) slug entries across all languages"
        $m
    } else {
        []
    }

    # --fill-translations implies --force: overwrite to update translations field.
    let effective_force = $force or $fill_translations

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

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

    for ct in $types {
        let langs = discover_dirs $"($root)/($ct)" $lang
        for language in $langs {
            let result = process_lang $root $ct $language $schema_abs $dry_run $effective_force $verbose $trans_map
            $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_contracts and not $dry_run {
        print ""
        validate_contracts $root $type $lang
    }
}

# ---------------------------------------------------------------------------
# Translation map construction
# ---------------------------------------------------------------------------

# Scan ALL *.md files (no type/lang filter) and build a flat list of
# { ct, stem, lang, slug } records for use by --fill-translations.
def build_translation_map [root: string] {
    let types = discover_dirs $root ""
    mut entries: list = []

    for ct in $types {
        let langs = discover_dirs $"($root)/($ct)" ""
        for lang in $langs {
            let lang_dir = $"($root)/($ct)/($lang)"
            let cats = discover_dirs $lang_dir ""
            for cat in $cats {
                let cat_dir = $"($lang_dir)/($cat)"
                let md_files = discover_md_files $cat_dir
                for md_path in $md_files {
                    let stem = $md_path | path parse | get stem
                    let raw = try { open --raw $md_path } catch { "" }
                    if ($raw | is-empty) { continue }
                    let fm = extract_frontmatter $raw
                    if ($fm | is-empty) { continue }
                    let parsed = try { $fm | from yaml } catch { null }
                    if $parsed == null { continue }
                    let slug_raw = try { $parsed | get slug } catch { null }
                    let slug = if $slug_raw != null and ($slug_raw | describe) == "string" {
                        $slug_raw | str trim
                    } else {
                        $stem
                    }
                    $entries = ($entries | append { ct: $ct, stem: $stem, lang: $lang, slug: $slug })
                }
            }
        }
    }

    $entries
}

# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------

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

def resolve_schema_dir [arg: string] {
    let d = if $arg != "" { $arg } else { "rustelo/nickel/content/metadata" }
    $d | path expand
}

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

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

def discover_md_files [cat_dir: string] {
    if not ($cat_dir | path exists) { return [] }
    (ls $cat_dir)
        | where type == "file"
        | where { |e| ($e.name | path parse | get extension) == "md" }
        | get name
        | sort
}

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

def process_lang [
    root: string, ct: string, lang: string,
    schema_abs: string, dry_run: bool, force: bool, verbose: bool,
    trans_map: list
] {
    let lang_dir = $"($root)/($ct)/($lang)"
    let cats     = discover_dirs $lang_dir ""

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

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

    mut written = 0
    mut skipped = 0
    mut errors  = 0

    for cat in $cats {
        let cat_dir = $"($lang_dir)/($cat)"
        let md_files = discover_md_files $cat_dir

        if ($md_files | is-empty) {
            if $verbose { print $"    ($cat)/: no *.md files" }
            continue
        }

        for md_path in $md_files {
            let stem     = $md_path | path parse | get stem
            let ncl_path = ($md_path | path parse | update extension "ncl" | path join)

            let result = process_md_file $md_path $ncl_path $ct $cat $lang $stem $schema_abs $dry_run $force $verbose $trans_map
            if $result == "written"  { $written = $written + 1 }
            if $result == "skipped"  { $skipped = $skipped + 1 }
            if $result == "error"    { $errors  = $errors  + 1 }

            if $verbose or $result == "written" {
                print $"    ($cat)/($stem).ncl → ($result)"
            }
        }
    }

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

# ---------------------------------------------------------------------------
# Single file processing
# ---------------------------------------------------------------------------

def process_md_file [
    md_path: string, ncl_path: string, ct: string, cat: string,
    lang: string, stem: string,
    schema_abs: string, dry_run: bool, force: bool, verbose: bool,
    trans_map: list
] {
    # Skip if NCL already exists and --force not set
    if (not $force) and ($ncl_path | path exists) { return "skipped" }

    let raw = try { open --raw $md_path } catch { return "error" }

    let frontmatter = extract_frontmatter $raw
    if ($frontmatter | is-empty) {
        print $"(ansi yellow)    Warning: no frontmatter in ($md_path)(ansi reset)"
        return "skipped"
    }

    let parsed = try { $frontmatter | from yaml } catch { |e|
        print $"(ansi red)    Error parsing YAML in ($md_path): ($e.msg)(ansi reset)"
        return "error"
    }

    # Compute relative import path from ncl_path to schema_abs.
    # Structure is always {content_root}/{type}/{lang}/{cat}/file.ncl
    # = 4 levels up from file dir reaches content root's parent (site/).
    let ncl_dir    = $ncl_path | path dirname | path expand
    let rel_schema = compute_rel_path $ncl_dir $schema_abs

    let is_recipe  = $RECIPE_TYPES | any { |t| $t == $ct }
    let schema_file = if $is_recipe { "recipe_metadata.ncl" } else { "post_metadata.ncl" }
    let factory     = if $is_recipe { "make_recipe" } else { "make_post" }
    let import_path = $"($rel_schema)/($schema_file)"

    let fields      = if $is_recipe {
        $POST_FIELDS ++ $RECIPE_EXTRA_FIELDS
    } else {
        $POST_FIELDS
    }

    let ncl_content = build_ncl $parsed $fields $import_path $factory $md_path $cat $ct $lang $stem $trans_map $verbose

    if $dry_run { return "written" }

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

# ---------------------------------------------------------------------------
# Relative path computation
# ---------------------------------------------------------------------------

def compute_rel_path [from_abs: string, to_abs: string] {
    # Split both paths into segments and find common prefix length.
    let from_parts = $from_abs | path split
    let to_parts   = $to_abs   | path split
    let min_len    = [$from_parts $to_parts] | each { length } | math min

    mut common = 0
    for i in 0..($min_len - 1) {
        let fp = $from_parts | get $i
        let tp = $to_parts   | get $i
        if $fp == $tp { $common = $common + 1 } else { break }
    }

    let up_count  = ($from_parts | length) - $common
    let down_parts = $to_parts | skip $common
    let ups        = 0..($up_count - 1) | each { ".." }
    let rel_parts  = $ups ++ $down_parts

    $rel_parts | str join "/"
}

# ---------------------------------------------------------------------------
# Frontmatter extraction
# ---------------------------------------------------------------------------

def extract_frontmatter [content: string] {
    let lines  = $content | lines
    let delims = $lines | enumerate | where { |e| $e.item | str trim | $in == "---" } | get index

    if ($delims | length) < 2 { return "" }

    let start = ($delims | first) + 1
    let stop  = $delims | get 1

    $lines | skip $start | first ($stop - $start) | str join "\n"
}

# ---------------------------------------------------------------------------
# NCL content builder
# ---------------------------------------------------------------------------

def build_ncl [
    parsed: record, fields: list<string>, import_path: string,
    factory: string, source_md: string, cat: string,
    ct: string, cur_lang: string, stem: string, trans_map: list,
    verbose: bool
] {
    let header = $"# Generated from ($source_md | path basename)
# Schema: ($import_path)
# Regenerate: nu scripts/content/md-to-ncl.nu --force

let schema = import \"($import_path)\" in

schema.($factory) \{"

    mut field_lines: list<string> = []

    for field in $fields {
        # Skip drop-list fields
        if ($DROP_FIELDS | any { |d| $d == $field }) { continue }

        # translations: computed from cross-lang stem map when available,
        # otherwise fall back to frontmatter value.
        if $field == "translations" {
            if not ($trans_map | is-empty) {
                let others = $trans_map
                    | where { |r| $r.ct == $ct and $r.stem == $stem and $r.lang != $cur_lang }
                if not ($others | is-empty) {
                    let entries = $others
                        | each { |r| $"($r.lang) = \"($r.slug)\"" }
                        | str join ", "
                    $field_lines = ($field_lines | append $"  translations = { ($entries) },")
                }
            } else {
                let raw_val = try { $parsed | get translations } catch { null }
                if $raw_val != null {
                    let ncl_val = to_ncl_value $raw_val "translations"
                    if $ncl_val != null {
                        $field_lines = ($field_lines | append $"  translations = ($ncl_val),")
                    }
                }
            }
            continue
        }

        let raw_val = try { $parsed | get $field } catch { null }

        # Skip null / missing
        if $raw_val == null { continue }

        # Skip author if it matches the schema default
        if $field == "author" {
            let s = $raw_val | into string | str trim
            if $s == $DEFAULT_AUTHOR or $s == "Jesús Pérez" { continue }
        }

        # Skip sort_order == 0 (schema default)
        if $field == "sort_order" and ($raw_val | into int) == 0 { continue }

        # Skip published == true (schema default)
        if $field == "published" and $raw_val == true { continue }

        # Skip featured == false (schema default)
        if $field == "featured" and $raw_val == false { continue }

        # Skip draft == false (schema default)
        if $field == "draft" and $raw_val == false { continue }

        let ncl_val = to_ncl_value $raw_val $field

        if $ncl_val == null { continue }

        $field_lines = ($field_lines | append $"  ($field) = ($ncl_val),")
    }

    if ($field_lines | is-empty) {
        return $"($header)\n}"
    }

    let body = $field_lines | str join "\n"
    $"($header)\n($body)\n}"
}

# ---------------------------------------------------------------------------
# YAML → NCL value conversion
# ---------------------------------------------------------------------------

def to_ncl_value [val: any, field: string] {
    # Null / empty
    if $val == null { return null }

    let type_name = $val | describe

    # Boolean
    if $type_name == "bool" {
        return ($val | into string)
    }

    # Integer / float
    if ($type_name | str starts-with "int") or ($type_name | str starts-with "float") {
        return ($val | into string)
    }

    # List → Nickel array  (describe returns "list<string>", "list<any>", etc.)
    if ($type_name | str starts-with "list") {
        let items = $val | each { |v| $"\"($v)\"" } | str join ", "
        return $"[($items)]"
    }

    # Record → Nickel record  (e.g. translations: {es: "slug-es"} in YAML)
    if ($type_name | str starts-with "record") {
        let entries = $val | transpose key value
            | each { |e|
                let v_esc = ($e.value | into string | str replace --all '\\' '\\\\' | str replace --all '"' '\\"')
                $"($e.key) = \"($v_esc)\""
            }
            | str join ", "
        return $"{ ($entries) }"
    }

    # String
    let s = $val | into string | str trim
    if $s == "" { return null }

    # tags field: might be empty string
    if $field == "tags" and $s == "" { return "[]" }

    # Escape backslashes and double-quotes for NCL string literal
    let escaped = $s | str replace --all '\\' '\\\\' | str replace --all '"' '\\"'
    $"\"($escaped)\""
}

# ---------------------------------------------------------------------------
# Contract validation
# ---------------------------------------------------------------------------

# Run `nickel export` on every post *.ncl file in {type}/{lang}/{cat}/ paths.
# Files at shallower depths (blog.ncl, recipes.ncl, content-kinds.ncl) are
# pre-existing type-level configs — not validated here.
def validate_contracts [root: string, type_filter: string, lang_filter: string] {
    print $"(ansi cyan)Validating Nickel contracts...(ansi reset)"

    # Collect all *.ncl files exactly 4 levels deep: type/lang/cat/file.ncl
    # Exclude _index.ncl (generated aggregators, not post metadata).
    let all_ncl = (glob $"($root)/*/*/*/*.ncl")
        | where { |p|
            let base = $p | path basename
            $base != "_index.ncl"
        }
        | where { |p|
            # Apply type/lang filters if provided
            let parts = $p | path split
            let n     = $parts | length
            # parts[-4] = type, parts[-3] = lang, parts[-2] = cat, parts[-1] = file
            let ok_type = if $type_filter != "" {
                ($parts | get ($n - 4)) == $type_filter
            } else { true }
            let ok_lang = if $lang_filter != "" {
                ($parts | get ($n - 3)) == $lang_filter
            } else { true }
            $ok_type and $ok_lang
        }
        | sort

    if ($all_ncl | is-empty) {
        print "  No post *.ncl files found."
        return
    }

    let total = $all_ncl | length
    mut passed = 0
    mut failed = 0

    for ncl_path in $all_ncl {
        let label = $ncl_path | path split | last 4 | str join "/"
        let result = try {
            nickel export $ncl_path --format json | ignore
            { ok: true, msg: "" }
        } catch { |e|
            { ok: false, msg: ($e.msg | str trim) }
        }

        if $result.ok {
            $passed = $passed + 1
            print $"  (ansi green)ok(ansi reset)   ($label)"
        } else {
            $failed = $failed + 1
            # Extract first meaningful error line from nickel output
            let err_line = nickel export $ncl_path --format json out+err>|
                | lines
                | where { |l| ($l | str trim | str length) > 0 }
                | where { |l| not ($l | str starts-with " ") }
                | first 1
                | str join ""
            print $"  (ansi red)FAIL(ansi reset) ($label)"
            print $"       ($err_line)"
        }
    }

    print ""
    if $failed == 0 {
        print $"(ansi green)All ($total) files pass contract validation.(ansi reset)"
    } else {
        print $"(ansi red)($failed) / ($total) files failed contract validation.(ansi reset)"
        exit 1
    }
}
