use crate::board::{Position, SfenError};
use crate::records::formats::common::{
    board_map_to_sfen, encode_text, encode_text_with_options, ensure_hand_sides,
    hand_counts_to_sfen, refresh_position_if_needed, BoardMap, EncodedText, ExportOptions,
    HandCounts, TextEncoding,
};
use crate::records::record::{
    GameRecord, GameRecordMetadata, GameRecordMetadataBuilder, MoveRecord, SpecialMove,
    SpecialMoveRecord,
};
use crate::records::time_control::{parse_csa_time_control, TimeControl};
use crate::types::{Color, Eval, GameResult, Piece, PieceType, Square};
use encoding_rs::SHIFT_JIS;
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum CsaError {
    #[error("SFEN parsing failed: {0}")]
    Sfen(#[from] SfenError),
    #[error("invalid CSA line: {0}")]
    InvalidLine(String),
    #[error("invalid CSA board line: {0}")]
    InvalidBoard(String),
    #[error("invalid CSA move line: {0}")]
    InvalidMove(String),
    #[error("illegal move at index {index}")]
    IllegalMove { index: usize },
    #[error("missing CSA end marker")]
    MissingEndMarker,
}

const INITIAL_COUNTS: [(char, [(&str, u8); 8]); 2] = [
    ('+', [("FU", 9), ("KY", 2), ("KE", 2), ("GI", 2), ("KI", 2), ("KA", 1), ("HI", 1), ("OU", 1)]),
    ('-', [("FU", 9), ("KY", 2), ("KE", 2), ("GI", 2), ("KI", 2), ("KA", 1), ("HI", 1), ("OU", 1)]),
];

fn base_piece(piece_code: &str) -> &str {
    match piece_code {
        "TO" => "FU",
        "NY" => "KY",
        "NK" => "KE",
        "NG" => "GI",
        "UM" => "KA",
        "RY" => "HI",
        _ => piece_code,
    }
}

fn parse_row_lines(lines: &[String]) -> Result<BoardMap, CsaError> {
    let mut board_map: BoardMap = HashMap::new();
    let mut rows: HashMap<u8, String> = HashMap::new();
    for line in lines {
        let rank = line
            .chars()
            .nth(1)
            .and_then(|ch| ch.to_digit(10))
            .ok_or_else(|| CsaError::InvalidBoard(line.clone()))? as u8;
        rows.insert(rank, line[2..].to_string());
    }
    for rank in 1..=9 {
        let row_spec = rows.get(&rank).cloned().unwrap_or_default();
        let compact: String = row_spec.chars().filter(|ch| *ch != ' ').collect();
        let mut file = 9u8;
        let mut idx = 0usize;
        let chars: Vec<char> = compact.chars().collect();
        while file >= 1 && idx < chars.len() {
            let token = chars[idx];
            if token == '+' || token == '-' {
                if idx + 2 >= chars.len() {
                    return Err(CsaError::InvalidBoard(row_spec));
                }
                let piece_code = format!("{}{}", chars[idx + 1], chars[idx + 2]);
                board_map.insert((file, rank), (token, piece_code));
                idx += 3;
            } else if token == '*' {
                idx += 1;
            } else {
                return Err(CsaError::InvalidBoard(row_spec));
            }
            if file == 1 {
                break;
            }
            file -= 1;
        }
    }
    Ok(board_map)
}

fn parse_piece_line(line: &str) -> Result<Vec<(char, String, String)>, CsaError> {
    let mut chars = line.chars();
    if chars.next() != Some('P') {
        return Err(CsaError::InvalidLine(line.to_string()));
    }
    let color = chars
        .next()
        .filter(|ch| *ch == '+' || *ch == '-')
        .ok_or_else(|| CsaError::InvalidLine(line.to_string()))?;
    let mut data: String = chars.collect();
    data.retain(|ch| ch != ' ');
    if data.is_empty() {
        return Ok(Vec::new());
    }
    if data.len() % 4 != 0 {
        return Err(CsaError::InvalidLine(line.to_string()));
    }
    let mut out = Vec::new();
    let bytes = data.as_bytes();
    for idx in (0..bytes.len()).step_by(4) {
        let square = String::from_utf8(bytes[idx..idx + 2].to_vec())
            .map_err(|_| CsaError::InvalidLine(line.to_string()))?;
        let piece_code = String::from_utf8(bytes[idx + 2..idx + 4].to_vec())
            .map_err(|_| CsaError::InvalidLine(line.to_string()))?;
        out.push((color, square, piece_code));
    }
    Ok(out)
}

fn parse_time_text(text: &str) -> Option<u32> {
    let mut seconds = 0u32;
    let mut multiplier = 1;
    let mut parsed_any = false;
    for part in text.split(['：', ':', ' ', '　']).filter(|part| !part.is_empty()).rev() {
        if let Ok(value) = part.parse::<u32>() {
            seconds += value * multiplier;
            multiplier *= 60;
            parsed_any = true;
        }
    }
    if parsed_any {
        Some(seconds * 1_000)
    } else {
        None
    }
}

fn parse_csa_time_token(token: &str) -> Option<u32> {
    let trimmed = token.trim();
    if trimmed.starts_with("T=") {
        return parse_time_text(trimmed.trim_start_matches("T="));
    }
    if trimmed.starts_with('T') {
        return parse_time_text(trimmed.trim_start_matches('T'));
    }
    None
}

fn parse_csa_comment_text(line: &str) -> Option<&str> {
    let comment = line.trim().strip_prefix('\'')?;
    Some(comment.strip_prefix('*').unwrap_or(comment).trim())
}

fn apply_csa_metadata(builder: &mut GameRecordMetadataBuilder, key: &str, value: &str) {
    match key {
        "EVENT" => {
            builder.event(Some(value.to_string()));
        }
        "SITE" => {
            builder.site(Some(value.to_string()));
        }
        "START_TIME" => {
            builder.start_date(Some(value.to_string()));
        }
        "END_TIME" => {
            builder.end_date(Some(value.to_string()));
        }
        "TIME_LIMIT" | "TIME" => {
            let parsed = parse_csa_time_control(value).or_else(|| TimeControl::from_spec(value));
            builder.time_control(parsed);
        }
        "TIME+" => {
            let parsed = parse_csa_time_control(value).or_else(|| TimeControl::from_spec(value));
            builder.black_time_control(parsed);
        }
        "TIME-" => {
            let parsed = parse_csa_time_control(value).or_else(|| TimeControl::from_spec(value));
            builder.white_time_control(parsed);
        }
        "MAX_MOVES" => {
            builder.max_moves(value.trim().parse::<u32>().ok());
        }
        "JISHOGI" => {
            builder.impasse_rule(Some(value.to_string()));
        }
        _ => {
            builder.add_attribute(key.to_string(), value.to_string());
        }
    }
}

/// 盤面を CSA の局面行 (P1..P9, P+, P-, +/-) に変換する。
#[must_use]
pub fn board_to_csa(pos: &Position) -> String {
    let mut lines = Vec::with_capacity(12);
    append_csa_position_lines(&mut lines, pos);
    lines.join("\n")
}

fn decode_start_board() -> BoardMap {
    let mut board = HashMap::new();
    let mut pos = Position::empty();
    pos.set_hirate();
    for rank in 1..=9 {
        for file in 1..=9 {
            let file_char = char::from(b'0' + file);
            let rank_char = char::from(b'a' + (rank - 1));
            let sq = Square::from_usi(&format!("{file_char}{rank_char}")).expect("valid square");
            let piece = pos.piece_on(sq);
            if piece == Piece::NONE {
                continue;
            }
            let color = if piece.color() == Color::BLACK { '+' } else { '-' };
            let piece_code = match piece.piece_type() {
                PieceType::PAWN => "FU",
                PieceType::LANCE => "KY",
                PieceType::KNIGHT => "KE",
                PieceType::SILVER => "GI",
                PieceType::GOLD => "KI",
                PieceType::BISHOP => "KA",
                PieceType::ROOK => "HI",
                PieceType::KING => "OU",
                PieceType::PRO_PAWN => "TO",
                PieceType::PRO_LANCE => "NY",
                PieceType::PRO_KNIGHT => "NK",
                PieceType::PRO_SILVER => "NG",
                PieceType::HORSE => "UM",
                PieceType::DRAGON => "RY",
                _ => "OU",
            };
            board.insert((file, rank), (color, piece_code.to_string()));
        }
    }
    board
}

fn terminal_from_marker(marker: &str, side_to_move: Color) -> SpecialMoveRecord {
    let is_black_turn = side_to_move == Color::BLACK;
    match marker {
        "%TORYO" => {
            if is_black_turn {
                SpecialMoveRecord::new(SpecialMove::Resign, GameResult::WhiteWin)
            } else {
                SpecialMoveRecord::new(SpecialMove::Resign, GameResult::BlackWin)
            }
        }
        "%SENNICHITE" => {
            SpecialMoveRecord::new(SpecialMove::RepetitionDraw, GameResult::DrawByRepetition)
        }
        "%HIKIWAKE" => SpecialMoveRecord::new(SpecialMove::Draw, GameResult::DrawByImpasse),
        "%JISHOGI" => SpecialMoveRecord::new(SpecialMove::Impasse, GameResult::DrawByImpasse),
        "%MAX_MOVES" => SpecialMoveRecord::new(SpecialMove::MaxMoves, GameResult::DrawByMaxPlies),
        "%KACHI" => {
            if is_black_turn {
                SpecialMoveRecord::new(
                    SpecialMove::EnteringOfKing,
                    GameResult::BlackWinByDeclaration,
                )
            } else {
                SpecialMoveRecord::new(
                    SpecialMove::EnteringOfKing,
                    GameResult::WhiteWinByDeclaration,
                )
            }
        }
        "%CHUDAN" => SpecialMoveRecord::new(SpecialMove::Interrupt, GameResult::Paused),
        "%TSUMI" => {
            if is_black_turn {
                SpecialMoveRecord::new(SpecialMove::Mate, GameResult::WhiteWin)
            } else {
                SpecialMoveRecord::new(SpecialMove::Mate, GameResult::BlackWin)
            }
        }
        "%FUZUMI" => SpecialMoveRecord::new(SpecialMove::NoMate, GameResult::Paused),
        "%ILLEGAL_MOVE" => {
            if is_black_turn {
                SpecialMoveRecord::new(SpecialMove::FoulWin, GameResult::BlackWinByIllegalMove)
            } else {
                SpecialMoveRecord::new(SpecialMove::FoulWin, GameResult::WhiteWinByIllegalMove)
            }
        }
        "%+ILLEGAL_ACTION" => {
            if is_black_turn {
                SpecialMoveRecord::new(SpecialMove::FoulWin, GameResult::BlackWinByIllegalMove)
            } else {
                SpecialMoveRecord::new(SpecialMove::FoulLose, GameResult::WhiteWinByIllegalMove)
            }
        }
        "%-ILLEGAL_ACTION" => {
            if is_black_turn {
                SpecialMoveRecord::new(SpecialMove::FoulLose, GameResult::BlackWinByIllegalMove)
            } else {
                SpecialMoveRecord::new(SpecialMove::FoulWin, GameResult::WhiteWinByIllegalMove)
            }
        }
        "%TIME_UP" => {
            if is_black_turn {
                SpecialMoveRecord::new(SpecialMove::Timeout, GameResult::BlackWinByTimeout)
            } else {
                SpecialMoveRecord::new(SpecialMove::Timeout, GameResult::WhiteWinByTimeout)
            }
        }
        "%ERROR" => {
            SpecialMoveRecord::new(SpecialMove::Unknown("ERROR".to_string()), GameResult::Error)
        }
        _ => SpecialMoveRecord::new(
            SpecialMove::Unknown(marker.trim_start_matches('%').to_string()),
            GameResult::Paused,
        ),
    }
}

fn marker_from_terminal(terminal: &SpecialMoveRecord) -> String {
    match terminal.kind() {
        SpecialMove::Interrupt => "%CHUDAN".to_string(),
        SpecialMove::Resign => "%TORYO".to_string(),
        SpecialMove::MaxMoves => "%MAX_MOVES".to_string(),
        SpecialMove::Impasse => "%JISHOGI".to_string(),
        SpecialMove::Draw => "%HIKIWAKE".to_string(),
        SpecialMove::RepetitionDraw => "%SENNICHITE".to_string(),
        SpecialMove::Mate => "%TSUMI".to_string(),
        SpecialMove::NoMate => "%FUZUMI".to_string(),
        SpecialMove::Timeout => "%TIME_UP".to_string(),
        SpecialMove::FoulWin | SpecialMove::FoulLose => "%ILLEGAL_MOVE".to_string(),
        SpecialMove::EnteringOfKing => "%KACHI".to_string(),
        SpecialMove::WinByDefault | SpecialMove::LoseByDefault | SpecialMove::Try => {
            "%KACHI".to_string()
        }
        SpecialMove::Unknown(name) => {
            if name.starts_with('%') {
                name.clone()
            } else {
                format!("%{name}")
            }
        }
    }
}

fn decode_csa_bytes(data: &[u8]) -> Cow<'_, str> {
    std::str::from_utf8(data).map_or_else(
        |_| {
            let (decoded, _, _) = SHIFT_JIS.decode(data);
            decoded
        },
        Cow::Borrowed,
    )
}

