#!/usr/bin/env julia
#############################################################################
## validate_external.jl
## Out-of-sample validation via held-out expert observations
##
## Design:
##   - Text data stays 100% intact (same parties, segments, indices)
##   - 20% of expert/LR observations held out (stratified by source)
##   - Expert-only parties (no text data) are never held out
##   - Model trains on 80% expert + 100% text
##   - Held-out expert ratings compared to model predictions
##
## Usage:
##   julia scripts/validate_external.jl prepare
##   julia 01_run_model.jl --data-dir validation/external_split/
##   julia 02_post_estimation.jl  # on training run
##   julia scripts/validate_external.jl compute <model_positions.csv>
#############################################################################

using CSV, DataFrames, Statistics, Random, Dates, Printf

const HOLDOUT_FRAC = 0.20
const SEED = 42

# =========================================================================
# STEP 1: Prepare train/test split
# =========================================================================

function prepare_holdout_data(base_dir::String=".")
    println("="^70)
    println("PREPARING OUT-OF-SAMPLE VALIDATION SPLIT")
    println("="^70)
    println()

    # Load data
    text_data = CSV.read(joinpath(base_dir, "text_data.csv"), DataFrame)
    expert = CSV.read(joinpath(base_dir, "expert.csv"), DataFrame)
    lr_data = CSV.read(joinpath(base_dir, "lr_data.csv"), DataFrame)

    println("Full dataset:")
    println("  text_data: $(nrow(text_data)) rows, $(length(unique(text_data.party))) parties")
    println("  expert:    $(nrow(expert)) rows, $(length(unique(expert.party))) parties")
    println("  lr_data:   $(nrow(lr_data)) rows, $(length(unique(lr_data.party))) parties")

    # Identify expert-only parties (no text data) — these are NEVER held out
    text_parties = Set(unique(text_data.party))
    expert_only_parties = Set(p for p in unique(vcat(expert.party, lr_data.party))
                              if !(p in text_parties))

    println()
    println("Expert-only parties (protected from holdout): $(length(expert_only_parties))")

    # Split expert data: stratified by source variable
    # Safeguard: ensure each party keeps at least one observation in training
    Random.seed!(SEED)
    expert.row_id = 1:nrow(expert)
    expert.is_holdout = falses(nrow(expert))

    for var_group in groupby(expert, :var)
        var_name = first(var_group.var)
        eligible = findall(row -> !(row.party in expert_only_parties), eachrow(var_group))

        n_holdout = round(Int, length(eligible) * HOLDOUT_FRAC)
        holdout_candidates = shuffle(eligible)

        # Track per-party counts to ensure at least 1 stays in training
        party_train_count = Dict{Int, Int}()
        for idx in eligible
            p = var_group.party[idx]
            party_train_count[p] = get(party_train_count, p, 0) + 1
        end

        n_held = 0
        for idx in holdout_candidates
            n_held >= n_holdout && break
            p = var_group.party[idx]
            if party_train_count[p] > 1  # keep at least 1 in training
                row_id = var_group.row_id[idx]
                expert.is_holdout[row_id] = true
                party_train_count[p] -= 1
                n_held += 1
            end
        end
    end

    # Split LR data: stratified by source variable (same safeguard)
    lr_data.row_id = 1:nrow(lr_data)
    lr_data.is_holdout = falses(nrow(lr_data))

    for var_group in groupby(lr_data, :var)
        var_name = first(var_group.var)
        eligible = findall(row -> !(row.party in expert_only_parties), eachrow(var_group))

        n_holdout = round(Int, length(eligible) * HOLDOUT_FRAC)
        holdout_candidates = shuffle(eligible)

        party_train_count = Dict{Int, Int}()
        for idx in eligible
            p = var_group.party[idx]
            party_train_count[p] = get(party_train_count, p, 0) + 1
        end

        n_held = 0
        for idx in holdout_candidates
            n_held >= n_holdout && break
            p = var_group.party[idx]
            if party_train_count[p] > 1
                row_id = var_group.row_id[idx]
                lr_data.is_holdout[row_id] = true
                party_train_count[p] -= 1
                n_held += 1
            end
        end
    end

    # Create train/test splits
    expert_train = expert[.!expert.is_holdout, Not([:row_id, :is_holdout])]
    expert_test = expert[expert.is_holdout, Not([:row_id, :is_holdout])]
    lr_train = lr_data[.!lr_data.is_holdout, Not([:row_id, :is_holdout])]
    lr_test = lr_data[lr_data.is_holdout, Not([:row_id, :is_holdout])]

    # Report split
    println()
    println("Split summary ($(round(100*HOLDOUT_FRAC))% holdout):")
    println("  expert train: $(nrow(expert_train)) rows ($(round(100*nrow(expert_train)/nrow(expert), digits=1))%)")
    println("  expert test:  $(nrow(expert_test)) rows ($(round(100*nrow(expert_test)/nrow(expert), digits=1))%)")
    println("  lr train:     $(nrow(lr_train)) rows ($(round(100*nrow(lr_train)/nrow(lr_data), digits=1))%)")
    println("  lr test:      $(nrow(lr_test)) rows ($(round(100*nrow(lr_test)/nrow(lr_data), digits=1))%)")

    # Report per-source breakdown
    println()
    println("Per-source breakdown (expert):")
    for var_name in sort(unique(expert.var))
        n_full = count(expert.var .== var_name)
        n_test = count(expert_test.var .== var_name)
        println("  $var_name: $(n_full - n_test) train / $n_test test")
    end
    println()
    println("Per-source breakdown (LR):")
    for var_name in sort(unique(lr_data.var))
        n_full = count(lr_data.var .== var_name)
        n_test = count(lr_test.var .== var_name)
        println("  $var_name: $(n_full - n_test) train / $n_test test")
    end

    # Verify: training set has same parties as full set
    train_parties_expert = Set(unique(expert_train.party))
    train_parties_lr = Set(unique(lr_train.party))
    full_parties_expert = Set(unique(expert.party))
    full_parties_lr = Set(unique(lr_data.party))

    lost_expert = setdiff(full_parties_expert, train_parties_expert)
    lost_lr = setdiff(full_parties_lr, train_parties_lr)

    println()
    if isempty(lost_expert) && isempty(lost_lr)
        println("✓ No parties lost from training set")
    else
        println("⚠ Parties lost from expert training: $(length(lost_expert))")
        println("⚠ Parties lost from LR training: $(length(lost_lr))")
    end

    # Save to output directory
    # Files use standard names so 01_run_model.jl can load with --data-dir
    output_dir = joinpath(base_dir, "validation", "external_split")
    mkpath(output_dir)

    # Training files (standard names for model loading)
    CSV.write(joinpath(output_dir, "text_data.csv"), text_data)  # UNCHANGED
    CSV.write(joinpath(output_dir, "expert.csv"), expert_train)
    CSV.write(joinpath(output_dir, "lr_data.csv"), lr_train)

    # Test files (for compute step)
    CSV.write(joinpath(output_dir, "expert_test.csv"), expert_test)
    CSV.write(joinpath(output_dir, "lr_data_test.csv"), lr_test)

    # Copy union mapping (needed by model)
    if isdir(joinpath(base_dir, "data"))
        mkpath(joinpath(output_dir, "data"))
        cp(joinpath(base_dir, "data", "union_mapping.csv"),
           joinpath(output_dir, "data", "union_mapping.csv"), force=true)
    end

    println()
    println("Files saved to: $output_dir")
    println("  text_data.csv  — IDENTICAL to original ($(nrow(text_data)) rows)")
    println("  expert.csv     — training only ($(nrow(expert_train)) rows)")
    println("  lr_data.csv    — training only ($(nrow(lr_train)) rows)")
    println("  expert_test.csv — held-out ($(nrow(expert_test)) rows)")
    println("  lr_data_test.csv — held-out ($(nrow(lr_test)) rows)")

    return output_dir
