#!/usr/bin/env julia
#=
02_post_estimation.jl - Extract party position estimates from Stan model output

Supports both model versions:
- 2D model (V1): Extracts economic_lr and galtan directly
- 4D model (V10): Extracts 4 traits + 2 derived scales

V10/V1 UPDATE: Handles segment-based indexing
- Maps segment results back to original party IDs
- Adds segment_num column to indicate which segment of the party
- Flags discontinuities for parties with multiple segments

This script:
1. Auto-detects the latest model run in model_outputs/
2. Loads the chain CSV files and data mappings
3. Detects model version from metadata or column names
4. Extracts posterior summaries for all segment-year positions
5. Maps Stan parameter indices back to real party IDs, segment numbers, and years
6. Saves output as wide-format CSV with uncertainty estimates

Usage:
    julia 02_post_estimation.jl

Output:
    party_positions_YYYY-MM-DD_HH-MM-SS.csv
=#

using CSV
using DataFrames
using Statistics
using JSON
using Dates
using Printf

# =============================================================================
# STEP 0: Auto-detect latest run
# =============================================================================

function find_latest_run(base_dir::String="outputs/model_outputs/latest")
    if !isdir(base_dir)
        error("Model outputs directory not found: $base_dir")
    end

    runs = filter(d -> startswith(d, "run_") && isdir(joinpath(base_dir, d)), readdir(base_dir))

    if isempty(runs)
        error("No runs found in $base_dir")
    end

    # Sort by timestamp in directory name (format: run_YYYY-MM-DD_HH-MM-SS)
    sort!(runs, rev=true)

    latest = joinpath(base_dir, runs[1])
    println("Found $(length(runs)) run(s). Using latest: $latest")
    return latest
end

# =============================================================================
# STEP 1: Load data and build segment-year lookup
# =============================================================================

function load_run_data(run_dir::String)
    println("\n" * "="^60)
    println("LOADING RUN DATA")
    println("="^60)

    data_dir = joinpath(run_dir, "data")
    chains_dir = joinpath(run_dir, "chains")

    # Check required files exist
    required_files = [
        joinpath(data_dir, "text_data.csv"),
        joinpath(data_dir, "expert_dim.csv"),
        joinpath(data_dir, "expert_lr.csv"),
        joinpath(run_dir, "metadata.json")
    ]

    for f in required_files
        if !isfile(f)
            error("Required file not found: $f")
        end
    end

    # Load data files
    println("Loading text_data.csv...")
    text_data = CSV.read(joinpath(data_dir, "text_data.csv"), DataFrame)
    println("  Rows: $(nrow(text_data))")

    println("Loading expert_dim.csv...")
    expert_dim = CSV.read(joinpath(data_dir, "expert_dim.csv"), DataFrame)
    println("  Rows: $(nrow(expert_dim))")

    println("Loading expert_lr.csv...")
    expert_lr = CSV.read(joinpath(data_dir, "expert_lr.csv"), DataFrame)
    println("  Rows: $(nrow(expert_lr))")

    println("Loading metadata.json...")
    metadata = JSON.parsefile(joinpath(run_dir, "metadata.json"))
    println("  year0: $(metadata["year0"])")
    println("  Model: $(metadata["model_file"])")

    # V10: Load segment_info if available
    segment_info_file = joinpath(data_dir, "segment_info.csv")
    segment_info = nothing
    if isfile(segment_info_file)
        println("Loading segment_info.csv (V10)...")
        segment_info = CSV.read(segment_info_file, DataFrame)
        println("  Segments: $(nrow(segment_info))")
    end

    # V10: Load segment_year_map if available
    segment_year_file = joinpath(data_dir, "segment_year_map.csv")
    segment_year_map = nothing
    if isfile(segment_year_file)
        println("Loading segment_year_map.csv (V10)...")
        segment_year_map = CSV.read(segment_year_file, DataFrame)
        println("  Segment-years: $(nrow(segment_year_map))")
    end

    # Find chain files
    chain_files = filter(f -> endswith(f, ".csv") && startswith(f, "chain_"), readdir(chains_dir))
    println("\nFound $(length(chain_files)) chain file(s)")

    return (
        text_data = text_data,
        expert_dim = expert_dim,
        expert_lr = expert_lr,
        metadata = metadata,
        segment_info = segment_info,
        segment_year_map = segment_year_map,
        chain_files = [joinpath(chains_dir, f) for f in sort(chain_files)],
        run_dir = run_dir
    )
end

