package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
import wang.yaojia.webterm.wire.Validation
import java.util.UUID

/**
 * # ProjectsViewModel (A23) — the Projects list state (mirrors web v0.6 `public/projects.ts` +
 * iOS `ProjectsViewModel`): the grouped grid over `GET /projects`, the cross-device favourites/collapse
 * round-trip over `GET/PUT /prefs`, and the "open Claude here" navigation signal.
 *
 * ### `/prefs` is a clobber-sensitive surface (R11 · plan §4.3)
 * `PUT /prefs` replaces the WHOLE blob server-side, so every write goes through [UiPrefs]'s
 * unknown-key-preserving API ([UiPrefs.withFavourites]/[UiPrefs.withCollapsed]) — any top-level key a
 * web/iOS/future-server client wrote is carried back verbatim. If the `/prefs` GET never succeeded
 * ([prefsBase] is null), a favourite/collapse toggle changes ONLY local state and is **never PUT** (an
 * empty-base PUT would wipe the server's favourites). A successful PUT adopts the server's sanitized
 * echo as the new truth. Prefs load ONCE (mirrors web `mountProjects.init`); the refresh cadence
 * re-fetches only projects so it never clobbers unsynced local edits.
 *
 * ### Untrusted server boundary (plan §8)
 * The list decodes tolerantly inside [ApiClient]; this VM never crashes on a partial list. Every server
 * string (name/path/branch) is carried INERT to the screen (plain `Text`, no linkify/markdown). A cwd
 * is server data → re-validated through [Validation.isAbsoluteCwd] before it is minted into an open
 * request (the same discipline the deep-link router uses).
 *
 * ### A plain presenter (JVM-testable, mirrors [SessionListViewModel]/[DiffViewModel])
 * Not an `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no
 * `Dispatchers.Main`. Its collaborator is the [ProjectsGateway] seam, so the grouping-parity,
 * favourite/collapse and prefs-round-trip logic are all JVM-tested; grid/sheet layout is device-QA.
 */
