import Observation
import SwiftUI

// MARK: - One row

/// One **History** row: a commit that touched this card's folder (05-card-window.md ▸ History).
///
/// A value rather than the `GitCommitRecord` itself, so the view renders strings a test has already
/// checked and never formats a date in a `body`.
struct CardHistoryRow: Identifiable, Equatable, Sendable {

    /// The commit's oid — the identity, and nothing the row shows.
    let id: String

    /// The commit's subject, exactly as the message engine wrote it.
    let subject: String

    /// "2 days ago · Claude" — the row's second line.
    let attribution: String
}

// MARK: - The seam

/// What the History section shows, as a pure function of commits and a clock
/// (05-card-window.md ▸ History: "newest first — semantic subject, relative date, author").
enum CardHistoryRows {

    /// The rows for one card's commits, newest first — which is the order the walk already answers
    /// in, so nothing here re-sorts and nothing can disagree with git about what "newest" means.
    nonisolated static func rows(
        for commits: [GitCommitRecord],
        now: Date = Date(),
        locale: Locale = .autoupdatingCurrent
    ) -> [CardHistoryRow] {
        commits.map { commit in
            CardHistoryRow(
                id: commit.oid,
                subject: commit.subject,
                attribution: attribution(of: commit, now: now, locale: locale)
            )
        }
    }

    /// "⟨relative date⟩ · ⟨author⟩", with the author dropped when there is none to name.
    ///
    /// The author is the commit's, which is where origin lives (06-history-undo.md ▸ Interaction with
    /// external writers) — so a foreign commit reads `Lanework External` and a `modified-by` agent
    /// reads its own name, with no rendering rule of this section's own. That is 05's claim that the
    /// trail "reads as a story, agent and hand edits included" arriving for free.
    nonisolated static func attribution(
        of commit: GitCommitRecord,
        now: Date = Date(),
        locale: Locale = .autoupdatingCurrent
    ) -> String {
        let when = relativeDate(commit.date, now: now, locale: locale)
        let author = commit.authorName.trimmingCharacters(in: .whitespacesAndNewlines)
        return author.isEmpty ? when : "\(when) · \(author)"
    }

    /// A relative date in the system's own words ("2 days ago"), with **"just now"** for anything
    /// inside a minute.
    ///
    /// The floor is a judgment call, recorded: `RelativeFormatStyle` renders a five-second-old commit
    /// as "in 0 seconds" whenever the clock rounds the wrong way, and a trail whose newest row reads
    /// as the future is worse than one that rounds down. Everything past a minute is the platform's
    /// answer verbatim, localized and abbreviated to suit a 26-character-wide sidebar.
    nonisolated static func relativeDate(
        _ date: Date,
        now: Date = Date(),
        locale: Locale = .autoupdatingCurrent
    ) -> String {
        guard now.timeIntervalSince(date) >= 60 else { return "just now" }
        var style = Date.RelativeFormatStyle(presentation: .named, unitsStyle: .wide)
        style.locale = locale
        return date.formatted(style.locale(locale))
    }
}

// MARK: - The loader

/// **One card window's commit trail** — the object the sidebar renders and the host refreshes.
///
/// ### Its existence is the section's visibility rule
///
/// "The section is **absent** on boards without app-managed git (mode none, repo-nested) — same
/// honesty rule as the popover's git section" (05 ▸ History), and the free tier has no git state at
/// all (12-editions.md). So the host builds one of these only in mode `git`, and `nil` is the whole
/// of the absence — no placeholder, no empty header, nothing to explain.
///
/// ### It re-reads rather than subscribes
///
/// The trail changes when a commit lands, which is exactly what `GitAutoCommitter.commitCount`
/// counts. The host re-asks on that number and on the card's own folder, so a trail refreshes after
/// every commit — the app's own, an agent's the watcher committed, and a restore's — without this
/// object learning what a committer is.
@MainActor
@Observable
final class CardHistory {

    /// The rows, newest first. Empty until the first load answers, which is also the honest answer for
    /// a card whose folder no commit has touched yet.
    private(set) var rows: [CardHistoryRow] = []

    /// Whether a load is in flight — what keeps the section from flashing "no history yet" during the
    /// first walk of a large repository.
    private(set) var isLoading = false

    init() {}

    /// Reads the commits that touched `cardFolderName` under `boardRoot`, off the main actor.
    func load(boardRoot: URL, cardFolderName: String) async {
        isLoading = true
        defer { isLoading = false }
        let commits = await Task.detached(priority: .utility) {
            GitHistoryWalk.commitsTouching(folderNamed: cardFolderName, at: boardRoot)
        }.value
        rows = CardHistoryRows.rows(for: commits)
    }
}

// MARK: - The section

/// The sidebar's **History** section: the card's commit trail, read-only, newest first
/// (05-card-window.md ▸ History).
///
/// ### No actions, deliberately
///
/// "Rows are focusable (arrows), but carry **no actions in v1** — restoring an old version stays a
/// git-client task for now; a per-row forward-restore and lane history are wishlist items,
/// deliberately." So the rows are text: selectable, copyable, and nothing else. The one restore this
/// milestone ships is board-level ⌘Z, which is a different gesture with a different target.
///
/// ### Empty says so, rather than disappearing
///
/// Contrast Details beside it, which vanishes when a card has no unknown keys. The distinction is
/// what an empty state would *imply*: an absent Details section implies nothing (most cards have no
/// unknown keys), while an absent History section on a git board would imply the board has no
/// history — the exact claim 05 reserves for boards that genuinely have none. A card whose folder is
/// newer than its last commit is a real and temporary state, and one quiet line is the honest way to
/// say so.
struct CardHistorySection: View {

    let history: CardHistory

    private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }

    var body: some View {
        VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
            CardSidebarSectionHeader(title: "History")

            if history.rows.isEmpty {
                Text(history.isLoading ? "Reading history…" : "No commits yet")
                    .font(.callout)
                    .foregroundStyle(.secondary)
                    .fixedSize(horizontal: false, vertical: true)
            } else {
                ForEach(history.rows) { row in
                    self.row(row)
                }
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }

    /// Subject over attribution — `CardDetailsSection.row`'s shape, inverted: there the quiet line is
    /// the key and the loud one the value; here the *subject* is what a reader scans for and the date
    /// and author are the qualifier. Same two fonts, same wrap-rather-than-truncate rule, so the two
    /// sections read as one column at any text size.
    private func row(_ row: CardHistoryRow) -> some View {
        VStack(alignment: .leading, spacing: 1) {
            Text(row.subject)
                .font(.callout)
                .textSelection(.enabled)
                .fixedSize(horizontal: false, vertical: true)
            Text(row.attribution)
                .font(.caption)
                .foregroundStyle(.secondary)
                .lineLimit(1)
                .truncationMode(.middle)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(row.subject), \(row.attribution)")
    }
}
