package com.fieldlabel.collector.ui.viewmodel

import android.graphics.Bitmap
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.media.RingtoneManager
import android.os.Build
import android.os.Looper
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkManager
import com.fieldlabel.collector.data.CollectionRepository
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.LocationSession
import com.fieldlabel.collector.domain.model.SpecimenPhoto
import android.net.Uri
import com.fieldlabel.collector.service.*
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import android.content.Context
import android.os.Handler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
import javax.inject.Inject

// UI の状態を表すクラス群
data class CollectionUiState(
    val isLoadingLocation: Boolean = false,
    val locationError: String?     = null,
    val currentSession: LocationSession? = null,
    val lastSavedRecord: CollectionRecord? = null,
    val printState: PrintState = PrintState.Idle,
    val labelBitmap: Bitmap?   = null,
    val pendingTagPhotoPath: String? = null,  // 撮影後タグ付け待ちの写真パス
    val isGeocodingAddress: Boolean = false,  // 住所取得バックグラウンド処理中フラグ
    val geocodingErrorCode: String?  = null   // 失敗時のエラーコード（NET_UNREACHABLE 等）
)

sealed class PrintState {
    object Idle      : PrintState()
    object Printing  : PrintState()
    data class Done (val message: String) : PrintState()
    data class Saved(val message: String) : PrintState()   // あとで印刷モード時の保存完了
    data class Error(val message: String) : PrintState()
}

