001package com.streamconverter.command.impl.csv;
002
003import com.opencsv.CSVReader;
004import com.opencsv.exceptions.CsvException;
005import com.streamconverter.StreamProcessingException;
006import com.streamconverter.command.ConsumerCommand;
007import java.io.IOException;
008import java.io.InputStream;
009import java.io.InputStreamReader;
010import java.nio.charset.StandardCharsets;
011import java.util.ArrayList;
012import java.util.HashSet;
013import java.util.List;
014import java.util.Objects;
015import java.util.Set;
016import org.slf4j.Logger;
017import org.slf4j.LoggerFactory;
018
019/**
020 * CSVデータのバリデーションを行うコマンドクラス
021 *
022 * <p>CSVデータの構造とデータ妥当性を検証します。以下の項目をチェックします:
023 *
024 * <ul>
025 *   <li>必須カラムの存在
026 *   <li>ヘッダー行の妥当性
027 *   <li>データ行の整合性
028 *   <li>重複ヘッダーの検出
029 * </ul>
030 *
031 * <p>使用例:
032 *
033 * <pre>
034 * String[] requiredColumns = {"id", "name", "email"};
035 * CsvValidateCommand validator = new CsvValidateCommand(requiredColumns);
036 * validator.consume(csvInputStream);
037 * </pre>
038 */
039public class CsvValidateCommand extends ConsumerCommand {
040  private static final Logger LOGGER = LoggerFactory.getLogger(CsvValidateCommand.class);
041
042  private final Set<String> requiredColumns;
043  private final boolean hasHeader;
044  private final int maxErrorsToReport;
045
046  /**
047   * 必須カラムを指定するコンストラクタ(ヘッダー行ありと仮定)
048   *
049   * @param requiredColumns 必須カラム名の配列
050   * @throws IllegalArgumentException 必須カラムがnullの場合
051   */
052  public CsvValidateCommand(final String... requiredColumns) {
053    this(true, 10, requiredColumns);
054  }
055
056  /**
057   * 詳細設定を指定するコンストラクタ
058   *
059   * @param hasHeader ヘッダー行の存在フラグ
060   * @param maxErrorsToReport 報告する最大エラー数
061   * @param requiredColumns 必須カラム名の配列
062   * @throws IllegalArgumentException requiredColumnsがnullの場合
063   */
064  public CsvValidateCommand(
065      final boolean hasHeader, final int maxErrorsToReport, final String... requiredColumns) {
066    super();
067    if (requiredColumns == null) {
068      throw new IllegalArgumentException("Required columns cannot be null");
069    }
070
071    this.hasHeader = hasHeader;
072    this.maxErrorsToReport = Math.max(1, maxErrorsToReport);
073
074    this.requiredColumns = new HashSet<>();
075    for (final String column : requiredColumns) {
076      if (column != null && !column.isBlank()) {
077        this.requiredColumns.add(column.trim());
078      }
079    }
080
081    if (requiredColumns.length == 0) {
082      LOGGER.info("No required columns specified, column validation will be skipped");
083    } else {
084      LOGGER.info("Required columns: {}", this.requiredColumns);
085    }
086  }
087
088  /**
089   * CSVバリデーションを実行します
090   *
091   * @param inputStream 検証対象のCSVデータを含む入力ストリーム
092   * @throws IOException I/Oエラーが発生した場合
093   * @throws StreamProcessingException CSVバリデーションエラーが発生した場合
094   */
095  @Override
096  public void consume(final InputStream inputStream) throws IOException {
097    Objects.requireNonNull(inputStream, "InputStream cannot be null");
098
099    LOGGER.info(
100        "Starting CSV validation - hasHeader: {}, requiredColumns: {}",
101        hasHeader,
102        requiredColumns.size());
103
104    List<String> validationErrors = new ArrayList<>();
105
106    try (InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
107        CSVReader csvReader = new CSVReader(reader)) {
108
109      List<String[]> allRows = csvReader.readAll();
110
111      if (allRows.isEmpty()) {
112        throw new StreamProcessingException("CSV validation failed: CSV file is empty");
113      }
114
115      LOGGER.debug("Read {} rows from CSV", allRows.size());
116
117      String[] headers = null;
118      int dataStartRow = 0;
119
120      if (hasHeader) {
121        headers = allRows.get(0);
122        dataStartRow = 1;
123        validateHeaders(headers, validationErrors);
124      }
125
126      // Check if CSV has only header row (no data rows)
127      if (hasHeader && allRows.size() == 1) {
128        validationErrors.add("CSV file contains only header, no data rows found");
129      }
130
131      validateDataRows(allRows, dataStartRow, headers, validationErrors);
132
133      if (!validationErrors.isEmpty()) {
134        handleValidationErrors(validationErrors);
135      }
136
137      LOGGER.info("CSV validation completed successfully");
138
139    } catch (CsvException e) {
140      LOGGER.error("CSV parsing error: {}", e.getMessage(), e);
141      throw new StreamProcessingException("Failed to parse CSV: " + e.getMessage(), e);
142    } catch (StreamProcessingException e) {
143      throw e;
144    } catch (Exception e) {
145      LOGGER.error("CSV validation failed: {}", e.getMessage(), e);
146      throw new StreamProcessingException("Failed to parse CSV: " + e.getMessage(), e);
147    }
148  }
149
150  /** ヘッダー行のバリデーション */
151  private void validateHeaders(String[] headers, List<String> errors) {
152    if (headers == null || headers.length == 0) {
153      errors.add("Header row is empty");
154      return;
155    }
156
157    // 重複ヘッダーチェック
158    Set<String> headerSet = new HashSet<>();
159    Set<String> duplicates = new HashSet<>();
160
161    for (String header : headers) {
162      if (header == null || header.trim().isEmpty()) {
163        errors.add("Header contains empty or null column");
164        continue;
165      }
166
167      String trimmedHeader = header.trim();
168      if (!headerSet.add(trimmedHeader)) {
169        duplicates.add(trimmedHeader);
170      }
171    }
172
173    if (!duplicates.isEmpty()) {
174      errors.add("Duplicate column headers: " + duplicates);
175    }
176
177    // 必須カラムの存在チェック
178    if (!requiredColumns.isEmpty()) {
179      Set<String> headerNames = new HashSet<>();
180      for (String header : headers) {
181        if (header != null) {
182          headerNames.add(header.trim());
183        }
184      }
185
186      Set<String> missingColumns = new HashSet<>(requiredColumns);
187      missingColumns.removeAll(headerNames);
188
189      if (!missingColumns.isEmpty()) {
190        errors.add("Missing required columns: " + missingColumns);
191      }
192    }
193
194    LOGGER.debug("Header validation completed - {} columns found", headers.length);
195  }
196
197  /** データ行のバリデーション */
198  private void validateDataRows(
199      List<String[]> allRows, int startRow, String[] headers, List<String> errors) {
200    int expectedColumnCount = headers != null ? headers.length : -1;
201    int validRows = 0;
202    int errorRows = 0;
203
204    for (int i = startRow; i < allRows.size(); i++) {
205      String[] row = allRows.get(i);
206      int rowNumber = i + 1; // 1-based row number
207
208      if (row == null) {
209        addError(errors, String.format("Row %d: null row", rowNumber));
210        errorRows++;
211        continue;
212      }
213
214      // カラム数チェック
215      if (expectedColumnCount > 0 && row.length != expectedColumnCount) {
216        int dataRowNumber = i + 1 - startRow; // Data row number (1-based, excluding header)
217        addError(
218            errors,
219            String.format(
220                "Row %d has inconsistent number of columns (expected %d, found %d)",
221                dataRowNumber, expectedColumnCount, row.length));
222        errorRows++;
223        continue;
224      }
225
226      // 空行チェック
227      boolean isEmptyRow = true;
228      for (String cell : row) {
229        if (cell != null && !cell.trim().isEmpty()) {
230          isEmptyRow = false;
231          break;
232        }
233      }
234
235      if (isEmptyRow) {
236        addError(errors, String.format("Row %d: Empty data row", rowNumber));
237        errorRows++;
238        continue;
239      }
240
241      validRows++;
242    }
243
244    LOGGER.debug("Data validation completed - {} valid rows, {} error rows", validRows, errorRows);
245
246    if (validRows == 0 && startRow < allRows.size()) {
247      errors.add("No valid data rows found");
248    }
249  }
250
251  /** エラーメッセージを追加(最大数制限あり) */
252  private void addError(List<String> errors, String error) {
253    if (errors.size() < maxErrorsToReport) {
254      errors.add(error);
255    } else if (errors.size() == maxErrorsToReport) {
256      errors.add("... and more errors (limit reached)");
257    }
258  }
259
260  /** バリデーションエラーの処理 */
261  private void handleValidationErrors(List<String> errors) {
262    StringBuilder errorBuilder = new StringBuilder();
263    errorBuilder.append("CSV validation failed with ").append(errors.size()).append(" error(s):");
264
265    for (int i = 0; i < errors.size(); i++) {
266      errorBuilder.append("\n  ").append(i + 1).append(". ").append(errors.get(i));
267      LOGGER.error("CSV validation error {}: {}", i + 1, errors.get(i));
268    }
269
270    String errorMessage = errorBuilder.toString();
271    LOGGER.error("CSV validation summary: {}", errorMessage);
272
273    // エラーメッセージが長すぎる場合は切り詰める(可読性向上のため)
274    String finalErrorMessage = errorMessage;
275    if (errorMessage.length() > 1000) {
276      finalErrorMessage = errorMessage.substring(0, 997) + "...";
277      LOGGER.warn(
278          "Error message truncated due to length (original: {} chars)", errorMessage.length());
279    }
280
281    throw new StreamProcessingException("CSV validation failed: " + finalErrorMessage);
282  }
283
284  /**
285   * 必須カラムを取得
286   *
287   * @return 必須カラムのセット
288   */
289  public Set<String> getRequiredColumns() {
290    return new HashSet<>(requiredColumns);
291  }
292
293  /**
294   * ヘッダー行の存在フラグを取得
295   *
296   * @return ヘッダー行ありの場合true
297   */
298  public boolean hasHeaderRow() {
299    return hasHeader;
300  }
301
302  /**
303   * 最大エラー報告数を取得
304   *
305   * @return 最大エラー報告数
306   */
307  public int getMaxErrorsToReport() {
308    return maxErrorsToReport;
309  }
310}