public class ProjectsViewModel(
    private val gateway: ProjectsGateway,
) {
    private val _uiState = MutableStateFlow(ProjectsUiState())

    /** The single snapshot `ProjectsScreen` renders from. */
    public val uiState: StateFlow<ProjectsUiState> = _uiState.asStateFlow()

    /**
     * The last known FULL prefs blob (known + unknown keys). `null` = never successfully loaded →
     * NEVER PUT (R11: an empty-base write would clobber the server's favourites).
     */
    private var prefsBase: UiPrefs? = null

    /** Live search-box binding — re-derives [ProjectsUiState.groups] on the next render. */
    public fun onSearchChange(text: String) {
        _uiState.update { it.copy(searchText = text) }
    }

    /** First entry: prefs (once) + projects. Prefs stay in memory afterward (mirrors web `init`). */
    public suspend fun load() {
        loadPrefsIfNeeded()
        refresh()
    }

    /** Re-fetch only projects (pull-to-refresh / retry). A failure keeps the old list + an error line. */
    public suspend fun refresh() {
        try {
            val projects = gateway.projects()
            _uiState.update { it.copy(projects = projects, fetchError = null, hasLoadedOnce = true) }
        } catch (cancel: CancellationException) {
            throw cancel
        } catch (error: Throwable) {
            _uiState.update { it.copy(fetchError = ProjectsCopy.fetchFailed(errorDetail(error))) }
        }
    }

    private suspend fun loadPrefsIfNeeded() {
        if (prefsBase != null) return
        try {
            adopt(gateway.prefs())
            _uiState.update { it.copy(prefsError = null) }
        } catch (cancel: CancellationException) {
            throw cancel
        } catch (_: Throwable) {
            _uiState.update { it.copy(prefsError = ProjectsCopy.PREFS_LOAD_FAILED) }
        }
    }

    // ── Favourites / collapse (cross-device prefs round-trip) ────────────────────────────────────

    public suspend fun toggleFavourite(path: String) {
        val current = _uiState.value.favourites
        val next = if (current.contains(path)) current.filterNot { it == path } else current + path
        _uiState.update { it.copy(favourites = next) }
        persistPrefs()
    }

    public suspend fun toggleCollapsed(key: String) {
        val current = _uiState.value.collapsedGroups
        // Store only the "collapsed" fact; expanded is the default (matches web/server sanitizers).
        val next = if (current[key] == true) current.filterKeys { it != key } else current + (key to true)
        _uiState.update { it.copy(collapsedGroups = next) }
        persistPrefs()
    }

    /**
     * Rewrite the two known keys on the last-known full blob (unknown keys carried through), PUT, then
     * adopt the server echo. No base (prefs never loaded) → local-only, never PUT (R11).
     */
    private suspend fun persistPrefs() {
        val base = prefsBase ?: return
        val next = base
            .withFavourites(_uiState.value.favourites)
            .withCollapsed(_uiState.value.collapsedGroups)
        prefsBase = next // optimistic: stack consecutive toggles on the same base
        try {
            adopt(gateway.putPrefs(next))
            _uiState.update { it.copy(prefsSyncError = null) }
        } catch (cancel: CancellationException) {
            throw cancel
        } catch (error: Throwable) {
            _uiState.update { it.copy(prefsSyncError = ProjectsCopy.prefsSyncFailed(errorDetail(error))) }
        }
    }

    private fun adopt(prefs: UiPrefs) {
        prefsBase = prefs
        _uiState.update { it.copy(favourites = prefs.favourites, collapsedGroups = prefs.collapsed) }
    }

    // ── Open Claude here (the core action — attach(null, cwd=projectPath)) ────────────────────────

    /**
     * Mint an [ProjectOpenRequest] for a fresh session in [cwd] (`attach(null, cwd)` + a `claude\r`
     * bootstrap). The path is server data → validated first; an invalid one surfaces an inert error and
     * mints nothing. A fresh [UUID] per request re-triggers `onChange` even for a repeated tap.
     */
    public fun requestOpenClaude(cwd: String) {
        if (!Validation.isAbsoluteCwd(cwd)) {
            _uiState.update { it.copy(openError = ProjectsCopy.OPEN_CLAUDE_INVALID_PATH) }
            return
        }
        _uiState.update {
            it.copy(
                openError = null,
                openRequest = ProjectOpenRequest(
                    id = UUID.randomUUID(),
                    cwd = cwd,
                    bootstrapInput = ProjectsCopy.CLAUDE_BOOTSTRAP_INPUT,
                ),
            )
        }
    }

    /** The nav layer clears the signal once it has routed the attach (so it does not re-fire). */
    public fun consumeOpenRequest() {
        _uiState.update { it.copy(openRequest = null) }
    }

    // ── Detail assembly (the detail-screen seam) ─────────────────────────────────────────────────

    public fun makeDetailViewModel(path: String): ProjectDetailViewModel =
        ProjectDetailViewModel.forGateway(gateway, path)

    private fun errorDetail(error: Throwable): String =
        (error as? ApiClientError)?.userMessage ?: error.message ?: error.toString()
}

// ── ProjectGrouping — the byte-parity grouping core (mirrors public/projects.ts + iOS) ───────────

/**
 * Pure namespace grouping — a LINE-FOR-LINE port of web v0.6 `public/projects.ts`
 * (`filterProjects`/`sortProjects`/`groupProjects`/`displayLabel`) so phone, iPad and web see the SAME
 * sections.
 *
 * **The frozen contract: group [ProjectGroup.key]s are byte-identical to web/iOS.** Collapse state is
 * persisted BY group key into the cross-device `/prefs.collapsed` blob, so a key drift = two ends losing
 * each other's collapse state. The three key shapes are frozen:
 *  - the sentinels [ACTIVE_GROUP_KEY] `" active"` / [OTHER_GROUP_KEY] `" other"` (leading space → can't
 *    collide with a real `First.Second` namespace);
 *  - a namespace group's key is its **first-seen casing** (`"Acme.Web"`, not the lower-cased bucket key).
 * [ProjectGroup.label] is local UI text (Chinese sentinels; namespace label = the key itself) — only the
 * KEY is frozen, so labels may diverge per platform without breaking collapse sync.
 */