function normalize_country_value(value)
    if ismissing(value)
        return missing
    end
    txt = strip(string(value))
    return isempty(txt) ? missing : txt
end

function build_party_country_map(text_data::DataFrame, expert_dim::DataFrame, expert_lr::DataFrame)
    merged = unique(vcat(
        select(text_data, :party, :country),
        select(expert_dim, :party, :country),
        select(expert_lr, :party, :country)
    ))

    party_to_country = Dict{Int, String}()
    for row in eachrow(merged)
        pid = tryparse(Int, string(row.party))
        if pid === nothing
            continue
        end
        c = normalize_country_value(row.country)
        if !ismissing(c)
            party_to_country[pid] = c
        end
    end
    return party_to_country
end

function load_constituent_to_union_map()::Dict{Int, Int}
    mapping_file = joinpath("data", "union_mapping.csv")
    constituent_to_union = Dict{Int, Int}()
    if isfile(mapping_file)
        union_df = CSV.read(mapping_file, DataFrame)
        for row in eachrow(union_df)
            constituent_to_union[row.expert_pf_id] = row.manifesto_pf_id
        end
    end
    return constituent_to_union
end

function resolve_party_country(pid_value,
                               party_to_country::Dict{Int, String},
                               constituent_to_union::Dict{Int, Int})
    pid = tryparse(Int, string(pid_value))
    if pid === nothing
        return missing, "unresolved"
    end

    if haskey(party_to_country, pid)
        return party_to_country[pid], "direct"
    end

    if haskey(constituent_to_union, pid)
        uid = constituent_to_union[pid]
        if haskey(party_to_country, uid)
            return party_to_country[uid], "union_fallback"
        end
    end

    return missing, "unresolved"
end

function apply_country_resolution!(df::DataFrame,
                                   party_col::Symbol,
                                   country_col::Symbol,
                                   party_to_country::Dict{Int, String},
                                   constituent_to_union::Dict{Int, Int})
    resolved_country = Union{Missing, String}[]
    source_counts = Dict("direct" => 0, "union_fallback" => 0, "unresolved" => 0)
    unresolved_parties = Set{Int}()

    for pid in df[!, party_col]
        country, source = resolve_party_country(pid, party_to_country, constituent_to_union)
        push!(resolved_country, country)
        source_counts[source] += 1
        if source == "unresolved"
            pid_int = tryparse(Int, string(pid))
            if pid_int !== nothing
                push!(unresolved_parties, pid_int)
            end
        end
    end

    df[!, country_col] = resolved_country
    return source_counts, sort!(collect(unresolved_parties))
end

function fill_missing_countries!(df::DataFrame,
                                 segment_info::Union{DataFrame, Nothing},
                                 party_to_country::Dict{Int, String},
                                 constituent_to_union::Dict{Int, Int})
    if !hasproperty(df, :country)
        source_counts, unresolved = apply_country_resolution!(
            df, :party_id, :country, party_to_country, constituent_to_union
        )
        return source_counts, unresolved
    end

    normalized = Union{Missing, String}[]
    for val in df.country
        push!(normalized, normalize_country_value(val))
    end
    df.country = normalized

    source_counts = Dict("direct" => 0, "union_fallback" => 0, "segment_info" => 0, "unresolved" => 0)

    segment_country_by_id = Dict{Int, String}()
    if segment_info !== nothing && hasproperty(segment_info, :country)
        for row in eachrow(segment_info)
            c = normalize_country_value(row.country)
            if !ismissing(c)
                segment_country_by_id[Int(row.segment_id)] = c
            end
        end
    end

    unresolved_parties = Set{Int}()
    for i in 1:nrow(df)
        if !ismissing(df.country[i])
            continue
        end

        if hasproperty(df, :segment_id) && haskey(segment_country_by_id, Int(df.segment_id[i]))
            df.country[i] = segment_country_by_id[Int(df.segment_id[i])]
            source_counts["segment_info"] += 1
            continue
        end

        country, source = resolve_party_country(df.party_id[i], party_to_country, constituent_to_union)
        if !ismissing(country)
            df.country[i] = country
            source_counts[source] += 1
        else
            source_counts["unresolved"] += 1
            pid_int = tryparse(Int, string(df.party_id[i]))
            pid_int !== nothing && push!(unresolved_parties, pid_int)
        end
    end

    return source_counts, sort!(collect(unresolved_parties))
end

# =============================================================================
# STEP 2: Build complete segment-year mapping (V10) or party-year mapping (V9)
# =============================================================================

