package com.fieldlabel.collector.service

import android.content.Context
import android.net.Uri
import com.fieldlabel.collector.data.CollectionRepository
import com.fieldlabel.collector.data.RouteLogRepository
import com.fieldlabel.collector.domain.model.CollectionRecord
import com.fieldlabel.collector.domain.model.CustomFieldDef
import com.fieldlabel.collector.domain.model.CustomFieldValue
import com.fieldlabel.collector.domain.model.RouteLog
import com.fieldlabel.collector.domain.model.RoutePoint
import com.fieldlabel.collector.domain.model.SpecimenPhoto
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.io.InputStream
import java.time.LocalDate
import java.time.LocalDateTime
import java.util.UUID
import java.util.zip.ZipInputStream
import javax.inject.Inject
import javax.inject.Singleton

// ──────────────────────────────────────────────────────────────────────────────
// インポート結果
// ──────────────────────────────────────────────────────────────────────────────
data class ImportResult(
    val imported:          Int,          // 新規取り込み件数
    val skipped:           Int,          // 重複スキップ件数
    val mediaImported:     Int = 0,      // 取り込んだメディアファイル数
    val valuesImported:    Int = 0,      // 取り込んだカスタム値数
    val routeLogsImported: Int = 0,      // 取り込んだ移動ログ件数
    val errors:            List<String>  // 行単位エラーメッセージ
)

