import Foundation
import os

/// Walks a board's folder tree and produces an immutable `BoardModel` snapshot — a pure
/// function of the tree (02-architecture.md § Layering ▸ Components). Enforces the fractal
/// layout's fail-fast and skip rules (01-storage-format.md § Fractal layout, Malformed input)
/// so a bad file either loudly rejects the whole load or is cleanly ignored — never a silent
/// partial result.
///
/// Level is position: root `index.md` → board, depth-1 folders → lanes, depth-2 folders →
/// cards. **Name shape gates level detection** (01-storage-format.md § Fractal layout ▸
/// Rules): only a folder whose name has UUIDv4's shape — lowercase hex, `8-4-4-4-12` — is a
/// lane/card *candidate* at those depths; see `isUUIDShaped` below for exactly what's checked.
/// Anything else — even a directory holding a perfectly valid `index.md` — is a stray: skipped
/// with a `.nonUUIDFolderIgnored` warning, preserved verbatim on disk, and never descended
/// into. A hand-made `notes/` folder (or a broken `index.md` inside one) can never brick a
/// load; only a UUID-shaped candidate that is itself missing `index.md` still gets the older
/// `.missingIndex` warning, and only a UUID-shaped candidate's `index.md` can fail-fast.
///
/// Reserved child names (`attachments/`, `comments/`) only matter as children *of a card*
/// (01-storage-format.md § Fractal layout ▸ Rules); since cards are leaves here — this loader
/// never scans a card folder's contents beyond checking for `index.md` — that reservation is
/// satisfied by construction and needs no explicit filtering. Doubly so under the shape rule:
/// were a card folder ever scanned, `attachments` and `comments` are non-UUID-shaped and would
/// read as strays, not levels — so they never need special-casing against the stray warning.
///
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
/// followed, whether it points to a file or a directory — this loader does not resolve
/// cross-volume or cyclic trees.
public enum BoardLoader: Sendable {

    /// Schema version this app understands; anything higher fails fast
    /// (01-storage-format.md § Malformed input). Internal rather than `private`: read by
    /// `BoardLoadError.Reason.description` below, and it is also the version `BoardWriter`
    /// stamps into files it creates — one symbol, so the app can never write a file its own
    /// loader would reject as newer-than-supported.
    static let supportedSchema = 1

    /// Board-level key for `BoardModel.template` — not schema-owned in the engine's sense
    /// (`FrontmatterKeys.schemaOwned`), because its value is opaque and read raw here rather
    /// than through a typed `FrontmatterDocument` accessor.
    private static let templateKey = "template"

    /// Internal rather than `private`: `BoardWriter` names the same file, and the loader and
    /// the writer must never disagree about which file a folder's content lives in.
    static let indexFileName = "index.md"

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")

    // MARK: - Entry point

    public static func load(boardRoot: URL) throws(BoardLoadError) -> LoadResult {
        try checkIsReadableDirectory(boardRoot)

        let boardIndexURL = boardRoot.appendingPathComponent(indexFileName)
        guard FileManager.default.fileExists(atPath: boardIndexURL.path) else {
            throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex)
        }
        let boardDocument = try readDocument(at: boardIndexURL, path: indexFileName)
        let boardSchema = try validatedSchema(in: boardDocument, path: indexFileName)

        var warnings: [LoadWarning] = []
        func warn(_ warning: LoadWarning) {
            warnings.append(warning)
            logger.warning("\(warning.description, privacy: .public)")
        }

        // Legal per the frontmatter table, meaningless at board level — ignore and log, never
        // tombstone (01-storage-format.md § Deletion).
        if !boardDocument.deleted.isMissing {
            warn(.boardLevelDeletedIgnored)
        }

        var lanes: [Lane] = []
        for laneURL in try directoryCandidates(in: boardRoot) {
            let laneName = laneURL.lastPathComponent
            guard isUUIDShaped(laneName) else {
                warn(.nonUUIDFolderIgnored(path: laneName))
                continue
            }
            guard hasIndex(laneURL) else {
                warn(.missingIndex(path: laneName))
                continue
            }

            let lanePath = laneName + "/" + indexFileName
            let laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath)
            let laneSchema = try validatedSchema(in: laneDocument, path: lanePath)
            let laneOrder = try validatedOrder(in: laneDocument, path: lanePath)

            var cards: [Card] = []
            for cardURL in try directoryCandidates(in: laneURL) {
                let cardName = cardURL.lastPathComponent
                let cardRelPath = laneName + "/" + cardName
                guard isUUIDShaped(cardName) else {
                    warn(.nonUUIDFolderIgnored(path: cardRelPath))
                    continue
                }
                guard hasIndex(cardURL) else {
                    warn(.missingIndex(path: cardRelPath))
                    continue
                }

                let cardPath = cardRelPath + "/" + indexFileName
                let cardDocument = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
                let cardSchema = try validatedSchema(in: cardDocument, path: cardPath)
                let cardOrder = try validatedOrder(in: cardDocument, path: cardPath)

                cards.append(Card(
                    id: ItemID(rawValue: cardName),
                    schema: cardSchema,
                    title: cardDocument.title,
                    created: cardDocument.created,
                    modified: cardDocument.modified,
                    modifiedBy: cardDocument.modifiedBy,
                    deleted: cardDocument.deleted,
                    background: cardDocument.background,
                    icon: cardDocument.icon,
                    iconColor: cardDocument.iconColor,
                    order: cardOrder,
                    document: cardDocument
                ))
            }