@HiltViewModel
class CollectionViewModel @Inject constructor(
    @ApplicationContext private val context: Context,
    private val repository: CollectionRepository,
    private val locationService: LocationService,
    private val geocodingService: GeocodingService,
    private val printerService: PrinterService,
    private val exportService: ExportService,
    private val importService: ImportService,
    private val workManager: WorkManager,
    private val handsFreeManager: HandsFreeManager
) : ViewModel() {

    private val _uiState = MutableStateFlow(CollectionUiState())
    val uiState: StateFlow<CollectionUiState> = _uiState.asStateFlow()

    val recentRecords       = repository.recentRecords
    val allRecords          = repository.allRecords
    val collectorName       = repository.collectorName
    val tapeWidthMm         = repository.tapeWidthMm
    val printModeImmediate  = repository.printModeImmediate

    // ── チューブ径（QRコード曲率補正）──────────────────────────────────────
    /** デフォルトチューブ径（設定値）。0f = フラット面 */
    val tubeDiameterDefault: StateFlow<Float> = repository.tubeDiameterDefault
        .stateIn(viewModelScope, SharingStarted.Eagerly, 0f)
    /** クイック選択プリセット */
    val tubePreset1: StateFlow<Float> = repository.tubePreset1
        .stateIn(viewModelScope, SharingStarted.Eagerly, 10f)
    val tubePreset2: StateFlow<Float> = repository.tubePreset2
        .stateIn(viewModelScope, SharingStarted.Eagerly, 16f)
    val tubePreset3: StateFlow<Float> = repository.tubePreset3
        .stateIn(viewModelScope, SharingStarted.Eagerly, 25f)
    /** 現在の標本に対して選択されているチューブ径（0f = フラット） */
    private val _sessionTubeDiameterMm = MutableStateFlow<Float>(0f)
    val sessionTubeDiameterMm: StateFlow<Float> = _sessionTubeDiameterMm.asStateFlow()

    /** セッション開始時にデフォルト径を適用する */
    init {
        viewModelScope.launch {
            repository.tubeDiameterDefault.collect { default ->
                _sessionTubeDiameterMm.value = default
            }
        }
    }

    /** 現在の標本のチューブ径を変更する */
    fun setTubeDiameterMm(diameterMm: Float) {
        _sessionTubeDiameterMm.value = diameterMm
    }

    /** 現在のセッションに紐づく写真を購読する（セッション切替時に自動更新） */
    val sessionPhotos: StateFlow<List<SpecimenPhoto>> = uiState
        .map { it.currentSession?.locationId }
        .distinctUntilChanged()
        .flatMapLatest { locationId ->
            if (locationId == null) flowOf(emptyList())
            else repository.getPhotosForLocationFlow(locationId)
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    /** 地点ごとの最初の写真（サムネイル表示用） */
    val firstPhotoByLocation: StateFlow<Map<String, SpecimenPhoto>> =
        repository.firstPhotoByLocation
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())

    // ── ハンズフリー状態 ──────────────────────────────────────────────────────
    /** ハンズフリー機能が設定で有効化されているか */
    val hfEnabled: StateFlow<Boolean> = repository.hfEnabled
        .stateIn(viewModelScope, SharingStarted.Eagerly, false)

    /** ハンズフリー中の画面操作ロック設定 */
    val hfScreenLock: StateFlow<Boolean> = repository.hfScreenLock
        .stateIn(viewModelScope, SharingStarted.Eagerly, true)

    /** ハンズフリーモードが現在アクティブか（ボタンを押してセッション中） */
    private val _hfActive = MutableStateFlow(false)
    val hfActive: StateFlow<Boolean> = _hfActive.asStateFlow()

    /** ハンズフリーの会話状態（UI表示用） */
    val hfConvState: StateFlow<ConvState> = handsFreeManager.convState

    /** 最後に認識したテキスト（UI表示用） */
    val hfLastText: StateFlow<String> = handsFreeManager.lastText

    /** カメラ起動要求（写真コマンド受信時に emit） */
    private val _voiceOpenCamera = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
    val voiceOpenCamera: SharedFlow<Unit> = _voiceOpenCamera.asSharedFlow()

    /** 地点写真コマンド受信時に emit — Composable でカメラを起動し、撮影後は地点へ自動紐づけ */
    private val _voiceOpenCameraForLocation = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
    val voiceOpenCameraForLocation: SharedFlow<Unit> = _voiceOpenCameraForLocation.asSharedFlow()

    /** 標本写真コマンド受信時に emit — Composable でカメラを起動し、撮影後は最新標本へ自動紐づけ */
    private val _voiceOpenCameraForSpecimen = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
    val voiceOpenCameraForSpecimen: SharedFlow<Unit> = _voiceOpenCameraForSpecimen.asSharedFlow()

    /** 録画コマンド受信時に emit — Composable で動画録画を起動する */
    private val _voiceStartVideoCapture = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
    val voiceStartVideoCapture: SharedFlow<Unit> = _voiceStartVideoCapture.asSharedFlow()

    /** ログスタートコマンド受信時に emit — Composable で RouteLogViewModel.startRecording() を呼ぶ */
    private val _voiceStartRouteLog = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
    val voiceStartRouteLog: SharedFlow<Unit> = _voiceStartRouteLog.asSharedFlow()

    /** ログストップコマンド受信時に emit — Composable で RouteLogViewModel.stopRecording() を呼ぶ */
    private val _voiceStopRouteLog = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
    val voiceStopRouteLog: SharedFlow<Unit> = _voiceStopRouteLog.asSharedFlow()

    /** フラッシュライトの現在のオン/オフ状態（ViewModel 内で管理） */
    private var isFlashlightOn = false

    /** 次の標本に設定する備考テキスト */
    private val _pendingVoiceNotes = MutableStateFlow("")
    val pendingVoiceNotes: StateFlow<String> = _pendingVoiceNotes.asStateFlow()

    // ── 音声録音 ─────────────────────────────────────────────────────────────
    private var mediaRecorder: MediaRecorder? = null
    private var audioRecordingFile: File? = null

    private val _isRecordingAudio = MutableStateFlow(false)
    val isRecordingAudio: StateFlow<Boolean> = _isRecordingAudio.asStateFlow()

    // ── カスタムフィールド ────────────────────────────────────────────────────
    /** 全フィールド定義（設定で登録されたもの全て） */
    val allCustomFieldDefs: StateFlow<List<CustomFieldDef>> =
        repository.allCustomFieldDefs
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    /** 現在セッションの地点に紐づくカスタム値（Flow） */
    val sessionCustomFieldValues: StateFlow<List<CustomFieldValue>> = uiState
        .map { it.currentSession?.locationId }
        .distinctUntilChanged()
        .flatMapLatest { locationId ->
            if (locationId == null) flowOf(emptyList())
            else repository.getCustomFieldValuesForLocationFlow(locationId)
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    /** 現在セッションの採集済み標本一覧（標本番号順） */
    val sessionSpecimens: StateFlow<List<CollectionRecord>> = uiState
        .map { it.currentSession?.locationId }
        .distinctUntilChanged()
        .flatMapLatest { locationId ->
            if (locationId == null) flowOf(emptyList())
            else repository.allRecords.map { records ->
                records.filter { it.locationId == locationId }
                    .sortedBy { it.specimenNumber }
            }
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    // ── タイマー ─────────────────────────────────────────────────────────────
    /** タイマー終了時刻（エポックミリ秒）。null = タイマー未設定 */
    private val _timerEndTimeMs = MutableStateFlow<Long?>(null)
    val timerEndTimeMs: StateFlow<Long?> = _timerEndTimeMs.asStateFlow()
    /** アラーム鳴動中フラグ（非HFモードでタイマー終了後、ユーザーが止めるまで true） */
    private val _timerAlarmActive = MutableStateFlow(false)
    val timerAlarmActive: StateFlow<Boolean> = _timerAlarmActive.asStateFlow()
    private var timerJob: Job? = null
    // @Volatile: alarmPlayer は IO スレッドで書き込み・メインスレッドで読み出しされるため可視性を保証する
    @Volatile private var alarmPlayer: MediaPlayer? = null
    private var timerSetMinutes: Int = 0  // 完了通知用に保持

    /** タイマーをセットする。既存のタイマーは自動キャンセルされる。 */
    fun startTimerMinutes(minutes: Int) {
        timerJob?.cancel()
        timerSetMinutes = minutes
        _timerEndTimeMs.value = System.currentTimeMillis() + minutes * 60_000L
        timerJob = viewModelScope.launch {
            delay(minutes * 60_000L)
            _timerEndTimeMs.value = null
            timerJob = null
            // バイブレーション: 短・短・長 のパターン（HF/非HF 共通）
            withContext(Dispatchers.IO) {
                try {
                    @Suppress("DEPRECATION")
                    val vibrator: android.os.Vibrator =
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                            context.getSystemService(android.os.VibratorManager::class.java)
                                .defaultVibrator
                        } else {
                            context.getSystemService(android.os.Vibrator::class.java)
                        }
                    val timings    = longArrayOf(0, 250, 100, 250, 100, 600)
                    val amplitudes = intArrayOf(
                        0, android.os.VibrationEffect.DEFAULT_AMPLITUDE,
                        0, android.os.VibrationEffect.DEFAULT_AMPLITUDE,
                        0, android.os.VibrationEffect.DEFAULT_AMPLITUDE
                    )
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                        vibrator.vibrate(
                            android.os.VibrationEffect.createWaveform(timings, amplitudes, -1)
                        )
                    } else {
                        @Suppress("DEPRECATION")
                        vibrator.vibrate(timings, -1)
                    }
                } catch (_: Exception) {}
            }
            if (_hfActive.value) {
                // ── HF モード: 3 ビープ + 音声通知 ──────────────────────────
                withContext(Dispatchers.IO) {
                    try {
                        val toneGen = android.media.ToneGenerator(
                            android.media.AudioManager.STREAM_ALARM, 80
                        )
                        repeat(3) {
                            toneGen.startTone(android.media.ToneGenerator.TONE_PROP_BEEP, 500)
                            delay(700)
                        }
                        toneGen.release()
                    } catch (_: Exception) {}
                }
                Handler(Looper.getMainLooper()).post {
                    handsFreeManager.speakAndResume(
                        if (handsFreeManager.language == "ja")
                            "${minutes}分のタイマーが終わりました。"
                        else "Timer finished. $minutes minutes have elapsed."
                    )
                }
            } else {
                // ── 非HF モード: ユーザーが止めるまでループアラーム ────────
                // フラグを IO 作業の前に立てる。
                // こうすることで stopAlarm() がここより先に呼ばれた場合、
                // プレーヤー生成後にフラグを確認して即停止できる。
                _timerAlarmActive.value = true
                withContext(Dispatchers.IO) {
                    try {
                        val alarmUri =
                            RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
                                ?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
                        val player = MediaPlayer().apply {
                            setAudioAttributes(
                                android.media.AudioAttributes.Builder()
                                    .setUsage(android.media.AudioAttributes.USAGE_ALARM)
                                    .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION)
                                    .build()
                            )
                            setDataSource(context, alarmUri)
                            isLooping = true
                            prepare()
                            start()
                        }
                        // stopAlarm() が IO ブロック実行中に呼ばれていた場合、
                        // フラグがすでに false → プレーヤーを即座に停止する
                        if (_timerAlarmActive.value) {
                            alarmPlayer = player
                        } else {
                            try { player.stop()    } catch (_: Exception) {}
                            try { player.release() } catch (_: Exception) {}
                        }
                    } catch (_: Exception) {}
                }
            }
        }
    }

    /** アラームを停止する（タイマーキャンセル時および停止ボタン押下時に呼ぶ）。 */
    fun stopAlarm() {
        // フラグを先に落とす（IO スレッドがプレーヤー生成中でも即座に検知できる）
        _timerAlarmActive.value = false
        // ローカル変数に取り出してからフィールドを null にする（可視性 + 二重停止防止）
        val player = alarmPlayer
        alarmPlayer = null
        // stop() と release() を別々の try で囲む（stop() が例外を投げても release() を必ず実行）
        try { player?.stop()    } catch (_: Exception) {}
        try { player?.release() } catch (_: Exception) {}
    }

    /** タイマーをキャンセルする。 */
    fun cancelTimer() {
        timerJob?.cancel()
        timerJob = null
        _timerEndTimeMs.value = null
        stopAlarm()
    }

    init {
        SyncWorker.enqueuePeriodicSync(workManager)
        // DataStore から PrinterService の各設定を同期する
        // （SettingsViewModel が先に生成されている場合は上書き操作だが値は同一）
        viewModelScope.launch {
            repository.printerLocalName.collect { name ->
                if (name.isNotBlank()) printerService.printerLocalName = name
            }
        }
        viewModelScope.launch {
            repository.labelLayout.collect { printerService.labelLayout = it }
        }
        viewModelScope.launch {
            repository.labelLengthMm.collect { printerService.labelLengthMm = it }
        }
        viewModelScope.launch {
            repository.labelFontScale.collect { printerService.labelFontScale = it }
        }
        viewModelScope.launch {
            repository.labelShowQr.collect { printerService.showQr = it }
        }
        viewModelScope.launch {
            repository.labelShowDate.collect { printerService.showDate = it }
        }
        viewModelScope.launch {
            repository.labelShowLocation.collect { printerService.showLocation = it }
        }
        viewModelScope.launch { repository.labelShowNotes    .collect { printerService.showNotes     = it } }
        viewModelScope.launch { repository.labelShowCollector.collect { printerService.showCollector = it } }
        viewModelScope.launch { repository.cutMode           .collect { printerService.cutMode       = it } }
        viewModelScope.launch { repository.elemIdScale        .collect { printerService.elemIdScale         = it } }
        viewModelScope.launch { repository.elemIdWeight       .collect { printerService.elemIdWeight        = it } }
        viewModelScope.launch { repository.elemDateScale      .collect { printerService.elemDateScale       = it } }
        viewModelScope.launch { repository.elemDateWeight     .collect { printerService.elemDateWeight      = it } }
        viewModelScope.launch { repository.elemLocationScale  .collect { printerService.elemLocationScale   = it } }
        viewModelScope.launch { repository.elemLocationWeight .collect { printerService.elemLocationWeight  = it } }
        viewModelScope.launch { repository.elemCollectorScale .collect { printerService.elemCollectorScale  = it } }
        viewModelScope.launch { repository.elemCollectorWeight.collect { printerService.elemCollectorWeight = it } }
        viewModelScope.launch { repository.elemNotesScale     .collect { printerService.elemNotesScale      = it } }
        viewModelScope.launch { repository.elemNotesWeight    .collect { printerService.elemNotesWeight     = it } }
        viewModelScope.launch { repository.elemQrWeight       .collect { printerService.elemQrWeight        = it } }
        viewModelScope.launch { repository.labelFontFamily       .collect { printerService.labelFontFamily      = it } }
        viewModelScope.launch { repository.labelShowCoordinates .collect { printerService.showCoordinates     = it } }

        // ── ハンズフリー設定を HandsFreeManager に同期 ─────────────────────────
        viewModelScope.launch { repository.hfWakeWord        .collect { handsFreeManager.wakeWord             = it } }
        viewModelScope.launch { repository.hfLanguage        .collect { handsFreeManager.language             = it } }
        viewModelScope.launch { repository.hfVoiceResponse   .collect { handsFreeManager.voiceResponseEnabled = it } }
        viewModelScope.launch { repository.hfRequireWakeWord .collect { handsFreeManager.requireWakeWord      = it } }

        // ── カスタムフィールド名を HandsFreeManager に同期（音声マッチング用） ──
        viewModelScope.launch {
            allCustomFieldDefs.collect { defs ->
                handsFreeManager.customFieldNames = defs.map { it.label }
            }
        }

        // ── ハンズフリーコマンドを collect ────────────────────────────────────
        viewModelScope.launch {
            handsFreeManager.commands.collect { cmd -> handleHfCommand(cmd) }
        }
    }

    // ── ハンズフリーコマンドハンドラ ──────────────────────────────────────────
    private fun handleHfCommand(cmd: HfCommand) {
        when (cmd) {
            is HfCommand.NewLocation -> {
                // currentSession から採集者名を取得。なければ DataStore のデフォルト採集者名を使う。
                viewModelScope.launch {
                    val name = _uiState.value.currentSession?.collectorName
                        ?: repository.collectorName.first().ifBlank { null }
                    if (name.isNullOrBlank()) {
                        // 採集者名が未設定 → ユーザーに伝えて CMD_WAIT に戻す
                        Handler(Looper.getMainLooper()).post {
                            handsFreeManager.speakAndResume(
                                if (handsFreeManager.language == "ja")
                                    "採集者名が設定されていません。設定画面で採集者名を入力してください。"
                                else "Collector name is not set. Please enter it in Settings."
                            )
                        }
                    } else {
                        startLocationSession(name)
                    }
                }
            }
            is HfCommand.AddSpecimen -> {
                val notes = _pendingVoiceNotes.value
                _pendingVoiceNotes.value = ""
                if (_uiState.value.currentSession == null) {
                    // セッション未開始 → ユーザーに伝えて CMD_WAIT に戻す
                    Handler(Looper.getMainLooper()).post {
                        handsFreeManager.speakAndResume(
                            if (handsFreeManager.language == "ja")
                                "地点が開始されていません。「新しい地点」と言って開始してください。"
                            else "No location session. Say 'new location' to start."
                        )
                    }
                } else {
                    addSpecimenAndPrint(notes)
                }
            }
            is HfCommand.OpenCamera -> {
                // カメラ起動前に Recognizer を破棄してループ音を防ぐ
                handsFreeManager.pauseForCamera()
                _voiceOpenCamera.tryEmit(Unit)
            }
            is HfCommand.OpenCameraForLocation -> {
                handsFreeManager.pauseForCamera()
                _voiceOpenCameraForLocation.tryEmit(Unit)
            }
            is HfCommand.OpenCameraForSpecimen -> {
                handsFreeManager.pauseForCamera()
                _voiceOpenCameraForSpecimen.tryEmit(Unit)
            }
            is HfCommand.SetNotes -> {
                _pendingVoiceNotes.value = cmd.text
            }
            is HfCommand.AnnounceStatus -> {
                val session = _uiState.value.currentSession
                handsFreeManager.notifyStatus(
                    locationId    = session?.locationId,
                    specimenCount = (session?.nextSpecimenNumber ?: 1) - 1
                )
            }
            is HfCommand.AnnounceSpecimenNumber -> {
                val session = _uiState.value.currentSession
                if (session == null) {
                    Handler(Looper.getMainLooper()).post {
                        handsFreeManager.speakAndResume(
                            if (handsFreeManager.language == "ja")
                                "地点が開始されていません。"
                            else "No location session active."
                        )
                    }
                } else {
                    val no = session.nextSpecimenNumber
                    Handler(Looper.getMainLooper()).post {
                        handsFreeManager.speakAndResume(
                            if (handsFreeManager.language == "ja")
                                "次の標本番号は${no}番です。"
                            else "The next specimen number is $no."
                        )
                    }
                }
            }
            is HfCommand.AnnounceDistance -> {
                viewModelScope.launch {
                    val session = _uiState.value.currentSession
                    if (session == null) {
                        Handler(Looper.getMainLooper()).post {
                            handsFreeManager.speakAndResume(
                                if (handsFreeManager.language == "ja")
                                    "地点が開始されていません。"
                                else "No location session active."
                            )
                        }
                        return@launch
                    }
                    try {
                        val current = locationService.getCurrentLocation()
                        val results = FloatArray(2)
                        android.location.Location.distanceBetween(
                            session.latitude, session.longitude,
                            current.latitude, current.longitude,
                            results
                        )
                        val distanceM = results[0].toInt()
                        val direction = bearingToDirection(results[1], handsFreeManager.language == "ja")
                        val msg = if (handsFreeManager.language == "ja") {
                            "採集地点から約${distanceM}メートル、${direction}の方向にいます。"
                        } else {
                            "You are about ${distanceM} meters to the ${direction} of the collection point."
                        }
                        Handler(Looper.getMainLooper()).post {
                            handsFreeManager.speakAndResume(msg)
                        }
                    } catch (e: Exception) {
                        Handler(Looper.getMainLooper()).post {
                            handsFreeManager.speakAndResume(
                                if (handsFreeManager.language == "ja")
                                    "現在地の取得に失敗しました。"
                                else "Failed to get current location."
                            )
                        }
                    }
                }
            }
            is HfCommand.ToggleFlashlight -> {
                try {
                    val cameraManager = context.getSystemService(android.hardware.camera2.CameraManager::class.java)
                    val cameraId = cameraManager?.cameraIdList?.firstOrNull()
                    if (cameraId != null) {
                        isFlashlightOn = !isFlashlightOn
                        cameraManager.setTorchMode(cameraId, isFlashlightOn)
                        Handler(Looper.getMainLooper()).post {
                            handsFreeManager.speakAndResume(
                                if (handsFreeManager.language == "ja")
                                    if (isFlashlightOn) "ライトをつけました。" else "ライトを消しました。"
                                else
                                    if (isFlashlightOn) "Flashlight on." else "Flashlight off."
                            )
                        }
                    } else {
                        Handler(Looper.getMainLooper()).post {
                            handsFreeManager.speakAndResume(
                                if (handsFreeManager.language == "ja")
                                    "フラッシュライトが使えません。"
                                else "Flashlight not available."
                            )
                        }
                    }
                } catch (e: Exception) {
                    Handler(Looper.getMainLooper()).post {
                        handsFreeManager.speakAndResume(
                            if (handsFreeManager.language == "ja")
                                "ライトの操作に失敗しました。"
                            else "Failed to toggle flashlight."
                        )
                    }
                }
            }
            is HfCommand.StartTimer -> {
                startTimerMinutes(cmd.minutes)
                Handler(Looper.getMainLooper()).post {
                    handsFreeManager.speakAndResume(
                        if (handsFreeManager.language == "ja")
                            "${cmd.minutes}分のタイマーをセットしました。"
                        else "Timer set for ${cmd.minutes} minutes."
                    )
                }
            }
            is HfCommand.AnnounceTimerRemaining -> {
                val endTime = _timerEndTimeMs.value
                val msg = if (endTime != null) {
                    val remainingMs = endTime - System.currentTimeMillis()
                    if (remainingMs > 0) {
                        val min = (remainingMs / 60_000).toInt()
                        val sec = ((remainingMs % 60_000) / 1000).toInt()
                        if (handsFreeManager.language == "ja") {
                            if (min > 0) "残り${min}分${sec}秒です。" else "残り${sec}秒です。"
                        } else {
                            if (min > 0) "$min minutes and $sec seconds remaining." else "$sec seconds remaining."
                        }
                    } else {
                        if (handsFreeManager.language == "ja") "タイマーはすでに終了しています。" else "The timer has already finished."
                    }
                } else {
                    if (handsFreeManager.language == "ja") "タイマーはセットされていません。" else "No timer is set."
                }
                Handler(Looper.getMainLooper()).post {
                    handsFreeManager.speakAndResume(msg)
                }
            }
            is HfCommand.EndSession -> {
                stopHandsFree()
            }
            is HfCommand.Repeat -> {
                // HandsFreeManager 内で完結
            }
            is HfCommand.StartVideoRecording -> {
                // 動画録画: カメラ起動前に Recognizer を破棄してループ音を防ぐ
                handsFreeManager.pauseForCamera()
                _voiceStartVideoCapture.tryEmit(Unit)
            }
            is HfCommand.StartAudioRecording -> {
                // 音声録音: SpeechRecognizer と MediaRecorder のマイク競合を避けるため
                // まず Recognizer を停止し（pauseForCamera）、それから録音を開始する。
                // 停止ボタン押下後に saveAudioRecordingForHF() を呼ぶと HF が再開する。
                handsFreeManager.pauseForCamera()
                startAudioRecording()
                Handler(Looper.getMainLooper()).post {
                    handsFreeManager.speak(
                        if (handsFreeManager.language == "ja")
                            "録音を開始しました。停止ボタンで保存してください。"
                        else "Recording started. Tap the Stop button to save."
                    )
                    // speak() の onDone は SPEAKING → CMD_WAIT の遷移を試みるが、
                    // isCameraOpen フラグにより Recognizer は起動しない（正常動作）。
                }
            }
            is HfCommand.SetCustomField -> {
                val session = _uiState.value.currentSession
                if (session == null) {
                    Handler(Looper.getMainLooper()).post {
                        handsFreeManager.speakAndResume(
                            if (handsFreeManager.language == "ja") "地点が開始されていません。"
                            else "No location session active."
                        )
                    }
                    return
                }
                val def = allCustomFieldDefs.value
                    .firstOrNull { it.label.equals(cmd.fieldName, ignoreCase = true) }
                if (def == null) {
                    Handler(Looper.getMainLooper()).post {
                        handsFreeManager.speakAndResume(
                            if (handsFreeManager.language == "ja")
                                "「${cmd.fieldName}」というフィールドが見つかりません。"
                            else "Field '${cmd.fieldName}' not found."
                        )
                    }
                    return
                }
                val latestNo = session.nextSpecimenNumber - 1
                val specimenId =
                    if (latestNo >= 1) "${session.locationId}-%02d".format(latestNo) else null
                when (def.appliesTo) {
                    "specimen" -> if (specimenId != null) saveSpecimenFieldValue(def.id, specimenId, cmd.value)
                                  else saveLocationFieldValue(def.id, cmd.value)
                    else       -> saveLocationFieldValue(def.id, cmd.value)   // "location" | "both"
                }
                Handler(Looper.getMainLooper()).post {
                    handsFreeManager.speakAndResume(
                        if (handsFreeManager.language == "ja")
                            "${def.label}に「${cmd.value}」を設定しました。"
                        else "Set ${def.label} to '${cmd.value}'."
                    )
                }
            }
            is HfCommand.StartRouteLog -> {
                _voiceStartRouteLog.tryEmit(Unit)
                Handler(Looper.getMainLooper()).post {
                    handsFreeManager.speakAndResume(
                        if (handsFreeManager.language == "ja") "経路ログの記録を開始しました。"
                        else "Route logging started."
                    )
                }
            }
            is HfCommand.StopRouteLog -> {
                _voiceStopRouteLog.tryEmit(Unit)
                Handler(Looper.getMainLooper()).post {
                    handsFreeManager.speakAndResume(
                        if (handsFreeManager.language == "ja") "経路ログの記録を停止しました。"
                        else "Route logging stopped."
                    )
                }
            }
        }
    }

    /** 方位角（度）を8方位の文字列に変換する */
    private fun bearingToDirection(bearing: Float, japanese: Boolean): String {
        val normalized = (bearing + 360f) % 360f
        return when {
            normalized < 22.5f || normalized >= 337.5f -> if (japanese) "北" else "north"
            normalized < 67.5f  -> if (japanese) "北東" else "northeast"
            normalized < 112.5f -> if (japanese) "東" else "east"
            normalized < 157.5f -> if (japanese) "南東" else "southeast"
            normalized < 202.5f -> if (japanese) "南" else "south"
            normalized < 247.5f -> if (japanese) "南西" else "southwest"
            normalized < 292.5f -> if (japanese) "西" else "west"
            else                -> if (japanese) "北西" else "northwest"
        }
    }

    // ── ハンズフリー制御 ──────────────────────────────────────────────────────
    /** ハンズフリーを開始する（メインスレッドから呼ぶ必要あり）。 */
    fun startHandsFree() {
        _hfActive.value = true
        Handler(Looper.getMainLooper()).post { handsFreeManager.start() }
    }

    /** ハンズフリーを停止する。 */
    fun stopHandsFree() {
        Handler(Looper.getMainLooper()).post { handsFreeManager.stop() }
        _hfActive.value = false
    }

    /** 備考テキストをクリアする（UI から手動でクリアしたいときに使う）。 */
    fun clearPendingVoiceNotes() {
        _pendingVoiceNotes.value = ""
    }

    // ───────────────────────────────────────────────────────────────────────
    // 位置情報 / セッション
    // ───────────────────────────────────────────────────────────────────────

    fun startLocationSession(collectorName: String) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoadingLocation = true, locationError = null) }
            try {
                val location = locationService.getCurrentLocation()

                // ① GPS 取得直後に address=null でセッションを作成して即表示。
                //    逆ジオコーディングの完了を待たないためUIがすぐ切り替わる。
                val session = repository.createLocationSession(
                    latitude      = location.latitude,
                    longitude     = location.longitude,
                    altitude      = if (location.hasAltitude()) location.altitude else null,
                    address       = null,
                    collectorName = collectorName
                )
                _uiState.update {
                    it.copy(
                        isLoadingLocation  = false,
                        isGeocodingAddress = true,   // 住所取得中インジケーター表示
                        currentSession     = session
                    )
                }

                // ② 逆ジオコーディングをバックグラウンドで継続実行し、完了後に住所を差し込む
                val geoResult = geocodingService.reverseGeocode(
                    location.latitude, location.longitude
                )
                if (geoResult is GeocodingResult.Success) {
                    session.address = geoResult.address
                }
                // ジオコーディング完了（成功・失敗問わず）後にフラグを落としてUI更新
                _uiState.update { state ->
                    if (state.currentSession?.locationId == session.locationId) {
                        state.copy(
                            isGeocodingAddress = false,
                            geocodingErrorCode = (geoResult as? GeocodingResult.Failure)?.code,
                            currentSession     = session
                        )
                    } else {
                        state.copy(isGeocodingAddress = false)
                    }
                }
                // ハンズフリー: 地点開始を通知
                if (_hfActive.value) {
                    Handler(Looper.getMainLooper()).post {
                        handsFreeManager.notifyLocationStarted(session.locationId)
                    }
                }
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(
                        isLoadingLocation  = false,
                        isGeocodingAddress = false,
                        locationError      = e.message ?: "位置情報の取得に失敗しました"
                    )
                }
                // ハンズフリー: 失敗時も PROCESSING から CMD_WAIT に戻す
                if (_hfActive.value) {
                    Handler(Looper.getMainLooper()).post {
                        handsFreeManager.speakAndResume(
                            if (handsFreeManager.language == "ja")
                                "位置情報の取得に失敗しました。もう一度お試しください。"
                            else "Failed to get location. Please try again."
                        )
                    }
                }
            }
        }
    }

    fun addSpecimenAndPrint(notes: String) {
        val session = _uiState.value.currentSession ?: return
        viewModelScope.launch {
            try {
                val immediate = repository.printModeImmediate.first()
                val tubeMm = _sessionTubeDiameterMm.value.let { if (it == 0f) null else it }
                if (immediate) {
                    // ── 随時印刷モード: 印刷成功後のみ標本番号を確定 ──────────────
                    // 仮レコードを生成（DB 保存・カウンター更新なし）
                    val record = repository.buildSpecimenRecord(session, notes, tubeMm)
                    _uiState.update { it.copy(lastSavedRecord = record) }
                    val result = printRecord(record)
                    if (result is PrintResult.Success) {
                        // 印刷成功 → カウンター進めて DB 保存
                        repository.finalizeSpecimen(session, record)
                        SyncWorker.enqueueOneTimeSync(workManager)
                        // ハンズフリー: 標本追加（印刷あり）を通知
                        if (_hfActive.value) {
                            Handler(Looper.getMainLooper()).post {
                                handsFreeManager.notifySpecimenAdded(record.specimenNumber, printed = true)
                            }
                        }
                    } else {
                        // 印刷失敗 → 番号を進めない（lastSavedRecord は仮記録のまま）
                        if (_hfActive.value) {
                            Handler(Looper.getMainLooper()).post {
                                handsFreeManager.speakAndResume(
                                    if (handsFreeManager.language == "ja")
                                        "印刷に失敗しました。もう一度お試しください。"
                                    else "Print failed. Please try again."
                                )
                            }
                        }
                    }
                } else {
                    // ── あとで一括印刷モード: 保存のみ ────────────────────────────
                    val record = repository.addSpecimenToSession(session, notes, tubeMm)
                    _uiState.update { it.copy(lastSavedRecord = record) }
                    val bitmap = printerService.buildLabelBitmap(record, tapeWidthMm.first())
                    _uiState.update {
                        it.copy(
                            labelBitmap = bitmap,
                            printState  = PrintState.Saved("登録完了: ${record.specimenId}")
                        )
                    }
                    SyncWorker.enqueueOneTimeSync(workManager)
                    // ハンズフリー: 標本追加（保存のみ）を通知
                    if (_hfActive.value) {
                        Handler(Looper.getMainLooper()).post {
                            handsFreeManager.notifySpecimenAdded(record.specimenNumber, printed = false)
                        }
                    }
                }
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(printState = PrintState.Error(e.message ?: "保存エラー"))
                }
                // ハンズフリー: 失敗時も PROCESSING から CMD_WAIT に戻す
                if (_hfActive.value) {
                    Handler(Looper.getMainLooper()).post {
                        handsFreeManager.speakAndResume(
                            if (handsFreeManager.language == "ja")
                                "標本の保存に失敗しました。"
                            else "Failed to save specimen."
                        )
                    }
                }
            }
        }
    }

    fun adjustSessionLocation(lat: Double, lon: Double) {
        val session = _uiState.value.currentSession ?: return
        session.latitude  = lat
        session.longitude = lon
        _uiState.update {
            it.copy(
                currentSession     = session,
                isGeocodingAddress = true,
                geocodingErrorCode = null
            )
        }
        viewModelScope.launch {
            val geoResult = geocodingService.reverseGeocode(lat, lon)
            if (geoResult is GeocodingResult.Success) {
                session.address = geoResult.address
            } else {
                session.address = null
            }
            _uiState.update { state ->
                if (state.currentSession?.locationId == session.locationId) {
                    state.copy(
                        isGeocodingAddress = false,
                        geocodingErrorCode = (geoResult as? GeocodingResult.Failure)?.code,
                        currentSession     = session
                    )
                } else {
                    state.copy(isGeocodingAddress = false)
                }
            }
        }
    }

    fun clearSession() {
        _uiState.update { it.copy(currentSession = null, lastSavedRecord = null) }
    }

    /**
     * 過去の地点IDからセッションを復元する。
     * DBから最大標本番号を取得して nextSpecimenNumber を設定する。
     */
    fun resumeLocationSession(locationId: String) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoadingLocation = true, locationError = null) }
            try {
                val records = repository.getByLocationId(locationId)
                if (records.isEmpty()) {
                    _uiState.update { it.copy(isLoadingLocation = false,
                        locationError = "地点 $locationId のレコードが見つかりません") }
                    return@launch
                }
                val first     = records.first()
                val maxNumber = records.maxOf { it.specimenNumber }
                val session   = com.fieldlabel.collector.domain.model.LocationSession(
                    locationId         = locationId,
                    latitude           = first.latitude,
                    longitude          = first.longitude,
                    altitude           = first.altitude,
                    address            = first.address,
                    collectionDate     = first.collectionDate,
                    collectorName      = first.collectorName,
                    nextSpecimenNumber = maxNumber + 1
                )
                _uiState.update { it.copy(isLoadingLocation = false, currentSession = session) }
            } catch (e: Exception) {
                _uiState.update { it.copy(isLoadingLocation = false,
                    locationError = e.message ?: "地点の読み込みに失敗しました") }
            }
        }
    }

    // ───────────────────────────────────────────────────────────────────────
    // 印刷
    // ───────────────────────────────────────────────────────────────────────

    fun buildPreview(record: CollectionRecord) {
        viewModelScope.launch {
            val widthMm = tapeWidthMm.first()
            val bitmap  = printerService.buildLabelBitmap(record, widthMm)
            _uiState.update { it.copy(labelBitmap = bitmap) }
        }
    }

    fun reprintRecord(record: CollectionRecord) {
        viewModelScope.launch { printRecord(record) }
    }

    private suspend fun printRecord(record: CollectionRecord): PrintResult {
        _uiState.update { it.copy(printState = PrintState.Printing) }
        val widthMm = tapeWidthMm.first()
        val result  = printerService.printLabel(record, widthMm)
        val bitmap  = printerService.buildLabelBitmap(record, widthMm)
        _uiState.update {
            it.copy(
                labelBitmap = bitmap,
                printState  = when (result) {
                    is PrintResult.Success -> PrintState.Done(result.message)
                    is PrintResult.Error   -> PrintState.Error(result.message)
                }
            )
        }
        SyncWorker.enqueueOneTimeSync(workManager)
        return result
    }

    /**
     * 地点ラベルを count 枚、1 回のジョブで連続印刷する。
     * ラベルは水平方向に連結されて 1 枚のテープとして排出される。
     */
    fun printLocationBatch(locationId: String, count: Int) {
        viewModelScope.launch {
            _uiState.update { it.copy(printState = PrintState.Printing) }
            try {
                val records = repository.getByLocationId(locationId)
                val rep = records.firstOrNull()
                    ?: run {
                        _uiState.update { it.copy(printState = PrintState.Error("対象レコードが見つかりません")) }
                        return@launch
                    }
                val widthMm = tapeWidthMm.first()
                // count 枚分の同じレコードリストを渡して連続印刷
                val result = printerService.printLocationLabelsAsBatch(
                    List(count) { rep }, widthMm
                )
                _uiState.update {
                    it.copy(printState = when (result) {
                        is PrintResult.Success ->
                            PrintState.Done("地点ラベル ${count}枚 連続印刷完了\n地点ID: $locationId")
                        is PrintResult.Error   ->
                            PrintState.Error(result.message)
                    })
                }
            } catch (e: Exception) {
                _uiState.update { it.copy(printState = PrintState.Error(e.message ?: "印刷エラー")) }
            }
        }
    }

    /**
     * 地点内の全標本ラベルを 1 回のジョブで連続印刷する（あとで一括印刷モード用）。
     * ラベルは水平方向に連結されて 1 枚のテープとして排出される。
     */
    fun printAllSpecimensInLocation(locationId: String) {
        viewModelScope.launch {
            _uiState.update { it.copy(printState = PrintState.Printing) }
            try {
                val records = repository.getByLocationId(locationId)
                if (records.isEmpty()) {
                    _uiState.update {
                        it.copy(printState = PrintState.Error("印刷対象の標本が見つかりません"))
                    }
                    return@launch
                }
                val widthMm = tapeWidthMm.first()
                val result  = printerService.printLabelsAsBatch(records, widthMm)
                _uiState.update {
                    it.copy(
                        printState = when (result) {
                            is PrintResult.Success ->
                                PrintState.Done("標本ラベル ${records.size}枚 連続印刷完了\n地点ID: $locationId")
                            is PrintResult.Error   ->
                                PrintState.Error(result.message)
                        }
                    )
                }
                SyncWorker.enqueueOneTimeSync(workManager)
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(printState = PrintState.Error(e.message ?: "印刷エラー"))
                }
            }
        }
    }

    fun setPrinterLocalName(name: String) {
        printerService.printerLocalName = name
    }

    fun clearPrintState() {
        _uiState.update { it.copy(printState = PrintState.Idle) }
    }

    // ── 採集画面内の印刷パネル用（sessionBatchPrintState） ──────────────────
    // PrintState（登録・印刷ボタン用）とは独立した状態で管理することで
    // ダイアログ表示を適切に分離する。

    private val _sessionBatchPrintState = MutableStateFlow<BatchPrintState>(BatchPrintState.Idle)
    val sessionBatchPrintState: StateFlow<BatchPrintState> = _sessionBatchPrintState.asStateFlow()

    /** 地点ラベルを count 枚連続印刷する（印刷パネル用） */
    fun printSessionLocationBatch(locationId: String, count: Int) {
        if (_sessionBatchPrintState.value is BatchPrintState.Printing) return
        viewModelScope.launch {
            _sessionBatchPrintState.value = BatchPrintState.Printing
            try {
                val records = repository.getByLocationId(locationId)
                val rep = records.firstOrNull()
                    ?: run {
                        _sessionBatchPrintState.value = BatchPrintState.Error("対象レコードが見つかりません")
                        return@launch
                    }
                val widthMm = tapeWidthMm.first()
                val result = printerService.printLocationLabelsAsBatch(List(count) { rep }, widthMm)
                _sessionBatchPrintState.value = when (result) {
                    is PrintResult.Success -> BatchPrintState.Done("地点ラベル ${count}枚 連続印刷完了")
                    is PrintResult.Error   -> BatchPrintState.Error(result.message)
                }
            } catch (e: Exception) {
                _sessionBatchPrintState.value = BatchPrintState.Error(e.message ?: "印刷エラー")
            }
        }
    }

    /** 地点内の全標本ラベルを一括印刷する（印刷パネル用） */
    fun printSessionAllSpecimens(locationId: String) {
        if (_sessionBatchPrintState.value is BatchPrintState.Printing) return
        viewModelScope.launch {
            _sessionBatchPrintState.value = BatchPrintState.Printing
            try {
                val records = repository.getByLocationId(locationId)
                if (records.isEmpty()) {
                    _sessionBatchPrintState.value = BatchPrintState.Error("印刷対象の標本が見つかりません")
                    return@launch
                }
                val widthMm = tapeWidthMm.first()
                val result = printerService.printLabelsAsBatch(records, widthMm)
                _sessionBatchPrintState.value = when (result) {
                    is PrintResult.Success -> BatchPrintState.Done("標本ラベル ${records.size}枚 連続印刷完了")
                    is PrintResult.Error   -> BatchPrintState.Error(result.message)
                }
            } catch (e: Exception) {
                _sessionBatchPrintState.value = BatchPrintState.Error(e.message ?: "印刷エラー")
            }
        }
    }

    /** 特定標本ラベルを1枚再印刷する（印刷パネル用） */
    fun reprintSingleSpecimen(record: CollectionRecord) {
        if (_sessionBatchPrintState.value is BatchPrintState.Printing) return
        viewModelScope.launch {
            _sessionBatchPrintState.value = BatchPrintState.Printing
            try {
                val widthMm = tapeWidthMm.first()
                val result = printerService.printLabel(record, widthMm)
                _sessionBatchPrintState.value = when (result) {
                    is PrintResult.Success -> BatchPrintState.Done("${record.specimenId} を印刷しました")
                    is PrintResult.Error   -> BatchPrintState.Error(result.message)
                }
            } catch (e: Exception) {
                _sessionBatchPrintState.value = BatchPrintState.Error(e.message ?: "印刷エラー")
            }
        }
    }

    fun clearSessionBatchPrintState() {
        _sessionBatchPrintState.value = BatchPrintState.Idle
    }

    // ───────────────────────────────────────────────────────────────────────
    // 標本レコード CRUD
    // ───────────────────────────────────────────────────────────────────────

    fun deleteRecord(record: CollectionRecord) {
        viewModelScope.launch { repository.deleteRecord(record) }
    }

    fun updateRecord(record: CollectionRecord) {
        viewModelScope.launch { repository.updateRecord(record) }
    }

    // ───────────────────────────────────────────────────────────────────────
    // 地点グループ CRUD
    // ───────────────────────────────────────────────────────────────────────

    /** 地点グループ（全標本 + 全写真）を削除する */
    fun deleteLocationGroup(locationId: String) {
        viewModelScope.launch {
            repository.getPhotosByLocationId(locationId).forEach { photo ->
                File(photo.filePath).delete()
            }
            repository.deleteByLocationId(locationId)
        }
    }

    /** 地点グループ内の全標本の採集者名を一括更新する */
    fun updateLocationCollector(locationId: String, collectorName: String) {
        viewModelScope.launch {
            repository.updateLocationCollector(locationId, collectorName)
        }
    }

    // ───────────────────────────────────────────────────────────────────────
    // 写真
    // ───────────────────────────────────────────────────────────────────────

    /** カメラ起動前に呼ぶ。写真ファイルを作成して返す。 */
    fun createPhotoFile(): File {
        val dir = File(context.filesDir, "photos").also { it.mkdirs() }
        return File(dir, "${UUID.randomUUID()}.jpg")
    }

    /** カメラ撮影成功後に呼ぶ。タグ付けダイアログ表示のため状態を更新する。 */
    fun onPhotoCaptured(filePath: String) {
        _uiState.update { it.copy(pendingTagPhotoPath = filePath) }
    }

    /**
     * タグ付けダイアログで確定後に呼ぶ。
     * @param specimenId 紐づける標本ID。null = 地点全体に紐づける
     */
    fun savePhotoWithTag(filePath: String, specimenId: String?) {
        val session = _uiState.value.currentSession ?: return
        viewModelScope.launch {
            val photo = SpecimenPhoto(
                id         = UUID.randomUUID().toString(),
                filePath   = filePath,
                locationId = session.locationId,
                specimenId = specimenId
            )
            repository.savePhoto(photo)
            _uiState.update { it.copy(pendingTagPhotoPath = null) }
            // ハンズフリーモード: タグ付けダイアログ確定後に CMD_WAIT へ復帰
            if (_hfActive.value) {
                Handler(Looper.getMainLooper()).post {
                    handsFreeManager.clearCameraFlag()
                    handsFreeManager.speakAndResume(
                        if (handsFreeManager.language == "ja") "写真を保存しました。"
                        else "Photo saved."
                    )
                }
            }
        }
    }

    /**
     * 地点写真コマンド用: タグ付けダイアログなしで地点全体に紐づけて保存する。
     * ハンズフリーモードの「地点写真」コマンドから呼ばれる。
     */
    fun savePhotoForLocation(filePath: String) {
        val session = _uiState.value.currentSession ?: return
        viewModelScope.launch {
            val photo = SpecimenPhoto(
                id         = UUID.randomUUID().toString(),
                filePath   = filePath,
                locationId = session.locationId,
                specimenId = null   // 地点全体に紐づける
            )
            repository.savePhoto(photo)
            _uiState.update { it.copy(pendingTagPhotoPath = null) }
            if (_hfActive.value) {
                Handler(Looper.getMainLooper()).post {
                    handsFreeManager.clearCameraFlag()
                    handsFreeManager.speakAndResume(
                        if (handsFreeManager.language == "ja")
                            "地点の写真を保存しました。"
                        else "Photo saved for this location."
                    )
                }
            }
        }
    }

    /**
     * 標本写真コマンド用: タグ付けダイアログなしで最新の標本に紐づけて保存する。
     * ハンズフリーモードの「標本写真」コマンドから呼ばれる。
     * 標本が1件もない場合は地点全体に紐づける。
     */
    fun savePhotoForLatestSpecimen(filePath: String) {
        val session = _uiState.value.currentSession ?: return
        viewModelScope.launch {
            val latestNo = session.nextSpecimenNumber - 1
            // specimenId は PhotoTagDialog と同じ書式（LocationId-NN）
            val specimenId = if (latestNo >= 1) "${session.locationId}-%02d".format(latestNo) else null
            val photo = SpecimenPhoto(
                id         = UUID.randomUUID().toString(),
                filePath   = filePath,
                locationId = session.locationId,
                specimenId = specimenId
            )
            repository.savePhoto(photo)
            _uiState.update { it.copy(pendingTagPhotoPath = null) }
            if (_hfActive.value) {
                val msg = if (handsFreeManager.language == "ja") {
                    if (specimenId != null) "標本${latestNo}番の写真を保存しました。"
                    else "まだ標本がありません。地点の写真として保存しました。"
                } else {
                    if (specimenId != null) "Photo saved for specimen $latestNo."
                    else "No specimens yet. Photo saved for the location."
                }
                Handler(Looper.getMainLooper()).post {
                    handsFreeManager.clearCameraFlag()
                    handsFreeManager.speakAndResume(msg)
                }
            }
        }
    }

    /** タグ付けをキャンセル。写真ファイルを削除し、ハンズフリーを CMD_WAIT に復帰する。 */
    fun cancelPhoto() {
        val path = _uiState.value.pendingTagPhotoPath ?: return
        File(path).delete()
        _uiState.update { it.copy(pendingTagPhotoPath = null) }
        if (_hfActive.value) {
            Handler(Looper.getMainLooper()).post {
                handsFreeManager.clearCameraFlag()
                handsFreeManager.speakAndResume(
                    if (handsFreeManager.language == "ja") "写真をキャンセルしました。"
                    else "Photo cancelled."
                )
            }
        }
    }

    /**
     * カメラアプリ自体をキャンセルした場合（撮影前に戻るボタン）に呼ぶ。
     * ハンズフリーを CMD_WAIT に復帰する。
     */
    fun onCameraCancel() {
        if (_hfActive.value) {
            Handler(Looper.getMainLooper()).post {
                handsFreeManager.clearCameraFlag()
                handsFreeManager.speakAndResume(
                    if (handsFreeManager.language == "ja") "撮影をキャンセルしました。"
                    else "Camera cancelled."
                )
            }
        } else {
            handsFreeManager.clearCameraFlag()
        }
    }

    /** 写真レコードと物理ファイルを削除する */
    fun deletePhoto(photo: SpecimenPhoto) {
        viewModelScope.launch { repository.deletePhoto(photo) }
    }

    /** 写真の紐づき（地点全体 or 特定標本）を変更する */
    fun updatePhotoTag(photoId: String, specimenId: String?) {
        viewModelScope.launch { repository.updatePhotoTag(photoId, specimenId) }
    }

    // ───────────────────────────────────────────────────────────────────────
    // 動画
    // ───────────────────────────────────────────────────────────────────────

    /** 動画ファイル保存先を生成する（CaptureVideo contract に渡す URI を作る前に呼ぶ） */
    fun createVideoFile(): File {
        val dir = File(context.filesDir, "videos").also { it.mkdirs() }
        return File(dir, "${UUID.randomUUID()}.mp4")
    }

    /** 動画撮影完了後に呼ぶ（filePath = 保存先ファイルパス）。 */
    fun saveVideo(filePath: String, specimenId: String?) {
        val session = _uiState.value.currentSession ?: return
        viewModelScope.launch {
            repository.savePhoto(SpecimenPhoto(
                id         = UUID.randomUUID().toString(),
                filePath   = filePath,
                locationId = session.locationId,
                specimenId = specimenId,
                mediaType  = "video"
            ))
        }
        if (_hfActive.value) {
            Handler(Looper.getMainLooper()).post {
                handsFreeManager.clearCameraFlag()
                handsFreeManager.speakAndResume(
                    if (handsFreeManager.language == "ja") "動画を保存しました。" else "Video saved."
                )
            }
        }
    }

    // ───────────────────────────────────────────────────────────────────────
    // 音声録音
    // ───────────────────────────────────────────────────────────────────────

    /** 音声録音を開始する。 */
    fun startAudioRecording() {
        if (_isRecordingAudio.value) return
        val dir  = File(context.filesDir, "audio").also { it.mkdirs() }
        val file = File(dir, "${UUID.randomUUID()}.m4a")
        audioRecordingFile = file
        try {
            @Suppress("DEPRECATION")
            val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                MediaRecorder(context)
            } else {
                MediaRecorder()
            }
            recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
            recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
            recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
            recorder.setAudioSamplingRate(44100)
            recorder.setAudioEncodingBitRate(128_000)
            recorder.setOutputFile(file.absolutePath)
            recorder.prepare()
            recorder.start()
            mediaRecorder = recorder
            _isRecordingAudio.value = true
        } catch (e: Exception) {
            audioRecordingFile = null
            android.util.Log.e("CollectionViewModel", "startAudioRecording failed", e)
        }
    }

    /** 音声録音を停止し、タグ選択前のファイルパスを返す。保存は saveAudioRecording() で行う。 */
    fun stopAudioRecordingGetPath(): String? {
        val recorder = mediaRecorder ?: return null
        val file     = audioRecordingFile ?: return null
        try {
            recorder.stop()
            recorder.release()
        } catch (e: Exception) {
            android.util.Log.e("CollectionViewModel", "stopAudioRecording failed", e)
        } finally {
            mediaRecorder      = null
            audioRecordingFile = null
            _isRecordingAudio.value = false
        }
        if (!file.exists() || file.length() == 0L) return null
        return file.absolutePath
    }

    /** stopAudioRecordingGetPath() で得たパスを指定の標本（または地点）に紐づけて保存する。 */
    fun saveAudioRecording(filePath: String, specimenId: String?) {
        val session = _uiState.value.currentSession ?: return
        viewModelScope.launch {
            repository.savePhoto(SpecimenPhoto(
                id         = UUID.randomUUID().toString(),
                filePath   = filePath,
                locationId = session.locationId,
                specimenId = specimenId,
                mediaType  = "audio"
            ))
        }
    }

    /** 音声録音を停止して即座に保存する（後方互換・HFモード向け）。 */
    fun stopAudioRecording(specimenId: String?) {
        val path = stopAudioRecordingGetPath() ?: return
        saveAudioRecording(path, specimenId)
    }

    /**
     * HF モード用: 録音停止 → 最新標本へ自動紐づけ保存 → HF を再開する。
     * UI の停止ボタンが hfActive を検知したときに呼ぶ。
     */
    fun saveAudioRecordingForHF(filePath: String) {
        val session = _uiState.value.currentSession ?: run {
            // セッションなし: ファイルだけ削除して HF 再開
            File(filePath).delete()
            Handler(Looper.getMainLooper()).post { handsFreeManager.clearCameraFlag() }
            return
        }
        val latestNo = session.nextSpecimenNumber - 1
        val specimenId =
            if (latestNo >= 1) "${session.locationId}-%02d".format(latestNo) else null
        viewModelScope.launch {
            repository.savePhoto(SpecimenPhoto(
                id         = UUID.randomUUID().toString(),
                filePath   = filePath,
                locationId = session.locationId,
                specimenId = specimenId,
                mediaType  = "audio"
            ))
        }
        Handler(Looper.getMainLooper()).post {
            handsFreeManager.clearCameraFlag()
            handsFreeManager.speakAndResume(
                if (handsFreeManager.language == "ja") "録音を保存しました。"
                else "Audio recording saved."
            )
        }
    }

    /** 録音をキャンセルする（ファイルを削除）。 */
    fun cancelAudioRecording() {
        try { mediaRecorder?.stop(); mediaRecorder?.release() } catch (_: Exception) {}
        mediaRecorder = null
        audioRecordingFile?.delete()
        audioRecordingFile = null
        _isRecordingAudio.value = false
    }

    override fun onCleared() {
        super.onCleared()
        cancelAudioRecording()
    }

    // ───────────────────────────────────────────────────────────────────────
    // カスタムフィールド値
    // ───────────────────────────────────────────────────────────────────────

    /** 標本レベルのカスタム値を保存・更新する */
    fun saveSpecimenFieldValue(fieldDefId: String, specimenId: String, value: String) {
        val session = _uiState.value.currentSession ?: return
        viewModelScope.launch {
            if (value.isBlank()) return@launch
            repository.upsertSpecimenFieldValue(fieldDefId, session.locationId, specimenId, value)
        }
    }

    /** 地点レベルのカスタム値を保存・更新する */
    fun saveLocationFieldValue(fieldDefId: String, value: String) {
        val session = _uiState.value.currentSession ?: return
        viewModelScope.launch {
            if (value.isBlank()) return@launch
            repository.upsertLocationFieldValue(fieldDefId, session.locationId, value)
        }
    }

    // ───────────────────────────────────────────────────────────────────────
    // 一括エクスポート（地点情報ページ）
    // ───────────────────────────────────────────────────────────────────────

    private val _exportState = MutableStateFlow<ExportState>(ExportState.Idle)
    val exportState: StateFlow<ExportState> = _exportState.asStateFlow()

    /**
     * 選択された地点 ID 群の標本・写真をまとめてエクスポートする。
     * @param locationIds  エクスポート対象の地点 ID セット
     * @param includePhotos  true → XLSX + 写真を ZIP にまとめる
     */
    fun exportSelectedLocations(locationIds: Set<String>, includePhotos: Boolean) {
        if (_exportState.value is ExportState.InProgress) return
        viewModelScope.launch(Dispatchers.IO) {
            _exportState.value = ExportState.InProgress
            try {
                val recs       = allRecords.first().filter { it.locationId in locationIds }
                val media      = locationIds.flatMap { repository.getPhotosByLocationId(it) }
                val fieldDefs  = repository.allCustomFieldDefs.first()
                val fieldValues = locationIds.flatMap { repository.getCustomFieldValuesForLocation(it) }
                val file = if (includePhotos) {
                    exportService.buildZip(recs, media, fieldDefs, fieldValues)
                } else {
                    exportService.buildXlsx(recs, emptyList(), fieldDefs, fieldValues)
                }
                _exportState.value = ExportState.Done(file)
            } catch (e: Throwable) {
                android.util.Log.e(
                    "CollectionViewModel",
                    "exportSelectedLocations failed: ${e.javaClass.simpleName}: ${e.message}", e
                )
                _exportState.value = ExportState.Error(e.message ?: "エクスポートに失敗しました")
            }
        }
    }

    fun clearExportState() {
        _exportState.value = ExportState.Idle
    }

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

    sealed class ImportUiState {
        object Idle       : ImportUiState()
        object InProgress : ImportUiState()
        data class Done (val result: ImportResult) : ImportUiState()
        data class Error(val message: String)      : ImportUiState()
    }

    private val _importState = MutableStateFlow<ImportUiState>(ImportUiState.Idle)
    val importState: StateFlow<ImportUiState> = _importState.asStateFlow()

    /**
     * ファイルピッカーで選択した URI（ZIP または XLSX）からインポートを実行する。
     */
    fun importData(uri: Uri) {
        if (_importState.value is ImportUiState.InProgress) return
        viewModelScope.launch(Dispatchers.IO) {
            _importState.value = ImportUiState.InProgress
            try {
                val result = importService.importFromUri(uri)
                _importState.value = ImportUiState.Done(result)
            } catch (e: Throwable) {
                android.util.Log.e(
                    "CollectionViewModel",
                    "importData failed: ${e.javaClass.simpleName}: ${e.message}", e
                )
                _importState.value = ImportUiState.Error(e.message ?: "インポートに失敗しました")
            }
        }
    }

    fun clearImportState() {
        _importState.value = ImportUiState.Idle
    }
}