/// バイト列から CSA 形式の棋譜を解析する。
///
/// UTF-8 として解釈を試み、失敗した場合は Shift_JIS としてデコードする。
/// BOM 付き入力にも対応する。
///
/// # Examples
///
/// ```
/// use rshogi::records::formats::csa;
///
/// let csa_bytes = b"V2.2\nPI\n+\n+7776FU\n%TORYO\n";
/// let record = csa::parse_csa_bytes(csa_bytes).unwrap();
/// assert_eq!(record.main_moves().count(), 1);
/// ```
pub fn parse_csa_bytes(data: &[u8]) -> Result<GameRecord, CsaError> {
    let decoded = decode_csa_bytes(data);
    parse_csa_str(&decoded)
}

/// UTF-8 文字列から CSA 形式の棋譜を解析する。
pub fn parse_csa_str(text: &str) -> Result<GameRecord, CsaError> {
    let mut raw_lines: Vec<String> =
        text.lines().map(|line| line.trim_end_matches('\r').to_string()).collect();
    if let Some(first) = raw_lines.first_mut() {
        *first = first.trim_start_matches('\u{feff}').to_string();
    }
    let mut lines: Vec<String> = Vec::new();
    for line in raw_lines {
        if line.starts_with('\'') || line.starts_with('N') || line.starts_with('$') {
            lines.push(line);
            continue;
        }
        for token in line.split(',') {
            if token.is_empty() {
                continue;
            }
            lines.push(token.to_string());
        }
    }

    let mut pos = Position::empty();
    let mut position_lines: Vec<String> = Vec::new();
    let mut side_to_move_token: Option<char> = None;
    let mut header_end = lines.len();
    let mut metadata_builder = GameRecordMetadata::builder();
    let mut metadata_comment_lines: Vec<String> = Vec::new();

    for (idx, line) in lines.iter().enumerate() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            header_end = idx + 1;
            break;
        }
        if trimmed.starts_with("'CSA encoding=") {
            continue;
        }
        if trimmed.starts_with('\'') {
            let comment = parse_csa_comment_text(trimmed).unwrap_or_default();
            if !comment.is_empty() {
                metadata_comment_lines.push(comment.to_string());
            }
            continue;
        }
        if let Some(stripped) = trimmed.strip_prefix('$') {
            if let Some((key, value)) = stripped.split_once(':') {
                apply_csa_metadata(&mut metadata_builder, key.trim(), value.trim());
            }
            continue;
        }
        if let Some(stripped) = trimmed.strip_prefix("N+") {
            metadata_builder.black_player(Some(stripped.trim().to_string()));
            continue;
        }
        if let Some(stripped) = trimmed.strip_prefix("N-") {
            metadata_builder.white_player(Some(stripped.trim().to_string()));
            continue;
        }
        if trimmed.starts_with('+') || trimmed.starts_with('-') || trimmed.starts_with('%') {
            if trimmed.len() >= 7 {
                header_end = idx;
                break;
            }
            side_to_move_token = trimmed.chars().next();
            continue;
        }
        if trimmed.starts_with('P') {
            position_lines.push(trimmed.to_string());
        }
    }

    let row_lines: Vec<String> = position_lines
        .iter()
        .filter(|line| {
            line.len() >= 2
                && line.starts_with('P')
                && line.chars().nth(1).unwrap().is_ascii_digit()
        })
        .cloned()
        .collect();
    let pi_line = position_lines.iter().find(|line| line.starts_with("PI")).cloned();
    let piece_lines: Vec<String> = position_lines
        .iter()
        .filter(|line| line.starts_with("P+") || line.starts_with("P-"))
        .cloned()
        .collect();

    let mut board_map: BoardMap = if position_lines.is_empty() {
        decode_start_board()
    } else if !row_lines.is_empty() {
        parse_row_lines(&row_lines)?
    } else if let Some(pi_line) = pi_line {
        let mut start = decode_start_board();
        let suffix = pi_line[2..].trim();
        if !suffix.is_empty() {
            if suffix.len() % 4 != 0 {
                return Err(CsaError::InvalidBoard(pi_line));
            }
            let bytes = suffix.as_bytes();
            for idx in (0..bytes.len()).step_by(4) {
                let square = String::from_utf8(bytes[idx..idx + 2].to_vec())
                    .map_err(|_| CsaError::InvalidBoard(pi_line.clone()))?;
                let file = square.chars().next().and_then(|ch| ch.to_digit(10)).unwrap_or(0);
                let rank = square.chars().nth(1).and_then(|ch| ch.to_digit(10)).unwrap_or(0);
                if file >= 1 && rank >= 1 {
                    start.remove(&(file as u8, rank as u8));
                }
            }
        }
        start
    } else {
        HashMap::new()
    };

    let mut hand_counts: HandCounts = HashMap::new();
    ensure_hand_sides(&mut hand_counts);
    let mut fill_remaining: HashMap<char, bool> = HashMap::new();
    fill_remaining.insert('+', false);
    fill_remaining.insert('-', false);

    for entry_line in piece_lines {
        for (color, square, piece_code) in parse_piece_line(&entry_line)? {
            if square == "00" {
                if piece_code == "AL" {
                    fill_remaining.insert(color, true);
                    continue;
                }
                let entry = hand_counts.entry(color).or_default().entry(piece_code).or_insert(0);
                *entry += 1;
            } else {
                let file = square.chars().next().and_then(|ch| ch.to_digit(10)).unwrap_or(0);
                let rank = square.chars().nth(1).and_then(|ch| ch.to_digit(10)).unwrap_or(0);
                if file >= 1 && rank >= 1 {
                    board_map.insert((file as u8, rank as u8), (color, piece_code));
                }
            }
        }
    }

    let mut placed_counts: HashMap<char, HashMap<String, u8>> = HashMap::new();
    for (color, initial) in INITIAL_COUNTS {
        let mut map = HashMap::new();
        for (piece_code, _) in initial {
            map.insert(piece_code.to_string(), 0);
        }
        placed_counts.insert(color, map);
    }

    for (color, piece_code) in board_map.values().map(|(c, p)| (*c, p.clone())) {
        let base = base_piece(&piece_code).to_string();
        if let Some(counts) = placed_counts.get_mut(&color) {
            if let Some(entry) = counts.get_mut(&base) {
                *entry += 1;
            }
        }
    }

    for (color, counts) in hand_counts.clone() {
        for (piece_code, count) in counts {
            let base = base_piece(&piece_code).to_string();
            if let Some(entries) = placed_counts.get_mut(&color) {
                if let Some(entry) = entries.get_mut(&base) {
                    *entry += count;
                }
            }
        }
    }

    for (color, initial) in INITIAL_COUNTS {
        if !*fill_remaining.get(&color).unwrap_or(&false) {
            continue;
        }
        for (piece_code, initial_count) in initial {
            let base = piece_code.to_string();
            let placed = placed_counts
                .get(&color)
                .and_then(|counts| counts.get(&base))
                .copied()
                .unwrap_or(0);
            if placed < initial_count {
                let remaining = initial_count - placed;
                let entry = hand_counts.entry(color).or_default().entry(base.clone()).or_insert(0);
                *entry += remaining;
            }
        }
    }

    let board_sfen = board_map_to_sfen(&board_map).map_err(CsaError::InvalidBoard)?;
    let hands_sfen = hand_counts_to_sfen(&hand_counts).map_err(CsaError::InvalidBoard)?;
    let turn = if side_to_move_token == Some('-') { "w" } else { "b" };
    let init_position_sfen = format!("{board_sfen} {turn} {hands_sfen} 1");

    if !metadata_comment_lines.is_empty() {
        metadata_builder.comment(Some(metadata_comment_lines.join("\n")));
    }
    let metadata = metadata_builder.build();

    pos.set_sfen(&init_position_sfen)?;

    let mut moves: Vec<MoveRecord> = Vec::new();
    let mut terminal: Option<SpecialMoveRecord> = None;
    let mut idx = header_end;
    let mut refresh_counter = 0usize;
    let mut pending_comment: Option<String> = None;
    let mut pending_time_ms: Option<u32> = None;

    while idx < lines.len() {
        let line = lines[idx].trim();
        idx += 1;
        if line.is_empty() {
            continue;
        }
        if line.starts_with("'**評価値=") {
            if let Some(last) = moves.last_mut() {
                if let Ok(value) = line.trim_start_matches("'**評価値=").parse::<i32>() {
                    last.set_eval(Some(Eval::from_i32(value)));
                }
            }
            continue;
        }
        if line.starts_with('\'') {
            let comment = parse_csa_comment_text(line).unwrap_or_default();
            if !comment.is_empty() {
                if let Some(last) = moves.last_mut() {
                    last.set_comment(Some(comment.to_string()));
                } else {
                    pending_comment = Some(comment.to_string());
                }
            }
            continue;
        }
        if line.starts_with('T') {
            if let Some(time_ms) = parse_csa_time_token(line) {
                if let Some(last) = moves.last_mut() {
                    last.set_time_ms(Some(time_ms));
                } else {
                    pending_time_ms = Some(time_ms);
                }
            }
            continue;
        }
        if line.starts_with('%') {
            let mut record =
                terminal_from_marker(line, pos.turn()).with_raw(Some(line.to_string()));
            let mut terminal_comment_lines: Vec<String> = Vec::new();
            while idx < lines.len() {
                let next = lines[idx].trim();
                if next.is_empty() {
                    idx += 1;
                    continue;
                }
                if next.starts_with('T') {
                    if let Some(time_ms) = parse_csa_time_token(next) {
                        record.set_time_ms(Some(time_ms));
                    }
                    idx += 1;
                    continue;
                }
                if next.starts_with('\'') {
                    let comment = parse_csa_comment_text(next).unwrap_or_default();
                    if !comment.is_empty() {
                        terminal_comment_lines.push(comment.to_string());
                    }
                    idx += 1;
                    continue;
                }
                break;
            }
            if !terminal_comment_lines.is_empty() {
                record.set_comment(Some(terminal_comment_lines.join("\n")));
            }
            terminal = Some(record);
            break;
        }
        if line.starts_with('+') || line.starts_with('-') {
            let move_token = line.split(',').next().unwrap_or(line);
            if move_token.len() < 7 {
                return Err(CsaError::InvalidMove(line.to_string()));
            }
            let mv = pos.move_from_csa(&move_token[1..7]);
            if !pos.is_legal_move32(mv) {
                return Err(CsaError::IllegalMove { index: moves.len() });
            }
            let mut mv_record = MoveRecord::new(mv.to_move());
            if let Some(comment) = pending_comment.take() {
                mv_record = mv_record.with_comment(Some(comment));
            }
            if let Some(time_ms) = line.split(',').skip(1).filter_map(parse_csa_time_token).next() {
                mv_record = mv_record.with_time_ms(Some(time_ms));
            } else if let Some(time_ms) = pending_time_ms.take() {
                mv_record = mv_record.with_time_ms(Some(time_ms));
            }
            pos.apply_move32(mv);
            moves.push(mv_record);
            refresh_position_if_needed(&mut pos, &mut refresh_counter)?;
            continue;
        }
        return Err(CsaError::InvalidLine(line.to_string()));
    }

    let mut record = GameRecord::from_main_line(init_position_sfen.clone(), moves, terminal)
        .map_err(|e| CsaError::InvalidLine(e.to_string()))?;
    record.set_metadata(metadata);
    Ok(record)
}