// ──────────────────────────────────────────────────────────────────────────────
// インポートサービス
//
// 対応フォーマット:
//   - ZIP (v2): manifest.json + records.xlsx + photos/
//   - XLSX のみ (v1/v2): 写真なしでレコードだけ取り込む
//
// 重複判定: specimen_id が DB に既存の場合はスキップ（上書き不可）。
// ──────────────────────────────────────────────────────────────────────────────
@Singleton
class ImportService @Inject constructor(
    @ApplicationContext private val context: Context,
    private val repository: CollectionRepository,
    private val routeLogRepository: RouteLogRepository
) {
    /**
     * URI（ファイルピッカーで選択した ZIP または XLSX）からインポートを実行する。
     */
    suspend fun importFromUri(uri: Uri): ImportResult = withContext(Dispatchers.IO) {
        val cr       = context.contentResolver
        val mimeType = cr.getType(uri) ?: ""
        val uriStr   = uri.toString().lowercase()
        val isZip    = mimeType.contains("zip") || uriStr.endsWith(".zip")

        val bytes = cr.openInputStream(uri)?.use { it.readBytes() }
            ?: return@withContext ImportResult(0, 0, errors = listOf("ファイルを開けませんでした"))

        if (isZip) importZip(bytes)
        else        importXlsx(bytes, mediaMap = null)
    }

    // ── ZIP インポート ────────────────────────────────────────────────────────

    private suspend fun importZip(zipBytes: ByteArray): ImportResult {
        var xlsxBytes: ByteArray? = null
        // zipRelPath → bytes（photos/ または media/ 以下のメディアファイル）
        val rawMedia = mutableMapOf<String, ByteArray>()

        ZipInputStream(zipBytes.inputStream()).use { zin ->
            var entry = zin.nextEntry
            while (entry != null) {
                when {
                    entry.name == "records.xlsx" -> xlsxBytes = zin.readBytes()
                    // v3: media/ prefix、v2後方互換: photos/ prefix
                    (entry.name.startsWith("media/") || entry.name.startsWith("photos/"))
                        && !entry.isDirectory -> rawMedia[entry.name] = zin.readBytes()
                }
                zin.closeEntry()
                entry = zin.nextEntry
            }
        }

        val xlsx = xlsxBytes
            ?: return ImportResult(0, 0, errors = listOf("ZIP 内に records.xlsx が見つかりません"))

        // メディアをアプリのファイルディレクトリに保存
        val mediaMap = mutableMapOf<String, String>()   // zipRelPath → localAbsPath
        val mediaRoot = File(context.filesDir, "imported_media")
        rawMedia.forEach { (zipPath, bytes) ->
            val relativePart = zipPath.removePrefix("media/").removePrefix("photos/")
            val localFile = File(mediaRoot, relativePart)
            localFile.parentFile?.mkdirs()
            localFile.writeBytes(bytes)
            mediaMap[zipPath] = localFile.absolutePath
        }

        return importXlsx(xlsx, mediaMap)
    }

    // ── XLSX インポート ───────────────────────────────────────────────────────

    /**
     * @param mediaMap zipRelPath → localAbsPath。XLSX のみインポート時は null。
     */
    private suspend fun importXlsx(
        xlsxBytes: ByteArray,
        mediaMap:  Map<String, String>?
    ): ImportResult = withContext(Dispatchers.IO) {

        // ① XLSX をパースして全シートの行リストに変換
        val allSheets = parseAllXlsxSheets(xlsxBytes)

        // v3: Specimens シートを優先、なければ最初のシート（v1/v2後方互換）
        val specimenSheet = allSheets["specimens"] ?: allSheets["sheet1"] ?: allSheets.values.firstOrNull()
        if (specimenSheet == null || specimenSheet.size < 2) {
            return@withContext ImportResult(0, 0, errors = listOf("シートにデータ行がありません"))
        }

        // ② ヘッダー行から列インデックスを解決（v1/v2/v3 対応）
        val header = specimenSheet.first().map { it.trim().lowercase() }
        val colMap = resolveColumnMap(header)

        val missing = REQUIRED_COLS.filter { (colMap[it] ?: -1) < 0 }
        if (missing.isNotEmpty()) {
            return@withContext ImportResult(
                0, 0,
                errors = listOf("必須列が見つかりません: ${missing.joinToString()}\nヘッダー行: ${header.joinToString()}")
            )
        }

        // ③ 既存 specimenId セットを取得（重複判定用）
        val existingIds = repository.getAllRecordsOnce().map { it.specimenId }.toHashSet()

        // ④ 標本データ行を処理
        val errors   = mutableListOf<String>()
        var imported  = 0
        var skipped   = 0

        val get = { row: List<String>, key: String ->
            val idx = colMap[key] ?: -1
            if (idx >= 0 && idx < row.size) row[idx].trim() else ""
        }

        specimenSheet.drop(1).forEachIndexed { i, row ->
            val rowNum = i + 2
            try {
                val specimenId = get(row, "specimen_id")
                val locationId = get(row, "location_id")
                if (specimenId.isBlank() || locationId.isBlank()) {
                    if (row.all { it.isBlank() }) return@forEachIndexed
                    errors.add("行$rowNum: specimen_id または location_id が空です")
                    return@forEachIndexed
                }
                if (specimenId in existingIds) { skipped++; return@forEachIndexed }

                val lat = get(row, "latitude").toDoubleOrNull()
                    ?: run { errors.add("行$rowNum [$specimenId]: 緯度が不正"); return@forEachIndexed }
                val lon = get(row, "longitude").toDoubleOrNull()
                    ?: run { errors.add("行$rowNum [$specimenId]: 経度が不正"); return@forEachIndexed }
                val date = try { LocalDate.parse(get(row, "collection_date")) }
                    catch (_: Exception) { errors.add("行$rowNum [$specimenId]: 採集日フォーマット不正"); return@forEachIndexed }

                repository.saveRecord(CollectionRecord(
                    id             = UUID.randomUUID().toString(),
                    locationId     = locationId,
                    specimenNumber = get(row, "specimen_number").toIntOrNull() ?: 1,
                    specimenId     = specimenId,
                    collectionDate = date,
                    latitude       = lat,
                    longitude      = lon,
                    altitude       = get(row, "altitude_m").toDoubleOrNull(),
                    address        = get(row, "address").ifBlank { null },
                    collectorName  = get(row, "collector"),
                    notes          = get(row, "notes"),
                    createdAt      = LocalDateTime.now()
                ))
                existingIds.add(specimenId)
                imported++
            } catch (e: Exception) {
                errors.add("行$rowNum: ${e.javaClass.simpleName}: ${e.message}")
            }
        }

        // ⑤ メディアシートのインポート（v3: Photos / Videos / Audio）
        var mediaImported = 0
        if (mediaMap != null) {
            listOf("photos" to "photo", "videos" to "video", "audio" to "audio").forEach { (sheetKey, mediaType) ->
                val sheet = allSheets[sheetKey] ?: return@forEach
                if (sheet.size < 2) return@forEach
                val h = sheet.first().map { it.trim().lowercase() }
                val idCol      = h.indexOf("${mediaType}_id").takeIf { it >= 0 } ?: h.indexOf("photo_id").takeIf { it >= 0 } ?: -1
                val locCol     = h.indexOf("location_id")
                val specCol    = h.indexOf("specimen_id")
                val pathCol    = h.indexOf("file_path")
                if (locCol < 0 || pathCol < 0) return@forEach
                sheet.drop(1).forEach { row ->
                    if (row.all { it.isBlank() }) return@forEach
                    val zipPath   = row.getOrElse(pathCol) { "" }.trim()
                    val locId     = row.getOrElse(locCol)  { "" }.trim()
                    val specId    = row.getOrElse(specCol) { "" }.trim().ifBlank { null }
                    val localPath = mediaMap[zipPath] ?: mediaMap["photos/${zipPath.removePrefix("media/")}"]
                    if (localPath != null && File(localPath).exists() && locId.isNotBlank()) {
                        try {
                            repository.savePhoto(SpecimenPhoto(
                                id         = UUID.randomUUID().toString(),
                                locationId = locId,
                                specimenId = specId,
                                filePath   = localPath,
                                takenAt    = LocalDateTime.now(),
                                mediaType  = mediaType
                            ))
                            mediaImported++
                        } catch (_: Exception) {}
                    }
                }
            }

            // v2後方互換: Specimens シートの photos 列
            if (allSheets["photos"] == null) {
                val photosColIdx = colMap["photos"] ?: -1
                if (photosColIdx >= 0) {
                    specimenSheet.drop(1).forEach { row ->
                        val specimenId = get(row, "specimen_id")
                        val locationId = get(row, "location_id")
                        if (specimenId.isBlank()) return@forEach
                        val photosStr = if (photosColIdx < row.size) row[photosColIdx].trim() else ""
                        photosStr.split(";").forEach { zipPath ->
                            val trimmed   = zipPath.trim().ifBlank { return@forEach }
                            val localPath = mediaMap[trimmed]
                            if (localPath != null && File(localPath).exists()) {
                                try {
                                    repository.savePhoto(SpecimenPhoto(
                                        id         = UUID.randomUUID().toString(),
                                        locationId = locationId,
                                        specimenId = specimenId,
                                        filePath   = localPath,
                                        takenAt    = LocalDateTime.now(),
                                        mediaType  = "photo"
                                    ))
                                    mediaImported++
                                } catch (_: Exception) {}
                            }
                        }
                    }
                }
            }
        }

        // ⑥ カスタム値シートのインポート（v3: Values）
        var valuesImported = 0
        val valuesSheet = allSheets["values"]
        if (valuesSheet != null && valuesSheet.size >= 2) {
            val vh = valuesSheet.first().map { it.trim().lowercase() }
            val vLabelCol  = vh.indexOf("field_label")
            val vUnitCol   = vh.indexOf("unit")
            val vTypeCol   = vh.indexOf("value_type")
            val vApplyCol  = vh.indexOf("applies_to")
            val vLocCol    = vh.indexOf("location_id")
            val vSpecCol   = vh.indexOf("specimen_id")
            val vValCol    = vh.indexOf("value")

            if (vLabelCol >= 0 && vLocCol >= 0 && vValCol >= 0) {
                // 既存フィールド定義をラベルで検索または新規作成
                val existingDefs = repository.getAllCustomFieldDefsOnce().associateBy { it.label }
                val defCache = mutableMapOf<String, String>()   // label → fieldDefId

                valuesSheet.drop(1).forEach { row ->
                    if (row.all { it.isBlank() }) return@forEach
                    val label   = row.getOrElse(vLabelCol) { "" }.trim().ifBlank { return@forEach }
                    val unit    = row.getOrElse(vUnitCol)  { "" }.trim()
                    val valType = row.getOrElse(vTypeCol)  { "text" }.trim().ifBlank { "text" }
                    val applies = row.getOrElse(vApplyCol) { "both" }.trim().ifBlank { "both" }
                    val locId   = row.getOrElse(vLocCol)   { "" }.trim().ifBlank { return@forEach }
                    val specId  = row.getOrElse(vSpecCol)  { "" }.trim().ifBlank { null }
                    val value   = row.getOrElse(vValCol)   { "" }.trim().ifBlank { return@forEach }

                    val fieldDefId = defCache.getOrPut(label) {
                        existingDefs[label]?.id ?: run {
                            val newDef = com.fieldlabel.collector.domain.model.CustomFieldDef(
                                id = UUID.randomUUID().toString(), label = label, unit = unit,
                                valueType = valType, appliesTo = applies, sortOrder = defCache.size
                            )
                            repository.saveCustomFieldDef(newDef)
                            newDef.id
                        }
                    }

                    try {
                        if (specId != null) {
                            repository.upsertSpecimenFieldValue(fieldDefId, locId, specId, value)
                        } else {
                            repository.upsertLocationFieldValue(fieldDefId, locId, value)
                        }
                        valuesImported++
                    } catch (_: Exception) {}
                }
            }
        }

        // ⑦ 移動ログシートのインポート（v4: 移動ログ / 移動ログ詳細）
        var routeLogsImported = 0
        val routeLogSheet  = allSheets["移動ログ"]   ?: allSheets["route_logs"]
        val routePointSheet = allSheets["移動ログ詳細"] ?: allSheets["route_points"]

        if (routeLogSheet != null && routeLogSheet.size >= 2) {
            val rh       = routeLogSheet.first().map { it.trim().lowercase() }
            val rIdCol   = rh.indexOf("route_id")
            val rNameCol = rh.indexOf("name")
            val rStartCol= rh.indexOf("start_time")
            val rEndCol  = rh.indexOf("end_time")
            val rDistCol = rh.indexOf("distance_m")

            if (rIdCol >= 0 && rNameCol >= 0 && rStartCol >= 0) {
                // 既存ログIDを取得して重複を避ける
                val existingLogIds = routeLogRepository.getAllLogsOnce().map { it.id }.toHashSet()
                val pointsByLogId  = mutableMapOf<String, MutableList<RoutePoint>>()

                // 測位点シートを先読み
                if (routePointSheet != null && routePointSheet.size >= 2) {
                    val ph       = routePointSheet.first().map { it.trim().lowercase() }
                    val pIdCol   = ph.indexOf("point_id")
                    val pLogCol  = ph.indexOf("route_id")
                    val pTsCol   = ph.indexOf("timestamp")
                    val pLatCol  = ph.indexOf("latitude")
                    val pLonCol  = ph.indexOf("longitude")
                    val pAltCol  = ph.indexOf("altitude_m")
                    val pAccCol  = ph.indexOf("accuracy_m")

                    if (pLogCol >= 0 && pLatCol >= 0 && pLonCol >= 0) {
                        routePointSheet.drop(1).forEach { row ->
                            if (row.all { it.isBlank() }) return@forEach
                            val routeId = row.getOrElse(pLogCol) { "" }.trim().ifBlank { return@forEach }
                            val lat     = row.getOrElse(pLatCol)  { "" }.trim().toDoubleOrNull() ?: return@forEach
                            val lon     = row.getOrElse(pLonCol)  { "" }.trim().toDoubleOrNull() ?: return@forEach
                            val alt     = row.getOrElse(pAltCol)  { "" }.trim().toDoubleOrNull() ?: 0.0
                            val acc     = row.getOrElse(pAccCol)  { "" }.trim().toFloatOrNull()  ?: 0f
                            val ts      = isoToEpoch(row.getOrElse(pTsCol) { "" }.trim())
                            val ptId    = row.getOrElse(pIdCol)   { "" }.trim().ifBlank { UUID.randomUUID().toString() }
                            pointsByLogId.getOrPut(routeId) { mutableListOf() }.add(
                                RoutePoint(ptId, routeId, lat, lon, alt, ts, acc)
                            )
                        }
                    }
                }

                routeLogSheet.drop(1).forEach { row ->
                    if (row.all { it.isBlank() }) return@forEach
                    val id    = row.getOrElse(rIdCol)   { "" }.trim().ifBlank { UUID.randomUUID().toString() }
                    val name  = row.getOrElse(rNameCol) { "" }.trim().ifBlank { "インポートルート" }
                    val start = isoToEpoch(row.getOrElse(rStartCol) { "" }.trim())
                    val end   = row.getOrElse(rEndCol)  { "" }.trim().let { if (it.isBlank()) null else isoToEpoch(it) }
                    val dist  = row.getOrElse(rDistCol) { "" }.trim().toDoubleOrNull() ?: 0.0

                    if (id in existingLogIds) return@forEach

                    try {
                        val pts = pointsByLogId[id] ?: emptyList()
                        // RouteLogRepository に直接挿入するため、内部 DAO を介して startNewLog + addPoint + finishLog
                        // ここでは手動で RouteLogEntity を組み立てる
                        routeLogRepository.importLog(
                            RouteLog(id, name, start, end, dist, pts.size),
                            pts
                        )
                        existingLogIds.add(id)
                        routeLogsImported++
                    } catch (_: Exception) {}
                }
            }
        }

        ImportResult(imported, skipped, mediaImported, valuesImported, routeLogsImported, errors)
    }

    /** ISO-8601 文字列を epoch ms に変換する（変換失敗時は現在時刻） */
    private fun isoToEpoch(iso: String): Long {
        if (iso.isBlank()) return System.currentTimeMillis()
        return try {
            java.time.LocalDateTime.parse(iso)
                .atZone(java.time.ZoneId.systemDefault())
                .toInstant()
                .toEpochMilli()
        } catch (_: Exception) {
            System.currentTimeMillis()
        }
    }

    // ── ヘッダー解決（v1/v2 互換） ────────────────────────────────────────────

    /**
     * ヘッダー行から各フィールドの列インデックスを解決する。
     * v2 (English keys) と v1 (日本語ヘッダー) の両方に対応する。
     */
    private fun resolveColumnMap(header: List<String>): Map<String, Int> {
        val result = mutableMapOf<String, Int>()
        header.forEachIndexed { idx, h ->
            val key = when (h) {
                // v2 英語キー
                "specimen_id"     -> "specimen_id"
                "location_id"     -> "location_id"
                "specimen_number" -> "specimen_number"
                "collection_date" -> "collection_date"
                "collector"       -> "collector"
                "address"         -> "address"
                "latitude"        -> "latitude"
                "longitude"       -> "longitude"
                "altitude_m"      -> "altitude_m"
                "notes"           -> "notes"
                "photos"          -> "photos"
                // v1 日本語ヘッダー（後方互換）
                "標本id", "標本ｉｄ"   -> "specimen_id"
                "地点id", "地点ｉｄ"   -> "location_id"
                "採集日"               -> "collection_date"
                "採集者名", "採集者"   -> "collector"
                "住所"                 -> "address"
                "緯度"                 -> "latitude"
                "経度"                 -> "longitude"
                "標高(m)", "標高（m）", "標高m" -> "altitude_m"
                "備考"                 -> "notes"
                "写真ファイル", "photos", "写真" -> "photos"
                else -> null
            }
            if (key != null && key !in result) result[key] = idx
        }
        return result
    }

    // ── XLSX パーサー ────────────────────────────────────────────────────────

    /**
     * XLSX バイト列をパースして全シートの行×列グリッドマップに変換する。
     * キー: シート名の lowercase（例: "specimens", "photos", "values"）または "sheet1" 等
     */
    private fun parseAllXlsxSheets(xlsxBytes: ByteArray): Map<String, List<List<String>>> {
        val entries = mutableMapOf<String, ByteArray>()
        ZipInputStream(xlsxBytes.inputStream()).use { zin ->
            var entry = zin.nextEntry
            while (entry != null) {
                val name = entry.name
                if (name == SHARED_STRINGS_PATH || name == "xl/workbook.xml" ||
                    name.matches(Regex("xl/worksheets/sheet\\d+\\.xml"))) {
                    entries[name] = zin.readBytes()
                }
                zin.closeEntry()
                entry = zin.nextEntry
            }
        }

        val sharedStrings = entries[SHARED_STRINGS_PATH]?.let { parseSharedStrings(it) } ?: emptyList()

        // workbook.xml からシート名とファイルの対応を取得
        val sheetNameMap = mutableMapOf<String, String>()  // sheetN.xml path → sheetName lowercase
        entries["xl/workbook.xml"]?.let { wb ->
            val xml = String(wb, Charsets.UTF_8)
            val regex = Regex("""<sheet\s[^>]*name="([^"]+)"[^>]*r:id="([^"]+)"""")
            // workbook.xml.rels からrId→target mapping が必要だが簡略化:
            // sheet 番号順に処理するので sheetId 順でよい
            val sheetIdRegex = Regex("""<sheet\s[^>]*name="([^"]+)"[^>]*sheetId="(\d+)"""")
            sheetIdRegex.findAll(xml).forEach { m ->
                val name    = m.groupValues[1].lowercase()
                val sheetId = m.groupValues[2]
                sheetNameMap["xl/worksheets/sheet$sheetId.xml"] = name
            }
        }

        val result = mutableMapOf<String, List<List<String>>>()
        entries.keys.filter { it.matches(Regex("xl/worksheets/sheet\\d+\\.xml")) }.sorted().forEach { path ->
            val sheetXml = entries[path] ?: return@forEach
            val sheetName = sheetNameMap[path] ?: path.removePrefix("xl/worksheets/").removeSuffix(".xml")
            result[sheetName] = parseSheet(sheetXml, sharedStrings)
        }
        return result
    }

    /** xl/sharedStrings.xml をパースして文字列リストに変換する */
    private fun parseSharedStrings(xml: ByteArray): List<String> {
        val result = mutableListOf<String>()
        val parser = android.util.Xml.newPullParser()
        parser.setInput(xml.inputStream(), "UTF-8")

        var inSi = false
        var inT  = false
        val sb   = StringBuilder()

        var event = parser.eventType
        while (event != XmlPullParser.END_DOCUMENT) {
            when (event) {
                XmlPullParser.START_TAG -> when (parser.name) {
                    "si" -> { inSi = true; sb.clear() }
                    "t"  -> if (inSi) inT = true
                }
                XmlPullParser.TEXT -> if (inT) sb.append(parser.text)
                XmlPullParser.END_TAG -> when (parser.name) {
                    "si" -> { result.add(sb.toString()); inSi = false }
                    "t"  -> inT = false
                }
            }
            event = parser.next()
        }
        return result
    }

    /** xl/worksheets/sheet1.xml をパースして行×列グリッドに変換する */
    private fun parseSheet(xml: ByteArray, sharedStrings: List<String>): List<List<String>> {
        val rows          = mutableListOf<List<String>>()
        var currentRow    = mutableListOf<Pair<Int, String>>()  // (1-based colIndex, value)
        var inRow         = false
        var cellType      = ""
        var cellRef       = ""
        var inV           = false
        var inIsT         = false
        val cellText      = StringBuilder()

        val parser = android.util.Xml.newPullParser()
        parser.setInput(xml.inputStream(), "UTF-8")

        var event = parser.eventType
        while (event != XmlPullParser.END_DOCUMENT) {
            when (event) {
                XmlPullParser.START_TAG -> when (parser.name) {
                    "row" -> {
                        inRow      = true
                        currentRow = mutableListOf()
                    }
                    "c" -> {
                        cellType = parser.getAttributeValue(null, "t") ?: ""
                        cellRef  = parser.getAttributeValue(null, "r") ?: ""
                        cellText.clear()
                    }
                    "v"  -> inV   = true
                    "t"  -> inIsT = true   // inside <is><t> (inlineStr)
                }
                XmlPullParser.TEXT -> {
                    if (inV || inIsT) cellText.append(parser.text)
                }
                XmlPullParser.END_TAG -> when (parser.name) {
                    "row" -> {
                        // スパース列リスト → 密なリストに変換（欠損セルは ""）
                        val maxCol = currentRow.maxOfOrNull { it.first } ?: 0
                        val dense  = MutableList(maxCol) { "" }
                        currentRow.forEach { (col, v) -> if (col in 1..maxCol) dense[col - 1] = v }
                        rows.add(dense)
                        inRow = false
                    }
                    "c" -> {
                        val col   = cellRefToCol(cellRef)
                        val value = when (cellType) {
                            "s"          -> {
                                // shared string index
                                val idx = cellText.toString().toIntOrNull() ?: -1
                                sharedStrings.getOrElse(idx) { "" }
                            }
                            "inlineStr",
                            "str"        -> cellText.toString()
                            "b"          -> if (cellText.toString() == "1") "TRUE" else "FALSE"
                            else         -> cellText.toString()   // 数値・日付
                        }
                        if (col > 0) currentRow.add(col to value)
                        inV   = false
                        inIsT = false
                        cellText.clear()
                    }
                    "v"  -> inV   = false
                    "t"  -> inIsT = false
                }
            }
            event = parser.next()
        }
        return rows
    }

    /**
     * セル参照 (例: "A1", "AA3") から 1-based 列インデックスを取得する。
     * 数字部分（行番号）は無視する。
     */
    private fun cellRefToCol(ref: String): Int {
        var col = 0
        for (ch in ref) {
            if (!ch.isLetter()) break
            col = col * 26 + (ch.uppercaseChar() - 'A' + 1)
        }
        return col
    }

    // ── 定数 ──────────────────────────────────────────────────────────────────

    companion object {
        private const val SHARED_STRINGS_PATH = "xl/sharedStrings.xml"

        /** インポート時に最低限必要な列キー */
        private val REQUIRED_COLS = listOf(
            "specimen_id", "location_id", "collection_date", "latitude", "longitude"
        )
    }
}