function build_segment_year_map(text_data::DataFrame, expert_dim::DataFrame, expert_lr::DataFrame,
                                 segment_info::Union{DataFrame, Nothing},
                                 segment_year_map::Union{DataFrame, Nothing},
                                 run_dir::String,
                                 year0::Int)
    println("\n" * "="^60)
    println("BUILDING SEGMENT-YEAR MAPPING")
    println("="^60)

    party_to_country = build_party_country_map(text_data, expert_dim, expert_lr)
    constituent_to_union = load_constituent_to_union_map()

    # V10: Use segment_year_map if available
    if segment_year_map !== nothing && segment_info !== nothing
        println("Using segment_year_map.csv (V10 mode)")

        # Convert relative Year to absolute year
        if hasproperty(segment_year_map, :Year)
            segment_year_map.year = segment_year_map.Year .+ year0
        elseif !hasproperty(segment_year_map, :year)
            error("segment_year_map has no Year or year column")
        end

        # Add party_id from segment_info if not already present
        if !hasproperty(segment_year_map, :party_id)
            segment_id_to_party = Dict(row.segment_id => row.party_id for row in eachrow(segment_info))
            segment_year_map.party_id = [segment_id_to_party[sid] for sid in segment_year_map.segment_id]
        end

        # Add segment_num from segment_info if not already present
        if !hasproperty(segment_year_map, :segment_num)
            segment_id_to_segnum = Dict(row.segment_id => row.segment_num for row in eachrow(segment_info))
            segment_year_map.segment_num = [segment_id_to_segnum[sid] for sid in segment_year_map.segment_id]
        end

        # Resolve/fill country column using segment metadata first, then direct and union-fallback lookup.
        source_counts, unresolved = fill_missing_countries!(
            segment_year_map, segment_info, party_to_country, constituent_to_union
        )
        direct_count = get(source_counts, "direct", 0)
        union_count = get(source_counts, "union_fallback", 0)
        segment_info_count = get(source_counts, "segment_info", 0)
        unresolved_count = count(ismissing, segment_year_map.country)
        println("  Country resolution fill counts: direct=$direct_count, union_fallback=$union_count, segment_info=$segment_info_count, unresolved_rows=$unresolved_count")
        if !isempty(unresolved)
            println("  Warning: unresolved country party IDs (first 20): $(unresolved[1:min(20, length(unresolved))])")
        end

        R = maximum(segment_year_map.rr)
        n_segments = length(unique(segment_year_map.segment_id))
        n_parties = length(unique(segment_year_map.party_id))

        println("Loaded segment_year_map: $(nrow(segment_year_map)) segment-years (R=$R)")
        println("  Unique segments: $n_segments")
        println("  Unique parties: $n_parties")

        # Count observed vs interpolated
        observed_rrs = Set{Int}()
        if hasproperty(text_data, :rr_man)
            union!(observed_rrs, Set(text_data.rr_man))
        end
        if hasproperty(expert_dim, :rr_exp_dim)
            union!(observed_rrs, Set(expert_dim.rr_exp_dim))
        end
        if hasproperty(expert_lr, :rr_exp_lr)
            union!(observed_rrs, Set(expert_lr.rr_exp_lr))
        end

        n_observed = length(intersect(Set(segment_year_map.rr), observed_rrs))
        n_interpolated = nrow(segment_year_map) - n_observed
        println("  Observed segment-years: $n_observed")
        println("  Interpolated segment-years: $n_interpolated")

        return segment_year_map, R, segment_info
    end

    # V9 fallback: Use party_year_map
    party_year_file = joinpath(run_dir, "data", "party_year_map.csv")
    if isfile(party_year_file)
        println("Loading party_year_map.csv (V9 fallback mode)")
        party_year_map = CSV.read(party_year_file, DataFrame)

        # Add party_id column (same as party for V9)
        if !hasproperty(party_year_map, :party_id)
            party_year_map.party_id = party_year_map.party
        end

        # Add segment_num column (always 1 for V9)
        if !hasproperty(party_year_map, :segment_num)
            party_year_map.segment_num = ones(Int, nrow(party_year_map))
        end

        # Add segment_id column (same as party index for V9)
        if !hasproperty(party_year_map, :segment_id)
            party_year_map.segment_id = party_year_map.party
        end

        # Convert relative Year to absolute year
        if hasproperty(party_year_map, :Year)
            party_year_map.year = party_year_map.Year .+ year0
        elseif !hasproperty(party_year_map, :year)
            error("party_year_map has no Year or year column")
        end

        # Resolve/fill country column
        if !hasproperty(party_year_map, :country)
            source_counts, unresolved = apply_country_resolution!(
                party_year_map, :party_id, :country, party_to_country, constituent_to_union
            )
            direct_count = source_counts["direct"]
            union_count = source_counts["union_fallback"]
            unresolved_count = source_counts["unresolved"]
            println("  Country resolution sources: direct=$direct_count, union_fallback=$union_count, unresolved=$unresolved_count")
            if !isempty(unresolved)
                println("  Warning: unresolved country party IDs (first 20): $(unresolved[1:min(20, length(unresolved))])")
            end
        else
            normalized = Union{Missing, String}[]
            for val in party_year_map.country
                push!(normalized, normalize_country_value(val))
            end
            party_year_map.country = normalized
        end

        R = maximum(party_year_map.rr)
        println("Loaded party_year_map: $(nrow(party_year_map)) party-years (R=$R)")

        return party_year_map, R, nothing
    end

    # Fallback: Reconstruct from data files
    @warn "No mapping file found, reconstructing from data (observed years only)"

    # Extract unique party-year-rr combinations from text_data
    text_map = unique(select(text_data, :party, :country, :year, :rr_man))
    rename!(text_map, :rr_man => :rr)
    text_map.party_id = text_map.party
    text_map.segment_num = ones(Int, nrow(text_map))

    expert_dim_map = unique(select(expert_dim, :party, :country, :year, :rr_exp_dim))
    rename!(expert_dim_map, :rr_exp_dim => :rr)
    expert_dim_map.party_id = expert_dim_map.party
    expert_dim_map.segment_num = ones(Int, nrow(expert_dim_map))

    expert_lr_map = unique(select(expert_lr, :party, :country, :year, :rr_exp_lr))
    rename!(expert_lr_map, :rr_exp_lr => :rr)
    expert_lr_map.party_id = expert_lr_map.party
    expert_lr_map.segment_num = ones(Int, nrow(expert_lr_map))

    combined = vcat(text_map, expert_dim_map, expert_lr_map)
    segment_year_map = unique(combined)
    sort!(segment_year_map, :rr)

    R = maximum(segment_year_map.rr)
    println("Reconstructed mapping: $(nrow(segment_year_map)) segment-years (R=$R)")

    return segment_year_map, R, nothing
