"""CSV 読込（変換行列・学習データ）．

SPEC_02「データファイル形式」に対応する読込と形式検証をまとめる．
旧実装（`org/src` の `Readfile()` / `CsvRead()`）と同じ実効データになるよう，
読み込む行数・列数と dtype（float32）を踏襲する．
"""

from __future__ import annotations

import csv
import logging
from dataclasses import dataclass
from pathlib import Path

import numpy as np
from numpy.typing import NDArray

logger = logging.getLogger(__name__)

# --- 形式に関する定数（SPEC_02「データファイル形式」）---

#: 変換行列 CSV の行数（多項式 17 項．SPEC_02 (1)）
TRANSFORM_COEFFICIENT_COUNT = 17
#: 変換行列 CSV の列数（a[i], b[i], c[i] の 3 列）
TRANSFORM_COLUMN_COUNT = 3

#: data.csv の学習サンプル数（旧実装 EXP_DATA_ROW = 84）
SAMPLE_COUNT = 84
#: SVM 入力の次元（L*a*b*．旧実装 EXP_INPUT_DIM = 3）
LAB_DIMENSION = 3
#: 舌色の分類数（紫・暗赤・紅・淡紅・淡白）
COLOR_COUNT = 5
#: 医師の人数（旧実装 EXP_OUTPUT_DIM 190 = 38 名 × 5 色）
DOCTOR_COUNT = 38
#: 医師評価列の数（旧実装 EXP_OUTPUT_DIM = 190）
EVALUATION_COLUMN_COUNT = DOCTOR_COUNT * COLOR_COUNT
#: data.csv から読み込む列数（旧実装は 3 + 190 = 193 列だけを読み，以降の列は無視する）
TRAINING_COLUMN_COUNT = LAB_DIMENSION + EVALUATION_COLUMN_COUNT
#: data.csv のヘッダ行数
HEADER_ROW_COUNT = 1

# 読込に使う文字コードの候補（同梱データは UTF-8．旧実装由来のファイルは Shift-JIS）
_ENCODINGS = ("utf-8", "cp932")


class CsvIoError(Exception):
    """CSV 読込に関するエラーの基底（`core` の専用例外）．"""


class DataFileNotFoundError(CsvIoError):
    """CSV ファイルが存在しない，または読み取れない．"""


class DataFormatError(CsvIoError):
    """CSV の形式が仕様と異なる（行数・列数不一致，数値でないセル等）．"""


@dataclass(frozen=True)
class TrainingData:
    """`data.csv` の学習データ（SPEC_02 (3) の SVM 入力）．

    Attributes:
        lab: 形状 (84, 3)・dtype float32．各行が 1 サンプルの L*a*b*．
        evaluations: 形状 (84, 190)・dtype int32．医師 38 名 × 5 色の 0/1 評価．
            列 `doctor_index * 5 + color_index` が医師ごとの one-hot に対応する．
    """

    lab: NDArray[np.float32]
    evaluations: NDArray[np.int32]

    @property
    def sample_count(self) -> int:
        """学習サンプル数（= 84）．"""
        return int(self.lab.shape[0])

    @property
    def output_dimension(self) -> int:
        """SVM の出力次元（= 医師数 × 5 色 = 190）．"""
        return int(self.evaluations.shape[1])

    @property
    def doctor_count(self) -> int:
        """医師の人数（= 38）．"""
        return self.output_dimension // COLOR_COUNT

    def color_columns(self, color_index: int) -> NDArray[np.int32]:
        """指定した色（0〜4）に対応する全医師分の評価列を返す（形状 (84, 医師数)）．"""
        if not 0 <= color_index < COLOR_COUNT:
            raise ValueError(f"color_index は 0〜{COLOR_COUNT - 1} の範囲: {color_index}")
        return self.evaluations[:, color_index::COLOR_COUNT]


def load_transform_matrix(path: Path | str) -> NDArray[np.float64]:
    """変換行列 CSV（17 行 × 3 列・ヘッダなし）を読み込む．

    Args:
        path: 変換行列 CSV のパス（ユーザーが診断時に選択するファイル）．

    Returns:
        形状 (17, 3)・dtype float64 の配列．列 0 が a[i]（X 用），
        列 1 が b[i]（Y 用），列 2 が c[i]（Z 用）の係数（SPEC_02 (1)）．

    Raises:
        DataFileNotFoundError: ファイルが存在しない・読み取れない場合．
        DataFormatError: 行数・列数が仕様と異なる，または数値でないセルがある場合．
    """
    csv_path = Path(path)
    rows = _read_rows(csv_path)

    if len(rows) != TRANSFORM_COEFFICIENT_COUNT:
        raise DataFormatError(
            f"変換行列 CSV は {TRANSFORM_COEFFICIENT_COUNT} 行必要ですが "
            f"{len(rows)} 行です: {csv_path}"
        )

    matrix = np.empty((TRANSFORM_COEFFICIENT_COUNT, TRANSFORM_COLUMN_COUNT), dtype=np.float64)
    for row_index, row in enumerate(rows):
        if len(row) != TRANSFORM_COLUMN_COUNT:
            raise DataFormatError(
                f"変換行列 CSV の {row_index + 1} 行目は "
                f"{TRANSFORM_COLUMN_COUNT} 列必要ですが {len(row)} 列です: {csv_path}"
            )
        for column_index, cell in enumerate(row):
            matrix[row_index, column_index] = _parse_float(
                cell, csv_path, row_index + 1, column_index + 1
            )

    logger.info("変換行列を読み込みました: %s", csv_path)
    return matrix