public object ProjectGrouping {
    /** Sentinel keys (leading space, mirrors public/projects.ts:83-84). Frozen — cross-device shared. */
    public const val ACTIVE_GROUP_KEY: String = " active"
    public const val OTHER_GROUP_KEY: String = " other"

    /** A namespace needs at least this many members to earn its own section (web `MIN_GROUP_SIZE`). */
    private const val MIN_GROUP_SIZE = 2

    /** Filter by name/path substring, case-insensitive (mirrors `filterProjects`). Never mutates input. */
    public fun filter(projects: List<ProjectInfo>, query: String): List<ProjectInfo> {
        val trimmed = query.trim()
        if (trimmed.isEmpty()) return projects
        val lower = trimmed.lowercase()
        return projects.filter {
            it.name.lowercase().contains(lower) || it.path.lowercase().contains(lower)
        }
    }

    /**
     * Favourites-first, then `lastActiveMs` descending (mirrors `sortProjects`). Kotlin `sortedWith` is
     * stable, so ties keep server order — matching JS's stable `Array.sort` byte-for-byte.
     */
    public fun sort(projects: List<ProjectInfo>, favourites: Set<String>): List<ProjectInfo> =
        projects.sortedWith(
            compareBy<ProjectInfo> { if (favourites.contains(it.path)) 0 else 1 }
                .thenByDescending { it.lastActiveMs ?: 0L },
        )

    /**
     * The card label inside a namespace group: the tail after the `<key>.` prefix (case-insensitive), so
     * `Billo.Platform.` isn't shouted on every card. Sentinel groups keep the full name (mirrors
     * `displayLabel`).
     */
    public fun displayLabel(name: String, groupKey: String): String {
        if (groupKey == ACTIVE_GROUP_KEY || groupKey == OTHER_GROUP_KEY) return name
        val prefix = "$groupKey."
        return if (name.lowercase().startsWith(prefix.lowercase())) name.substring(prefix.length) else name
    }

    /** Running = any non-exited session (same predicate as web `hasRunningSession`). */
    public fun hasRunningSession(project: ProjectInfo): Boolean = project.sessions.any { !it.exited }

    /**
     * Group into collapsible namespace sections (mirrors `groupProjects`). Running projects duplicate
     * into a pinned "Active now"; sub-[MIN_GROUP_SIZE] namespaces fall into "Other"; no real namespace
     * groups → a single header-less `flat` group (keyed [OTHER_GROUP_KEY]). Never mutates input.
     */
    public fun group(projects: List<ProjectInfo>, favourites: Set<String>): List<ProjectGroup> {
        val (namespaceGroups, other) = bucketByNamespace(projects, favourites)

        // Grouping bought nothing — one flat, header-less grid (web's `flat` fallback).
        if (namespaceGroups.isEmpty()) {
            return listOf(
                makeGroup(OTHER_GROUP_KEY, ProjectsCopy.ALL_GROUP_LABEL, ProjectGroupKind.FLAT, projects, favourites),
            )
        }

        val orderedNamespaces = namespaceGroups.sortedWith(
            compareByDescending<ProjectGroup> { maxLastActive(it.projects) }.thenBy { it.label },
        )

        val groups = ArrayList<ProjectGroup>()
        val active = projects.filter(::hasRunningSession)
        if (active.isNotEmpty()) {
            groups.add(makeGroup(ACTIVE_GROUP_KEY, ProjectsCopy.ACTIVE_GROUP_LABEL, ProjectGroupKind.ACTIVE, active, favourites))
        }
        groups.addAll(orderedNamespaces)
        if (other.isNotEmpty()) {
            groups.add(makeGroup(OTHER_GROUP_KEY, ProjectsCopy.OTHER_GROUP_LABEL, ProjectGroupKind.OTHER, other, favourites))
        }
        return groups
    }