end

# =============================================================================
# STEP 3: Load and combine chains
# =============================================================================

function load_chains(chain_files::Vector{String})
    println("\n" * "="^60)
    println("LOADING STAN CHAINS")
    println("="^60)
    flush(stdout)

    chains = DataFrame[]

    # The full Stan CSVs are very wide (hundreds of thousands of columns).  For
    # post-estimation we only need party-position generated quantities.  Reading
    # all columns can take hours and allocate many GB of irrelevant parameters.
    post_estimation_prefixes = (
        "economic_lr.",
        "galtan.",
        "pro_market.",
        "pro_welfare.",
        "cosmopolitan.",
        "traditional.",
    )
    keep_post_estimation_col(_i, name) = any(startswith(String(name), p) for p in post_estimation_prefixes)

    for (i, f) in enumerate(chain_files)
        println("Loading chain $i: $(basename(f))...")
        flush(stdout)
        # Skip comment lines (Stan header) and parse only needed quantities.
        chain = CSV.read(f, DataFrame; comment="#", select=keep_post_estimation_col)
        println("  Samples: $(nrow(chain)), Parameters: $(ncol(chain))")
        flush(stdout)
        push!(chains, chain)
    end

    # Combine chains
    println("Combining selected chain columns...")
    flush(stdout)
    combined = vcat(chains...)
    println("\nCombined: $(nrow(combined)) total samples")
    println("Selected parameters: $(ncol(combined))")
    flush(stdout)

    return combined
end

# =============================================================================
# STEP 4: Extract generated quantities
# =============================================================================

"""
Detect model version from chain column names.
Returns "2dim" or "4dim".
"""
function detect_model_version(chains::DataFrame)
    cols = names(chains)
    # 2D model has economic_lr but NOT pro_market
    has_economic_lr = any(c -> startswith(string(c), "economic_lr."), cols)
    has_pro_market = any(c -> startswith(string(c), "pro_market."), cols)

    if has_economic_lr && !has_pro_market
        return "2dim"
    elseif has_pro_market
        return "4dim"
    else
        error("Could not detect model version from chain columns")
    end
end