fn append_csa_position_lines(lines: &mut Vec<String>, pos: &Position) {
    for rank in 1..=9u8 {
        let mut row = format!("P{rank}");
        for file in (1..=9u8).rev() {
            let file_char = char::from(b'0' + file);
            let rank_char = char::from(b'a' + (rank - 1));
            let sq = Square::from_usi(&format!("{file_char}{rank_char}")).expect("valid square");
            let piece = pos.piece_on(sq);
            if piece == Piece::NONE {
                row.push_str("*  ");
                continue;
            }
            let color = if piece.color() == Color::BLACK { '+' } else { '-' };
            let piece_code = match piece.piece_type() {
                PieceType::PAWN => "FU",
                PieceType::LANCE => "KY",
                PieceType::KNIGHT => "KE",
                PieceType::SILVER => "GI",
                PieceType::GOLD => "KI",
                PieceType::BISHOP => "KA",
                PieceType::ROOK => "HI",
                PieceType::KING => "OU",
                PieceType::PRO_PAWN => "TO",
                PieceType::PRO_LANCE => "NY",
                PieceType::PRO_KNIGHT => "NK",
                PieceType::PRO_SILVER => "NG",
                PieceType::HORSE => "UM",
                PieceType::DRAGON => "RY",
                _ => "OU",
            };
            row.push(color);
            row.push_str(piece_code);
        }
        lines.push(row);
    }

    for (color, label) in [(Color::BLACK, "P+"), (Color::WHITE, "P-")] {
        let hand = pos.hand(color);
        let mut line = label.to_string();
        for piece_code in ["FU", "KY", "KE", "GI", "KI", "KA", "HI"] {
            let piece_type = match piece_code {
                "FU" => PieceType::PAWN,
                "KY" => PieceType::LANCE,
                "KE" => PieceType::KNIGHT,
                "GI" => PieceType::SILVER,
                "KI" => PieceType::GOLD,
                "KA" => PieceType::BISHOP,
                "HI" => PieceType::ROOK,
                _ => PieceType::PAWN,
            };
            let hand_piece =
                crate::types::HandPiece::from_piece_type(piece_type).expect("valid hand piece");
            let count = hand.count(hand_piece);
            for _ in 0..count {
                line.push_str("00");
                line.push_str(piece_code);
            }
        }
        lines.push(line);
    }

    let stm = if pos.turn() == Color::BLACK { "+" } else { "-" };
    lines.push(stm.to_string());
}

