#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
analyze_film_hall_excel.py

Excel にまとめた基板単体 (sub) と薄膜+基板 (tot) の Hall 測定結果から、
2層モデルで薄膜 (film) の有効 Hall 物性を計算する。

使い方:
    python analyze_film_hall_excel.py input.xlsx
    python analyze_film_hall_excel.py input.xlsx -o output.xlsx
    python analyze_film_hall_excel.py input.xlsx --sheet Sheet1

標準出力:
    {inputのstem}_analyzed.xlsx

前提:
    - 基板、薄膜は同じ carrier type として扱う。
    - RH は符号なしの大きさとして扱う。
      ユーザ側で n 型を正に統一した表を入力する想定。
    - density 計算では abs(RH_sheet) を使う。
    - tot の RH_total は、装置に入力した厚さ thickness_tot_cm を使って
      体積 Hall 係数 [cm^3/C] に換算済みの値として扱う。
      このスクリプトでは、入力表の d_film[um] を thickness_tot_cm として使う。

必要な列名の例:
    T[K]
    d_sub[mm]
    d_film[um]
    G_sub[S/sq]
    RH_sub[cm3/C]
    G_tot[S/sq]
    RH_total[cm3/C]

出力シート:
    analyzed_wide : 1行 = 1温度。sub/tot/film を横持ちで出力。
    analyzed_long : 1行 = 1温度 x 1 layer。プロットやピボット向き。
    README_Hall   : 使った式と列の説明。

関連リンク:
    :doc:`analyze_2layer_Hall_excel_usage`