function extract_estimates(chains::DataFrame, segment_year_map::DataFrame, R::Int)
    println("\n" * "="^60)
    println("EXTRACTING POSTERIOR ESTIMATES")
    println("="^60)

    # Auto-detect model version from columns
    model_version = detect_model_version(chains)
    println("Detected model version: $model_version")

    # Select quantities based on model version
    if model_version == "2dim"
        # 2D model: economic_lr and galtan are directly estimated
        # (general_lr is computed in Stan for anchoring but not extracted as output)
        quantities = ["economic_lr", "galtan"]
        test_col = "economic_lr.1"
    else
        # 4D model: 4 traits + 2 derived scales
        quantities = ["pro_market", "pro_welfare", "cosmopolitan", "traditional", "economic_lr", "galtan"]
        test_col = "pro_market.1"
    end

    # Check that columns exist
    if !hasproperty(chains, Symbol(test_col))
        error("Column $test_col not found in chains. Available columns: $(first(names(chains), 10))...")
    end

    n_samples = nrow(chains)
    println("Samples per parameter: $n_samples")

    # Load union mapping for adding union_party_id column
    union_mapping_file = joinpath("data", "union_mapping.csv")
    constituent_to_union_pf = Dict{Int, Int}()
    if isfile(union_mapping_file)
        union_df = CSV.read(union_mapping_file, DataFrame)
        for row in eachrow(union_df)
            constituent_to_union_pf[row.expert_pf_id] = row.manifesto_pf_id
        end
    end

    # Pre-allocate output DataFrame
    n_rows = nrow(segment_year_map)

    # Add union_party_id column: NA for standalone parties, union PF ID for constituents
    union_ids = Union{Int, Missing}[]
    for pid in segment_year_map.party_id
        pid_int = isa(pid, Integer) ? pid : tryparse(Int, string(pid))
        if pid_int !== nothing && haskey(constituent_to_union_pf, pid_int)
            push!(union_ids, constituent_to_union_pf[pid_int])
        else
            push!(union_ids, missing)
        end
    end

    output = DataFrame(
        party_id = segment_year_map.party_id,
        union_party_id = union_ids,
        segment_num = segment_year_map.segment_num,
        country = segment_year_map.country,
        year = segment_year_map.year,
        rr = segment_year_map.rr
    )

    # Add columns for each quantity
    for q in quantities
        output[!, Symbol(q)] = zeros(Float64, n_rows)
        output[!, Symbol("$(q)_se")] = zeros(Float64, n_rows)
        output[!, Symbol("$(q)_q025")] = zeros(Float64, n_rows)
        output[!, Symbol("$(q)_q975")] = zeros(Float64, n_rows)
    end

    println("Extracting estimates for $(n_rows) segment-year positions...")

    # Progress tracking
    prog_interval = max(1, n_rows ÷ 20)

    for (i, row) in enumerate(eachrow(segment_year_map))
        r = row.rr

        # Progress
        if i % prog_interval == 0 || i == n_rows
            pct = round(100 * i / n_rows, digits=1)
            print("\r  Progress: $pct% ($i / $n_rows)")
        end

        for q in quantities
            col_name = Symbol("$q.$r")

            if !hasproperty(chains, col_name)
                @warn "Column $col_name not found (rr=$r)" maxlog=5
                continue
            end

            samples = chains[!, col_name]

            # Compute summary statistics
            output[i, Symbol(q)] = mean(samples)
            output[i, Symbol("$(q)_se")] = std(samples)
            output[i, Symbol("$(q)_q025")] = quantile(samples, 0.025)
            output[i, Symbol("$(q)_q975")] = quantile(samples, 0.975)
        end
    end
    println()  # Newline after progress

    # Remove the rr column from final output (internal only)
    select!(output, Not(:rr))

    return output
end

# =============================================================================
# STEP 5: Validation
# =============================================================================