    /**
     * Bucket by lower-cased namespace key (de-dup), keeping the first-seen casing as the display/key and
     * first-seen insertion order (mirrors JS `Map` iteration). Sub-[MIN_GROUP_SIZE] buckets collapse into
     * "Other". Returns (namespace groups in insertion order, the "other" pile).
     */
    private fun bucketByNamespace(
        projects: List<ProjectInfo>,
        favourites: Set<String>,
    ): Pair<List<ProjectGroup>, List<ProjectInfo>> {
        val bucketOrder = ArrayList<String>()
        val buckets = HashMap<String, Bucket>()
        val other = ArrayList<ProjectInfo>()
        for (project in projects) {
            val namespace = namespaceKey(project.name)
            if (namespace == null) {
                other.add(project)
                continue
            }
            val lowerKey = namespace.lowercase()
            val existing = buckets[lowerKey]
            if (existing != null) {
                existing.items.add(project)
            } else {
                buckets[lowerKey] = Bucket(namespace, arrayListOf(project))
                bucketOrder.add(lowerKey)
            }
        }

        val groups = ArrayList<ProjectGroup>()
        for (lowerKey in bucketOrder) {
            val bucket = buckets[lowerKey] ?: continue
            if (bucket.items.size < MIN_GROUP_SIZE) {
                other.addAll(bucket.items)
            } else {
                groups.add(makeGroup(bucket.display, bucket.display, ProjectGroupKind.NAMESPACE, bucket.items, favourites))
            }
        }
        return groups to other
    }

    /** First two dot-segments (`'a.b.c'` → `'a.b'`; < 2 segments → null). Empty segments kept (JS `split`). */
    private fun namespaceKey(name: String): String? {
        val segments = name.split(".")
        if (segments.size < 2) return null
        return segments.take(2).joinToString(".")
    }

    private fun maxLastActive(items: List<ProjectInfo>): Long =
        items.fold(0L) { acc, p -> maxOf(acc, p.lastActiveMs ?: 0L) }

    private fun makeGroup(
        key: String,
        label: String,
        kind: ProjectGroupKind,
        items: List<ProjectInfo>,
        favourites: Set<String>,
    ): ProjectGroup = ProjectGroup(
        key = key,
        label = label,
        kind = kind,
        projects = sort(items, favourites),
        activeCount = items.count(::hasRunningSession),
    )

    private class Bucket(val display: String, val items: MutableList<ProjectInfo>)
}

/** Section kind (mirrors web `ProjectGroupKind`). */
public enum class ProjectGroupKind { ACTIVE, NAMESPACE, OTHER, FLAT }

/** One collapsible section snapshot (mirrors web `ProjectGroup`). [key] is the frozen cross-device id. */
public data class ProjectGroup(
    /** Collapse-state persistence key — byte-identical to web/iOS (`/prefs` shared). */
    val key: String,
    val label: String,
    val kind: ProjectGroupKind,
    /** Already sorted (favourites-first → recency). */
    val projects: List<ProjectInfo>,
    /** Projects with a running session (a collapsed section never silently buries active work). */
    val activeCount: Int,
) {
    /** "Active now" is always shown; `flat` has no chrome — only namespace/other collapse. */
    val isCollapsible: Boolean get() = kind == ProjectGroupKind.NAMESPACE || kind == ProjectGroupKind.OTHER
}

/** "Open Claude here" nav signal (attach(null, cwd) + a bootstrap input). Consumed by the nav layer. */
public data class ProjectOpenRequest(
    val id: UUID,
    /** New session working directory (already validated as an absolute path). */
    val cwd: String,
    /** First input injected after attach (`null` = plain shell). */
    val bootstrapInput: String?,
)

/**
 * The immutable Projects snapshot. Raw inputs ([projects]/[favourites]/[collapsedGroups]/[searchText])
 * are stored; [groups] derives the byte-parity grouping on demand (mirrors iOS's computed `groups`).
 */