fn format_csa_time_limit(tc: &TimeControl) -> Option<(String, String)> {
    let base = tc.base_seconds();
    let byoyomi = tc.byoyomi_seconds();
    let increment = tc.increment_seconds();
    if base == 0 && byoyomi == 0 && increment == 0 {
        return None;
    }
    if increment == 0 {
        let hours = base / 3600;
        let minutes = (base % 3600) / 60;
        return Some(("TIME_LIMIT".to_string(), format!("{hours:02}:{minutes:02}+{byoyomi}")));
    }
    Some(("TIME".to_string(), tc.to_spec()))
}

fn append_csa_metadata(lines: &mut Vec<String>, metadata: &GameRecordMetadata) {
    let mut emitted: HashSet<String> = HashSet::new();
    if let Some(comment) = metadata.comment() {
        for line in comment.lines() {
            let trimmed = line.trim();
            if trimmed.is_empty() {
                continue;
            }
            lines.push(format!("'{trimmed}"));
        }
    }
    if let Some(black) = metadata.black_player() {
        lines.push(format!("N+{black}"));
        emitted.insert("N+".to_string());
    }
    if let Some(white) = metadata.white_player() {
        lines.push(format!("N-{white}"));
        emitted.insert("N-".to_string());
    }
    if let Some(event) = metadata.event() {
        lines.push(format!("$EVENT:{event}"));
        emitted.insert("EVENT".to_string());
    }
    if let Some(site) = metadata.site() {
        lines.push(format!("$SITE:{site}"));
        emitted.insert("SITE".to_string());
    }
    if let Some(start) = metadata.start_date() {
        lines.push(format!("$START_TIME:{start}"));
        emitted.insert("START_TIME".to_string());
    }
    if let Some(end) = metadata.end_date() {
        lines.push(format!("$END_TIME:{end}"));
        emitted.insert("END_TIME".to_string());
    }
    if let Some(tc) = metadata.time_control() {
        if let Some((key, text)) = format_csa_time_limit(tc) {
            lines.push(format!("${key}:{text}"));
            emitted.insert(key);
        }
    }
    if let Some(tc) = metadata.black_time_control() {
        lines.push(format!("$TIME+:{}", tc.to_spec()));
        emitted.insert("TIME+".to_string());
    }
    if let Some(tc) = metadata.white_time_control() {
        lines.push(format!("$TIME-:{}", tc.to_spec()));
        emitted.insert("TIME-".to_string());
    }
    if let Some(max_moves) = metadata.max_moves() {
        lines.push(format!("$MAX_MOVES:{max_moves}"));
        emitted.insert("MAX_MOVES".to_string());
    }
    if let Some(rule) = metadata.impasse_rule() {
        lines.push(format!("$JISHOGI:{rule}"));
        emitted.insert("JISHOGI".to_string());
    }
    if !metadata.attributes().is_empty() {
        let mut keys: Vec<&String> = metadata.attributes().keys().collect();
        keys.sort();
        for key in keys {
            if emitted.contains(key.as_str()) {
                continue;
            }
            if let Some(value) = metadata.attributes().get(key) {
                lines.push(format!("${key}:{value}"));
            }
        }
    }
}