            lanes.append(Lane(
                id: ItemID(rawValue: laneName),
                schema: laneSchema,
                title: laneDocument.title,
                created: laneDocument.created,
                modified: laneDocument.modified,
                modifiedBy: laneDocument.modifiedBy,
                deleted: laneDocument.deleted,
                background: laneDocument.background,
                icon: laneDocument.icon,
                iconColor: laneDocument.iconColor,
                order: laneOrder,
                width: laneDocument.width,
                cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue }),
                document: laneDocument
            ))
        }

        let model = BoardModel(
            rootURL: boardRoot,
            schema: boardSchema,
            title: boardDocument.title,
            created: boardDocument.created,
            modified: boardDocument.modified,
            modifiedBy: boardDocument.modifiedBy,
            deleted: boardDocument.deleted,
            background: boardDocument.background,
            icon: boardDocument.icon,
            iconColor: boardDocument.iconColor,
            template: boardDocument.value(for: templateKey),
            lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }),
            document: boardDocument
        )

        return LoadResult(model: model, warnings: warnings)
    }

    // MARK: - Filesystem helpers

    private static func checkIsReadableDirectory(_ url: URL) throws(BoardLoadError) {
        var isDirectory: ObjCBool = false
        guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
            throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "no such file or directory"))
        }
        guard isDirectory.boolValue else {
            throw BoardLoadError(path: ".", reason: .notADirectory)
        }
    }

    private static func hasIndex(_ folder: URL) -> Bool {
        FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path)
    }

    /// The lowercase hex characters `isUUIDShaped` accepts in each `-`-delimited group.
    private static let lowercaseHexDigits = Set("0123456789abcdef")

    /// Whether `name` has UUIDv4's shape — lowercase hex, `8-4-4-4-12` — gating lane/card level
    /// detection (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level
    /// detection"). Deliberately permissive about *which* nibbles matter: the version (13th hex
    /// digit) and variant (17th hex digit) are **not** validated, so any lowercase-hex string in
    /// this shape reads as a candidate — whether or not it was actually minted by
    /// `UUID().uuidString.lowercased()`. That reading is intentional, not an oversight: the
    /// loader's job is recognizing the folder-naming *convention*, not re-deriving RFC 4122
    /// conformance every load. Case-sensitive — an uppercase or mixed-case UUID string is a
    /// stray, matching `ItemID`'s byte-perfect, never-normalized storage of the folder name
    /// (`BoardModel.swift`).
    ///
    /// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same
    /// candidates the loader walked, and level detection has to be one rule, not two.
    static func isUUIDShaped(_ name: String) -> Bool {
        let groups = name.split(separator: "-", omittingEmptySubsequences: false)
        guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
        return groups.allSatisfy { $0.allSatisfy(lowercaseHexDigits.contains) }
    }

    /// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding
    /// hidden entries (`.DS_Store`, `.git`, …) and symlinks — the loader's uniform stray
    /// tolerance (01-storage-format.md § Fractal layout ▸ Rules). Stray *files* are excluded
    /// here too: only directories are level candidates at all, and the caller further narrows
    /// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing
    /// anything else with them.
    ///
    /// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no
    /// candidates" rather than failing the whole load — fail-fast is reserved for the board
    /// root and for malformed `index.md` content, not transient directory-listing races below
    /// it.
    /// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` enumerates
    /// siblings through this same door, so the writer's idea of "the children" can never drift
    /// from the loader's. It is also why `BoardWriter`'s temp files are dot-prefixed — the
    /// `.skipsHiddenFiles` here is what makes a crashed write's residue invisible to a load.
    static func directoryCandidates(in folder: URL) throws(BoardLoadError) -> [URL] {
        guard let entries = try? FileManager.default.contentsOfDirectory(
            at: folder,
            includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
            options: [.skipsHiddenFiles]
        ) else {
            return []
        }

        return entries
            .filter { url in
                guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
                    return false
                }
                return values.isDirectory == true && values.isSymbolicLink != true
            }
            .sorted { $0.lastPathComponent < $1.lastPathComponent }
    }

    // MARK: - Document reading + field validation

    /// **Strict, byte-faithful UTF-8** — the same decode `BoardWriter` uses, and for the same
    /// reason: Foundation's NSString-backed `String(contentsOf:encoding:)` silently strips a
    /// leading BOM, which would let a BOM'd file *load* here and then refuse every write over
    /// in `BoardWriter` — a baffling split. 01-storage-format.md § Fractal layout ▸ Rules is
    /// explicit that a BOM'd file is rejected at load (it fails the frontmatter delimiter);
    /// decoding byte-faithfully is what makes that stated rejection actually happen.
    private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument {
        let text: String
        do {
            let data = try Data(contentsOf: url)
            guard let decoded = String(validating: data, as: UTF8.self) else {
                throw BoardLoadError(path: path, reason: .unparseableYAML(message: "file is not UTF-8", line: nil))
            }
            text = decoded
        } catch let error as BoardLoadError {
            throw error
        } catch {
            throw BoardLoadError(
                path: path,
                reason: .unparseableYAML(message: "could not read file: \(error.localizedDescription)", line: nil)
            )
        }

        do {
            return try FrontmatterDocument.parse(text)
        } catch {
            let line: Int? = if case let .unparseableYAML(_, line) = error { line } else { nil }
            throw BoardLoadError(path: path, reason: .unparseableYAML(message: error.description, line: line))
        }
    }

    private static func validatedSchema(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Int {
        switch document.schema {
        case .missing:
            throw BoardLoadError(path: path, reason: .missingSchema)
        case let .malformed(raw):
            throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
        case let .valid(value):
            guard value <= supportedSchema else {
                throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
            }
            return value
        }
    }

    private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double {
        switch document.order {
        case .missing:
            throw BoardLoadError(path: path, reason: .missingOrder)
        case let .malformed(raw):
            throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw))
        case let .valid(value):
            return value
        }
    }
}