end

# =========================================================================
# STEP 2: Compute held-out validation metrics
# =========================================================================

function compute_holdout_metrics(model_file::String, test_dir::String)
    println()
    println("="^70)
    println("COMPUTING HELD-OUT VALIDATION METRICS")
    println("="^70)

    # Load model output
    model = CSV.read(model_file, DataFrame)
    party_col = hasproperty(model, :party_id) ? :party_id : :party
    println("Model output: $(nrow(model)) party-years")

    # Load test data
    expert_test = CSV.read(joinpath(test_dir, "expert_test.csv"), DataFrame)
    lr_test = CSV.read(joinpath(test_dir, "lr_data_test.csv"), DataFrame)
    println("Held-out expert: $(nrow(expert_test)) observations")
    println("Held-out LR: $(nrow(lr_test)) observations")

    # Build lookup: (party, year) → model estimates
    model_lookup = Dict{Tuple{Int,Int}, NamedTuple}()
    for row in eachrow(model)
        key = (row[party_col], row.year)
        model_lookup[key] = (
            economic_lr = row.economic_lr,
            galtan = row.galtan,
            economic_lr_se = hasproperty(row, :economic_lr_se) ? row.economic_lr_se : missing,
            galtan_se = hasproperty(row, :galtan_se) ? row.galtan_se : missing,
            economic_lr_q025 = hasproperty(row, :economic_lr_q025) ? row.economic_lr_q025 : missing,
            economic_lr_q975 = hasproperty(row, :economic_lr_q975) ? row.economic_lr_q975 : missing,
            galtan_q025 = hasproperty(row, :galtan_q025) ? row.galtan_q025 : missing,
            galtan_q975 = hasproperty(row, :galtan_q975) ? row.galtan_q975 : missing,
        )
    end

    # Map expert variables to dimensions
    econ_vars = Set(["lrecon_ches", "lrecon_poppa", "lrecon_gps", "lrecon_vparty", "welf_vparty"])
    galtan_vars = Set(["galtan_ches", "libcon_gps", "immig_vparty", "lgbt_vparty",
                       "culsup_vparty", "relig_vparty", "gender_vparty"])

    # Process expert test observations
    results = NamedTuple[]

    for row in eachrow(expert_test)
        key = (row.party, row.year)
        haskey(model_lookup, key) || continue
        m = model_lookup[key]

        if row.var in econ_vars
            dim = "economic_lr"
            model_val = m.economic_lr
            model_q025 = m.economic_lr_q025
            model_q975 = m.economic_lr_q975
        elseif row.var in galtan_vars
            dim = "galtan"
            model_val = m.galtan
            model_q025 = m.galtan_q025
            model_q975 = m.galtan_q975
        else
            continue
        end

        covered = !ismissing(model_q025) && !ismissing(model_q975) &&
                  row.val >= model_q025 && row.val <= model_q975

        push!(results, (
            party = row.party,
            country = row.country,
            year = row.year,
            var = row.var,
            dimension = dim,
            expert_val = row.val,
            model_val = model_val,
            error = row.val - model_val,
            abs_error = abs(row.val - model_val),
            covered_95 = ismissing(model_q025) ? missing : covered,
        ))
    end

    if isempty(results)
        println("ERROR: No matching test observations found")
        return nothing
    end

    results_df = DataFrame(results)

    # Compute and report metrics
    println()
    println("-"^70)
    println("HELD-OUT VALIDATION RESULTS")
    println("-"^70)

    # Overall
    overall_r = cor(results_df.expert_val, results_df.model_val)
    overall_mae = mean(results_df.abs_error)
    overall_rmse = sqrt(mean(results_df.error .^ 2))
    println()
    println(@sprintf("Overall: r=%.4f, MAE=%.4f, RMSE=%.4f, n=%d",
                     overall_r, overall_mae, overall_rmse, nrow(results_df)))

    # By dimension
    println()
    println("By dimension:")
    for dim in sort(unique(results_df.dimension))
        d = filter(r -> r.dimension == dim, results_df)
        r_val = cor(d.expert_val, d.model_val)
        mae = mean(d.abs_error)
        rmse = sqrt(mean(d.error .^ 2))
        cov = count(skipmissing(d.covered_95)) / count(!ismissing, d.covered_95)
        println(@sprintf("  %-12s: r=%.4f, MAE=%.4f, RMSE=%.4f, CIC95=%.1f%%, n=%d",
                         dim, r_val, mae, rmse, 100*cov, nrow(d)))
    end

    # By source
    println()
    println("By source:")
    for var in sort(unique(results_df.var))
        v = filter(r -> r.var == var, results_df)
        nrow(v) < 5 && continue
        r_val = cor(v.expert_val, v.model_val)
        mae = mean(v.abs_error)
        println(@sprintf("  %-20s: r=%.4f, MAE=%.4f, n=%d", var, r_val, mae, nrow(v)))
    end

    return results_df