fn csa_position_p_command(sfen: &str) -> Option<(&'static str, &'static str)> {
    match sfen {
        "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1" => Some(("PI", "+")),
        "lnsgkgsn1/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL w - 1" => Some(("PI11KY", "-")),
        "1nsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL w - 1" => Some(("PI91KY", "-")),
        "lnsgkgsnl/1r7/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL w - 1" => Some(("PI22KA", "-")),
        "lnsgkgsnl/7b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL w - 1" => Some(("PI82HI", "-")),
        "lnsgkgsn1/7b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL w - 1" => {
            Some(("PI82HI11KY", "-"))
        }
        "lnsgkgsnl/9/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL w - 1" => Some(("PI82HI22KA", "-")),
        "1nsgkgsn1/9/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL w - 1" => {
            Some(("PI82HI22KA11KY91KY", "-"))
        }
        "2sgkgs2/9/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL w - 1" => {
            Some(("PI82HI22KA21KE81KE11KY91KY", "-"))
        }
        "3gkg3/9/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL w - 1" => {
            Some(("PI82HI22KA31GI71GI21KE81KE11KY91KY", "-"))
        }
        "4k4/9/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL w - 1" => {
            Some(("PI82HI22KA41KI61KI31GI71GI21KE81KE11KY91KY", "-"))
        }
        _ => None,
    }
}