def load_training_data(path: Path | str) -> TrainingData:
    """学習データ `data.csv`（ヘッダ 1 行 + 84 サンプル）を読み込む．

    列 1〜3 が L*a*b*，列 4〜193 が医師 38 名 × 5 色の 0/1 評価である．
    旧実装（`CsvRead(..., 84, 3 + 190, 2, 1)`）と同じく先頭 193 列のみを読み，
    194 列目以降（全サンプルで全 0 の未使用ブロック）は無視する．

    Args:
        path: `data.csv` のパス（同梱データ．`core` はパスを固定しない）．

    Returns:
        Lab と医師評価を保持する `TrainingData`．

    Raises:
        DataFileNotFoundError: ファイルが存在しない・読み取れない場合．
        DataFormatError: 行数・列数が仕様と異なる，数値でないセル，
            または評価列に 0/1 以外の値がある場合．
    """
    csv_path = Path(path)
    rows = _read_rows(csv_path)

    expected_row_count = HEADER_ROW_COUNT + SAMPLE_COUNT
    if len(rows) != expected_row_count:
        raise DataFormatError(
            f"data.csv はヘッダ 1 行 + {SAMPLE_COUNT} サンプルの "
            f"{expected_row_count} 行必要ですが {len(rows)} 行です: {csv_path}"
        )

    lab = np.empty((SAMPLE_COUNT, LAB_DIMENSION), dtype=np.float32)
    evaluations = np.empty((SAMPLE_COUNT, EVALUATION_COLUMN_COUNT), dtype=np.int32)

    for sample_index, row in enumerate(rows[HEADER_ROW_COUNT:]):
        line_number = sample_index + HEADER_ROW_COUNT + 1
        if len(row) < TRAINING_COLUMN_COUNT:
            raise DataFormatError(
                f"data.csv の {line_number} 行目は {TRAINING_COLUMN_COUNT} 列以上必要ですが "
                f"{len(row)} 列です: {csv_path}"
            )
        for lab_index, cell in enumerate(row[:LAB_DIMENSION]):
            lab[sample_index, lab_index] = _parse_float(cell, csv_path, line_number, lab_index + 1)
        for offset, cell in enumerate(row[LAB_DIMENSION:TRAINING_COLUMN_COUNT]):
            evaluations[sample_index, offset] = _parse_binary(
                cell, csv_path, line_number, LAB_DIMENSION + offset + 1
            )

    logger.info(
        "学習データを読み込みました: %s（%d サンプル × %d 列）",
        csv_path,
        SAMPLE_COUNT,
        TRAINING_COLUMN_COUNT,
    )
    return TrainingData(lab=lab, evaluations=evaluations)


def _read_rows(csv_path: Path) -> list[list[str]]:
    """CSV を読み，空行を除いた行のリスト（各行はセルのリスト）を返す．"""
    text = _read_text(csv_path)
    rows = list(csv.reader(text.splitlines()))
    return [row for row in rows if any(cell.strip() for cell in row)]


def _read_text(csv_path: Path) -> str:
    """CSV をテキストとして読む（UTF-8 優先，失敗時は Shift-JIS を試す）．"""
    try:
        data = csv_path.read_bytes()
    except OSError as error:
        raise DataFileNotFoundError(f"CSV ファイルを読み込めません: {csv_path}") from error

    for encoding in _ENCODINGS:
        try:
            return data.decode(encoding)
        except UnicodeDecodeError:
            continue
    raise DataFormatError(f"CSV の文字コードを判別できません（UTF-8・Shift-JIS 以外）: {csv_path}")


def _cell_position(csv_path: Path, line_number: int, column_number: int) -> str:
    """エラーメッセージ用にセル位置を表す文字列を組み立てる．"""
    return f"{csv_path} の {line_number} 行 {column_number} 列"


def _parse_float(cell: str, csv_path: Path, line_number: int, column_number: int) -> float:
    """セルを実数として解釈する．数値でなければ `DataFormatError`．"""
    try:
        return float(cell.strip())
    except ValueError as error:
        raise DataFormatError(
            f"{_cell_position(csv_path, line_number, column_number)}が数値ではありません: {cell!r}"
        ) from error


def _parse_binary(cell: str, csv_path: Path, line_number: int, column_number: int) -> int:
    """医師評価のセル（0 または 1）を解釈する．それ以外は `DataFormatError`．"""
    value = _parse_float(cell, csv_path, line_number, column_number)
    if value not in (0.0, 1.0):
        raise DataFormatError(
            f"{_cell_position(csv_path, line_number, column_number)}は "
            f"0 か 1 である必要があります: {cell!r}"
        )
    return int(value)