function validate_output(output::DataFrame, segment_info::Union{DataFrame, Nothing})
    println("\n" * "="^60)
    println("VALIDATION CHECKS")
    println("="^60)

    all_passed = true

    # Detect which columns are present (2D vs 4D model)
    has_4d = hasproperty(output, :pro_market)

    # Check 1: Range check - all estimates should be in [0, 1]
    println("\n1. Range check (all values in [0, 1]):")

    if has_4d
        check_cols = [:pro_market, :pro_welfare, :cosmopolitan, :traditional, :economic_lr, :galtan]
    else
        check_cols = [:economic_lr, :galtan]
    end

    for col in check_cols
        if !hasproperty(output, col)
            continue
        end
        vals = output[!, col]
        min_val, max_val = extrema(vals)
        in_range = min_val >= 0 && max_val <= 1
        status = in_range ? "PASS" : "FAIL"
        println("   $col: [$(@sprintf("%.4f", min_val)), $(@sprintf("%.4f", max_val))] - $status")
        all_passed = all_passed && in_range
    end

    # Check 2: Anchor party checks
    println("\n2. Anchor party checks:")

    # Define anchor parties with expected ranges (for 2D model)
    # Includes both union IDs (V3) and individual constituent IDs (V4)
    anchor_parties = [
        (id=211, name="CDU/CSU", country="DE", econ=(0.50, 0.70), galtan=(0.45, 0.70)),
        (id=1375, name="CDU", country="DE", econ=(0.50, 0.70), galtan=(0.45, 0.65)),
        (id=1731, name="CSU", country="DE", econ=(0.50, 0.70), galtan=(0.55, 0.75)),
        (id=383, name="SPD", country="DE", econ=(0.30, 0.50), galtan=(0.30, 0.55)),
        (id=1516, name="Labour", country="GB", econ=(0.30, 0.55), galtan=(0.30, 0.55)),
        (id=1567, name="Conservatives", country="GB", econ=(0.55, 0.80), galtan=(0.50, 0.75)),
        (id=487, name="SAP", country="SE", econ=(0.30, 0.50), galtan=(0.35, 0.55)),
    ]

    n_checked = 0
    n_passed = 0

    for anchor in anchor_parties
        party_rows = filter(r -> r.party_id == anchor.id, output)

        if nrow(party_rows) == 0
            println("   $(anchor.name) ($(anchor.id)): NOT FOUND")
            continue
        end

        # Use most recent 20 years of data as reference period
        max_year = maximum(party_rows.year)
        ref_rows = filter(r -> r.year >= max_year - 20, party_rows)
        if nrow(ref_rows) == 0
            ref_rows = party_rows
        end

        n_checked += 1

        mean_econ = mean(ref_rows.economic_lr)
        mean_galtan = mean(ref_rows.galtan)

        econ_ok = anchor.econ[1] <= mean_econ <= anchor.econ[2]
        galtan_ok = anchor.galtan[1] <= mean_galtan <= anchor.galtan[2]
        all_ok = econ_ok && galtan_ok

        if all_ok
            n_passed += 1
        end

        status = all_ok ? "PASS" : "WARN"
        econ_marker = econ_ok ? "" : "*"
        galtan_marker = galtan_ok ? "" : "*"

        @printf("   %-15s econ=%.2f%s [%.2f-%.2f]  galtan=%.2f%s [%.2f-%.2f]  %s\n",
            anchor.name, mean_econ, econ_marker, anchor.econ[1], anchor.econ[2],
            mean_galtan, galtan_marker, anchor.galtan[1], anchor.galtan[2], status)
    end

    if n_checked > 0
        println("   Anchor check: $n_passed/$n_checked within expected ranges")
        println("   Note: Model integrates text + expert data; deviations from expert-only expectations are normal")
    end

    # Check 3: Coverage check
    println("\n3. Coverage check:")
    println("   Total segment-year positions: $(nrow(output))")
    println("   Unique parties: $(length(unique(output.party_id)))")
    blank_country_rows = count(ismissing, output.country)
    println("   Unique countries: $(length(unique(skipmissing(output.country))))")
    if blank_country_rows == 0
        println("   Blank country rows: 0 - PASS")
    else
        println("   Blank country rows: $blank_country_rows - FAIL")
        all_passed = false
    end
    println("   Year range: $(minimum(output.year)) - $(maximum(output.year))")

    # V10: Check segment distribution
    segment_counts = combine(groupby(output, :party_id), nrow => :n_years)
    parties_multi_segment = filter(r -> r.party_id in
        [p for p in unique(output.party_id) if length(unique(filter(x -> x.party_id == p, output).segment_num)) > 1],
        segment_counts)
    if nrow(parties_multi_segment) > 0
        println("\n   Parties with multiple segments: $(length(unique(parties_multi_segment.party_id)))")
    end

    # Check 4: No duplicates (party_id, segment_num, year should be unique)
    println("\n4. Duplicate check:")
    dup_count = nrow(output) - nrow(unique(select(output, :party_id, :segment_num, :year)))
    if dup_count == 0
        println("   No duplicate (party_id, segment_num, year) combinations - PASS")
    else
        println("   WARNING: Found $dup_count duplicate combinations!")
        all_passed = false
    end

    # Check 5: SE reasonableness
    println("\n5. Standard error check:")
    se_cols = has_4d ?
        [:pro_market_se, :pro_welfare_se, :cosmopolitan_se, :traditional_se] :
        [:economic_lr_se, :galtan_se]

    for col in se_cols
        if !hasproperty(output, col)
            continue
        end
        vals = output[!, col]
        mean_se = mean(vals)
        max_se = maximum(vals)
        println("   $col: mean=$(@sprintf("%.4f", mean_se)), max=$(@sprintf("%.4f", max_se))")
    end

    println("\n" * "-"^60)
    if all_passed
        println("All validation checks PASSED")
    else
        println("Some validation checks FAILED - please review output carefully")
    end

    return all_passed