/// [`GameRecord`] を CSA 形式の文字列に変換する。
pub fn export_csa(record: &GameRecord) -> Result<String, CsaError> {
    let mut pos = Position::empty();
    pos.set_sfen(record.init_position_sfen())?;

    let mut lines: Vec<String> = vec!["V2.2".to_string()];
    append_csa_metadata(&mut lines, record.metadata());
    if let Some((pi, turn)) = csa_position_p_command(record.init_position_sfen()) {
        lines.push(pi.to_string());
        lines.push(turn.to_string());
    } else {
        append_csa_position_lines(&mut lines, &pos);
    }
    let mut refresh_counter = 0usize;

    for (index, mv_record) in record.main_moves().enumerate() {
        let mv16 = mv_record.mv();
        let mv = pos.move32_from_move(mv16);
        if !pos.is_legal_move(mv16) {
            return Err(CsaError::IllegalMove { index });
        }
        let prefix = if pos.turn() == Color::BLACK { '+' } else { '-' };
        let csa = mv
            .to_csa()
            .ok_or_else(|| CsaError::InvalidMove(format!("invalid move at index {index}")))?;
        lines.push(format!("{prefix}{csa}"));
        let elapsed = mv_record.time_ms().unwrap_or(0);
        let total_seconds = elapsed / 1_000;
        lines.push(format!("T{total_seconds}"));
        if let Some(comment) = mv_record.comment() {
            for line in comment.lines() {
                let trimmed = line.trim();
                if trimmed.is_empty() {
                    continue;
                }
                lines.push(format!("'*{trimmed}"));
            }
        }
        pos.apply_move(mv16);
        refresh_position_if_needed(&mut pos, &mut refresh_counter)?;
    }

    if let Some(terminal) = record.main_terminal() {
        lines.push(marker_from_terminal(terminal));
        if let Some(time_ms) = terminal.time_ms() {
            lines.push(format!("T{}", time_ms / 1_000));
        }
        if let Some(comment) = terminal.comment() {
            for line in comment.lines() {
                let trimmed = line.trim();
                if trimmed.is_empty() {
                    continue;
                }
                lines.push(format!("'*{trimmed}"));
            }
        }
    }
    Ok(format!("{}\n", lines.join("\n")))
}