"""

from __future__ import annotations

import argparse
import math
import re
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple

try:
    import openpyxl
    from openpyxl import load_workbook
    from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
    from openpyxl.utils import get_column_letter
except ImportError as exc:
    raise SystemExit(
        "ERROR: openpyxl が必要です。\n"
        "インストール例: pip install openpyxl"
    ) from exc


E_CHARGE = 1.602176634e-19  # C


# ----------------------------------------------------------------------
# Numeric utilities
# ----------------------------------------------------------------------


def to_float(value: Any) -> Optional[float]:
    """Excel cell value を float に変換する。#N/A, 空欄などは None。

    概要:
        Excelのセル値を安全に浮動小数点数に変換します。
    詳細説明:
        None、空文字列、"#N/A"などのエラー値、NaN、InfをNoneとして処理します。
        数値形式のゆらぎ（カンマ、全角マイナスなど）も吸収します。
    引数 (Parameters):
        :param value: 変換するExcelのセル値。
    戻り値 (Returns):
        :returns: 変換された浮動小数点数、またはNone（変換不能な場合）。
    """
    if value is None:
        return None
    if isinstance(value, (int, float)):
        if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
            return None
        return float(value)
    s = str(value).strip()
    if not s:
        return None
    if s.upper() in {"#N/A", "N/A", "NA", "NAN", "INF", "-INF", "ERROR"}:
        return None
    # Excel/日本語出力で混ざることがある表記を軽く吸収
    s = s.replace(",", "")
    s = s.replace("−", "-").replace("－", "-")
    try:
        v = float(s)
    except ValueError:
        return None
    if math.isnan(v) or math.isinf(v):
        return None
    return v


def safe_div(num: Optional[float], den: Optional[float]) -> Optional[float]:
    """ゼロ除算やNone値入力を安全に処理する浮動小数点数の除算。

    概要:
        分子または分母がNoneの場合、あるいは分母がゼロの場合にNoneを返します。
    詳細説明:
        浮動小数点数の除算 `num / den` を行いますが、numまたはdenがNoneの場合、
        あるいはdenが0の場合には `ValueError` や `ZeroDivisionError` を発生させず、
        代わりにNoneを返します。
    引数 (Parameters):
        :param num: 分子となる数値。
        :param den: 分母となる数値。
    戻り値 (Returns):
        :returns: 除算結果の浮動小数点数、またはNone（計算不能な場合）。
    """
    if num is None or den is None or den == 0:
        return None
    return num / den


def density_from_rh_sheet(rh_sheet_cm2_c: Optional[float]) -> Optional[float]:
    """シートホール係数からシートキャリア密度を計算する。

    概要:
        シートホール係数 `RH_sheet [cm^2/C]` からシートキャリア密度 `n_sheet [cm^-2]` を計算します。
    詳細説明:
        `n_sheet = 1 / (e * |RH_sheet|)` の式を使用します。ここで `e` は電気素量 `E_CHARGE` です。
        `rh_sheet_cm2_c` がNoneまたはゼロの場合はNoneを返します。
    引数 (Parameters):
        :param rh_sheet_cm2_c: シートホール係数 `[cm^2/C]`。
    戻り値 (Returns):
        :returns: 計算されたシートキャリア密度 `[cm^-2]`、またはNone。
    """
    """n_sheet [cm^-2] = 1/(e |RH_sheet|)."""
    if rh_sheet_cm2_c is None or rh_sheet_cm2_c == 0:
        return None
    return 1.0 / (E_CHARGE * abs(rh_sheet_cm2_c))


# ----------------------------------------------------------------------
# Header utilities
# ----------------------------------------------------------------------


def normalize_header(s: Any) -> str:
    """Excelの列名を正規化し、ゆらぎを吸収する。

    概要:
        Excelの列名に含まれる可能性のある表記のゆらぎ（大文字小文字、特殊文字、スペースなど）を吸収し、
        標準的な形式に正規化します。
    詳細説明:
        前後の空白を削除し、文字列を小文字に変換します。
        全角/半角の「μ」「µ」を「u」に、全角/半角のハイフンを半角ハイフンに統一します。
        スペース、アンダースコア、括弧、スラッシュなどの区切り文字を削除します。
    引数 (Parameters):
        :param s: 正規化する列名の文字列。
    戻り値 (Returns):
        :returns: 正規化された列名の文字列。
    """
    if s is None:
        return ""
    x = str(s).strip().lower()
    x = x.replace("μ", "u").replace("µ", "u")
    x = x.replace("−", "-").replace("－", "-")
    # 単位の ^ や - は情報として残すより、候補名側でゆるく合わせる
    x = re.sub(r"[\s_、,，\[\]\(\)（）{}<>/\\:=]+", "", x)
    return x


def make_header_map(header_row: Iterable[Any]) -> Dict[str, int]:
    """ヘッダー行から正規化された列名と1ベースの列インデックスのマッピングを生成する。

    概要:
        Excelのヘッダー行を受け取り、各列の生の小文字名と正規化された名前をキーとして、
        対応する1ベースの列インデックスを値とする辞書を作成します。
    詳細説明:
        `normalize_header` 関数を使用して列名を正規化し、ヘッダーマップに格納します。
        同じ正規化名が存在する場合、最初に見つかった列のインデックスが採用されます。
    引数 (Parameters):
        :param header_row: Excelのヘッダー行のセル値のIterable。
    戻り値 (Returns):
        :returns: 正規化列名または小文字列名から1ベースの列インデックスへのマッピング辞書。
    """
    header_map: Dict[str, int] = {}
    for idx, h in enumerate(header_row, start=1):
        if h is None:
            continue
        raw = str(h).strip()
        if raw:
            header_map.setdefault(raw.lower(), idx)
            header_map.setdefault(normalize_header(raw), idx)
    return header_map


def find_col(header_map: Dict[str, int], candidates: Iterable[str], required: bool = True) -> Optional[int]:
    """指定された候補リストに基づいてヘッダーマップから列インデックスを検索する。

    概要:
        与えられたヘッダーマップと列名の候補リストを使用して、
        該当する列の1ベースのインデックスを検索します。
    詳細説明:
        `candidates` リスト内の各候補名に対し、生の小文字名と正規化された名前の両方で
        `header_map` を検索します。
        `required` がTrueで列が見つからない場合、`KeyError` を発生させます。
        `required` がFalseで列が見つからない場合はNoneを返します。
    引数 (Parameters):
        :param header_map: `make_header_map` で作成されたヘッダーマッピング辞書。
        :param candidates: 検索する列名の候補のリスト。
        :param required: 列が必須であるかを示すブール値。Trueの場合、見つからないとKeyErrorが発生します。
    戻り値 (Returns):
        :returns: 見つかった列の1ベースのインデックス、または見つからずrequired=Falseの場合はNone。
    :raises KeyError: required=Trueで列が見つからない場合。
    """
    for c in candidates:
        if c.lower() in header_map:
            return header_map[c.lower()]
        nc = normalize_header(c)
        if nc in header_map:
            return header_map[nc]
    if required:
        raise KeyError("required column not found. candidates=" + ", ".join(candidates))
    return None


COLUMN_CANDIDATES = {
    "T_K": ["T[K]", "T_K", "temperature_K", "temp_K", "T"],
    "d_sub_mm": ["d_sub[mm]", "d_sub_mm", "sub_thickness_mm", "thickness_sub_mm", "dsub[mm]"],
    "d_film_um": ["d_film[um]", "d_film[µm]", "d_film[μm]", "d_film_um", "film_thickness_um", "thickness_film_um"],
    "G_sub": ["G_sub[S/sq]", "G_sub", "Gsub", "sheet_conductance_sub", "G_sub_S_per_sq"],
    "RH_sub": ["RH_sub[cm3/C]", "RH_sub", "RHsub", "RH_sub_cm3_C", "Hall_coeff_sub"],
    "RH_sub_sheet_input": ["RH,sub,sheet[cm2/C]", "RH_sub_sheet[cm2/C]", "RHsubsheet[cm2/C]", "RH_sub_sheet"],
    "G_tot": ["G_tot[S/sq]", "G_tot", "Gtot", "sheet_conductance_tot", "G_total[S/sq]"],
    "G_film_input": ["G_film[S/sq]", "G_film", "Gfilm", "sheet_conductance_film"],
    "I_total_A": ["I_total[A]", "I_tot[A]", "Itotal[A]"],
    "B_total_T": ["B_total[Tesla]", "B_total[T]", "B_tot[T]", "Btotal[Tesla]"],
    "VH_total_V": ["VH_total[V]", "V_H_total[V]", "VH_tot[V]", "VHall_total[V]"],
    "RH_tot": ["RH_total[cm3/C]", "RH_tot[cm3/C]", "RH_total", "RH_tot", "RHtotal[cm3/C]"],
    "RH_tot_sheet_input": ["Rhtot,sheet[cm2/C]", "RH_tot_sheet[cm2/C]", "RH,total,sheet[cm2/C]", "RHtot_sheet"],
}


# ----------------------------------------------------------------------
# Hall model
# ----------------------------------------------------------------------


def analyze_two_layer_row(
    *,
    T_K: Optional[float],
    thickness_sub_cm: float,
    thickness_film_cm: float,
    G_sub: float,
    RH_sub_volume: float,
    G_tot: float,
    RH_tot_volume: float,
    source_sheet: str,
    source_row: int,
    optional_input: Optional[Dict[str, Optional[float]]] = None,
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
    """2層ホールモデルを用いて、Excelの1行分の測定データから薄膜のホール物性を解析する。

    概要:
        基板 (sub) と薄膜+基板 (tot) のHall測定値から、
        薄膜 (film) のシートコンダクタンス、シートホール係数、移動度、キャリア密度などを計算します。
    詳細説明:
        入力された体積Hall係数をシートHall係数に変換し、2層ホールモデルの式に基づいて
        薄膜の物性を抽出します。計算結果は、ワイド形式 (1行にsub/tot/filmを横並び) と
        ロング形式 (1行に1層の物性) の2種類の辞書リストとして返されます。
        G_filmが0以下の場合など、計算が困難な場合には警告が生成されます。
    引数 (Parameters):
        :param T_K: 温度 [K]。
        :param thickness_sub_cm: 基板の厚さ [cm]。
        :param thickness_film_cm: 薄膜の厚さ [cm]。
        :param G_sub: 基板のシートコンダクタンス [S/sq]。
        :param RH_sub_volume: 基板の体積Hall係数 [cm^3/C]。
        :param G_tot: 全体（薄膜+基板）のシートコンダクタンス [S/sq]。
        :param RH_tot_volume: 全体（薄膜+基板）の体積Hall係数 [cm^3/C]。
        :param source_sheet: 元データのシート名。
        :param source_row: 元データの行番号。
        :param optional_input: オプションの入力データを含む辞書。G_film_inputなどの比較用データ。
    戻り値 (Returns):
        :returns: (wide_row, long_rows) のタプル。
                  wide_row: 全ての計算結果と入力値を1行にまとめた辞書。
                  long_rows: 各層（sub, tot, film）の計算結果をそれぞれ1行とした辞書のリスト。
    :raises ValueError: thickness_sub_cm または thickness_film_cm が正でない場合。
    """
    optional_input = optional_input or {}
    warning: List[str] = []

    if thickness_sub_cm <= 0:
        raise ValueError("d_sub must be positive")
    if thickness_film_cm <= 0:
        raise ValueError("d_film must be positive")

    # sub/tot の体積 Hall 係数をシート Hall 係数へ変換。
    # input.xlsx では tot の RH_total は d_film を装置に入力した apparent RH として扱う。
    RH_sub_sheet = RH_sub_volume / thickness_sub_cm
    RH_tot_sheet = RH_tot_volume / thickness_film_cm

    G_film = G_tot - G_sub
    if G_film <= 0:
        warning.append("G_film = G_tot - G_sub <= 0; film cannot be extracted")
        RH_film_sheet = None
    else:
        RH_film_sheet = (G_tot**2 * RH_tot_sheet - G_sub**2 * RH_sub_sheet) / (G_film**2)

    # Optional input consistency checks
    G_film_input = optional_input.get("G_film_input")
    if G_film_input is not None and abs(G_film_input - G_film) > max(1e-30, abs(G_film) * 1e-8):
        warning.append("G_film input differs from calculated G_tot-G_sub")

    def layer_props(
        layer: str,
        thickness_cm: float,
        G_sheet: Optional[float],
        RH_sheet: Optional[float],
        RH_volume: Optional[float],
        note: str = "",
    ) -> Dict[str, Any]:
        sigma = safe_div(G_sheet, thickness_cm)
        mu = None if G_sheet is None or RH_sheet is None else G_sheet * RH_sheet
        n_sheet = density_from_rh_sheet(RH_sheet)
        n_volume = safe_div(n_sheet, thickness_cm)
        return {
            "source_sheet": source_sheet,
            "source_row": source_row,
            "T_K": T_K,
            "layer": layer,
            "carrier_type_assumed": "n (RH stored as positive magnitude)",
            "thickness_cm": thickness_cm,
            "G_sheet_S_per_sq": G_sheet,
            "sigma_S_per_cm": sigma,
            "RH_volume_cm3_per_C": RH_volume,
            "RH_sheet_cm2_per_C": RH_sheet,
            "mu_H_avg_cm2_per_Vs": mu,
            "n_sheet_cm_minus2": n_sheet,
            "n_volume_cm_minus3": n_volume,
            "note": note,
        }

    sub = layer_props(
        "sub",
        thickness_sub_cm,
        G_sub,
        RH_sub_sheet,
        RH_sub_volume,
        "substrate-only measured value",
    )
    tot = layer_props(
        "tot",
        thickness_film_cm,
        G_tot,
        RH_tot_sheet,
        RH_tot_volume,
        "film+substrate apparent value; thickness is d_film used for RH_total conversion",
    )
    film_RH_volume = None if RH_film_sheet is None else RH_film_sheet * thickness_film_cm
    film = layer_props(
        "film",
        thickness_film_cm,
        G_film,
        RH_film_sheet,
        film_RH_volume,
        "extracted by two-layer Hall model",
    )

    long_rows = [sub, tot, film]

    wide: Dict[str, Any] = {
        "source_sheet": source_sheet,
        "source_row": source_row,
        "T_K": T_K,
        "d_sub_cm": thickness_sub_cm,
        "d_film_cm": thickness_film_cm,
        "d_sub_mm": thickness_sub_cm / 0.1,
        "d_film_um": thickness_film_cm / 1e-4,
        "I_total_A": optional_input.get("I_total_A"),
        "B_total_T": optional_input.get("B_total_T"),
        "VH_total_V": optional_input.get("VH_total_V"),
        "warning": "; ".join(warning),
    }

    for prefix, props in [("sub", sub), ("tot", tot), ("film", film)]:
        for key in [
            "G_sheet_S_per_sq",
            "sigma_S_per_cm",
            "RH_volume_cm3_per_C",
            "RH_sheet_cm2_per_C",
            "mu_H_avg_cm2_per_Vs",
            "n_sheet_cm_minus2",
            "n_volume_cm_minus3",
        ]:
            wide[f"{prefix}_{key}"] = props[key]

    # input に存在した比較用列も最後に残す
    wide["input_RH_sub_sheet_cm2_per_C"] = optional_input.get("RH_sub_sheet_input")
    wide["input_RH_tot_sheet_cm2_per_C"] = optional_input.get("RH_tot_sheet_input")
    wide["input_G_film_S_per_sq"] = optional_input.get("G_film_input")

    return wide, long_rows


# ----------------------------------------------------------------------
# Excel I/O
# ----------------------------------------------------------------------


def iter_data_rows(ws_values) -> Iterable[Tuple[int, Dict[str, Any]]]:
    """指定 worksheet からデータ行を辞書として返す。

    概要:
        OpenPyXLのワークシートオブジェクトから、ヘッダーを解釈してデータ行を辞書の形でイテレートします。
    詳細説明:
        ワークシートの最初の行をヘッダーとして読み込み、`make_header_map` と `find_col` を使用して
        必要な列とオプションの列を特定します。
        各データ行は辞書として抽出され、`to_float` 関数によって値が浮動小数点数に変換されます。
        完全に空の行はスキップされます。
    引数 (Parameters):
        :param ws_values: 読み込むOpenPyXLのワークシートオブジェクト。
    戻り値 (Returns):
        :returns: (行番号, データ辞書) のタプルのIterable。
    """
    header_values = [cell.value for cell in ws_values[1]]
    header_map = make_header_map(header_values)

    required_cols = {
        key: find_col(header_map, candidates, required=True)
        for key, candidates in COLUMN_CANDIDATES.items()
        if key in {"T_K", "d_sub_mm", "d_film_um", "G_sub", "RH_sub", "G_tot", "RH_tot"}
    }
    optional_cols = {
        key: find_col(header_map, candidates, required=False)
        for key, candidates in COLUMN_CANDIDATES.items()
        if key not in required_cols
    }

    for row_idx in range(2, ws_values.max_row + 1):
        vals = {key: ws_values.cell(row_idx, col).value for key, col in required_cols.items()}
        # 完全な空行はスキップ
        if all(v is None or str(v).strip() == "" for v in vals.values()):
            continue

        record: Dict[str, Any] = {key: to_float(value) for key, value in vals.items()}
        for key, col in optional_cols.items():
            record[key] = to_float(ws_values.cell(row_idx, col).value) if col else None
        yield row_idx, record


def remove_sheet_if_exists(wb, name: str) -> None:
    """ワークブックから指定された名前のシートが存在すれば削除する。

    概要:
        OpenPyXLのワークブックから、指定された名前のシートが存在する場合に削除します。
    詳細説明:
        主に、新しい解析結果シートを書き込む前に、以前のシートをクリーンアップするために使用されます。
        シートが存在しない場合は何も行いません。
    引数 (Parameters):
        :param wb: 処理対象のOpenPyXLワークブックオブジェクト。
        :param name: 削除するシートの名前。
    戻り値 (Returns):
        :returns: None
    """
    if name in wb.sheetnames:
        ws = wb[name]
        wb.remove(ws)


def write_table(ws, rows: List[Dict[str, Any]], title: Optional[str] = None) -> None:
    """辞書リストをExcelワークシートに書き込み、基本的なスタイルを設定する。

    概要:
        辞書のリストをOpenPyXLのワークシートにテーブル形式で書き込み、
        読みやすいように基本的なスタイル（ヘッダー、数値フォーマット、列幅など）を設定します。
    詳細説明:
        辞書リストの最初の辞書のキーをヘッダーとして使用し、各辞書の値をデータ行として書き込みます。
        オプションでタイトル行を追加できます。
        ヘッダー行は太字、背景色付きで中央揃えに、データ行は数値に応じて適切なフォーマット（例: 科学表記）が適用されます。
        フィルタ、フリーズペイン、自動列幅調整も行われます。
    引数 (Parameters):
        :param ws: 書き込み先のOpenPyXLワークシートオブジェクト。
        :param rows: 書き込むデータの辞書リスト。
        :param title: テーブルのタイトルとして使用する文字列（オプション）。
    戻り値 (Returns):
        :returns: None
    """
    if not rows:
        ws.append(["no data"])
        return

    headers = list(rows[0].keys())
    if title:
        ws.append([title])
        ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers))
        cell = ws.cell(1, 1)
        cell.font = Font(bold=True, size=14, color="FFFFFF")
        cell.fill = PatternFill("solid", fgColor="1F4E78")
        cell.alignment = Alignment(horizontal="center")
        header_row = 2
    else:
        header_row = 1

    ws.append(headers)
    for row in rows:
        ws.append([row.get(h) for h in headers])

    # style
    header_fill = PatternFill("solid", fgColor="D9EAF7")
    thin = Side(style="thin", color="D9D9D9")
    for cell in ws[header_row]:
        cell.font = Font(bold=True)
        cell.fill = header_fill
        cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
        cell.border = Border(bottom=thin)

    ws.freeze_panes = ws.cell(header_row + 1, 1)
    ws.auto_filter.ref = ws.dimensions

    # number formats
    for col_idx, h in enumerate(headers, start=1):
        h_low = h.lower()
        for row_idx in range(header_row + 1, ws.max_row + 1):
            c = ws.cell(row_idx, col_idx)
            if isinstance(c.value, (int, float)):
                if h_low in {"source_row"}:
                    c.number_format = "0"
                elif h_low.endswith("_k"):
                    c.number_format = "0.00"
                elif "warning" in h_low or "note" in h_low:
                    pass
                else:
                    c.number_format = "0.000000E+00"

    # column widths
    for col_idx, h in enumerate(headers, start=1):
        col_letter = get_column_letter(col_idx)
        max_len = len(str(h))
        for row_idx in range(1, min(ws.max_row, 80) + 1):
            v = ws.cell(row_idx, col_idx).value
            if v is not None:
                max_len = max(max_len, len(str(v)))
        ws.column_dimensions[col_letter].width = min(max(max_len + 2, 11), 34)

    for row in ws.iter_rows():
        for cell in row:
            cell.alignment = Alignment(vertical="top", wrap_text=True)


def write_readme(ws) -> None:
    """2層ホール解析のREADMEシートをExcelワークシートに書き込む。

    概要:
        2層ホール解析に関する前提、使用される方程式、および出力シートの説明を含むREADME情報を、
        OpenPyXLワークシートに書き込みます。
    詳細説明:
        シートのタイトルとセクションタイトルにスタイルが適用され、
        テキストは折り返し表示されるように設定されます。
    引数 (Parameters):
        :param ws: 書き込み先のOpenPyXLワークシートオブジェクト。
    戻り値 (Returns):
        :returns: None
    """
    lines = [
        ["2-layer Hall analysis README"],
        [""],
        ["Assumptions"],
        ["- sub = substrate-only Hall measurement."],
        ["- tot = film + substrate Hall measurement."],
        ["- RH values are treated as positive magnitudes. User has unified n-type data to positive RH."],
        ["- Carrier densities use abs(RH_sheet)."],
        ["- d_sub[mm] is converted to cm by d_sub_cm = d_sub_mm * 0.1."],
        ["- d_film[um] is converted to cm by d_film_cm = d_film_um * 1e-4."],
        ["- For tot, d_film_cm is used as the apparent thickness used to convert RH_total to RH_tot_sheet."],
        [""],
        ["Equations"],
        ["RH_sub_sheet = RH_sub_volume / d_sub_cm"],
        ["RH_tot_sheet = RH_tot_volume / d_film_cm"],
        ["G_film = G_tot - G_sub"],
        ["RH_tot_sheet = (G_film^2 RH_film_sheet + G_sub^2 RH_sub_sheet) / G_tot^2"],
        ["RH_film_sheet = (G_tot^2 RH_tot_sheet - G_sub^2 RH_sub_sheet) / G_film^2"],
        ["mu_H = G_sheet * RH_sheet"],
        ["n_sheet = 1 / (e * abs(RH_sheet))"],
        ["n_volume = n_sheet / thickness_cm"],
        ["sigma = G_sheet / thickness_cm"],
        [""],
        ["Output sheets"],
        ["analyzed_wide: one row per temperature, sub/tot/film side-by-side."],
        ["analyzed_long: one row per temperature and layer, convenient for plotting/pivot tables."],
    ]
    for row in lines:
        ws.append(row)
    ws.column_dimensions["A"].width = 120
    ws["A1"].font = Font(bold=True, size=14, color="FFFFFF")
    ws["A1"].fill = PatternFill("solid", fgColor="1F4E78")
    for r in range(1, ws.max_row + 1):
        ws.cell(r, 1).alignment = Alignment(wrap_text=True, vertical="top")
        if ws.cell(r, 1).value in {"Assumptions", "Equations", "Output sheets"}:
            ws.cell(r, 1).font = Font(bold=True, color="1F4E78")


def analyze_workbook(input_path: Path, output_path: Path, sheet_name: Optional[str] = None) -> Tuple[int, int]:
    """入力Excelファイルから2層ホールデータを読み込み、解析し、結果を新しいExcelファイルに書き出す。

    概要:
        指定されたExcelファイルからホール測定データを読み込み、各行に対して2層ホールモデル解析を実行し、
        解析結果をワイド形式、ロング形式、およびREADMEを含む新しいExcelファイルに出力します。
    詳細説明:
        `input_path` で指定されたExcelブックを読み込み、`sheet_name` で指定されたシート（または最初のシート）から
        `iter_data_rows` を使ってデータを抽出します。
        各データ行は `analyze_two_layer_row` によって解析され、結果は `wide_rows` と `long_rows` に収集されます。
        解析が失敗した行はスキップされ、警告メッセージが生成されます。
        最終的に、`output_path` に指定されたExcelファイルに3つの新しいシート (`analyzed_wide`, `analyzed_long`, `README_Hall`)
        として結果が書き込まれます。既存の同名シートは上書きされます。
    引数 (Parameters):
        :param input_path: 入力Excelファイルのパスオブジェクト。
        :param output_path: 出力Excelファイルのパスオブジェクト。
        :param sheet_name: 解析対象のシート名（Noneの場合は最初のシートが使用されます）。
    戻り値 (Returns):
        :returns: (解析された行数, スキップされた行数) のタプル。
    :raises KeyError: 指定されたシート名が見つからない場合。
    """
    # values workbook: formula cached values for reading
    wb_values = load_workbook(input_path, data_only=True)
    # output workbook: preserve source workbook as much as possible
    wb_out = load_workbook(input_path)

    if sheet_name is None:
        source_ws_values = wb_values[wb_values.sheetnames[0]]
        source_sheet_name = source_ws_values.title
    else:
        if sheet_name not in wb_values.sheetnames:
            raise KeyError(f"sheet not found: {sheet_name}")
        source_ws_values = wb_values[sheet_name]
        source_sheet_name = sheet_name

    wide_rows: List[Dict[str, Any]] = []
    long_rows: List[Dict[str, Any]] = []
    skipped = 0

    for row_idx, record in iter_data_rows(source_ws_values):
        required = ["d_sub_mm", "d_film_um", "G_sub", "RH_sub", "G_tot", "RH_tot"]
        if any(record.get(k) is None for k in required):
            skipped += 1
            continue
        try:
            d_sub_cm = record["d_sub_mm"] * 0.1
            d_film_cm = record["d_film_um"] * 1e-4
            wide, longs = analyze_two_layer_row(
                T_K=record.get("T_K"),
                thickness_sub_cm=d_sub_cm,
                thickness_film_cm=d_film_cm,
                G_sub=record["G_sub"],
                RH_sub_volume=record["RH_sub"],
                G_tot=record["G_tot"],
                RH_tot_volume=record["RH_tot"],
                source_sheet=source_sheet_name,
                source_row=row_idx,
                optional_input=record,
            )
        except Exception as exc:
            skipped += 1
            wide = {
                "source_sheet": source_sheet_name,
                "source_row": row_idx,
                "T_K": record.get("T_K"),
                "warning": f"failed: {exc}",
            }
            longs = []
        wide_rows.append(wide)
        long_rows.extend(longs)

    for name in ["analyzed_wide", "analyzed_long", "README_Hall"]:
        remove_sheet_if_exists(wb_out, name)

    ws_wide = wb_out.create_sheet("analyzed_wide")
    write_table(ws_wide, wide_rows, title="Two-layer Hall analysis - wide format")

    ws_long = wb_out.create_sheet("analyized_long")
    write_table(ws_long, long_rows, title="Two-layer Hall analysis - long format")

    ws_readme = wb_out.create_sheet("README_Hall")
    write_readme(ws_readme)

    wb_out.save(output_path)
    return len(wide_rows), skipped


# ----------------------------------------------------------------------
# CLI
# ----------------------------------------------------------------------


def initialize() -> argparse.Namespace:
    """コマンドライン引数を解析する。

    概要:
        スクリプトの実行に必要なコマンドライン引数を定義し、解析します。
    詳細説明:
        入力Excelファイルのパスは必須です。
        出力Excelファイルのパスと解析対象のシート名はオプションです。
        出力パスが指定されない場合、入力ファイル名に基づいて自動生成されます。
        ヘルプメッセージはスクリプトの目的を説明します。
    引数 (Parameters):
        なし
    戻り値 (Returns):
        :returns: 解析されたコマンドライン引数を格納する argparse.Namespace オブジェクト。
    """
    parser = argparse.ArgumentParser(
        description="Analyze film Hall properties from sub/tot data in an Excel workbook."
    )
    parser.add_argument("input_xlsx", help="input Excel file, e.g. input.xlsx")
    parser.add_argument(
        "-o", "--output",
        default=None,
        help="output Excel file. Default: {input_stem}_analyzed.xlsx",
    )
    parser.add_argument(
        "--sheet",
        default=None,
        help="source sheet name. Default: first sheet",
    )
    return parser.parse_args()


def main() -> None:
    """スクリプトのエントリーポイント。

    概要:
        コマンドライン引数の解析、入力ファイルの検証、およびホール解析ワークフロー全体を実行します。
    詳細説明:
        `initialize` 関数を呼び出して引数を取得し、入力ファイルの存在を確認します。
        出力ファイルのパスが指定されていない場合は、入力ファイル名から自動生成します。
        `analyze_workbook` 関数を呼び出してExcel解析を実行し、
        処理された行数とスキップされた行数を標準出力に表示します。
    引数 (Parameters):
        なし
    戻り値 (Returns):
        :returns: None
    :raises SystemExit: 入力ファイルが見つからない場合。
    """
    args = initialize()
    input_path = Path(args.input_xlsx)
    if not input_path.exists():
        raise SystemExit(f"ERROR: input file not found: {input_path}")

    output_path = Path(args.output) if args.output else input_path.with_name(input_path.stem + "_analyzed.xlsx")

    n_rows, skipped = analyze_workbook(input_path, output_path, sheet_name=args.sheet)
    print("=== Two-layer Hall analysis completed ===")
    print(f"Input : {input_path}")
    print(f"Output: {output_path}")
    print(f"Analyzed rows: {n_rows}")
    print(f"Skipped rows : {skipped}")


if __name__ == "__main__":
    main()