// MARK: - Result

/// A successful load: the snapshot plus anything tolerated-but-notable encountered along the
/// way. `warnings` is also logged as it accumulates (`os.Logger(subsystem: "dev.rzen.indie.Kanban",
/// category: "loader")`) so it shows up in Console even if a caller never inspects it.
public struct LoadResult: Sendable {
    public var model: BoardModel
    public var warnings: [LoadWarning]
}

/// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError`
/// for what does.
public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
    /// A **UUID-shaped** folder below the board root has no `index.md` — skipped, not
    /// fail-fast (an interrupted two-step create must not brick the board). `path` is relative
    /// to the board root. Only reachable for a folder that passed `isUUIDShaped`; a
    /// non-UUID-shaped folder missing `index.md` gets `.nonUUIDFolderIgnored` instead, never
    /// this case.
    case missingIndex(path: String)

    /// A lane/card-depth folder whose name doesn't have UUIDv4's shape (`isUUIDShaped`) —
    /// skipped, not fail-fast, regardless of whether it holds a valid `index.md`, a broken one,
    /// or none at all (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level
    /// detection"). Preserved verbatim on disk, never descended into. `path` is relative to the
    /// board root.
    case nonUUIDFolderIgnored(path: String)

    /// A board-level `deleted:` key is legal per the frontmatter table but meaningless
    /// (01-storage-format.md § Deletion) — ignored, never tombstones the board.
    case boardLevelDeletedIgnored

    public var description: String {
        switch self {
        case let .missingIndex(path):
            "\(path): folder has no index.md, skipped"
        case let .nonUUIDFolderIgnored(path):
            "\(path): folder name is not UUID-shaped, ignored as a stray"
        case .boardLevelDeletedIgnored:
            "index.md: board-level 'deleted' key is meaningless, ignored"
        }
    }
}

// MARK: - Error

/// A fail-fast structural failure loading a board — loud and specific: `path` (relative to
/// the board root where one exists) plus `reason` says exactly what's wrong. No partial
/// loads: throwing this means `BoardLoader.load` produced nothing at all.
public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertible {
    public let path: String
    public let reason: Reason

    public var description: String { "\(path): \(reason.description)" }

    public enum Reason: Sendable, Equatable, CustomStringConvertible {
        /// The board root itself has no `index.md` — unlike every level below it, this is not
        /// skip-and-warn: there is no board without one.
        case boardRootMissingIndex
        /// Wraps any `FrontmatterError` from parsing — bad delimiters, bad YAML, a
        /// frontmatter block that isn't a mapping. `line` is 1-based within the file when the
        /// underlying error carries one.
        case unparseableYAML(message: String, line: Int?)
        case missingSchema
        case malformedSchema(raw: String)
        /// `schema` is present, valid, and greater than this app's `supportedSchema`.
        case schemaNewerThanApp(found: Int)
        /// `order` is required on lanes and cards, never on the board itself.
        case missingOrder
        case malformedOrder(raw: String)
        /// The board root exists but is a file, not a directory.
        case notADirectory
        /// The board root doesn't exist, or its contents couldn't be listed.
        case unreadableRoot(message: String)

        public var description: String {
            switch self {
            case .boardRootMissingIndex:
                "board root is missing index.md"
            case let .unparseableYAML(message, line):
                if let line {
                    "unparseable YAML at line \(line): \(message)"
                } else {
                    "unparseable YAML: \(message)"
                }
            case .missingSchema:
                "missing required 'schema' field"
            case let .malformedSchema(raw):
                "malformed 'schema' field: \(raw)"
            case let .schemaNewerThanApp(found):
                "schema \(found) is newer than this app supports (schema \(BoardLoader.supportedSchema))"
            case .missingOrder:
                "missing required 'order' field"
            case let .malformedOrder(raw):
                "malformed 'order' field: \(raw)"
            case .notADirectory:
                "board root is not a directory"
            case let .unreadableRoot(message):
                "board root is unreadable: \(message)"
            }
        }
    }
}