/// [`GameRecord`] を CSA 形式でエンコードしたバイト列に変換する。
///
/// # Examples
///
/// ```
/// use rshogi::records::formats::csa;
/// use rshogi::records::formats::common::TextEncoding;
///
/// # let csa_text = "V2.2\nPI\n+\n+7776FU\n%TORYO\n";
/// # let record = csa::parse_csa_str(csa_text).unwrap();
/// let encoded = csa::export_csa_bytes(&record, TextEncoding::Utf8).unwrap();
/// assert!(!encoded.had_unmappable_chars());
/// let text = std::str::from_utf8(encoded.bytes()).unwrap();
/// assert!(text.contains("+7776FU"));
/// ```
pub fn export_csa_bytes(
    record: &GameRecord,
    encoding: TextEncoding,
) -> Result<EncodedText, CsaError> {
    let text = export_csa(record)?;
    Ok(encode_text(&text, encoding))
}

/// [`GameRecord`] を CSA 形式でエンコードしたバイト列に変換する。
///
/// [`ExportOptions`] を受け取る拡張版。v1 では `encoding` のみを解釈する。
pub fn export_csa_bytes_with_options(
    record: &GameRecord,
    options: ExportOptions,
) -> Result<EncodedText, CsaError> {
    let text = export_csa(record)?;
    Ok(encode_text_with_options(&text, options))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::board::hirate_position;
    use crate::records::record::SpecialMove;
    use crate::types::Move;

    #[test]
    fn test_csa_roundtrip_basic() {
        let pos = hirate_position();
        let mv = Move::from_usi("7g7f").unwrap();
        let record = GameRecord::from_main_line_with_result(
            pos.to_sfen(None),
            vec![MoveRecord::new(mv)],
            GameResult::BlackWin,
        )
        .unwrap();

        let csa = export_csa(&record).unwrap();
        assert!(csa.ends_with('\n'));
        let parsed = parse_csa_str(&csa).unwrap();
        assert_eq!(parsed.main_moves().count(), 1);
        assert_eq!(parsed.result(), GameResult::BlackWin);
    }

    #[test]
    fn test_parse_csa_with_commas_and_comments() {
        let csa = "\
V2.2
N+Alice
N-Bob
$EVENT:TestEvent
PI
+
+7776FU,T12
'初手コメント
-3334FU,T6
%TORYO
";
        let record = parse_csa_str(csa).unwrap();
        assert_eq!(record.metadata().black_player(), Some("Alice"));
        assert_eq!(record.metadata().white_player(), Some("Bob"));
        assert_eq!(record.metadata().event(), Some("TestEvent"));
        assert_eq!(record.main_moves().count(), 2);
        assert_eq!(record.moves()[0].mv().to_usi(), "7g7f");
        assert_eq!(record.moves()[0].time_ms(), Some(12_000));
        assert_eq!(record.moves()[0].comment(), Some("初手コメント"));
        assert_eq!(record.moves()[1].mv().to_usi(), "3c3d");
        assert_eq!(record.moves()[1].time_ms(), Some(6_000));
        assert_eq!(record.moves().last().unwrap().mv().to_usi(), "3c3d");
        assert_eq!(record.result(), GameResult::WhiteWin);
    }

    #[test]
    fn test_parse_csa_terminal_inline_time_and_side_time_settings() {
        let csa = "\
V2.2
$TIME+:600+30+0
$TIME-:300+10+0
$MAX_MOVES:256
$JISHOGI:24点法
PI
+
+7776FU
-3334FU
%TSUMI,T0
'終局コメント1
'終局コメント2
";
        let record = parse_csa_str(csa).unwrap();

        let black_tc = record.metadata().black_time_control().expect("black time control");
        assert_eq!(black_tc.base_seconds(), 600);
        assert_eq!(black_tc.byoyomi_seconds(), 30);
        assert_eq!(black_tc.increment_seconds(), 0);

        let white_tc = record.metadata().white_time_control().expect("white time control");
        assert_eq!(white_tc.base_seconds(), 300);
        assert_eq!(white_tc.byoyomi_seconds(), 10);
        assert_eq!(white_tc.increment_seconds(), 0);

        assert_eq!(record.metadata().max_moves(), Some(256));
        assert_eq!(record.metadata().impasse_rule(), Some("24点法"));

        let terminal = record.main_terminal().expect("terminal");
        assert_eq!(terminal.kind(), &SpecialMove::Mate);
        assert_eq!(terminal.result(), GameResult::WhiteWin);
        assert_eq!(terminal.time_ms(), Some(0));
        assert_eq!(terminal.comment(), Some("終局コメント1\n終局コメント2"));
    }

    #[test]
    fn test_parse_csa_extended_comments_without_asterisk_prefix_in_result() {
        let csa = "\
V2.2
PI
+
+7776FU
'*初手コメント
-3334FU
%TORYO
'*終局コメント
";
        let record = parse_csa_str(csa).unwrap();
        assert_eq!(record.moves()[0].comment(), Some("初手コメント"));
        let terminal = record.main_terminal().expect("terminal");
        assert_eq!(terminal.comment(), Some("終局コメント"));
    }

    #[test]
    fn test_export_csa_roundtrip_preserves_extended_comments() {
        let pos = hirate_position();
        let mv = Move::from_usi("7g7f").unwrap();
        let record = GameRecord::from_main_line(
            pos.to_sfen(None),
            vec![MoveRecord::new(mv).with_comment(Some("opening".to_string()))],
            Some(
                SpecialMoveRecord::new(SpecialMove::Resign, GameResult::BlackWin)
                    .with_comment(Some("terminal".to_string())),
            ),
        )
        .unwrap();

        let csa = export_csa(&record).unwrap();
        assert!(csa.contains("'*opening"));
        assert!(csa.contains("'*terminal"));

        let parsed = parse_csa_str(&csa).unwrap();
        assert_eq!(parsed.moves()[0].comment(), Some("opening"));
        let terminal = parsed.main_terminal().expect("terminal");
        assert_eq!(terminal.comment(), Some("terminal"));
    }

    #[test]
    fn test_parse_csa_without_end_marker() {
        let csa = "\
V2.2
PI
+
+7776FU
-3334FU
";

        let record = parse_csa_str(csa).unwrap();
        assert_eq!(record.main_moves().count(), 2);
        assert_eq!(record.moves()[0].mv().to_usi(), "7g7f");
        assert_eq!(record.moves()[1].mv().to_usi(), "3c3d");
        assert!(record.main_terminal().is_none());
        assert_eq!(record.result(), GameResult::Invalid);
    }

    #[test]
    fn test_export_csa_without_terminal() {
        let pos = hirate_position();
        let record = GameRecord::from_main_line(
            pos.to_sfen(None),
            vec![
                MoveRecord::new(Move::from_usi("7g7f").unwrap()).with_time_ms(Some(12_000)),
                MoveRecord::new(Move::from_usi("3c3d").unwrap()),
            ],
            None,
        )
        .unwrap();

        let csa = export_csa(&record).unwrap();
        assert!(csa.contains("+7776FU"));
        assert!(csa.contains("-3334FU"));
        assert!(csa.contains("T12"));
        assert!(!csa.contains("%TORYO"));
        assert!(!csa.contains("%KACHI"));
        assert!(csa.ends_with('\n'));
    }

    // --- Phase 2 acceptance tests ---

    /// A07: UTF-8 CSA bytes の解析
    #[test]
    fn test_parse_csa_bytes_utf8() {
        let csa = "\
V2.2
N+先手
N-後手
PI
+
+7776FU
%TORYO
";
        let record = parse_csa_bytes(csa.as_bytes()).unwrap();
        assert_eq!(record.main_moves().count(), 1);
        assert_eq!(record.metadata().black_player(), Some("先手"));
        assert_eq!(record.metadata().white_player(), Some("後手"));
    }

    /// A08: Shift_JIS CSA bytes の解析
    #[test]
    fn test_parse_csa_bytes_shift_jis() {
        let csa_utf8 = "\
V2.2
N+先手
N-後手
PI
+
+7776FU
%TORYO
";
        let (encoded, _, _) = encoding_rs::SHIFT_JIS.encode(csa_utf8);
        let record = parse_csa_bytes(&encoded).unwrap();
        assert_eq!(record.main_moves().count(), 1);
        assert_eq!(record.metadata().black_player(), Some("先手"));
    }

    /// A10: CSA bytes export (Shift_JIS)
    #[test]
    fn test_export_csa_bytes_shift_jis() {
        let pos = hirate_position();
        let mv = Move::from_usi("7g7f").unwrap();
        let record = GameRecord::from_main_line_with_result(
            pos.to_sfen(None),
            vec![MoveRecord::new(mv)],
            GameResult::BlackWin,
        )
        .unwrap();

        let encoded = export_csa_bytes(&record, TextEncoding::ShiftJis).unwrap();
        assert!(!encoded.had_unmappable_chars());
        // Shift_JIS bytes should be parseable back
        let parsed = parse_csa_bytes(encoded.bytes()).unwrap();
        assert_eq!(parsed.main_moves().count(), 1);
    }

    /// A13: canonical exporter policy — deterministic output
    #[test]
    fn test_export_csa_deterministic() {
        let pos = hirate_position();
        let mv = Move::from_usi("7g7f").unwrap();
        let record = GameRecord::from_main_line_with_result(
            pos.to_sfen(None),
            vec![MoveRecord::new(mv)],
            GameResult::BlackWin,
        )
        .unwrap();

        let output1 = export_csa(&record).unwrap();
        let output2 = export_csa(&record).unwrap();
        assert_eq!(output1, output2);
    }

    #[test]
    fn test_export_csa_bytes_with_options() {
        let pos = hirate_position();
        let mv = Move::from_usi("7g7f").unwrap();
        let record = GameRecord::from_main_line_with_result(
            pos.to_sfen(None),
            vec![MoveRecord::new(mv)],
            GameResult::BlackWin,
        )
        .unwrap();

        let encoded =
            export_csa_bytes_with_options(&record, ExportOptions::new(TextEncoding::Utf8)).unwrap();
        assert!(!encoded.had_unmappable_chars());
        assert!(std::str::from_utf8(encoded.bytes()).unwrap().contains("+7776FU"));
    }
}