end

# =========================================================================
# Main
# =========================================================================

function main()
    args = ARGS

    if isempty(args) || args[1] == "prepare"
        output_dir = prepare_holdout_data()

        println()
        println("="^70)
        println("NEXT STEPS")
        println("="^70)
        println("""
1. Run model on training data:
   julia 01_run_model.jl --data-dir $output_dir

2. Run post-estimation on training run output:
   julia 02_post_estimation.jl

3. Compute held-out metrics:
   julia scripts/validate_external.jl compute <party_positions_file.csv>
""")

    elseif args[1] == "compute" && length(args) >= 2
        model_file = args[2]
        test_dir = joinpath("validation", "external_split")

        isfile(model_file) || error("Model file not found: $model_file")
        isdir(test_dir) || error("Test data not found. Run 'prepare' first.")

        results_df = compute_holdout_metrics(model_file, test_dir)

        if results_df !== nothing
            timestamp = Dates.format(now(), "yyyy-mm-dd_HH-MM-SS")
            output_file = joinpath("validation", "external_validation_$(timestamp).csv")
            CSV.write(output_file, results_df)
            println()
            println("Detailed results saved: $output_file")
        end

    else
        println("Usage:")
        println("  julia scripts/validate_external.jl prepare")
        println("  julia scripts/validate_external.jl compute <party_positions.csv>")
    end
end

if abspath(PROGRAM_FILE) == @__FILE__
    main()
end