public data class ProjectsUiState(
    val searchText: String = "",
    val hasLoadedOnce: Boolean = false,
    /** Last project refresh failed (old list kept — one dropped poll never clears the screen). */
    val fetchError: String? = null,
    /** Prefs load failed: favourites/collapse are local-only this session, never written back. */
    val prefsError: String? = null,
    /** Last `PUT /prefs` failed (the local change is kept; the next toggle retries). */
    val prefsSyncError: String? = null,
    /** "Open Claude here" rejected (invalid path). */
    val openError: String? = null,
    /** Favourited project paths (order preserved: a new favourite appends). */
    val favourites: List<String> = emptyList(),
    /** Group key → collapsed (only `true` stored; expanded is the default). */
    val collapsedGroups: Map<String, Boolean> = emptyMap(),
    val projects: List<ProjectInfo> = emptyList(),
    val openRequest: ProjectOpenRequest? = null,
) {
    /** Search-filtered → web/iOS-identical grouping (favourites-first sort happens inside each group). */
    val groups: List<ProjectGroup>
        get() = ProjectGrouping.group(ProjectGrouping.filter(projects, searchText), favourites.toSet())

    val isSearching: Boolean get() = searchText.trim().isNotEmpty()

    /** Searching force-expands every matching section (results never hide behind a collapsed caret). */
    public fun isCollapsed(group: ProjectGroup): Boolean =
        group.isCollapsible && !isSearching && collapsedGroups[group.key] == true

    public fun isFavourite(path: String): Boolean = favourites.contains(path)

    /** Empty-state copy: no projects vs no search match (mirrors web `renderGrid`'s two messages). */
    val emptyStateMessage: String?
        get() {
            if (!hasLoadedOnce || fetchError != null) return null
            if (projects.isEmpty()) return ProjectsCopy.EMPTY_NO_PROJECTS
            if (ProjectGrouping.filter(projects, searchText).isEmpty()) return ProjectsCopy.EMPTY_NO_MATCH
            return null
        }
}

// ── Gateway seam (abstracts ApiClient so the VM is JVM-tested against a fake) ─────────────────────

/**
 * Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses.
 * The W5 additions (PR / recent commits / worktree create-remove-prune) let the detail page and the
 * [WorktreeViewModel] stay JVM-tested against a fake; the guarded worktree writes flow through the
 * :api-client Origin-stamping point (plan §Security).
 */
public interface ProjectsGateway {
    public suspend fun projects(): List<ProjectInfo>
    public suspend fun prefs(): UiPrefs
    public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
    public suspend fun projectDetail(path: String): ProjectDetail

    // ── W5: read-only PR + recent commits ──────────────────────────────────────────────────
    public suspend fun projectPr(path: String): PrStatus
    public suspend fun projectLog(path: String, n: Int? = null): GitLogResult

    // ── W5: guarded worktree write ─────────────────────────────────────────────────────────
    public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
    public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult>
    public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult>
}

/** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */
public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGateway {
    override suspend fun projects(): List<ProjectInfo> = api.projects()
    override suspend fun prefs(): UiPrefs = api.prefs()
    override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
    override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
    override suspend fun projectPr(path: String): PrStatus = api.projectPr(path)
    override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n)
    override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
        api.createWorktree(path, branch, base)

    override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> =
        api.removeWorktree(path, worktreePath, force)

    override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> = api.pruneWorktrees(path)
}

/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */
public object ProjectsCopy {
    public const val TITLE: String = "项目"
    public const val SEARCH_PROMPT: String = "筛选项目…"
    public const val ACTIVE_GROUP_LABEL: String = "活跃中"
    public const val OTHER_GROUP_LABEL: String = "其他"
    public const val ALL_GROUP_LABEL: String = "全部项目"
    public const val DIRTY_BADGE: String = "未提交"
    public const val OPEN_CLAUDE_LABEL: String = "Claude"
    public const val EMPTY_NO_PROJECTS: String = "未发现项目。请检查主机的 PROJECT_ROOTS 配置。"
    public const val EMPTY_NO_MATCH: String = "没有匹配的项目。"
    public const val PREFS_LOAD_FAILED: String = "云端收藏/折叠状态加载失败，本次修改仅在本机生效。"
    public const val OPEN_CLAUDE_INVALID_PATH: String = "项目路径无效，无法开新会话。"

    /** Enter is `\r` (0x0D), not `\n` — the classic synthesize-input gotcha (CLAUDE.md Gotchas). */
    public const val CLAUDE_BOOTSTRAP_INPUT: String = "claude\r"

    public fun fetchFailed(detail: String): String = "刷新项目列表失败：$detail"
    public fun prefsSyncFailed(detail: String): String = "收藏/折叠状态同步失败：$detail"
    public fun activeCountBadge(count: Int): String = "● $count 活跃"
}