end

# =============================================================================
# STEP 6: Save output
# =============================================================================

function save_output(output::DataFrame, metadata::Dict, segment_info::Union{DataFrame, Nothing}, run_dir::String; outdir::String="outputs/estimations/latest")
    println("\n" * "="^60)
    println("SAVING OUTPUT")
    println("="^60)

    timestamp = Dates.format(now(), "yyyy-mm-dd_HH-MM-SS")
    mkpath(outdir)

    # Delete previous output files
    for f in readdir(outdir)
        if startswith(f, "party_positions_") && (endswith(f, ".csv") || endswith(f, ".txt") || endswith(f, ".tex"))
            rm(joinpath(outdir, f))
            println("  Deleted old: $f")
        end
    end

    # Save main CSV
    csv_file = joinpath(outdir, "party_positions_$timestamp.csv")
    CSV.write(csv_file, output)
    println("Saved: $csv_file")
    println("  Rows: $(nrow(output))")
    println("  Columns: $(ncol(output))")

    # Count parties with multiple segments
    n_multi_segment = 0
    if segment_info !== nothing
        party_segment_counts = combine(groupby(segment_info, :party_id), nrow => :n_segments)
        n_multi_segment = count(r -> r.n_segments > 1, eachrow(party_segment_counts))
    end

    # Save metadata
    meta_file = joinpath(outdir, "party_positions_$(timestamp)_metadata.txt")
    open(meta_file, "w") do f
        println(f, "Party Positions Dataset - Metadata")
        println(f, "="^50)
        println(f, "")
        println(f, "Generated: $(Dates.format(now(), "yyyy-mm-dd HH:MM:SS"))")
        println(f, "Source run: $(basename(run_dir))")
        println(f, "Model file: $(get(metadata, "model_file", "unknown"))")
        println(f, "")
        println(f, "Dataset size:")
        println(f, "  Segment-year observations: $(nrow(output))")
        println(f, "  Unique parties: $(length(unique(output.party_id)))")
        if n_multi_segment > 0
            println(f, "  Parties with multiple segments: $n_multi_segment")
        end
        println(f, "  Unique countries: $(length(unique(output.country)))")
        println(f, "  Year range: $(minimum(output.year)) - $(maximum(output.year))")
        println(f, "")
        println(f, "Columns:")
        println(f, "  party_id: PartyFacts ID (integer) - individual party (e.g., CDU=1375, CSU=1731)")
        println(f, "  union_party_id: PartyFacts ID of parent union (NA for standalone parties)")
        println(f, "  segment_num: Segment number within party (1, 2, 3...)")
        println(f, "  country: ISO2 country code")
        println(f, "  year: Calendar year")
        println(f, "")
        println(f, "Segment-Based Indexing:")
        println(f, "  - Parties are split into segments at gaps > 7 years")
        println(f, "  - Each segment is estimated independently (no continuity across gaps)")
        println(f, "  - Segments with < 3 observations are dropped")
        println(f, "  - segment_num=1 is the main segment; higher numbers indicate gaps in data")
        println(f, "")

        # Check if this is 2D or 4D output
        is_2d = !hasproperty(output, :pro_market)

        if is_2d
            println(f, "Model: 2D Direct Bipolar")
            println(f, "")
            println(f, "Bipolar scales (0 = left/GAL, 1 = right/TAN):")
            println(f, "  economic_lr: Economic left-right position (directly estimated)")
            println(f, "  galtan: GAL-TAN cultural position (directly estimated)")
            # Note: general_lr is computed internally for cross-dimensional anchoring
            # but not reported as output (the two dimension-specific estimates are preferred)
        else
            println(f, "Model: 4D Unipolar")
            println(f, "")
            println(f, "Dimensions (0 = low, 1 = high):")
            println(f, "  pro_market: Pro-market economic position")
            println(f, "  pro_welfare: Pro-welfare state position")
            println(f, "  cosmopolitan: Cosmopolitan/GAL cultural position")
            println(f, "  traditional: Traditional/TAN cultural position")
            println(f, "")
            println(f, "Derived bipolar scales (0 = left/GAL, 1 = right/TAN):")
            println(f, "  economic_lr: Economic left-right (derived from pro_market - pro_welfare)")
            println(f, "  galtan: GAL-TAN (derived from traditional - cosmopolitan)")
        end
        println(f, "")
        println(f, "Uncertainty columns:")
        println(f, "  *_se: Standard error (posterior SD)")
        println(f, "  *_q025: 2.5th percentile (lower 95% CI)")
        println(f, "  *_q975: 97.5th percentile (upper 95% CI)")
        println(f, "")
        println(f, "Model convergence:")
        println(f, "  Mean R-hat: $(get(metadata, "mean_rhat", "N/A"))")
        println(f, "  Max R-hat: $(get(metadata, "max_rhat", "N/A"))")
        println(f, "  Mean ESS: $(get(metadata, "mean_ess", "N/A"))")
        println(f, "  Min ESS: $(get(metadata, "min_ess", "N/A"))")
    end
    println("Saved: $meta_file")


# =============================================================================
# STEP 5b: Verify no union/alliance IDs in output
# =============================================================================

function verify_no_unions_in_output(output::DataFrame)
    println("\n" * "="^60)
    println("UNION ID VERIFICATION")
    println("="^60)

    union_mapping_file = joinpath("data", "union_mapping.csv")
    if !isfile(union_mapping_file)
        println("  No union_mapping.csv found — skipping verification")
        return
    end

    union_df = CSV.read(union_mapping_file, DataFrame)
    union_pf_ids = Set(union_df.manifesto_pf_id)
    output_pf_ids = Set(output.party_id)

    violations = intersect(union_pf_ids, output_pf_ids)

    if isempty(violations)
        println("  PASS: No union/alliance PF IDs found in output")
        println("  Checked $(length(union_pf_ids)) union IDs against $(length(output_pf_ids)) output parties")
    else
        println("  WARNING: $(length(violations)) union PF IDs found in output")
        println("  (This is expected if union_mapping.csv was updated after the model run)")
        for v in sort(collect(violations))
            n_rows = count(r -> r.party_id == v, eachrow(output))
            println("    PF $v: $n_rows rows")
        end
    end
end

# =============================================================================
# MAIN
# =============================================================================

function main()
    println("="^60)
    println("POST-ESTIMATION: 4D Latent Trait Model (V10)")
    println("="^60)
    println("Started: $(Dates.format(now(), "yyyy-mm-dd HH:MM:SS"))")

    # Step 0: Find run directory (CLI --run-dir or auto-detect latest)
    run_dir = nothing
    output_dir = nothing
    for (i, arg) in enumerate(ARGS)
        if arg == "--run-dir" && i < length(ARGS)
            run_dir = ARGS[i + 1]
        elseif startswith(arg, "--run-dir=")
            run_dir = split(arg, "=", limit=2)[2]
        elseif arg == "--output-dir" && i < length(ARGS)
            output_dir = ARGS[i + 1]
        elseif startswith(arg, "--output-dir=")
            output_dir = split(arg, "=", limit=2)[2]
        end
    end
    if run_dir === nothing
        run_dir = find_latest_run()
    else
        println("Using specified run directory: $run_dir")
    end

    # Step 1: Load run data
    data = load_run_data(run_dir)

    # Step 2: Build segment-year mapping
    year0 = data.metadata["year0"]
    segment_year_map, R, segment_info = build_segment_year_map(
        data.text_data, data.expert_dim, data.expert_lr,
        data.segment_info, data.segment_year_map, data.run_dir, year0
    )

    # Step 3: Load chains
    chains = load_chains(data.chain_files)

    # Step 4: Extract estimates
    output = extract_estimates(chains, segment_year_map, R)

    # Step 5: Validate
    validate_output(output, segment_info)

    # Step 5b: Verify no union/alliance IDs in output
    verify_no_unions_in_output(output)

    # Step 6: Save output
    effective_output_dir = output_dir !== nothing ? output_dir : "outputs/estimations/latest"
    csv_file, meta_file = save_output(output, data.metadata, segment_info, run_dir; outdir=effective_output_dir)

    println("\n" * "="^60)
    println("COMPLETE")
    println("="^60)
    println("Output files:")
    println("  $csv_file")
    println("  $meta_file")
    println("\nFinished: $(Dates.format(now(), "yyyy-mm-dd HH:MM:SS"))")

    return output
end

# Run if executed directly
if abspath(PROGRAM_FILE) == @__FILE__
    main()
end
